fix: иконки шторки в единой круглой рамке; кэш Задач для быстрого показа

- Шторка: КАЖДАЯ иконка теперь в белом круге с рамкой и мягкой тенью,
  все одного размера (глиф внутри 56% рамки) — как на мобильном сайте
  (было: плоские глифы без рамки, разного размера).
- Задачи грузятся быстрее: TasksCache (в памяти на время процесса) —
  списки и задачи по спискам показываются мгновенно из кэша при
  повторном открытии раздела/списка, сеть обновляет в фоне. Гонки
  учтены: результат применяется, только если пользователь ещё на этом
  списке.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
b-dev-mobile
2026-07-10 07:52:01 +00:00
parent 25cfbe9bdc
commit e1e0427066
3 changed files with 74 additions and 20 deletions
@@ -236,32 +236,35 @@ private fun F7AppMenuGridItem(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
val localIcon = item.localIcon
if (localIcon != null) {
// Нативный пункт: иконка в круглом бейдже с зелёной обводкой (стиль glass)
// Единая круглая рамка (белый круг + рамка + мягкая тень) под КАЖДУЮ иконку —
// как на мобильном сайте: все иконки одного размера в кружке. Глиф внутри меньше рамки.
val frameShape = CircleShape
Box(
modifier = Modifier
.size(MenuIconSize)
.clip(CircleShape)
.shadow(2.dp, frameShape, spotColor = Color(0xFFE6E6E6))
.clip(frameShape)
.background(Color.White)
.border(1.5.dp, F7Colors.Green30, CircleShape),
.border(1.dp, Color(0xFFECECEC), frameShape),
contentAlignment = Alignment.Center,
) {
val localIcon = item.localIcon
if (localIcon != null) {
Icon(
localIcon,
contentDescription = item.label,
tint = F7Colors.Primary,
modifier = Modifier.size(MenuIconSize * 0.46f),
modifier = Modifier.size(MenuIconSize * 0.5f),
)
}
} else {
AsyncImage(
model = item.iconUrl,
contentDescription = item.label,
modifier = Modifier.size(MenuIconSize),
modifier = Modifier.size(MenuIconSize * 0.56f),
contentScale = ContentScale.Fit,
)
}
}
Text(
text = item.label,
style = MaterialTheme.typography.labelLarge.copy(
@@ -0,0 +1,28 @@
package ru.forbion.f7cloud.feature.tasks
/**
* Лёгкий кэш в памяти на время жизни процесса: списки задач и задачи по каждому списку.
* VM пересоздаётся при каждом открытии раздела Задач — без кэша это полный PROPFIND+REPORT
* заново. Кэш даёт мгновенный показ, сеть обновляет в фоне.
*/
internal object TasksCache {
@Volatile
private var lists: List<TaskListItem>? = null
private val tasksByList = java.util.concurrent.ConcurrentHashMap<String, List<TaskItem>>()
fun cachedLists(): List<TaskListItem>? = lists
fun putLists(value: List<TaskListItem>) {
lists = value
}
fun cachedTasks(listHref: String): List<TaskItem>? = tasksByList[listHref]
fun putTasks(listHref: String, value: List<TaskItem>) {
tasksByList[listHref] = value
}
fun invalidateTasks(listHref: String) {
tasksByList.remove(listHref)
}
}
@@ -74,10 +74,27 @@ class TasksViewModel(
val state: StateFlow<TasksUiState> = _state.asStateFlow()
fun load(session: AuthSession) {
// Мгновенно показываем кэш (списки + задачи выбранного), сеть обновляет в фоне.
val cachedLists = TasksCache.cachedLists()
if (cachedLists != null && cachedLists.isNotEmpty()) {
val selected = _state.value.selectedListHref
?: _state.value.defaultListHref
?: cachedLists.firstOrNull()?.href
_state.update {
it.copy(
loading = false,
lists = cachedLists,
selectedListHref = selected,
defaultListHref = it.defaultListHref ?: cachedLists.firstOrNull()?.href,
tasks = selected?.let { href -> TasksCache.cachedTasks(href) } ?: it.tasks,
)
}
}
viewModelScope.launch(Dispatchers.IO) {
_state.update { it.copy(loading = it.lists.isEmpty(), error = null) }
runCatching { repository.listTaskLists(session) }
.onSuccess { lists ->
TasksCache.putLists(lists)
val selected = _state.value.selectedListHref
?: _state.value.defaultListHref
?: lists.firstOrNull()?.href
@@ -107,6 +124,8 @@ class TasksViewModel(
_state.update {
it.copy(
selectedListHref = listHref,
// Мгновенно из кэша, если есть — иначе пусто до загрузки.
tasks = TasksCache.cachedTasks(listHref).orEmpty(),
searchQuery = "",
detailTask = null,
createInputExpanded = false,
@@ -381,8 +400,12 @@ class TasksViewModel(
_state.update { it.copy(loading = _state.value.tasks.isEmpty(), error = null) }
runCatching { repository.loadTasks(session, listHref) }
.onSuccess { tasks ->
TasksCache.putTasks(listHref, tasks)
// Обновляем список задач, только если пользователь всё ещё на этом списке.
if (_state.value.selectedListHref == listHref) {
_state.update { it.copy(loading = false, tasks = tasks) }
}
}
.onFailure { t ->
_state.update {
it.copy(