[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>
<SelectionState runConfigName="app">
<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">
<handle>
<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">
<component name="ASMSmaliIdeaPluginConfiguration">
<asm skipDebug="true" skipFrames="true" skipCode="false" expandFrames="false" />
<groovy codeStyle="LEGACY" />
</component>
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />

View File

@ -92,6 +92,9 @@ dependencies {
// Image loading
implementation(libs.coil.compose)
// Emoji picker
implementation(libs.androidx.emoji2.emojipicker)
// Security
implementation(libs.androidx.security.crypto)

View File

@ -20,6 +20,7 @@
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:windowSoftInputMode="adjustNothing"
android:theme="@style/Theme.Yobble">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View File

@ -6,10 +6,10 @@ import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.navigation.compose.rememberNavController
import dagger.hilt.android.AndroidEntryPoint
import org.yobble.messenger.data.local.SessionManager
@ -30,7 +30,8 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
// Required for smooth keyboard animations (official docs)
WindowCompat.setDecorFitsSystemWindows(window, false)
requestNotificationPermission()
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.NotificationChannel
import android.app.NotificationManager
import coil.Coil
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.disk.DiskCache
import coil.memory.MemoryCache
import dagger.hilt.android.HiltAndroidApp
import okhttp3.OkHttpClient
import org.yobble.messenger.data.local.CacheManager
import org.yobble.messenger.data.local.SessionManager
import org.yobble.messenger.di.CoilClient
import java.io.File
import javax.inject.Inject
@ -17,6 +20,7 @@ import javax.inject.Inject
class YobbleApp : Application(), ImageLoaderFactory {
@Inject
@CoilClient
lateinit var okHttpClient: OkHttpClient
@Inject
@ -31,9 +35,27 @@ class YobbleApp : Application(), ImageLoaderFactory {
}
override fun newImageLoader(): ImageLoader {
return buildImageLoader()
}
private var currentImageLoaderUserId: String? = null
fun resetImageLoaderIfNeeded() {
val userId = sessionManager.userId
if (userId != currentImageLoaderUserId) {
currentImageLoaderUserId = userId
Coil.setImageLoader(buildImageLoader())
}
}
private fun buildImageLoader(): ImageLoader {
val builder = ImageLoader.Builder(this)
.okHttpClient(okHttpClient)
.crossfade(true)
.memoryCache {
MemoryCache.Builder(this)
.maxSizePercent(0.25)
.build()
}
val userId = sessionManager.userId
if (userId != null) {

View File

@ -36,6 +36,8 @@ class SessionManager @Inject constructor(
private val _activeAccountId = MutableStateFlow(accountsPrefs.getString(KEY_ACTIVE_ACCOUNT, null))
val activeAccountId: StateFlow<String?> = _activeAccountId.asStateFlow()
private var pendingNewAccount = false
private val sessionCaches = mutableMapOf<String, SharedPreferences>()
private val localPrefs: SharedPreferences = context.getSharedPreferences(
@ -76,10 +78,9 @@ class SessionManager @Inject constructor(
get() = accessToken != null
fun saveSession(accessToken: String, refreshToken: String, userId: String) {
val accountId = _activeAccountId.value ?: userId
if (_activeAccountId.value == null) {
setActiveAccount(accountId)
}
val accountId = if (pendingNewAccount) userId else (_activeAccountId.value ?: userId)
pendingNewAccount = false
setActiveAccount(accountId)
val prefs = getSessionPrefs(accountId)
prefs.edit {
putString(KEY_ACCESS_TOKEN, accessToken)
@ -121,8 +122,7 @@ class SessionManager @Inject constructor(
}
fun prepareNewAccountLogin() {
_activeAccountId.value = null
accountsPrefs.edit { remove(KEY_ACTIVE_ACCOUNT) }
pendingNewAccount = true
}
fun removeAccount(accountId: String) {
@ -198,6 +198,10 @@ class SessionManager @Inject constructor(
return localPrefs.getString("draft_$chatId", null) ?: ""
}
var navStyle: String
get() = localPrefs.getString("nav_style", "bottom_bar") ?: "bottom_bar"
set(value) = localPrefs.edit { putString("nav_style", value) }
companion object {
private const val KEY_ACCESS_TOKEN = "access_token"
private const val KEY_REFRESH_TOKEN = "refresh_token"

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(
@Query("chat_id") chatId: String,
@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>
@POST("v1/chat/private/create")
@ -24,11 +25,26 @@ interface ChatPrivateApi {
@Query("target_user_id") targetUserId: String
): Response<PrivateChatCreateResponseDto>
@POST("v1/chat/private/send")
@POST("v1/chat/private/message/send")
suspend fun sendMessage(
@Body request: PrivateMessageSendRequestDto
): Response<PrivateMessageSendResponseDto>
@POST("v1/chat/private/message/delete")
suspend fun deleteMessage(
@Body request: PrivateMessageDeleteRequestDto
): Response<PrivateMessageDeleteResponseDto>
@PUT("v1/chat/private/message/edit")
suspend fun editMessage(
@Body request: PrivateMessageEditRequestDto
): Response<PrivateMessageEditResponseDto>
@POST("v1/chat/private/message/mark-read")
suspend fun markRead(
@Body request: PrivateChatMarkReadRequestDto
): Response<PrivateChatMarkReadResponseDto>
@HTTP(method = "DELETE", path = "v1/chat/private/delete", hasBody = true)
suspend fun deleteChat(
@Body request: PrivateChatDeleteRequestDto

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
data class PrivateChatDeleteRequestDto(
@SerialName("chat_id") val chatId: String
)
@Serializable
data class PrivateMessageDeleteRequestDto(
@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
@ -39,6 +59,7 @@ data class PrivateChatListDataDto(
data class PrivateChatListItemDto(
@SerialName("chat_id") val chatId: String,
@SerialName("chat_type") val chatType: String,
@SerialName("chat_companion_ids") val chatCompanionIds: List<String>? = null,
@SerialName("chat_data") val chatData: ProfileByUserIdDataDto? = null,
@SerialName("last_message") val lastMessage: MessageItemDto? = null,
@SerialName("created_at") val createdAt: String,
@ -58,6 +79,7 @@ data class MessageItemDto(
@SerialName("is_viewed") val isViewed: Boolean,
@SerialName("viewed_at") val viewedAt: String? = null,
@SerialName("created_at") val createdAt: String,
@SerialName("is_edited") val isEdited: Boolean = false,
@SerialName("updated_at") val updatedAt: String? = null
)
@ -65,7 +87,8 @@ data class MessageItemDto(
data class MessageForwardDto(
@SerialName("forward_type") val forwardType: String? = null,
@SerialName("forward_sender_id") val forwardSenderId: String? = null,
@SerialName("forward_message_id") val forwardMessageId: Int? = null
@SerialName("forward_message_id") val forwardMessageId: Int? = null,
@SerialName("forward_chat_data") val forwardChatData: JsonObject? = null
)
@Serializable
@ -107,4 +130,44 @@ data class PrivateMessageSendDataDto(
@SerialName("created_at") val createdAt: String
)
@Serializable
data class PrivateMessageDeleteResponseDto(
@SerialName("status") val status: String,
@SerialName("data") val data: PrivateMessageDeleteDataDto
)
@Serializable
data class PrivateMessageDeleteDataDto(
@SerialName("message_id") val messageId: Int,
@SerialName("chat_id") val chatId: String,
@SerialName("deleted_at") val deletedAt: String,
@SerialName("delete_for_all") val deleteForAll: Boolean
)
@Serializable
data class PrivateMessageEditResponseDto(
@SerialName("status") val status: String,
@SerialName("data") val data: PrivateMessageEditDataDto
)
@Serializable
data class PrivateMessageEditDataDto(
@SerialName("message_id") val messageId: Int,
@SerialName("chat_id") val chatId: String,
@SerialName("content") val content: String,
@SerialName("updated_at") val updatedAt: String
)
@Serializable
data class PrivateChatMarkReadResponseDto(
@SerialName("status") val status: String,
@SerialName("data") val data: PrivateChatMarkReadDataDto
)
@Serializable
data class PrivateChatMarkReadDataDto(
@SerialName("chat_id") val chatId: String,
@SerialName("marked_count") val markedCount: Int
)
// endregion

View File

@ -48,7 +48,8 @@ data class ContactInfoDto(
@SerialName("custom_name") val customName: String? = null,
@SerialName("friend_code") val friendCode: Boolean = false,
@SerialName("created_at") val createdAt: String,
@SerialName("last_seen_at") val lastSeenAt: String? = null
@SerialName("last_seen_at") val lastSeenAt: String? = null,
@SerialName("avatars") val avatars: AvatarsBlockDto? = null
)
@Serializable
@ -104,7 +105,8 @@ data class BlacklistInfoDto(
@SerialName("user_id") val userId: String,
@SerialName("login") val login: String? = null,
@SerialName("full_name") val fullName: String? = null,
@SerialName("created_at") val createdAt: String
@SerialName("created_at") val createdAt: String,
@SerialName("avatars") val avatars: AvatarsBlockDto? = null
)
@Serializable

View File

@ -21,12 +21,9 @@ data class ProfilePermissionsRequestDto(
@SerialName("last_seen_visibility") val lastSeenVisibility: Int? = null,
@SerialName("show_bio_to_non_contacts") val showBioToNonContacts: Boolean? = null,
@SerialName("show_stories_to_non_contacts") val showStoriesToNonContacts: Boolean? = null,
@SerialName("allow_server_chats") val allowServerChats: Boolean? = null,
@SerialName("public_invite_permission") val publicInvitePermission: Int? = null,
@SerialName("group_invite_permission") val groupInvitePermission: Int? = null,
@SerialName("call_permission") val callPermission: Int? = null,
@SerialName("force_auto_delete_messages_in_private") val forceAutoDeleteMessagesInPrivate: Boolean? = null,
@SerialName("max_message_auto_delete_seconds") val maxMessageAutoDeleteSeconds: Int? = null,
@SerialName("auto_delete_after_days") val autoDeleteAfterDays: Int? = null
)
@ -46,7 +43,8 @@ data class ProfileDataDto(
@SerialName("login") val login: String,
@SerialName("full_name") val fullName: String? = null,
@SerialName("bio") val bio: String? = null,
@SerialName("is_verified") val isVerified: Boolean? = false,
@SerialName("verification") val verification: VerificationItemDto? = null,
@SerialName("partner_verifications") val partnerVerifications: List<VerificationItemDto> = emptyList(),
@SerialName("rating") val rating: RatingDataDto,
@SerialName("balances") val balances: List<WalletBalanceDto>,
@SerialName("created_at") val createdAt: String,
@ -67,10 +65,11 @@ data class ProfileByUserIdDataDto(
@SerialName("full_name") val fullName: String? = null,
@SerialName("custom_name") val customName: String? = null,
@SerialName("bio") val bio: String? = null,
@SerialName("is_verified") val isVerified: Boolean? = false,
@SerialName("verification") val verification: VerificationItemDto? = null,
@SerialName("partner_verifications") val partnerVerifications: List<VerificationItemDto> = emptyList(),
@SerialName("is_system") val isSystem: Boolean? = false,
@SerialName("rating") val rating: RatingDataDto,
@SerialName("last_seen") val lastSeen: Int? = null,
@SerialName("last_seen_at") val lastSeenAt: String? = null,
@SerialName("created_at") val createdAt: String,
@SerialName("avatars") val avatars: AvatarsBlockDto? = null,
@SerialName("permissions") val permissions: PermissionsResponseDto,
@ -127,32 +126,36 @@ data class RelationshipStatusDto(
@SerialName("is_current_user_in_blacklist_of_target") val isCurrentInBlacklist: Boolean
)
@Serializable
data class VerificationItemDto(
@SerialName("type") val type: String,
@SerialName("reason") val reason: String? = null,
@SerialName("issuer_id") val issuerId: String,
@SerialName("issuer_name") val issuerName: String? = null,
@SerialName("issued_at") val issuedAt: String? = null,
@SerialName("expires_at") val expiresAt: String? = null
)
@Serializable
data class MyProfilePermissionsDto(
@SerialName("is_searchable") val isSearchable: Boolean,
@SerialName("allow_message_forwarding") val allowMessageForwarding: Boolean,
@SerialName("allow_messages_from_non_contacts") val allowMessagesFromNonContacts: Boolean,
@SerialName("show_profile_photo_to_non_contacts") val showProfilePhotoToNonContacts: Boolean,
@SerialName("last_seen_visibility") val lastSeenVisibility: Int,
@SerialName("show_bio_to_non_contacts") val showBioToNonContacts: Boolean,
@SerialName("show_stories_to_non_contacts") val showStoriesToNonContacts: Boolean,
@SerialName("allow_server_chats") val allowServerChats: Boolean,
@SerialName("public_invite_permission") val publicInvitePermission: Int,
@SerialName("group_invite_permission") val groupInvitePermission: Int,
@SerialName("call_permission") val callPermission: Int,
@SerialName("force_auto_delete_messages_in_private") val forceAutoDeleteMessagesInPrivate: Boolean,
@SerialName("max_message_auto_delete_seconds") val maxMessageAutoDeleteSeconds: Int? = null,
@SerialName("is_searchable") val isSearchable: Boolean = true,
@SerialName("allow_message_forwarding") val allowMessageForwarding: Boolean = true,
@SerialName("allow_messages_from_non_contacts") val allowMessagesFromNonContacts: Boolean = true,
@SerialName("show_profile_photo_to_non_contacts") val showProfilePhotoToNonContacts: Boolean = true,
@SerialName("last_seen_visibility") val lastSeenVisibility: Int = 0,
@SerialName("show_bio_to_non_contacts") val showBioToNonContacts: Boolean = true,
@SerialName("show_stories_to_non_contacts") val showStoriesToNonContacts: Boolean = true,
@SerialName("public_invite_permission") val publicInvitePermission: Int = 0,
@SerialName("group_invite_permission") val groupInvitePermission: Int = 0,
@SerialName("call_permission") val callPermission: Int = 0,
@SerialName("auto_delete_after_days") val autoDeleteAfterDays: Int? = null
)
@Serializable
data class UserProfilePermissionsDto(
@SerialName("is_searchable") val isSearchable: Boolean? = null,
@SerialName("allow_message_forwarding") val allowMessageForwarding: Boolean,
@SerialName("allow_messages_from_non_contacts") val allowMessagesFromNonContacts: Boolean,
@SerialName("allow_server_chats") val allowServerChats: Boolean,
@SerialName("force_auto_delete_messages_in_private") val forceAutoDeleteMessagesInPrivate: Boolean,
@SerialName("max_message_auto_delete_seconds") val maxMessageAutoDeleteSeconds: Int? = null
@SerialName("allow_message_forwarding") val allowMessageForwarding: Boolean = true,
@SerialName("allow_messages_from_non_contacts") val allowMessagesFromNonContacts: Boolean = true
)
// endregion

View File

@ -12,11 +12,12 @@ class AuthInterceptor @Inject constructor(
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val token = sessionManager.accessToken
val isRefreshRequest = request.url.encodedPath.contains("token/refresh")
val newRequest = request.newBuilder()
.header("User-Agent", BuildConfig.USER_AGENT)
.apply {
if (token != null) {
if (token != null && !isRefreshRequest) {
header("Authorization", "Bearer $token")
}
}

View File

@ -17,6 +17,12 @@ class TokenAuthenticator @Inject constructor(
) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
// Don't retry if we already tried refreshing
if (responseCount(response) >= 2) {
sessionManager.clearSession()
return null
}
val accessToken = sessionManager.accessToken ?: return null
val refreshToken = sessionManager.refreshToken ?: return null
@ -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 okhttp3.OkHttpClient
import org.json.JSONObject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.yobble.messenger.BuildConfig
import org.yobble.messenger.data.local.SessionManager
import org.yobble.messenger.data.remote.api.AuthApi
import org.yobble.messenger.data.remote.dto.TokenRefreshRequestDto
import javax.inject.Inject
import javax.inject.Provider
import javax.inject.Singleton
sealed class SocketEvent {
@ -21,9 +27,12 @@ sealed class SocketEvent {
@Singleton
class SocketManager @Inject constructor(
private val sessionManager: SessionManager
private val sessionManager: SessionManager,
private val authApiProvider: Provider<AuthApi>
) {
private var socket: Socket? = null
private var isRefreshing = false
private val scope = CoroutineScope(Dispatchers.IO)
private val _events = MutableSharedFlow<SocketEvent>(extraBufferCapacity = 64)
val events: SharedFlow<SocketEvent> = _events.asSharedFlow()
@ -103,6 +112,10 @@ class SocketManager @Inject constructor(
if (error is Exception) {
Log.e(TAG, "Error details:", error)
}
val errorStr = error.toString()
if (errorStr.contains("IP address mismatch") || errorStr.contains("authentication_failed")) {
refreshTokenAndReconnect()
}
}
// Server confirms connection
@ -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() {
Log.d(TAG, "Disconnecting socket")
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
) : ChatRepository {
private val chatDataMap = mutableMapOf<String, ProfileByUserIdDataDto>()
fun getChatData(chatId: String): ProfileByUserIdDataDto? = chatDataMap[chatId]
override suspend fun getChatList(offset: Int, limit: Int): NetworkResult<PrivateChatListResponseDto> {
val result = safeApiCall(json) { chatApi.getChatList(offset, limit) }
if (result is NetworkResult.Success && offset == 0) {
chatCacheManager.saveChatList(result.data.data.items)
if (result is NetworkResult.Success) {
result.data.data.items.forEach { chat ->
chat.chatData?.let { chatDataMap[chat.chatId] = it }
}
if (offset == 0) {
chatCacheManager.saveChatList(result.data.data.items)
}
}
return result
}
@ -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) {
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.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Qualifier
import kotlinx.serialization.json.Json
import okhttp3.Cache
import okhttp3.MediaType.Companion.toMediaType
@ -12,6 +13,7 @@ import okhttp3.logging.HttpLoggingInterceptor
import org.yobble.messenger.BuildConfig
import org.yobble.messenger.data.local.CacheManager
import org.yobble.messenger.data.local.SessionManager
import org.yobble.messenger.data.remote.api.AchievementApi
import org.yobble.messenger.data.remote.api.AuthApi
import org.yobble.messenger.data.remote.api.ChatPrivateApi
import org.yobble.messenger.data.remote.api.FeedApi
@ -25,6 +27,10 @@ import retrofit2.Retrofit
import java.io.File
import javax.inject.Singleton
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class CoilClient
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@ -65,6 +71,24 @@ object NetworkModule {
return builder.build()
}
@Provides
@Singleton
@CoilClient
fun provideCoilOkHttpClient(
authInterceptor: AuthInterceptor,
tokenAuthenticator: TokenAuthenticator
): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.addInterceptor(
HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.HEADERS
}
)
.authenticator(tokenAuthenticator)
.build()
}
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient, json: Json): Retrofit {
@ -111,4 +135,10 @@ object NetworkModule {
fun provideFeedApi(retrofit: Retrofit): FeedApi {
return retrofit.create(FeedApi::class.java)
}
@Provides
@Singleton
fun provideAchievementApi(retrofit: Retrofit): AchievementApi {
return retrofit.create(AchievementApi::class.java)
}
}

View File

@ -4,11 +4,13 @@ import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.yobble.messenger.data.repository.AchievementRepositoryImpl
import org.yobble.messenger.data.repository.AuthRepositoryImpl
import org.yobble.messenger.data.repository.ChatRepositoryImpl
import org.yobble.messenger.data.repository.FeedRepositoryImpl
import org.yobble.messenger.data.repository.ProfileRepositoryImpl
import org.yobble.messenger.data.repository.UserRepositoryImpl
import org.yobble.messenger.domain.repository.AchievementRepository
import org.yobble.messenger.domain.repository.AuthRepository
import org.yobble.messenger.domain.repository.ChatRepository
import org.yobble.messenger.domain.repository.FeedRepository
@ -39,4 +41,8 @@ abstract class RepositoryModule {
@Binds
@Singleton
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 createChat(targetUserId: String): NetworkResult<PrivateChatCreateResponseDto>
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(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface,
navigationIconContentColor = MaterialTheme.colorScheme.onSurface
)
)
}

View File

@ -1,5 +1,6 @@
package org.yobble.messenger.presentation.auth.login
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
@ -7,20 +8,24 @@ import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChatBubble
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.collectLatest
@ -59,19 +64,11 @@ fun LoginScreen(
) {
Spacer(modifier = Modifier.height(80.dp))
Icon(
imageVector = Icons.Default.ChatBubble,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Yobble",
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onBackground
fontSize = 36.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp))
@ -82,9 +79,9 @@ fun LoginScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(40.dp))
Spacer(modifier = Modifier.height(48.dp))
OutlinedTextField(
TextField(
value = uiState.login,
onValueChange = viewModel::onLoginChange,
label = { Text("Login") },
@ -99,12 +96,13 @@ fun LoginScreen(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
),
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,
onValueChange = viewModel::onPasswordChange,
label = { Text("Password") },
@ -126,42 +124,64 @@ fun LoginScreen(
IconButton(onClick = viewModel::togglePasswordVisibility) {
Icon(
imageVector = if (uiState.isPasswordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = if (uiState.isPasswordVisible) "Hide password" else "Show password"
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
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(
onClick = viewModel::login,
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
),
.height(50.dp),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
contentPadding = PaddingValues(0.dp),
enabled = !uiState.isLoading
) {
if (uiState.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Text(
"Log In",
style = MaterialTheme.typography.labelLarge
)
Box(
modifier = Modifier
.fillMaxSize()
.background(
if (!uiState.isLoading)
Brush.linearGradient(listOf(primary, primaryContainer))
else
Brush.linearGradient(
listOf(
primary.copy(alpha = 0.5f),
primaryContainer.copy(alpha = 0.5f)
)
)
),
contentAlignment = Alignment.Center
) {
if (uiState.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Text(
"Log In",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onPrimary,
fontWeight = FontWeight.Bold
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(12.dp))
TextButton(onClick = viewModel::requestLoginCode) {
Text(
@ -186,10 +206,21 @@ fun LoginScreen(
Text(
"Register",
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
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
@ -15,7 +16,10 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
@ -52,12 +56,17 @@ fun RegisterScreen(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { Text("Register") },
title = { Text("Create Account", fontWeight = FontWeight.Bold) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface,
navigationIconContentColor = MaterialTheme.colorScheme.onSurface
)
)
}
) { padding ->
@ -71,14 +80,6 @@ fun RegisterScreen(
) {
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Create your account",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Enter your details to get started",
style = MaterialTheme.typography.bodyMedium,
@ -87,7 +88,7 @@ fun RegisterScreen(
Spacer(modifier = Modifier.height(32.dp))
OutlinedTextField(
TextField(
value = uiState.login,
onValueChange = viewModel::onLoginChange,
label = { Text("Login") },
@ -102,12 +103,13 @@ fun RegisterScreen(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
),
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,
onValueChange = viewModel::onPasswordChange,
label = { Text("Password") },
@ -126,17 +128,19 @@ fun RegisterScreen(
IconButton(onClick = viewModel::togglePasswordVisibility) {
Icon(
imageVector = if (uiState.isPasswordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = if (uiState.isPasswordVisible) "Hide password" else "Show password"
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
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,
onValueChange = viewModel::onConfirmPasswordChange,
label = { Text("Confirm Password") },
@ -155,17 +159,19 @@ fun RegisterScreen(
IconButton(onClick = viewModel::toggleConfirmPasswordVisibility) {
Icon(
imageVector = if (uiState.isConfirmPasswordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = if (uiState.isConfirmPasswordVisible) "Hide password" else "Show password"
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
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,
onValueChange = viewModel::onInviteChange,
label = { Text("Invite Code (optional)") },
@ -181,33 +187,54 @@ fun RegisterScreen(
}
),
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(
onClick = viewModel::register,
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
),
.height(50.dp),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
contentPadding = PaddingValues(0.dp),
enabled = !uiState.isLoading
) {
if (uiState.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Text(
"Register",
style = MaterialTheme.typography.labelLarge
)
Box(
modifier = Modifier
.fillMaxSize()
.background(
if (!uiState.isLoading)
Brush.linearGradient(listOf(primary, primaryContainer))
else
Brush.linearGradient(
listOf(
primary.copy(alpha = 0.5f),
primaryContainer.copy(alpha = 0.5f)
)
)
),
contentAlignment = Alignment.Center
) {
if (uiState.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Text(
"Create Account",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onPrimary,
fontWeight = FontWeight.Bold
)
}
}
}
@ -226,10 +253,21 @@ fun RegisterScreen(
Text(
"Log In",
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
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
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.automirrored.filled.ArrowBack
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.outlined.EmojiEmotions
import androidx.compose.material.icons.filled.Verified
import androidx.compose.ui.viewinterop.AndroidView
import androidx.emoji2.emojipicker.EmojiPickerView
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
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.formatUtcToLocalTime
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun ChatScreen(
onNavigateBack: () -> Unit,
@ -47,10 +59,17 @@ fun ChatScreen(
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
var selectedMessage by remember { mutableStateOf<MessageItemDto?>(null) }
var editingMessage by remember { mutableStateOf<MessageItemDto?>(null) }
var deletingMessage by remember { mutableStateOf<MessageItemDto?>(null) }
var deleteForAll by remember { mutableStateOf(false) }
var showEmojiPicker by remember { mutableStateOf(false) }
val clipboardManager = LocalClipboardManager.current
val keyboardController = LocalSoftwareKeyboardController.current
val inputFocusRequester = remember { androidx.compose.ui.focus.FocusRequester() }
val listState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
// Save scroll position when leaving the chat
DisposableEffect(Unit) {
onDispose {
val messages = viewModel.uiState.value.messages
@ -58,22 +77,16 @@ fun ChatScreen(
val firstVisibleIndex = listState.firstVisibleItemIndex
val reversed = messages.asReversed()
val messageId = reversed.getOrNull(firstVisibleIndex)?.messageId
if (messageId != null) {
viewModel.saveScrollPosition(messageId)
}
if (messageId != null) viewModel.saveScrollPosition(messageId)
}
}
}
// Scroll to saved position after initial load
LaunchedEffect(uiState.scrollToMessageId) {
val targetId = uiState.scrollToMessageId ?: return@LaunchedEffect
val targetInt = targetId.toIntOrNull() ?: return@LaunchedEffect
val reversed = uiState.messages.asReversed()
val index = reversed.indexOfFirst { it.messageId == targetInt }
if (index >= 0) {
listState.scrollToItem(index)
}
val index = uiState.messages.asReversed().indexOfFirst { it.messageId == targetInt }
if (index >= 0) listState.scrollToItem(index)
viewModel.clearScrollTarget()
}
@ -81,130 +94,49 @@ fun ChatScreen(
viewModel.events.collectLatest { event ->
when (event) {
is ChatEvent.ShowError -> snackbarHostState.showSnackbar(event.message)
is ChatEvent.ScrollToBottom -> {}
}
}
}
// Show scroll-to-bottom button when scrolled up more than 15 messages
val showScrollToBottom by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 15
val messageCount = uiState.messages.size
LaunchedEffect(messageCount) {
if (messageCount > 0 && listState.firstVisibleItemIndex < 3) {
listState.animateScrollToItem(0)
}
}
// Load more when scrolled near the top (high indices in reversed layout)
val shouldLoadMore by remember {
val showScrollToBottom by remember {
derivedStateOf { listState.firstVisibleItemIndex > 15 }
}
val hasMore = uiState.hasMore
val isLoading = uiState.isLoading
val shouldLoadMore by remember(hasMore, isLoading) {
derivedStateOf {
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
val totalItems = listState.layoutInfo.totalItemsCount
lastVisible >= totalItems - 3 && uiState.hasMore && !uiState.isLoading
lastVisible >= totalItems - 3 && hasMore && !isLoading
}
}
LaunchedEffect(shouldLoadMore) {
if (shouldLoadMore) viewModel.loadMore()
}
Scaffold(
modifier = Modifier.imePadding(),
contentWindowInsets = WindowInsets(0),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
CenterAlignedTopAppBar(
title = {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.clickable {
uiState.otherUserId?.let { onNavigateToProfile(it) }
}
) {
Row(verticalAlignment = Alignment.CenterVertically) {
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)
) {
// Layout: Column with statusBarsPadding on top, imePadding on input at bottom
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.statusBarsPadding()
.navigationBarsPadding()
.imePadding()
) {
// Top bar — fixed, not affected by keyboard
ChatTopBar(uiState, onNavigateBack, onNavigateToProfile)
// Messages — takes remaining space, shrinks when keyboard opens
Box(modifier = Modifier.weight(1f)) {
if (uiState.isLoading && uiState.messages.isEmpty()) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary,
@ -223,171 +155,352 @@ fun ChatScreen(
items(uiState.messages.asReversed(), key = { it.messageId }) { message ->
MessageBubble(
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()) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary
)
Box(Modifier.fillMaxWidth().padding(8.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.primary)
}
}
}
}
}
AnimatedVisibility(
visible = showScrollToBottom,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp)
) {
if (showScrollToBottom) {
SmallFloatingActionButton(
onClick = {
coroutineScope.launch {
listState.animateScrollToItem(0)
}
},
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.primary
onClick = { coroutineScope.launch { listState.animateScrollToItem(0) } },
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.primary,
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp)
) {
Icon(
Icons.Default.KeyboardArrowDown,
contentDescription = "Scroll to bottom"
)
Icon(Icons.Default.KeyboardArrowDown, contentDescription = "Scroll to bottom")
}
}
SnackbarHost(snackbarHostState, modifier = Modifier.align(Alignment.BottomCenter))
}
// Input bar
if (uiState.canSendMessage) {
MessageInputBar(
text = uiState.messageText,
onTextChange = viewModel::onMessageTextChange,
onSend = viewModel::sendMessage,
isSending = uiState.isSending,
showEmojiPicker = showEmojiPicker,
focusRequester = inputFocusRequester,
onToggleEmoji = {
if (showEmojiPicker) {
showEmojiPicker = false
inputFocusRequester.requestFocus()
keyboardController?.show()
} else {
keyboardController?.hide()
showEmojiPicker = true
}
}
)
} else {
Text(
text = "You can't send messages to this user",
modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface)
.padding(16.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
// Emoji picker panel
if (showEmojiPicker) {
AndroidView(
factory = { context ->
EmojiPickerView(context).apply {
emojiGridColumns = 8
setOnEmojiPickedListener { emoji ->
val current = viewModel.uiState.value.messageText
viewModel.onMessageTextChange(current + emoji.emoji)
}
}
},
modifier = Modifier
.fillMaxWidth()
.height(280.dp)
.background(MaterialTheme.colorScheme.surface)
)
}
}
// Dialogs
MessageActionsSheet(selectedMessage, uiState.currentUserId, clipboardManager,
onDismiss = { selectedMessage = null },
onEdit = { editingMessage = it; viewModel.onMessageTextChange(it.content ?: ""); selectedMessage = null },
onDelete = { deletingMessage = it; deleteForAll = false; selectedMessage = null }
)
DeleteMessageDialog(deletingMessage, uiState.currentUserId, deleteForAll,
onDeleteForAllChange = { deleteForAll = it },
onConfirm = { viewModel.deleteMessage(it.messageId, deleteForAll); deletingMessage = null },
onDismiss = { deletingMessage = null }
)
EditMessageDialog(editingMessage, uiState.messageText, viewModel::onMessageTextChange,
onConfirm = { viewModel.editMessage(it.messageId, uiState.messageText.trim()); editingMessage = null; viewModel.onMessageTextChange("") },
onDismiss = { editingMessage = null; viewModel.onMessageTextChange("") }
)
}
// region TopBar
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ChatTopBar(uiState: ChatUiState, onNavigateBack: () -> Unit, onNavigateToProfile: (String) -> Unit) {
CenterAlignedTopAppBar(
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable(enabled = uiState.otherUserId != null) {
uiState.otherUserId?.let { onNavigateToProfile(it) }
}
) {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(uiState.chatTitle, fontWeight = FontWeight.Bold, fontSize = 17.sp, maxLines = 1)
if (uiState.isVerified) {
Spacer(Modifier.width(4.dp))
Icon(Icons.Default.Verified, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
}
}
val lastSeenText = formatLastSeen(uiState.otherLastSeen)
if (lastSeenText.isNotEmpty()) {
Text(lastSeenText, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
}
}
if (uiState.otherUserId != null) {
Spacer(Modifier.width(8.dp))
UserAvatar(uiState.otherUserId, uiState.otherAvatarFileId, uiState.chatTitle, 32.dp, 13.sp)
}
}
},
navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } },
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface,
navigationIconContentColor = MaterialTheme.colorScheme.onSurface
)
)
}
// endregion
// 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
private fun MessageBubble(
message: MessageItemDto,
isOutgoing: Boolean
) {
val bubbleColor = if (isOutgoing)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.surfaceVariant
private fun DeleteMessageDialog(msg: MessageItemDto?, currentUserId: String, deleteForAll: Boolean, onDeleteForAllChange: (Boolean) -> Unit, onConfirm: (MessageItemDto) -> Unit, onDismiss: () -> Unit) {
if (msg == null) return
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Delete message?") },
text = {
if (msg.senderId == currentUserId) {
Row(Modifier.fillMaxWidth().clickable { onDeleteForAllChange(!deleteForAll) }, verticalAlignment = Alignment.CenterVertically) {
Checkbox(deleteForAll, onDeleteForAllChange)
Spacer(Modifier.width(4.dp))
Text("Delete for everyone")
}
}
},
confirmButton = { TextButton(onClick = { onConfirm(msg) }) { Text("Delete", color = MaterialTheme.colorScheme.error) } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }
)
}
val textColor = if (isOutgoing)
MaterialTheme.colorScheme.onPrimary
else
MaterialTheme.colorScheme.onSurface
@Composable
private fun EditMessageDialog(msg: MessageItemDto?, text: String, onTextChange: (String) -> Unit, onConfirm: (MessageItemDto) -> Unit, onDismiss: () -> Unit) {
if (msg == null) return
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Edit message") },
text = {
TextField(text, onTextChange, Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp),
colors = TextFieldDefaults.colors(focusedContainerColor = MaterialTheme.colorScheme.surface, unfocusedContainerColor = MaterialTheme.colorScheme.surface, focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent))
},
confirmButton = { TextButton(onClick = { onConfirm(msg) }, enabled = text.isNotBlank()) { Text("Save") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }
)
}
val timeColor = if (isOutgoing)
MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f)
else
MaterialTheme.colorScheme.onSurfaceVariant
@Composable
private fun ActionRow(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, color: Color = MaterialTheme.colorScheme.onSurface, onClick: () -> Unit) {
Row(Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 24.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(icon, null, tint = color, modifier = Modifier.size(22.dp))
Spacer(Modifier.width(16.dp))
Text(label, style = MaterialTheme.typography.bodyLarge, color = color)
}
}
val alignment = if (isOutgoing) Arrangement.End else Arrangement.Start
val shape = if (isOutgoing)
RoundedCornerShape(16.dp, 16.dp, 4.dp, 16.dp)
else
RoundedCornerShape(16.dp, 16.dp, 16.dp, 4.dp)
// endregion
// 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)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = alignment
) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start) {
Box(
modifier = Modifier
.widthIn(max = 280.dp)
.clip(shape)
.background(bubbleColor)
Modifier.widthIn(max = 280.dp).clip(shape)
.combinedClickable(onClick = {}, onLongClick = onLongClick)
.then(if (isOutgoing) Modifier.background(Brush.linearGradient(listOf(primary, primaryContainer))) else Modifier.background(MaterialTheme.colorScheme.surfaceVariant))
.padding(horizontal = 12.dp, vertical = 8.dp)
) {
Column {
if (!message.content.isNullOrBlank()) {
Text(
text = message.content,
color = textColor,
fontSize = 15.sp
)
}
Spacer(modifier = Modifier.height(2.dp))
Text(
text = time,
color = timeColor,
fontSize = 11.sp,
modifier = Modifier.align(Alignment.End)
)
if (!message.content.isNullOrBlank()) Text(message.content, color = textColor, fontSize = 15.sp)
Spacer(Modifier.height(2.dp))
Text("${if (message.isEdited) "edited " else ""}$time", color = timeColor, fontSize = 11.sp, modifier = Modifier.align(Alignment.End))
}
}
}
}
// endregion
// region InputBar
@Composable
private fun MessageInputBar(
text: String,
onTextChange: (String) -> Unit,
onSend: () -> Unit,
isSending: Boolean
isSending: Boolean,
showEmojiPicker: Boolean = false,
onToggleEmoji: () -> Unit = {},
focusRequester: androidx.compose.ui.focus.FocusRequester? = null,
modifier: Modifier = Modifier
) {
Surface(
shadowElevation = 8.dp,
color = MaterialTheme.colorScheme.surface
val canSend = text.isNotBlank() && !isSending
Row(
modifier = modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp)
.navigationBarsPadding(),
verticalAlignment = Alignment.CenterVertically
) {
TextField(
value = text,
onValueChange = onTextChange,
modifier = Modifier.weight(1f),
placeholder = { Text("Message") },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { onSend() }),
maxLines = 4,
colors = TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
),
shape = RoundedCornerShape(24.dp)
// Emoji toggle
IconButton(onClick = onToggleEmoji, modifier = Modifier.size(44.dp)) {
Icon(
if (showEmojiPicker) Icons.Default.Keyboard else Icons.Outlined.EmojiEmotions,
contentDescription = "Emoji",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
IconButton(
onClick = onSend,
enabled = text.isNotBlank() && !isSending,
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(
if (text.isNotBlank() && !isSending)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.surfaceVariant
)
) {
}
// Input field with attach inside
TextField(
value = text,
onValueChange = onTextChange,
modifier = Modifier.weight(1f)
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier),
placeholder = {
Text("Message...", color = MaterialTheme.colorScheme.onSurfaceVariant)
},
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { onSend() }),
maxLines = 4,
trailingIcon = {
Icon(
Icons.AutoMirrored.Filled.Send,
contentDescription = "Send",
tint = if (text.isNotBlank() && !isSending)
MaterialTheme.colorScheme.onPrimary
else
MaterialTheme.colorScheme.onSurfaceVariant
Icons.Default.AttachFile,
contentDescription = "Attach",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.size(24.dp)
.clickable { /* TODO: attach */ }
)
}
},
colors = TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
cursorColor = MaterialTheme.colorScheme.primary
),
shape = RoundedCornerShape(24.dp)
)
Spacer(Modifier.width(8.dp))
// Send
IconButton(
onClick = onSend,
enabled = canSend,
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(
if (canSend)
Brush.linearGradient(
listOf(
MaterialTheme.colorScheme.primary,
MaterialTheme.colorScheme.primaryContainer
)
)
else
Brush.linearGradient(
listOf(
MaterialTheme.colorScheme.surfaceVariant,
MaterialTheme.colorScheme.surfaceVariant
)
)
)
) {
Icon(
Icons.AutoMirrored.Filled.Send,
contentDescription = "Send",
tint = if (canSend)
MaterialTheme.colorScheme.onPrimary
else
MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
}
}
}
// endregion

View File

@ -17,6 +17,7 @@ import kotlinx.serialization.json.Json
import org.yobble.messenger.data.local.SessionManager
import org.yobble.messenger.data.remote.NetworkResult
import org.yobble.messenger.data.remote.dto.MessageItemDto
import org.yobble.messenger.data.remote.dto.ProfileByUserIdDataDto
import org.yobble.messenger.data.remote.socket.SocketEvent
import org.yobble.messenger.data.remote.socket.SocketManager
import org.yobble.messenger.data.repository.ChatRepositoryImpl
@ -36,12 +37,13 @@ data class ChatUiState(
val currentUserId: String = "",
val otherUserId: String? = null,
val otherAvatarFileId: String? = null,
val otherLastSeen: Int? = null,
val otherLastSeen: String? = null,
val scrollToMessageId: String? = null
)
sealed class ChatEvent {
data class ShowError(val message: String) : ChatEvent()
data object ScrollToBottom : ChatEvent()
}
@HiltViewModel
@ -68,6 +70,8 @@ class ChatViewModel @Inject constructor(
val events: SharedFlow<ChatEvent> = _events.asSharedFlow()
private val savedMessageId = sessionManager.getLastReadMessageId(chatId)
private val otherUserFromChatList: ProfileByUserIdDataDto? =
(chatRepository as? ChatRepositoryImpl)?.getChatData(chatId)
init {
loadCachedThenNetwork()
@ -90,12 +94,7 @@ class ChatViewModel @Inject constructor(
if (current.none { it.messageId == message.messageId }) {
val updated = current + message
_uiState.update { it.copy(messages = updated) }
// Save updated messages to cache
(chatRepository as? ChatRepositoryImpl)?.let { repo ->
viewModelScope.launch {
repo.getCachedChatMessages(chatId) // trigger save via reversed list
}
}
_events.emit(ChatEvent.ScrollToBottom)
}
} catch (e: Exception) {
Log.w("ChatViewModel", "Failed to parse socket message, reloading", e)
@ -145,6 +144,8 @@ class ChatViewModel @Inject constructor(
is NetworkResult.Success -> {
val items = result.data.data.items.reversed()
applyMessages(items, hasMore = result.data.data.hasMore, fromCache = false)
// Auto mark-read
markAllRead()
}
is NetworkResult.Error -> {
_uiState.update { it.copy(isLoading = false) }
@ -165,20 +166,29 @@ class ChatViewModel @Inject constructor(
private fun applyMessages(items: List<MessageItemDto>, hasMore: Boolean, fromCache: Boolean) {
val otherMessage = items.firstOrNull { it.senderId != _uiState.value.currentUserId }
val otherUser = otherMessage?.senderData
// Fall back to chat list data if no other user message found
val cachedChatData = if (otherUser == null) otherUserFromChatList else null
val title = otherUser?.customName
?: otherUser?.fullName
?: otherUser?.login
?: cachedChatData?.customName
?: cachedChatData?.fullName
?: cachedChatData?.login
?: _uiState.value.chatTitle
val otherUserId = otherMessage?.senderId ?: cachedChatData?.userId ?: _uiState.value.otherUserId
val scrollTarget = if (_uiState.value.messages.isEmpty() && !fromCache) savedMessageId else null
_uiState.update {
it.copy(
messages = items,
chatTitle = title,
otherUserId = otherMessage?.senderId,
otherAvatarFileId = otherUser?.avatars?.current?.fileId,
otherLastSeen = otherUser?.lastSeen,
isVerified = otherUser?.isVerified == true,
canSendMessage = otherUser?.permissions?.youCanSendMessage != false,
otherUserId = otherUserId,
otherAvatarFileId = otherUser?.avatars?.current?.fileId ?: cachedChatData?.avatars?.current?.fileId ?: it.otherAvatarFileId,
otherLastSeen = otherUser?.lastSeenAt ?: cachedChatData?.lastSeenAt ?: it.otherLastSeen,
isVerified = otherUser?.verification != null || cachedChatData?.verification != null,
canSendMessage = otherUser?.permissions?.youCanSendMessage ?: cachedChatData?.permissions?.youCanSendMessage ?: true,
hasMore = hasMore,
isLoading = if (fromCache) true else false,
scrollToMessageId = scrollTarget
@ -230,6 +240,57 @@ class ChatViewModel @Inject constructor(
sessionManager.saveDraft(chatId, _uiState.value.messageText)
}
private fun markAllRead() {
val chatId = _uiState.value.chatId
if (chatId.isBlank()) return
viewModelScope.launch {
chatRepository.markRead(chatId, markAll = true)
}
}
fun deleteMessage(messageId: Int, deleteForAll: Boolean = false) {
val chatId = _uiState.value.chatId
viewModelScope.launch {
when (chatRepository.deleteMessage(chatId, messageId, deleteForAll)) {
is NetworkResult.Success -> {
_uiState.update { state ->
state.copy(messages = state.messages.filter { it.messageId != messageId })
}
}
is NetworkResult.Error -> {
_events.emit(ChatEvent.ShowError("Failed to delete message"))
}
is NetworkResult.Exception -> {
_events.emit(ChatEvent.ShowError("Connection error"))
}
}
}
}
fun editMessage(messageId: Int, newContent: String) {
val chatId = _uiState.value.chatId
if (newContent.isBlank()) return
viewModelScope.launch {
when (val result = chatRepository.editMessage(chatId, messageId, newContent)) {
is NetworkResult.Success -> {
_uiState.update { state ->
state.copy(messages = state.messages.map {
if (it.messageId == messageId)
it.copy(content = result.data.data.content, isEdited = true, updatedAt = result.data.data.updatedAt)
else it
})
}
}
is NetworkResult.Error -> {
_events.emit(ChatEvent.ShowError("Failed to edit message"))
}
is NetworkResult.Exception -> {
_events.emit(ChatEvent.ShowError("Connection error"))
}
}
}
}
fun onMessageTextChange(text: String) {
_uiState.update { it.copy(messageText = text) }
}
@ -245,6 +306,7 @@ class ChatViewModel @Inject constructor(
is NetworkResult.Success -> {
sessionManager.saveDraft(chatId, "")
loadMessages()
_events.emit(ChatEvent.ScrollToBottom)
}
is NetworkResult.Error -> {
_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.sp
import coil.compose.SubcomposeAsyncImage
import coil.compose.SubcomposeAsyncImageContent
import coil.compose.AsyncImagePainter
import coil.request.CachePolicy
import coil.request.ImageRequest
import org.yobble.messenger.BuildConfig
@ -35,20 +38,24 @@ fun UserAvatar(
SubcomposeAsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(avatarUrl)
.crossfade(true)
.memoryCacheKey(avatarUrl)
.diskCacheKey(avatarUrl)
.memoryCachePolicy(CachePolicy.ENABLED)
.diskCachePolicy(CachePolicy.ENABLED)
.crossfade(false)
.build(),
contentDescription = "Avatar",
modifier = Modifier
.size(size)
.clip(CircleShape),
contentScale = ContentScale.Crop,
error = {
InitialsAvatar(displayName, size, fontSize)
},
loading = {
InitialsAvatar(displayName, size, fontSize)
contentScale = ContentScale.Crop
) {
when (painter.state) {
is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent()
is AsyncImagePainter.State.Error -> InitialsAvatar(displayName, size, fontSize)
else -> InitialsAvatar(displayName, size, fontSize)
}
)
}
} else {
InitialsAvatar(displayName, size, fontSize)
}

View File

@ -113,11 +113,13 @@ fun ContactsScreen(
} else {
val listState = rememberLazyListState()
val shouldLoadMore by remember {
val hasMore = uiState.hasMore
val isLoading = uiState.isLoading
val shouldLoadMore by remember(hasMore, isLoading) {
derivedStateOf {
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
val totalItems = listState.layoutInfo.totalItemsCount
lastVisible >= totalItems - 3 && uiState.hasMore && !uiState.isLoading
lastVisible >= totalItems - 3 && hasMore && !isLoading
}
}
LaunchedEffect(shouldLoadMore) {
@ -239,7 +241,7 @@ private fun ContactItem(
) {
UserAvatar(
userId = contact.userId,
fileId = null,
fileId = contact.avatars?.current?.fileId,
displayName = displayName,
size = 48.dp,
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

View File

@ -1,9 +1,15 @@
package org.yobble.messenger.presentation.main
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.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
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.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -39,6 +46,21 @@ import androidx.lifecycle.compose.LifecycleResumeEffect
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
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.contacts.ContactsScreen
import org.yobble.messenger.presentation.settings.SettingsScreen
@ -66,20 +88,35 @@ fun HomeScreen(
onAccountSwitched: () -> Unit,
onAddAccount: () -> Unit,
onLogout: () -> Unit,
viewModel: HomeViewModel = hiltViewModel()
viewModel: HomeViewModel = hiltViewModel(),
accountsViewModel: AccountSwitcherViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val accountsState by accountsViewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val tabs = HomeTab.entries
val pagerState = rememberPagerState(pageCount = { tabs.size })
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
var navBarHeightDp by remember { mutableStateOf(0.dp) }
var selectedChatIds by remember { mutableStateOf(setOf<String>()) }
var selectedContactIds by remember { mutableStateOf(setOf<String>()) }
var deleteContactIds by remember { mutableStateOf(setOf<String>()) }
var renameContactId by remember { mutableStateOf<String?>(null) }
var showAccountPopup by remember { mutableStateOf(false) }
// Clear selection when switching tabs
LaunchedEffect(selectedTab) {
@ -112,13 +149,181 @@ fun HomeScreen(
selectedContactIds = emptySet()
}
selectedTab != 0 -> {
coroutineScope.launch {
pagerState.animateScrollToPage(0)
if (useDrawer) {
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(
snackbarHost = {
SnackbarHost(
@ -164,10 +369,10 @@ fun HomeScreen(
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
actionIconContentColor = MaterialTheme.colorScheme.onPrimary
containerColor = MaterialTheme.colorScheme.surfaceVariant,
titleContentColor = MaterialTheme.colorScheme.onSurface,
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
actionIconContentColor = MaterialTheme.colorScheme.onSurface
)
)
} else {
@ -178,15 +383,28 @@ fun HomeScreen(
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 = {
IconButton(onClick = onNavigateToSearch) {
Icon(Icons.Default.Search, contentDescription = "Search")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
actionIconContentColor = MaterialTheme.colorScheme.onPrimary
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface,
actionIconContentColor = MaterialTheme.colorScheme.onSurface
)
)
}
@ -198,7 +416,8 @@ fun HomeScreen(
.fillMaxSize()
.padding(padding)
) {
if (SWIPE_NAVIGATION_ENABLED) {
if (SWIPE_NAVIGATION_ENABLED && !useDrawer) {
// Swipeable tabs with HorizontalPager
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
@ -239,6 +458,8 @@ fun HomeScreen(
viewModel.logout()
onLogout()
},
navStyle = uiState.navStyle,
onToggleNavStyle = viewModel::toggleNavStyle,
bottomPadding = navBarHeightDp
)
}
@ -278,71 +499,168 @@ fun HomeScreen(
viewModel.logout()
onLogout()
},
navStyle = uiState.navStyle,
onToggleNavStyle = viewModel::toggleNavStyle,
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(
modifier = Modifier
.align(Alignment.BottomCenter)
.background(MaterialTheme.colorScheme.background)
.onGloballyPositioned { coordinates ->
val heightPx = coordinates.size.height
navBarHeightDp = with(density) { heightPx.toDp() }
}
.padding(horizontal = 16.dp, vertical = 12.dp)
.clip(RoundedCornerShape(24.dp))
.navigationBarsPadding(),
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
tonalElevation = 8.dp
},
containerColor = MaterialTheme.colorScheme.background,
tonalElevation = 0.dp
) {
val activeAccount = uiState.activeAccount
val indicatorColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f)
val indicatorColor = Color.Transparent
tabs.forEachIndexed { index, tab ->
NavigationBarItem(
selected = selectedTab == index,
onClick = {
coroutineScope.launch {
if (SWIPE_NAVIGATION_ENABLED) {
pagerState.animateScrollToPage(index)
} else {
pagerState.scrollToPage(index)
if (tab == HomeTab.SETTINGS) {
NavigationBarItem(
selected = selectedTab == index,
onClick = {
coroutineScope.launch {
if (SWIPE_NAVIGATION_ENABLED) pagerState.animateScrollToPage(index)
else 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 = {
Box(
modifier = Modifier
.size(36.dp)
.then(
if (selectedTab == index)
Modifier.clip(CircleShape).background(indicatorColor)
else Modifier
),
contentAlignment = Alignment.Center
) {
if (tab == HomeTab.SETTINGS && activeAccount != null) {
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
},
icon = {
if (tab.icon != null) Icon(
tab.icon, contentDescription = null,
modifier = Modifier.size(22.dp)
)
},
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
)
)
)
}
}
}
}
}
}
}
ModalNavigationDrawer(
drawerState = drawerState,
gesturesEnabled = useDrawer,
drawerContent = drawerContent,
content = scaffoldContent
)
}
@Composable
@ -368,6 +686,8 @@ private fun TabContent(
onAccountSwitched: () -> Unit,
onAddAccount: () -> Unit,
onLogout: () -> Unit,
navStyle: String = "bottom_bar",
onToggleNavStyle: () -> Unit = {},
bottomPadding: androidx.compose.ui.unit.Dp
) {
when (tab) {
@ -399,6 +719,8 @@ private fun TabContent(
onAccountSwitched = onAccountSwitched,
onAddAccount = onAddAccount,
onLogout = onLogout,
navStyle = navStyle,
onToggleNavStyle = onToggleNavStyle,
bottomPadding = bottomPadding
)
}
@ -442,11 +764,13 @@ private fun ChatsContent(
} else {
val listState = rememberLazyListState()
val shouldLoadMore by remember {
val hasMore = uiState.hasMore
val isLoading = uiState.isLoading
val shouldLoadMore by remember(hasMore, isLoading) {
derivedStateOf {
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
val totalItems = listState.layoutInfo.totalItemsCount
lastVisible >= totalItems - 3 && uiState.hasMore && !uiState.isLoading
lastVisible >= totalItems - 3 && hasMore && !isLoading
}
}
LaunchedEffect(shouldLoadMore) {
@ -507,12 +831,12 @@ private fun ChatListItem(
?: chatData?.fullName
?: chatData?.login
?: chat.chatType.replaceFirstChar { it.uppercase() }
val isVerified = chatData?.isVerified == true
val isVerified = chatData?.verification != null
val lastMessageText = chat.lastMessage?.content ?: ""
val time = formatUtcToLocalTime(chat.lastMessage?.createdAt)
val bgColor = if (isSelected)
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
else
Color.Transparent
@ -524,7 +848,7 @@ private fun ChatListItem(
onClick = onClick,
onLongClick = onLongClick
)
.padding(horizontal = 16.dp, vertical = 12.dp),
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
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.update
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.SessionManager
import org.yobble.messenger.data.remote.NetworkResult
@ -28,7 +30,8 @@ data class HomeUiState(
val chats: List<PrivateChatListItemDto> = emptyList(),
val isLoading: Boolean = false,
val hasMore: Boolean = false,
val activeAccount: AccountInfo? = null
val activeAccount: AccountInfo? = null,
val navStyle: String = "bottom_bar"
)
sealed class HomeEvent {
@ -40,7 +43,8 @@ class HomeViewModel @Inject constructor(
private val chatRepository: ChatRepository,
private val authRepository: AuthRepository,
private val socketManager: SocketManager,
private val sessionManager: SessionManager
private val sessionManager: SessionManager,
application: Application
) : ViewModel() {
private val _uiState = MutableStateFlow(HomeUiState())
@ -52,6 +56,7 @@ class HomeViewModel @Inject constructor(
private var refreshJob: Job? = null
init {
(application as? YobbleApp)?.resetImageLoaderIfNeeded()
socketManager.connect()
loadCachedThenNetwork()
loadActiveAccount()
@ -61,7 +66,13 @@ class HomeViewModel @Inject constructor(
fun refreshActiveAccount() {
val activeId = sessionManager.activeAccountId.value
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()

View File

@ -3,9 +3,11 @@ package org.yobble.messenger.presentation.profile
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
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.filled.CameraAlt
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.Verified
import androidx.compose.material3.*
@ -26,12 +30,14 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.collectLatest
import org.yobble.messenger.data.remote.dto.AchievementItemDto
import org.yobble.messenger.presentation.common.FullScreenImageViewer
import org.yobble.messenger.presentation.common.UserAvatar
import org.yobble.messenger.presentation.common.buildAvatarUrl
@ -78,10 +84,10 @@ fun ProfileScreen(
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
actionIconContentColor = MaterialTheme.colorScheme.onPrimary
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface,
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
actionIconContentColor = MaterialTheme.colorScheme.onSurface
)
)
}
@ -207,44 +213,55 @@ fun ProfileScreen(
if (profile.login.isNotBlank()) {
Text(
text = "@${profile.login}",
style = MaterialTheme.typography.bodyLarge,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Rating
if (profile.rating != null) {
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Star,
contentDescription = "Rating",
tint = Color(0xFFFFC107),
modifier = Modifier.size(20.dp)
// Stats row
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
if (profile.rating != null) {
ProfileStat(
value = String.format("%.1f", profile.rating),
label = "Rating"
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = String.format("%.1f", profile.rating),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
}
ProfileStat(
value = formatUtcToShortDate(profile.createdAt),
label = "Joined"
)
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) {
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(onClick = viewModel::startEditing) {
Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(6.dp))
Text("Edit profile")
}
Spacer(modifier = Modifier.height(8.dp))
}
if (!uiState.isMyProfile && profile.canSendMessage && onNavigateToChat != null) {
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = viewModel::createChat,
enabled = !uiState.isCreatingChat
enabled = !uiState.isCreatingChat,
shape = RoundedCornerShape(12.dp)
) {
if (uiState.isCreatingChat) {
CircularProgressIndicator(
@ -258,9 +275,10 @@ fun ProfileScreen(
Text("Send message")
}
}
Spacer(modifier = Modifier.height(8.dp))
}
Spacer(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.height(8.dp))
if (uiState.isEditing) {
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))
}
}
@ -297,34 +328,65 @@ private fun ProfileInfoSection(
.padding(horizontal = 16.dp)
) {
if (!profile.bio.isNullOrBlank()) {
ProfileInfoCard(label = "Bio", value = profile.bio)
Spacer(modifier = Modifier.height(12.dp))
}
ProfileInfoCard(
label = "Member since",
value = formatUtcToLocalDate(profile.createdAt)
)
if (isMyProfile && profile.balances.isNotEmpty()) {
Spacer(modifier = Modifier.height(12.dp))
ProfileInfoCard(
label = "Balance",
value = profile.balances.joinToString("\n") {
"${it.displayBalance ?: it.balance} ${it.currency.uppercase()}"
}
Text(
text = "About",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
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) {
val rel = profile.relationship
if (rel != null) {
Spacer(modifier = Modifier.height(12.dp))
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) {
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
private fun ProfileInfoCard(label: String, value: String) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
shape = RoundedCornerShape(12.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = value,
style = MaterialTheme.typography.bodyLarge
)
}
private fun ProfileStat(value: String, label: String) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = value,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@ -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 {
return try {
val zonedUtc = java.time.ZonedDateTime.parse(isoString)
@ -447,3 +722,13 @@ private fun formatUtcToLocalDate(isoString: String): String {
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.launch
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.RatingDataDto
import org.yobble.messenger.data.remote.dto.RelationshipStatusDto
import org.yobble.messenger.data.remote.dto.WalletBalanceDto
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.ProfileRepository
import javax.inject.Inject
@ -46,7 +48,9 @@ data class ProfileUiState(
val isCreatingChat: Boolean = false,
val isEditing: Boolean = false,
val editFullName: String = "",
val editBio: String = ""
val editBio: String = "",
val achievements: Map<String, List<AchievementItemDto>> = emptyMap(),
val isLoadingAchievements: Boolean = false
)
sealed class ProfileEvent {
@ -60,6 +64,7 @@ class ProfileViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val profileRepository: ProfileRepository,
private val chatRepository: ChatRepository,
private val achievementRepository: AchievementRepository,
private val sessionManager: SessionManager
) : ViewModel() {
@ -74,6 +79,7 @@ class ProfileViewModel @Inject constructor(
init {
loadProfile()
loadAchievements()
}
fun loadProfile() {
@ -96,7 +102,7 @@ class ProfileViewModel @Inject constructor(
login = p.login,
displayName = p.fullName ?: p.login,
bio = p.bio,
isVerified = p.isVerified == true,
isVerified = p.verification != null,
rating = p.rating.rating,
createdAt = p.createdAt,
avatarFileId = p.avatars?.current?.fileId,
@ -139,7 +145,7 @@ class ProfileViewModel @Inject constructor(
login = p.login ?: "",
displayName = p.customName ?: p.fullName ?: p.login?.let { "@$it" } ?: "User",
bio = p.bio,
isVerified = p.isVerified == true,
isVerified = p.verification != null,
isSystem = p.isSystem == true,
rating = p.rating.rating,
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() {
if (!isMyProfile) return
val profile = _uiState.value.profile ?: return

View File

@ -79,8 +79,8 @@ fun SearchScreen(
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary
containerColor = MaterialTheme.colorScheme.surface,
navigationIconContentColor = MaterialTheme.colorScheme.onSurface
)
)
}
@ -149,7 +149,7 @@ private fun SearchResultItem(
?: profile?.fullName
?: profile?.login?.let { "@$it" }
?: "User"
val isVerified = profile?.isVerified == true
val isVerified = profile?.verification != null
val rating = profile?.rating?.rating
Row(

View File

@ -61,9 +61,9 @@ fun BlacklistScreen(
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface,
navigationIconContentColor = MaterialTheme.colorScheme.onSurface
)
)
},
@ -158,7 +158,7 @@ private fun BlacklistItem(
) {
UserAvatar(
userId = item.userId,
fileId = null,
fileId = item.avatars?.current?.fileId,
displayName = displayName,
size = 44.dp,
fontSize = 16.sp

View File

@ -52,9 +52,9 @@ fun ChangePasswordScreen(
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface,
navigationIconContentColor = MaterialTheme.colorScheme.onSurface
)
)
}

View File

@ -46,9 +46,9 @@ fun PrivacyScreen(
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface,
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))
SectionHeader("Groups & Invites")
ToggleItem(
title = "Allow server chats",
subtitle = "Participate in server-based chats",
checked = permissions.allowServerChats,
enabled = !uiState.isSavingPermissions,
onToggle = { value ->
viewModel.updatePermission {
ProfilePermissionsRequestDto(allowServerChats = value)
}
}
)
SelectItem(
title = "Public invite",
options = listOf("Nobody" to 0, "Contacts" to 1, "Everyone" to 2),

View File

@ -1,23 +1,28 @@
package org.yobble.messenger.presentation.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Computer
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Tablet
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.collectLatest
import org.yobble.messenger.data.remote.dto.UserSessionItemDto
import org.yobble.messenger.util.formatUtcToLocalTime
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -42,16 +47,16 @@ fun SessionsScreen(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { Text("Sessions", fontWeight = FontWeight.Bold) },
title = { Text("Active Sessions", fontWeight = FontWeight.Bold) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface,
navigationIconContentColor = MaterialTheme.colorScheme.onSurface
)
)
}
@ -71,9 +76,26 @@ fun SessionsScreen(
.fillMaxSize()
.padding(padding),
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 }
if (otherSessions.size > 1) {
item {
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()) {
item {
Text(
"Other sessions",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold
)
}
items(otherSessions, key = { it.id }) { session ->
SessionCard(
session = session,
@ -130,76 +129,120 @@ private fun SessionCard(
session: UserSessionItemDto,
onRevoke: (() -> Unit)?
) {
val deviceInfo = parseDeviceInfo(session.userAgent, session.clientType)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = if (session.isCurrent)
MaterialTheme.colorScheme.primaryContainer
MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
else
MaterialTheme.colorScheme.surfaceVariant
),
shape = RoundedCornerShape(12.dp)
shape = RoundedCornerShape(16.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Row(
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(
Icons.Default.PhoneAndroid,
deviceInfo.icon,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
tint = if (session.isCurrent)
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.height(4.dp))
Spacer(modifier = Modifier.width(12.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 = "IP: ${session.ipAddress}",
text = "Active: ${formatSessionDate(session.lastRefreshAt)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Last active: ${formatSessionDate(session.lastRefreshAt)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Created: ${formatSessionDate(session.createdAt)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (onRevoke != null) {
Spacer(modifier = Modifier.height(8.dp))
TextButton(
onClick = onRevoke,
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text("Revoke")
if (onRevoke != null) {
Spacer(modifier = Modifier.height(8.dp))
TextButton(
onClick = onRevoke,
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
),
contentPadding = PaddingValues(0.dp),
modifier = Modifier.height(32.dp)
) {
Text("Terminate session", fontSize = 13.sp)
}
}
}
}
}
}
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 {
return try {
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.material.icons.Icons
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.Block
import androidx.compose.material.icons.filled.Close
@ -45,6 +46,8 @@ fun SettingsScreen(
onAccountSwitched: () -> Unit,
onAddAccount: () -> Unit,
onLogout: () -> Unit,
navStyle: String = "bottom_bar",
onToggleNavStyle: () -> Unit = {},
bottomPadding: Dp = 0.dp,
accountsViewModel: AccountSwitcherViewModel = hiltViewModel()
) {
@ -63,9 +66,10 @@ fun SettingsScreen(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp)
.padding(top = 8.dp, bottom = bottomPadding + 8.dp)
) {
// Centered profile section
// Profile card
if (activeAccount != null) {
ProfileSection(
account = activeAccount,
@ -73,11 +77,7 @@ fun SettingsScreen(
)
}
HorizontalDivider(
modifier = Modifier.padding(vertical = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant,
thickness = 0.5.dp
)
Spacer(modifier = Modifier.height(12.dp))
// Other accounts
if (otherAccounts.isNotEmpty()) {
@ -87,31 +87,34 @@ fun SettingsScreen(
onClick = { accountsViewModel.switchTo(account.accountId) },
onRemove = { accountsViewModel.removeAccount(account.accountId) }
)
Spacer(modifier = Modifier.height(4.dp))
}
Spacer(modifier = Modifier.height(4.dp))
}
// Add account
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable {
accountsViewModel.prepareNewAccountLogin()
onAddAccount()
}
.padding(horizontal = 16.dp, vertical = 12.dp),
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
}
@ -124,57 +127,114 @@ fun SettingsScreen(
)
}
HorizontalDivider(
modifier = Modifier.padding(vertical = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant,
thickness = 0.5.dp
)
Spacer(modifier = Modifier.height(16.dp))
// Settings items
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
)
// UI Style
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
shape = RoundedCornerShape(16.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onToggleNavStyle)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text("Navigation", style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
Text(
if (navStyle == "drawer") "Side menu" else "Bottom bar",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = navStyle == "drawer",
onCheckedChange = { onToggleNavStyle() }
)
}
}
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onLogout,
// Settings group card
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
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
.clickable(onClick = onLogout),
colors = CardDefaults.cardColors(
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(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.clickable(onClick = onClick)
.padding(vertical = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally
@ -229,7 +290,7 @@ private fun ProfileSection(
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 32.dp)
modifier = Modifier.padding(horizontal = 16.dp)
)
}
}
@ -246,11 +307,14 @@ private fun AccountItem(
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
.padding(vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
InitialsAvatar(
UserAvatar(
userId = account.userId,
fileId = account.avatarFileId,
displayName = displayName,
size = 40.dp,
fontSize = 16.sp
@ -292,7 +356,8 @@ private fun SettingsMenuItem(
icon: ImageVector,
title: String,
subtitle: String,
onClick: () -> Unit
onClick: () -> Unit,
showDivider: Boolean = true
) {
Row(
modifier = Modifier
@ -301,13 +366,21 @@ private fun SettingsMenuItem(
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Box(
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(10.dp))
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)),
contentAlignment = Alignment.Center
) {
Icon(
icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
@ -323,7 +396,15 @@ private fun SettingsMenuItem(
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowRight,
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(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface,
navigationIconContentColor = MaterialTheme.colorScheme.onSurface
)
)
}

View File

@ -2,30 +2,31 @@ package org.yobble.messenger.ui.theme
import androidx.compose.ui.graphics.Color
// Telegram-style primary palette
val TelegramBlue = Color(0xFF2AABEE)
val TelegramBlueDark = Color(0xFF0088CC)
val TelegramBlueLight = Color(0xFF6DD0FF)
// Primary palette
val YobbleBlue = Color(0xFF5AB1FD)
val YobbleBlueDark = Color(0xFF3A8FD4)
val YobbleBlueLight = Color(0xFF71B1FF)
// Light theme colors
val LightBackground = Color(0xFFF7F7F8)
val LightBackground = Color(0xFFF5F7FA)
val LightSurface = Color(0xFFFFFFFF)
val LightSurfaceVariant = Color(0xFFEFEFF4)
val LightOnBackground = Color(0xFF1C1C1E)
val LightOnSurface = Color(0xFF1C1C1E)
val LightOnSurfaceVariant = Color(0xFF8E8E93)
val LightOutline = Color(0xFFC7C7CC)
val LightSurfaceVariant = Color(0xFFEBEEF3)
val LightOnBackground = Color(0xFF1A1C20)
val LightOnSurface = Color(0xFF1A1C20)
val LightOnSurfaceVariant = Color(0xFF6E7787)
val LightOutline = Color(0xFFD0D5DD)
// Dark theme colors
val DarkBackground = Color(0xFF1C1C1E)
val DarkSurface = Color(0xFF2C2C2E)
val DarkSurfaceVariant = Color(0xFF3A3A3C)
val DarkOnBackground = Color(0xFFF2F2F7)
val DarkOnSurface = Color(0xFFF2F2F7)
val DarkOnSurfaceVariant = Color(0xFF8E8E93)
val DarkOutline = Color(0xFF48484A)
// Dark theme colors — deep atmospheric blues
val DarkBackground = Color(0xFF070F18)
val DarkSurface = Color(0xFF101A26)
val DarkSurfaceVariant = Color(0xFF1B2735)
val DarkSurfaceContainerHighest = Color(0xFF243242)
val DarkOnBackground = Color(0xFFE6EEFD)
val DarkOnSurface = Color(0xFFE6EEFD)
val DarkOnSurfaceVariant = Color(0xFF7A8899)
val DarkOutline = Color(0xFF2E3D4F)
// Semantic colors
val ErrorRed = Color(0xFFFF3B30)
val ErrorRed = Color(0xFFD7383B)
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
private val LightColorScheme = lightColorScheme(
primary = TelegramBlue,
primary = YobbleBlue,
onPrimary = OnPrimary,
primaryContainer = TelegramBlueLight,
onPrimaryContainer = TelegramBlueDark,
secondary = TelegramBlueDark,
primaryContainer = YobbleBlueLight,
onPrimaryContainer = YobbleBlueDark,
secondary = YobbleBlueDark,
onSecondary = OnPrimary,
background = LightBackground,
onBackground = LightOnBackground,
@ -29,11 +29,11 @@ private val LightColorScheme = lightColorScheme(
)
private val DarkColorScheme = darkColorScheme(
primary = TelegramBlue,
primary = YobbleBlue,
onPrimary = OnPrimary,
primaryContainer = TelegramBlueDark,
onPrimaryContainer = TelegramBlueLight,
secondary = TelegramBlueLight,
primaryContainer = YobbleBlueDark,
onPrimaryContainer = YobbleBlueLight,
secondary = YobbleBlueLight,
onSecondary = DarkOnBackground,
background = DarkBackground,
onBackground = DarkOnBackground,
@ -41,7 +41,9 @@ private val DarkColorScheme = darkColorScheme(
onSurface = DarkOnSurface,
surfaceVariant = DarkSurfaceVariant,
onSurfaceVariant = DarkOnSurfaceVariant,
surfaceContainerHighest = DarkSurfaceContainerHighest,
outline = DarkOutline,
outlineVariant = DarkOutline,
error = ErrorRed,
onError = OnPrimary
)
@ -66,4 +68,4 @@ fun YobbleTheme(
typography = Typography,
content = content
)
}
}

View File

@ -1,11 +1,10 @@
package org.yobble.messenger.util
import java.time.Duration
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
fun formatUtcToLocalTime(isoString: String?): String {
if (isoString.isNullOrBlank()) return ""
@ -18,22 +17,29 @@ fun formatUtcToLocalTime(isoString: String?): String {
}
}
fun formatLastSeen(secondsAgo: Int?): String {
if (secondsAgo == null) return ""
fun formatLastSeen(lastSeenAt: String?): String {
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 (minutes < 60) return "last seen ${minutes}m ago"
if (seconds < 60) return "online"
val hours = minutes / 60
if (hours < 24) return "last seen ${hours}h ago"
val minutes = seconds / 60
if (minutes < 60) return "last seen ${minutes}m ago"
val days = hours / 24
if (days == 1) return "last seen yesterday"
if (days < 7) return "last seen ${days}d ago"
val hours = minutes / 60
if (hours < 24) return "last seen ${hours}h ago"
val seen = Instant.now().minusSeconds(secondsAgo.toLong())
val seenDate = seen.atZone(ZoneId.systemDefault()).toLocalDate()
return "last seen ${seenDate.format(DateTimeFormatter.ofPattern("dd.MM.yyyy"))}"
val days = hours / 24
if (days == 1L) return "last seen yesterday"
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"?>
<resources>
<style name="Theme.Yobble" parent="android:Theme.Material.Light.NoActionBar" />
</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>
</style>
</resources>

View File

@ -22,6 +22,7 @@ firebaseBom = "33.7.0"
googleServices = "4.4.2"
socketIo = "2.1.1"
coil = "2.7.0"
emojiPicker = "1.5.0"
[libraries]
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" }
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]
android-application = { id = "com.android.application", version.ref = "agp" }