More caching and UI fixes

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-04-02 18:28:06 +03:00
Unverified
parent c1960f2aa0
commit e072be3290
7 changed files with 166 additions and 34 deletions
@@ -445,6 +445,7 @@ object ApiClient {
settings.remove("current_user_id") settings.remove("current_user_id")
token = null token = null
user = null user = null
runCatching { ProfileCache.clear() }
} }
fun getTokenSafely() = token ?: throw IllegalStateException("Not authenticated") fun getTokenSafely() = token ?: throw IllegalStateException("Not authenticated")
@@ -48,7 +48,12 @@ data class UserProfile(
val verified: Boolean? = null, val verified: Boolean? = null,
val suspended: Boolean? = null, val suspended: Boolean? = null,
@SerialName("suspension_reason") val suspensionReason: String? = 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 @Serializable
@@ -1,15 +1,108 @@
package ru.fromchat.api 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 * In-memory profile cache with disk persistence. [get] reads a volatile snapshot (lock-free).
* and reload in the background. * [put] copy-on-writes the map and schedules an async flush of the full list to settings.
*/ */
object ProfileCache { object ProfileCache {
private val cache = mutableMapOf<Int, UserProfile>() 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<Int, UserProfile> = emptyMap()
private val persistMutex = Mutex()
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
fun get(userId: Int): UserProfile? = profiles[userId]
fun put(profile: UserProfile) { 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)
}
}
} }
} }
@@ -22,6 +22,7 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.ProfileCache
import ru.fromchat.api.UpdateSyncManager import ru.fromchat.api.UpdateSyncManager
import ru.fromchat.api.WebSocketManager import ru.fromchat.api.WebSocketManager
import ru.fromchat.net.NetworkConnectivity import ru.fromchat.net.NetworkConnectivity
@@ -53,6 +54,8 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
// Load persisted token and user data // Load persisted token and user data
ApiClient.loadPersistedData() ApiClient.loadPersistedData()
runCatching { ProfileCache.hydrateFromDisk() }
// Initialize update sync state for the current user (if any) // Initialize update sync state for the current user (if any)
runCatching { runCatching {
UpdateSyncManager.initializeFromStorage(ApiClient.user?.id) UpdateSyncManager.initializeFromStorage(ApiClient.user?.id)
@@ -1,5 +1,6 @@
package ru.fromchat.ui package ru.fromchat.ui
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
import androidx.compose.foundation.Indication 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). * 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. * [indication] when non-null is used for the clickable (e.g. ripple); null = no indication.
* [clipShape] when non-null clips the scaled result. * [clipShape] when non-null clips the scaled result.
* [animationSpec] drives the scale transition (default is a spring).
*/ */
fun Modifier.scaleOnPress( fun Modifier.scaleOnPress(
scale: Float = 0.96f, scale: Float = 0.96f,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
indication: Indication? = null, indication: Indication? = null,
clipShape: Shape? = null, clipShape: Shape? = null,
interactionSource: MutableInteractionSource? = null interactionSource: MutableInteractionSource? = null,
animationSpec: AnimationSpec<Float> = spring()
): Modifier = composed { ): Modifier = composed {
val source = interactionSource ?: remember { MutableInteractionSource() } val source = interactionSource ?: remember { MutableInteractionSource() }
var pressed by remember { mutableStateOf(false) } var pressed by remember { mutableStateOf(false) }
@@ -62,7 +65,7 @@ fun Modifier.scaleOnPress(
val scaleValue by animateFloatAsState( val scaleValue by animateFloatAsState(
targetValue = if (pressed) scale else 1f, targetValue = if (pressed) scale else 1f,
animationSpec = spring(), animationSpec = animationSpec,
label = "scaleOnPress" label = "scaleOnPress"
) )
@@ -3,6 +3,7 @@ package ru.fromchat.ui.main
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -101,8 +102,9 @@ fun SettingsTab(
) { innerPadding -> ) { innerPadding ->
Column( Column(
Modifier Modifier
.padding(innerPadding) .fillMaxWidth()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(innerPadding)
) { ) {
var materialYouSwitch by remember { var materialYouSwitch by remember {
mutableStateOf( mutableStateOf(
@@ -1,5 +1,7 @@
package ru.fromchat.ui.profile 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.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.LocalIndication
@@ -45,7 +47,11 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
@@ -76,6 +82,12 @@ private data class ProfileUiState(
val linkStatus: String? = null val linkStatus: String? = null
) )
private val profileActionCardPressSpring = spring<Float>(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessLow,
visibilityThreshold = 0.001f
)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ProfileScreen( fun ProfileScreen(
@@ -95,10 +107,11 @@ fun ProfileScreen(
val navController = LocalNavController.current val navController = LocalNavController.current
val hideBackButton = navController.currentDestination?.route == "chat" val hideBackButton = navController.currentDestination?.route == "chat"
val targetUserId = userId.takeIf { it != null && it > 0 } 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) { var state by remember(targetUserId, ownUserId) {
val cached = targetUserId?.let { ProfileCache.get(it) } val cached = cacheLookupId?.let { ProfileCache.get(it) }
mutableStateOf( mutableStateOf(
ProfileUiState( ProfileUiState(
profile = cached, profile = cached,
@@ -108,7 +121,11 @@ fun ProfileScreen(
) )
} }
LaunchedEffect(fetchKey) { val latestUi by rememberUpdatedState(state)
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(cacheLookupId, targetUserId, lifecycleOwner) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
runCatching { runCatching {
if (targetUserId == null) { if (targetUserId == null) {
ApiClient.getOwnProfile() ApiClient.getOwnProfile()
@@ -117,14 +134,22 @@ fun ProfileScreen(
} }
}.onSuccess { profile -> }.onSuccess { profile ->
ProfileCache.put(profile) ProfileCache.put(profile)
state = state.copy(profile = profile, isLoading = false, error = null) state = latestUi.copy(profile = profile, isLoading = false, error = null)
}.onFailure { }.onFailure { err ->
state = state.copy( val fallbackId = targetUserId ?: ownUserId
error = if (state.profile == null) it.message ?: "Unable to load profile" else null, 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 isLoading = false
) )
} }
} }
}
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
@@ -154,9 +179,7 @@ fun ProfileScreen(
} }
) { innerPadding -> ) { innerPadding ->
Box( Box(
modifier = Modifier modifier = Modifier.fillMaxSize()
.fillMaxSize()
.padding(innerPadding)
) { ) {
val errorMessage = state.error val errorMessage = state.error
val profile = state.profile val profile = state.profile
@@ -171,9 +194,9 @@ fun ProfileScreen(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxWidth()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
, .padding(innerPadding),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
when { when {
@@ -267,7 +290,7 @@ fun ProfileScreen(
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(36.dp))
Row( Row(
modifier = Modifier modifier = Modifier
@@ -292,7 +315,8 @@ fun ProfileScreen(
.scaleOnPress( .scaleOnPress(
scale = 0.90f, scale = 0.90f,
interactionSource = primarySource, interactionSource = primarySource,
clipShape = MaterialTheme.shapes.extraLarge clipShape = MaterialTheme.shapes.extraLarge,
animationSpec = profileActionCardPressSpring
), ),
shape = MaterialTheme.shapes.extraLarge, shape = MaterialTheme.shapes.extraLarge,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer) colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer)
@@ -331,7 +355,8 @@ fun ProfileScreen(
.scaleOnPress( .scaleOnPress(
scale = 0.90f, scale = 0.90f,
interactionSource = linkSource, interactionSource = linkSource,
clipShape = MaterialTheme.shapes.extraLarge clipShape = MaterialTheme.shapes.extraLarge,
animationSpec = profileActionCardPressSpring
), ),
shape = MaterialTheme.shapes.extraLarge, shape = MaterialTheme.shapes.extraLarge,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer) colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer)