ux: pull-to-refresh на всех списках (Этап 2 п.12)

Раньше свайп-обновление было только в Файлах. Добавлено (Material3
PullToRefreshBox): Почта (входящие/исходящие, force-обновление), Конференции
(список бесед — заменяет убранную кнопку ↻), Контакты (force + CTag — дёшево),
Задачи (список), Карточки (доски и открытая доска). Контакты: refresh()
получил параметр force (pull-to-refresh форсит sync мимо TTL; с CTag-проверкой
это 1 лёгкий PROPFIND).
This commit is contained in:
b-dev-mobile
2026-07-09 12:48:08 +00:00
parent 286ab9c8bf
commit d1d61d0cf8
6 changed files with 88 additions and 32 deletions
@@ -21,6 +21,8 @@ import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -51,6 +53,7 @@ import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7CreateButton
import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ContactsScreen(
session: AuthSession,
@@ -94,21 +97,21 @@ fun ContactsScreen(
)
F7CreateButton(onClick = vm::openAddSheet, size = 40.dp)
}
if (state.syncing && state.contacts.isNotEmpty()) {
Text(
"Обновление…",
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
style = MaterialTheme.typography.labelSmall,
color = F7Colors.TextSecondary,
)
}
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(state.filteredContacts, key = { "${it.uid}|${it.email}" }) { contact ->
ContactListRow(
contact = contact,
onClick = { vm.openContact(contact) },
)
HorizontalDivider(color = F7Colors.Border.copy(alpha = 0.6f))
PullToRefreshBox(
isRefreshing = state.syncing && state.contacts.isNotEmpty(),
onRefresh = { vm.refresh(session, force = true) },
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(state.filteredContacts, key = { "${it.uid}|${it.email}" }) { contact ->
ContactListRow(
contact = contact,
onClick = { vm.openContact(contact) },
)
HorizontalDivider(color = F7Colors.Border.copy(alpha = 0.6f))
}
}
}
}
@@ -99,14 +99,16 @@ class ContactsViewModel(
}
}
fun refresh(session: AuthSession, showLoading: Boolean = false) {
fun refresh(session: AuthSession, showLoading: Boolean = false, force: Boolean = false) {
viewModelScope.launch(Dispatchers.IO) {
if (showLoading && _state.value.contacts.isEmpty()) {
_state.update { it.copy(loading = true, error = null) }
} else {
_state.update { it.copy(syncing = true, error = null) }
}
runCatching { repository.syncContacts(session) }
// force (pull-to-refresh) с CTag-проверкой дешёвый: 1 лёгкий PROPFIND,
// полная закачка vCard — только если адресные книги реально менялись
runCatching { repository.syncContacts(session, force = force) }
.onFailure { t ->
_state.update {
it.copy(
@@ -1,10 +1,13 @@
package ru.forbion.f7cloud.feature.deck
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -17,6 +20,7 @@ import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler
import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DeckScreen(
session: AuthSession,
@@ -57,23 +61,33 @@ fun DeckScreen(
},
) {
val detail = state.boardDetail
if (detail != null) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(detail.stacks, key = { it.id }) { stack ->
F7ListCard {
Text(stack.title, style = MaterialTheme.typography.titleSmall)
stack.cards.forEach { card ->
val prefix = if (card.done) "" else ""
Text(prefix + card.title, style = MaterialTheme.typography.bodyMedium)
PullToRefreshBox(
isRefreshing = state.loading && (state.boards.isNotEmpty() || detail != null),
onRefresh = {
if (detail != null) vm.openBoard(session, detail.boardId) else vm.load(session)
},
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
) {
if (detail != null) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(detail.stacks, key = { it.id }) { stack ->
F7ListCard {
Text(stack.title, style = MaterialTheme.typography.titleSmall)
stack.cards.forEach { card ->
val prefix = if (card.done) "" else ""
Text(prefix + card.title, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
}
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(state.boards, key = { it.id }) { board ->
F7ListCard(onClick = { vm.openBoard(session, board.id) }) {
Text(board.title, style = MaterialTheme.typography.titleSmall)
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(state.boards, key = { it.id }) { board ->
F7ListCard(onClick = { vm.openBoard(session, board.id) }) {
Text(board.title, style = MaterialTheme.typography.titleSmall)
}
}
}
}
@@ -24,6 +24,8 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -485,6 +487,8 @@ fun MailScreen(
onOutboxRetry = { vm.retryOutboxMessage(session, it) },
onOutboxDelete = { vm.deleteOutboxMessage(session, it) },
onLoadMore = { vm.loadMore(session) },
refreshing = state.loading,
onRefresh = { vm.load(session, forceRefresh = true) },
)
}
MailNavigationSidebar(
@@ -520,6 +524,7 @@ fun MailScreen(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MailInboxScreen(
session: AuthSession,
@@ -542,6 +547,8 @@ private fun MailInboxScreen(
onOutboxRetry: (Int) -> Unit,
onOutboxDelete: (Int) -> Unit,
onLoadMore: () -> Unit,
refreshing: Boolean = false,
onRefresh: () -> Unit = {},
) {
val listItems = remember(messages) { buildMailInboxListItems(messages) }
val folderTitle = mailFolderListTitle(selectedFolder, searchQuery, searchParams)
@@ -595,11 +602,14 @@ private fun MailInboxScreen(
if (!error.isNullOrBlank()) {
Text(text = error, color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium)
}
Box(
PullToRefreshBox(
isRefreshing = refreshing && !loading,
onRefresh = onRefresh,
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
) {
Box(modifier = Modifier.fillMaxSize()) {
if (loading) {
Box(
modifier = Modifier.fillMaxSize(),
@@ -705,6 +715,7 @@ private fun MailInboxScreen(
}
}
}
}
}
}
@@ -29,6 +29,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -318,11 +320,14 @@ fun TalkScreen(
onCreateRoom = vm::openCreateRoom,
onToggleFavorite = { vm.toggleFavorite(session, it.token) },
onMarkUnread = { vm.markRoomUnread(session, it.token) },
refreshing = state.loading && state.rooms.isNotEmpty(),
onRefresh = { vm.load(session) },
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RoomListContent(
session: AuthSession,
@@ -337,6 +342,8 @@ private fun RoomListContent(
onCreateRoom: () -> Unit,
onToggleFavorite: (TalkRoom) -> Unit,
onMarkUnread: (TalkRoom) -> Unit,
refreshing: Boolean = false,
onRefresh: () -> Unit = {},
) {
Column(
modifier = Modifier.fillMaxSize(),
@@ -351,6 +358,13 @@ private fun RoomListContent(
onCreateClick = onCreateRoom,
)
TalkFilterChips(selected = roomFilter, onClear = onClearFilter)
PullToRefreshBox(
isRefreshing = refreshing,
onRefresh = onRefresh,
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
) {
if (rooms.isEmpty()) {
Column(
modifier = Modifier
@@ -391,6 +405,7 @@ private fun RoomListContent(
}
}
}
}
}
}
@@ -11,6 +11,8 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -143,6 +145,7 @@ fun TasksScreen(
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TasksMainContent(
session: AuthSession,
@@ -190,6 +193,13 @@ private fun TasksMainContent(
modifier = Modifier.fillMaxWidth(),
)
} else {
PullToRefreshBox(
isRefreshing = state.loading,
onRefresh = onReload,
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -229,6 +239,7 @@ private fun TasksMainContent(
}
}
}
}
}
}
}