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?,
onListClick: (TaskListItem) -> Unit,
modifier: Modifier = Modifier,
smartKeys: List<SmartListKey> = emptyList(),
smartCounts: Map<SmartListKey, Int> = emptyMap(),
onSmartClick: (SmartListKey) -> Unit = {},
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
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 {
Row(
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
fun TasksGroupHeader(
title: String,
@@ -90,12 +90,15 @@ fun TasksScreen(
CircularProgressIndicator(color = F7Colors.Primary)
}
}
state.selectedListHref == null -> {
!state.showTaskList -> {
TasksListNavView(
serverUrl = session.serverUrl,
lists = state.lists,
selectedHref = null,
onListClick = { vm.openList(session, it.href) },
smartKeys = state.visibleSmartKeys,
smartCounts = state.smartCounts,
onSmartClick = { vm.openSmartList(session, it) },
)
}
else -> {
@@ -31,10 +31,30 @@ data class TasksUiState(
val error: String? = null,
val unauthorized: Boolean = false,
val detailTask: TaskItem? = null,
// Умные коллекции (P1): агрегат задач всех списков + режим видимости + выбранная коллекция.
val allTasks: List<TaskItem> = emptyList(),
val smartVisibility: SmartListVisibility = SmartListVisibility.DEFAULT,
val selectedSmartKey: SmartListKey? = null,
) {
val selectedList: TaskListItem?
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 {
val q = searchQuery.trim()
return q.isEmpty() ||
@@ -107,6 +127,8 @@ class TasksViewModel(
)
}
selected?.let { loadTasks(session, it) }
// Агрегат всех списков — для умных коллекций и их счётчиков.
loadAllTasks(session)
}
.onFailure { t ->
_state.update {
@@ -124,6 +146,7 @@ class TasksViewModel(
_state.update {
it.copy(
selectedListHref = listHref,
selectedSmartKey = null,
// Мгновенно из кэша, если есть — иначе пусто до загрузки.
tasks = TasksCache.cachedTasks(listHref).orEmpty(),
searchQuery = "",
@@ -135,10 +158,32 @@ class TasksViewModel(
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() {
_state.update {
it.copy(
selectedListHref = null,
selectedSmartKey = null,
tasks = emptyList(),
detailTask = null,
createInputExpanded = false,
@@ -218,7 +263,11 @@ class TasksViewModel(
priority: Int,
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()) {
_state.update { it.copy(error = "Введите название задачи") }
return
@@ -248,7 +297,7 @@ class TasksViewModel(
createInputText = "",
)
}
loadTasks(session, listHref)
reloadCurrent(session)
}.onFailure { t ->
_state.update {
it.copy(
@@ -266,7 +315,6 @@ class TasksViewModel(
runCatching {
repository.toggleComplete(session, task, !task.isCompleted)
}.onSuccess {
val listHref = _state.value.selectedListHref
val detail = _state.value.detailTask
if (detail?.uid == task.uid) {
_state.update {
@@ -276,7 +324,7 @@ class TasksViewModel(
))
}
}
listHref?.let { loadTasks(session, it) }
reloadCurrent(session)
}.onFailure { t ->
_state.update {
it.copy(
@@ -309,7 +357,7 @@ class TasksViewModel(
)
}.onSuccess {
_state.update { it.copy(saving = false, detailTask = null) }
_state.value.selectedListHref?.let { loadTasks(session, it) }
reloadCurrent(session)
}.onFailure { t ->
_state.update {
it.copy(
@@ -331,7 +379,7 @@ class TasksViewModel(
_state.update {
it.copy(saving = false, detailTask = it.detailTask?.takeIf { d -> d.uid != task.uid })
}
_state.value.selectedListHref?.let { loadTasks(session, it) }
reloadCurrent(session)
}
.onFailure { t ->
_state.update {
@@ -353,7 +401,7 @@ class TasksViewModel(
runCatching { repository.deleteTask(session, task) }
.onSuccess {
_state.update { it.copy(saving = false, detailTask = null) }
_state.value.selectedListHref?.let { loadTasks(session, it) }
reloadCurrent(session)
}
.onFailure { t ->
_state.update {
@@ -376,7 +424,7 @@ class TasksViewModel(
runCatching { repository.deleteCompletedTasks(session, completed) }
.onSuccess {
_state.update { it.copy(deletingCompleted = false, completedExpanded = false) }
loadTasks(session, listHref)
reloadCurrent(session)
}
.onFailure { t ->
_state.update {
@@ -391,8 +439,46 @@ class TasksViewModel(
}
fun refreshTasks(session: AuthSession) {
_state.value.selectedListHref?.let { loadTasks(session, it) }
?: load(session)
val href = _state.value.selectedListHref
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) {