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:
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user