[feat/fix]:
- redesign: dark theme, new UI for all screens - feat: message edit/delete/mark-read, emoji picker, drawer navigation, achievements - fix: keyboard sync (adjustNothing + imePadding), token refresh on IP mismatch, pagination, profile API update
This commit is contained in:
parent
8a6035be49
commit
4f3df5e440
2
.idea/deploymentTargetSelector.xml
generated
2
.idea/deploymentTargetSelector.xml
generated
@ -4,7 +4,7 @@
|
||||
<selectionStates>
|
||||
<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
4
.idea/misc.xml
generated
@ -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" />
|
||||
|
||||
@ -92,6 +92,9 @@ dependencies {
|
||||
// Image loading
|
||||
implementation(libs.coil.compose)
|
||||
|
||||
// Emoji picker
|
||||
implementation(libs.androidx.emoji2.emojipicker)
|
||||
|
||||
// Security
|
||||
implementation(libs.androidx.security.crypto)
|
||||
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -0,0 +1,17 @@
|
||||
package org.yobble.messenger.data.remote.api
|
||||
|
||||
import org.yobble.messenger.data.remote.dto.AchievementListResponseDto
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Path
|
||||
|
||||
interface AchievementApi {
|
||||
|
||||
@GET("v1/achievement/my")
|
||||
suspend fun getMyAchievements(): Response<AchievementListResponseDto>
|
||||
|
||||
@GET("v1/achievement/user/{user_id}")
|
||||
suspend fun getUserAchievements(
|
||||
@Path("user_id") userId: String
|
||||
): Response<AchievementListResponseDto>
|
||||
}
|
||||
@ -16,7 +16,8 @@ interface ChatPrivateApi {
|
||||
suspend fun getChatHistory(
|
||||
@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
|
||||
|
||||
@ -0,0 +1,30 @@
|
||||
package org.yobble.messenger.data.remote.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class AchievementListResponseDto(
|
||||
@SerialName("status") val status: String,
|
||||
@SerialName("data") val data: AchievementListDataDto
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AchievementListDataDto(
|
||||
@SerialName("items") val items: Map<String, List<AchievementItemDto>> = emptyMap()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AchievementItemDto(
|
||||
@SerialName("achievement_id") val achievementId: Int,
|
||||
@SerialName("code") val code: String,
|
||||
@SerialName("name") val name: String,
|
||||
@SerialName("description") val description: String? = null,
|
||||
@SerialName("icon") val icon: String? = null,
|
||||
@SerialName("category") val category: String? = null,
|
||||
@SerialName("badge_type") val badgeType: String,
|
||||
@SerialName("is_completed") val isCompleted: Boolean,
|
||||
@SerialName("unlocked_at") val unlockedAt: String? = null,
|
||||
@SerialName("progress") val progress: Int? = null,
|
||||
@SerialName("required_progress") val requiredProgress: Int? = null
|
||||
)
|
||||
@ -15,8 +15,28 @@ data class PrivateMessageSendRequestDto(
|
||||
|
||||
@Serializable
|
||||
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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
package org.yobble.messenger.data.repository
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.yobble.messenger.data.remote.NetworkResult
|
||||
import org.yobble.messenger.data.remote.api.AchievementApi
|
||||
import org.yobble.messenger.data.remote.dto.AchievementListResponseDto
|
||||
import org.yobble.messenger.data.remote.safeApiCall
|
||||
import org.yobble.messenger.domain.repository.AchievementRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
class AchievementRepositoryImpl @Inject constructor(
|
||||
private val achievementApi: AchievementApi,
|
||||
private val json: Json
|
||||
) : AchievementRepository {
|
||||
|
||||
override suspend fun getMyAchievements(): NetworkResult<AchievementListResponseDto> {
|
||||
return safeApiCall(json) { achievementApi.getMyAchievements() }
|
||||
}
|
||||
|
||||
override suspend fun getUserAchievements(userId: String): NetworkResult<AchievementListResponseDto> {
|
||||
return safeApiCall(json) { achievementApi.getUserAchievements(userId) }
|
||||
}
|
||||
}
|
||||
@ -15,10 +15,19 @@ class ChatRepositoryImpl @Inject constructor(
|
||||
private val chatCacheManager: ChatCacheManager
|
||||
) : 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
package org.yobble.messenger.domain.repository
|
||||
|
||||
import org.yobble.messenger.data.remote.NetworkResult
|
||||
import org.yobble.messenger.data.remote.dto.AchievementListResponseDto
|
||||
|
||||
interface AchievementRepository {
|
||||
suspend fun getMyAchievements(): NetworkResult<AchievementListResponseDto>
|
||||
suspend fun getUserAchievements(userId: String): NetworkResult<AchievementListResponseDto>
|
||||
}
|
||||
@ -8,5 +8,8 @@ interface ChatRepository {
|
||||
suspend fun getChatHistory(chatId: String, beforeMessageId: Int? = null, limit: Int = 30): NetworkResult<PrivateChatHistoryResponseDto>
|
||||
suspend fun 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>
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) }
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
10
app/src/main/res/values-v29/themes.xml
Normal file
10
app/src/main/res/values-v29/themes.xml
Normal file
@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.Yobble" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
<item name="android:enforceNavigationBarContrast">false</item>
|
||||
<item name="android:enforceStatusBarContrast">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
@ -1,5 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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>
|
||||
|
||||
@ -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" }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user