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")
token = null
user = null
runCatching { ProfileCache.clear() }
}
fun getTokenSafely() = token ?: throw IllegalStateException("Not authenticated")
@@ -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
@@ -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<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) {
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.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)
@@ -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<Float> = 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"
)
@@ -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(
@@ -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<Float>(
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,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 {
if (targetUserId == null) {
ApiClient.getOwnProfile()
@@ -117,14 +134,22 @@ fun ProfileScreen(
}
}.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,
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
)
}
}
}
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
@@ -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)