From e072be329032020d577be9e23cf3ab949601d926 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Thu, 2 Apr 2026 18:28:06 +0300 Subject: [PATCH] More caching and UI fixes Signed-off-by: denis0001-dev --- .../kotlin/ru/fromchat/api/ApiClient.kt | 1 + .../kotlin/ru/fromchat/api/Models.kt | 7 +- .../kotlin/ru/fromchat/api/ProfileCache.kt | 103 +++++++++++++++++- .../commonMain/kotlin/ru/fromchat/ui/App.kt | 3 + .../kotlin/ru/fromchat/ui/TouchEffects.kt | 7 +- .../kotlin/ru/fromchat/ui/main/SettingsTab.kt | 4 +- .../ru/fromchat/ui/profile/ProfileScreen.kt | 75 ++++++++----- 7 files changed, 166 insertions(+), 34 deletions(-) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt index dca4297..3af52c0 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt @@ -445,6 +445,7 @@ object ApiClient { settings.remove("current_user_id") token = null user = null + runCatching { ProfileCache.clear() } } fun getTokenSafely() = token ?: throw IllegalStateException("Not authenticated") diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt index 247ab60..86bd459 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt @@ -48,7 +48,12 @@ data class UserProfile( val verified: Boolean? = null, val suspended: Boolean? = null, @SerialName("suspension_reason") val suspensionReason: String? = null, - val deleted: Boolean? = null + val deleted: Boolean? = null, + /** + * Client-only: true when this row was built from public-chat message metadata, not a full + * `/user/...` response. The backend does not send this key. + */ + @SerialName("client_preview_only") val isClientPreviewOnly: Boolean = false ) @Serializable diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ProfileCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ProfileCache.kt index 60109d5..871012d 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ProfileCache.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ProfileCache.kt @@ -1,15 +1,108 @@ package ru.fromchat.api +import com.pr0gramm3r101.utils.settings.settings +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import kotlin.concurrent.Volatile + /** - * In-memory cache for user profiles. Used to show profile immediately from cache - * and reload in the background. + * In-memory profile cache with disk persistence. [get] reads a volatile snapshot (lock-free). + * [put] copy-on-writes the map and schedules an async flush of the full list to settings. */ object ProfileCache { - private val cache = mutableMapOf() + private const val SETTINGS_KEY = "profile_cache_profiles_v1" - fun get(userId: Int): UserProfile? = cache[userId] + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = true + } + + @Volatile + private var profiles: Map = emptyMap() + + private val persistMutex = Mutex() + private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + fun get(userId: Int): UserProfile? = profiles[userId] fun put(profile: UserProfile) { - cache[profile.id] = profile + val cur = profiles + profiles = cur + (profile.id to profile) + schedulePersist() + } + + /** + * Fills or refreshes a lightweight profile from a public chat [Message] (username, avatar URL). + * Skips when a full API profile is already stored ([UserProfile.isClientPreviewOnly] is false). + */ + fun mergePreviewFromPublicMessage(message: Message) { + val uid = message.user_id + if (uid <= 0) return + val existing = get(uid) + if (existing != null && !existing.isClientPreviewOnly) return + + val uname = message.username.ifBlank { existing?.username ?: return } + val display = existing?.displayName?.takeIf { it.isNotBlank() } ?: uname + val pic = message.profile_picture?.takeIf { it.isNotBlank() } ?: existing?.profilePicture + + put( + UserProfile( + id = uid, + username = uname, + displayName = display, + profilePicture = pic, + bio = existing?.bio, + online = existing?.online ?: false, + lastSeen = existing?.lastSeen, + createdAt = existing?.createdAt, + verified = existing?.verified, + suspended = existing?.suspended, + suspensionReason = existing?.suspensionReason, + deleted = existing?.deleted, + isClientPreviewOnly = true + ) + ) + } + + /** + * Load stored profiles into memory (merged: existing runtime entries win on id clash). + * Call after [ApiClient.loadPersistedData]. + */ + suspend fun hydrateFromDisk() { + persistMutex.withLock { + val raw = settings.getString(SETTINGS_KEY, "").ifBlank { return } + runCatching { + val list = json.decodeFromString(ListSerializer(UserProfile.serializer()), raw) + val fromDisk = list.associateBy { it.id } + profiles = fromDisk + profiles + } + } + } + + suspend fun clear() { + persistMutex.withLock { + profiles = emptyMap() + settings.putString(SETTINGS_KEY, "") + } + } + + private fun schedulePersist() { + ioScope.launch { + persistMutex.withLock { + val snap = profiles + val blob = json.encodeToString( + ListSerializer(UserProfile.serializer()), + snap.values.toList() + ) + settings.putString(SETTINGS_KEY, blob) + } + } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt index 85eccd9..dedaa6b 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt @@ -22,6 +22,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import ru.fromchat.api.ApiClient +import ru.fromchat.api.ProfileCache import ru.fromchat.api.UpdateSyncManager import ru.fromchat.api.WebSocketManager import ru.fromchat.net.NetworkConnectivity @@ -53,6 +54,8 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) { // Load persisted token and user data ApiClient.loadPersistedData() + runCatching { ProfileCache.hydrateFromDisk() } + // Initialize update sync state for the current user (if any) runCatching { UpdateSyncManager.initializeFromStorage(ApiClient.user?.id) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/TouchEffects.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/TouchEffects.kt index 77255bb..dc66e82 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/TouchEffects.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/TouchEffects.kt @@ -1,5 +1,6 @@ package ru.fromchat.ui +import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.foundation.Indication @@ -29,13 +30,15 @@ import kotlinx.coroutines.launch * When [interactionSource] is non-null: uses it for scale only (caller adds clickable on child so ripple scales with content). * [indication] when non-null is used for the clickable (e.g. ripple); null = no indication. * [clipShape] when non-null clips the scaled result. + * [animationSpec] drives the scale transition (default is a spring). */ fun Modifier.scaleOnPress( scale: Float = 0.96f, onClick: (() -> Unit)? = null, indication: Indication? = null, clipShape: Shape? = null, - interactionSource: MutableInteractionSource? = null + interactionSource: MutableInteractionSource? = null, + animationSpec: AnimationSpec = spring() ): Modifier = composed { val source = interactionSource ?: remember { MutableInteractionSource() } var pressed by remember { mutableStateOf(false) } @@ -62,7 +65,7 @@ fun Modifier.scaleOnPress( val scaleValue by animateFloatAsState( targetValue = if (pressed) scale else 1f, - animationSpec = spring(), + animationSpec = animationSpec, label = "scaleOnPress" ) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/SettingsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/SettingsTab.kt index c215d45..eab9ecf 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/SettingsTab.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/SettingsTab.kt @@ -3,6 +3,7 @@ package ru.fromchat.ui.main import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding @@ -101,8 +102,9 @@ fun SettingsTab( ) { innerPadding -> Column( Modifier - .padding(innerPadding) + .fillMaxWidth() .verticalScroll(rememberScrollState()) + .padding(innerPadding) ) { var materialYouSwitch by remember { mutableStateOf( diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt index feecaeb..6ee8e52 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt @@ -1,5 +1,7 @@ package ru.fromchat.ui.profile +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.SharedTransitionScope import androidx.compose.foundation.LocalIndication @@ -45,7 +47,11 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Rect @@ -76,6 +82,12 @@ private data class ProfileUiState( val linkStatus: String? = null ) +private val profileActionCardPressSpring = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessLow, + visibilityThreshold = 0.001f +) + @OptIn(ExperimentalMaterial3Api::class) @Composable fun ProfileScreen( @@ -95,10 +107,11 @@ fun ProfileScreen( val navController = LocalNavController.current val hideBackButton = navController.currentDestination?.route == "chat" val targetUserId = userId.takeIf { it != null && it > 0 } - val fetchKey = targetUserId ?: 0 + val ownUserId = ApiClient.user?.id?.takeIf { it > 0 } + val cacheLookupId = targetUserId ?: ownUserId - var state by remember(fetchKey) { - val cached = targetUserId?.let { ProfileCache.get(it) } + var state by remember(targetUserId, ownUserId) { + val cached = cacheLookupId?.let { ProfileCache.get(it) } mutableStateOf( ProfileUiState( profile = cached, @@ -108,21 +121,33 @@ fun ProfileScreen( ) } - LaunchedEffect(fetchKey) { - runCatching { - if (targetUserId == null) { - ApiClient.getOwnProfile() - } else { - ApiClient.getProfileById(targetUserId) + val latestUi by rememberUpdatedState(state) + val lifecycleOwner = LocalLifecycleOwner.current + + LaunchedEffect(cacheLookupId, targetUserId, lifecycleOwner) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + runCatching { + if (targetUserId == null) { + ApiClient.getOwnProfile() + } else { + ApiClient.getProfileById(targetUserId) + } + }.onSuccess { profile -> + ProfileCache.put(profile) + state = latestUi.copy(profile = profile, isLoading = false, error = null) + }.onFailure { err -> + val fallbackId = targetUserId ?: ownUserId + val fallback = fallbackId?.let { ProfileCache.get(it) } + state = latestUi.copy( + error = if (latestUi.profile == null && fallback == null) { + err.message ?: "Unable to load profile" + } else { + null + }, + profile = latestUi.profile ?: fallback, + isLoading = false + ) } - }.onSuccess { profile -> - ProfileCache.put(profile) - state = state.copy(profile = profile, isLoading = false, error = null) - }.onFailure { - state = state.copy( - error = if (state.profile == null) it.message ?: "Unable to load profile" else null, - isLoading = false - ) } } @@ -154,9 +179,7 @@ fun ProfileScreen( } ) { innerPadding -> Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) + modifier = Modifier.fillMaxSize() ) { val errorMessage = state.error val profile = state.profile @@ -171,9 +194,9 @@ fun ProfileScreen( Column( modifier = Modifier - .fillMaxSize() + .fillMaxWidth() .verticalScroll(rememberScrollState()) - , + .padding(innerPadding), horizontalAlignment = Alignment.CenterHorizontally ) { when { @@ -267,7 +290,7 @@ fun ProfileScreen( style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(36.dp)) Row( modifier = Modifier @@ -292,7 +315,8 @@ fun ProfileScreen( .scaleOnPress( scale = 0.90f, interactionSource = primarySource, - clipShape = MaterialTheme.shapes.extraLarge + clipShape = MaterialTheme.shapes.extraLarge, + animationSpec = profileActionCardPressSpring ), shape = MaterialTheme.shapes.extraLarge, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer) @@ -331,7 +355,8 @@ fun ProfileScreen( .scaleOnPress( scale = 0.90f, interactionSource = linkSource, - clipShape = MaterialTheme.shapes.extraLarge + clipShape = MaterialTheme.shapes.extraLarge, + animationSpec = profileActionCardPressSpring ), shape = MaterialTheme.shapes.extraLarge, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer)