feat(tasks): P1 — умные коллекции в панели навигации (агрегат + счётчики + открытие)

TasksListNavView рисует видимые коллекции (visibleKeys по режиму+счётчику),
тап → openSmartList (агрегат всех списков, фильтр по коллекции). VM: loadAllTasks
агрегирует все списки, reloadCurrent обновляет вид после мутаций, create в коллекции
идёт в список по умолчанию. Гейт compile+detekt+test зелёный (25 юнитов).
Осталось по P1: 3-state настройки видимости + персист (SharedPreferences).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
b-mob-tasks
2026-07-13 06:00:59 +00:00
parent e405dad8fa
commit 23f7565a1a
3 changed files with 165 additions and 11 deletions
@@ -237,11 +237,23 @@ fun TasksListNavView(
selectedHref: String?, selectedHref: String?,
onListClick: (TaskListItem) -> Unit, onListClick: (TaskListItem) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
smartKeys: List<SmartListKey> = emptyList(),
smartCounts: Map<SmartListKey, Int> = emptyMap(),
onSmartClick: (SmartListKey) -> Unit = {},
) { ) {
LazyColumn( LazyColumn(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
items(smartKeys, key = { "smart-${it.name}" }) { key ->
SmartCollectionRow(
serverUrl = serverUrl,
title = key.title,
icon = key.icon,
count = smartCounts[key] ?: 0,
onClick = { onSmartClick(key) },
)
}
item { item {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -304,6 +316,59 @@ fun TasksListNavView(
} }
} }
@Composable
private fun SmartCollectionRow(
serverUrl: String,
title: String,
icon: String,
count: Int,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(40.dp)
.clip(RoundedCornerShape(8.dp))
.background(TasksFieldBg)
.tasksFieldShadow()
.clickable { onClick() }
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
AsyncImage(
model = tasksAsset(serverUrl, icon),
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Text(
title,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = F7Colors.TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (count > 0) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(100.dp))
.background(F7Colors.PrimaryLight)
.padding(horizontal = 8.dp, vertical = 2.dp),
contentAlignment = Alignment.Center,
) {
Text(
count.toString(),
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = F7Colors.PrimaryDark,
)
}
}
}
}
@Composable @Composable
fun TasksGroupHeader( fun TasksGroupHeader(
title: String, title: String,
@@ -90,12 +90,15 @@ fun TasksScreen(
CircularProgressIndicator(color = F7Colors.Primary) CircularProgressIndicator(color = F7Colors.Primary)
} }
} }
state.selectedListHref == null -> { !state.showTaskList -> {
TasksListNavView( TasksListNavView(
serverUrl = session.serverUrl, serverUrl = session.serverUrl,
lists = state.lists, lists = state.lists,
selectedHref = null, selectedHref = null,
onListClick = { vm.openList(session, it.href) }, onListClick = { vm.openList(session, it.href) },
smartKeys = state.visibleSmartKeys,
smartCounts = state.smartCounts,
onSmartClick = { vm.openSmartList(session, it) },
) )
} }
else -> { else -> {
@@ -31,10 +31,30 @@ data class TasksUiState(
val error: String? = null, val error: String? = null,
val unauthorized: Boolean = false, val unauthorized: Boolean = false,
val detailTask: TaskItem? = null, val detailTask: TaskItem? = null,
// Умные коллекции (P1): агрегат задач всех списков + режим видимости + выбранная коллекция.
val allTasks: List<TaskItem> = emptyList(),
val smartVisibility: SmartListVisibility = SmartListVisibility.DEFAULT,
val selectedSmartKey: SmartListKey? = null,
) { ) {
val selectedList: TaskListItem? val selectedList: TaskListItem?
get() = lists.firstOrNull { it.href == selectedListHref } get() = lists.firstOrNull { it.href == selectedListHref }
/** Счётчики умных коллекций по агрегату всех списков (для бейджей в панели). */
val smartCounts: Map<SmartListKey, Int>
get() = TasksSmartLists.counts(allTasks, LocalDate.now())
/** Умные коллекции, видимые в панели (режим + счётчик). */
val visibleSmartKeys: List<SmartListKey>
get() = TasksSmartLists.visibleKeys(smartVisibility, smartCounts)
/** Заголовок текущего экрана списка: реальный список или умная коллекция. */
val currentTitle: String
get() = selectedSmartKey?.title ?: selectedList?.displayName ?: ""
/** Показывать экран задач (реальный список ИЛИ умная коллекция выбраны). */
val showTaskList: Boolean
get() = selectedListHref != null || selectedSmartKey != null
private fun matchesSearch(task: TaskItem): Boolean { private fun matchesSearch(task: TaskItem): Boolean {
val q = searchQuery.trim() val q = searchQuery.trim()
return q.isEmpty() || return q.isEmpty() ||
@@ -107,6 +127,8 @@ class TasksViewModel(
) )
} }
selected?.let { loadTasks(session, it) } selected?.let { loadTasks(session, it) }
// Агрегат всех списков — для умных коллекций и их счётчиков.
loadAllTasks(session)
} }
.onFailure { t -> .onFailure { t ->
_state.update { _state.update {
@@ -124,6 +146,7 @@ class TasksViewModel(
_state.update { _state.update {
it.copy( it.copy(
selectedListHref = listHref, selectedListHref = listHref,
selectedSmartKey = null,
// Мгновенно из кэша, если есть — иначе пусто до загрузки. // Мгновенно из кэша, если есть — иначе пусто до загрузки.
tasks = TasksCache.cachedTasks(listHref).orEmpty(), tasks = TasksCache.cachedTasks(listHref).orEmpty(),
searchQuery = "", searchQuery = "",
@@ -135,10 +158,32 @@ class TasksViewModel(
loadTasks(session, listHref) loadTasks(session, listHref)
} }
/** Открыть умную коллекцию (агрегат всех списков, отфильтрованный по [key]). */
fun openSmartList(session: AuthSession, key: SmartListKey) {
_state.update {
it.copy(
selectedSmartKey = key,
selectedListHref = null,
tasks = TasksSmartLists.filter(key, it.allTasks, LocalDate.now()),
searchQuery = "",
detailTask = null,
createInputExpanded = false,
createInputText = "",
)
}
loadAllTasks(session)
}
/** Сменить режим видимости умной коллекции (шит настроек). */
fun setSmartMode(key: SmartListKey, mode: SmartListMode) {
_state.update { it.copy(smartVisibility = it.smartVisibility.withMode(key, mode)) }
}
fun closeList() { fun closeList() {
_state.update { _state.update {
it.copy( it.copy(
selectedListHref = null, selectedListHref = null,
selectedSmartKey = null,
tasks = emptyList(), tasks = emptyList(),
detailTask = null, detailTask = null,
createInputExpanded = false, createInputExpanded = false,
@@ -218,7 +263,11 @@ class TasksViewModel(
priority: Int, priority: Int,
parentUid: String = "", parentUid: String = "",
) { ) {
val listHref = _state.value.selectedListHref ?: return // В умной коллекции реального списка нет — создаём в список по умолчанию.
val listHref = _state.value.selectedListHref
?: _state.value.defaultListHref
?: _state.value.lists.firstOrNull()?.href
?: return
if (summary.isBlank()) { if (summary.isBlank()) {
_state.update { it.copy(error = "Введите название задачи") } _state.update { it.copy(error = "Введите название задачи") }
return return
@@ -248,7 +297,7 @@ class TasksViewModel(
createInputText = "", createInputText = "",
) )
} }
loadTasks(session, listHref) reloadCurrent(session)
}.onFailure { t -> }.onFailure { t ->
_state.update { _state.update {
it.copy( it.copy(
@@ -266,7 +315,6 @@ class TasksViewModel(
runCatching { runCatching {
repository.toggleComplete(session, task, !task.isCompleted) repository.toggleComplete(session, task, !task.isCompleted)
}.onSuccess { }.onSuccess {
val listHref = _state.value.selectedListHref
val detail = _state.value.detailTask val detail = _state.value.detailTask
if (detail?.uid == task.uid) { if (detail?.uid == task.uid) {
_state.update { _state.update {
@@ -276,7 +324,7 @@ class TasksViewModel(
)) ))
} }
} }
listHref?.let { loadTasks(session, it) } reloadCurrent(session)
}.onFailure { t -> }.onFailure { t ->
_state.update { _state.update {
it.copy( it.copy(
@@ -309,7 +357,7 @@ class TasksViewModel(
) )
}.onSuccess { }.onSuccess {
_state.update { it.copy(saving = false, detailTask = null) } _state.update { it.copy(saving = false, detailTask = null) }
_state.value.selectedListHref?.let { loadTasks(session, it) } reloadCurrent(session)
}.onFailure { t -> }.onFailure { t ->
_state.update { _state.update {
it.copy( it.copy(
@@ -331,7 +379,7 @@ class TasksViewModel(
_state.update { _state.update {
it.copy(saving = false, detailTask = it.detailTask?.takeIf { d -> d.uid != task.uid }) it.copy(saving = false, detailTask = it.detailTask?.takeIf { d -> d.uid != task.uid })
} }
_state.value.selectedListHref?.let { loadTasks(session, it) } reloadCurrent(session)
} }
.onFailure { t -> .onFailure { t ->
_state.update { _state.update {
@@ -353,7 +401,7 @@ class TasksViewModel(
runCatching { repository.deleteTask(session, task) } runCatching { repository.deleteTask(session, task) }
.onSuccess { .onSuccess {
_state.update { it.copy(saving = false, detailTask = null) } _state.update { it.copy(saving = false, detailTask = null) }
_state.value.selectedListHref?.let { loadTasks(session, it) } reloadCurrent(session)
} }
.onFailure { t -> .onFailure { t ->
_state.update { _state.update {
@@ -376,7 +424,7 @@ class TasksViewModel(
runCatching { repository.deleteCompletedTasks(session, completed) } runCatching { repository.deleteCompletedTasks(session, completed) }
.onSuccess { .onSuccess {
_state.update { it.copy(deletingCompleted = false, completedExpanded = false) } _state.update { it.copy(deletingCompleted = false, completedExpanded = false) }
loadTasks(session, listHref) reloadCurrent(session)
} }
.onFailure { t -> .onFailure { t ->
_state.update { _state.update {
@@ -391,8 +439,46 @@ class TasksViewModel(
} }
fun refreshTasks(session: AuthSession) { fun refreshTasks(session: AuthSession) {
_state.value.selectedListHref?.let { loadTasks(session, it) } val href = _state.value.selectedListHref
?: load(session) when {
href != null -> loadTasks(session, href)
_state.value.selectedSmartKey != null -> loadAllTasks(session)
else -> load(session)
}
}
/** Перезагрузить текущий вид: реальный список и/или агрегат (если открыта умная коллекция). */
private fun reloadCurrent(session: AuthSession) {
val href = _state.value.selectedListHref
href?.let { loadTasks(session, it) }
if (_state.value.selectedSmartKey != null || href == null) {
loadAllTasks(session)
}
}
/** Загрузить задачи ВСЕХ списков в агрегат (умные коллекции + счётчики). */
private fun loadAllTasks(session: AuthSession) {
val lists = _state.value.lists
if (lists.isEmpty()) return
viewModelScope.launch(Dispatchers.IO) {
val aggregate = mutableListOf<TaskItem>()
lists.forEach { list ->
runCatching { repository.loadTasks(session, list.href) }
.onSuccess { tasks ->
TasksCache.putTasks(list.href, tasks)
aggregate += tasks
}
}
_state.update { st ->
st.copy(
allTasks = aggregate,
// Если открыта умная коллекция — переотфильтровать её содержимое.
tasks = st.selectedSmartKey?.let {
TasksSmartLists.filter(it, aggregate, LocalDate.now())
} ?: st.tasks,
)
}
}
} }
private fun loadTasks(session: AuthSession, listHref: String) { private fun loadTasks(session: AuthSession, listHref: String) {