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.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect 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.F7CreateButton
import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ContactsScreen( fun ContactsScreen(
session: AuthSession, session: AuthSession,
@@ -94,14 +97,13 @@ fun ContactsScreen(
) )
F7CreateButton(onClick = vm::openAddSheet, size = 40.dp) F7CreateButton(onClick = vm::openAddSheet, size = 40.dp)
} }
if (state.syncing && state.contacts.isNotEmpty()) { PullToRefreshBox(
Text( isRefreshing = state.syncing && state.contacts.isNotEmpty(),
"Обновление…", onRefresh = { vm.refresh(session, force = true) },
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), modifier = Modifier
style = MaterialTheme.typography.labelSmall, .weight(1f)
color = F7Colors.TextSecondary, .fillMaxWidth(),
) ) {
}
LazyColumn(modifier = Modifier.fillMaxSize()) { LazyColumn(modifier = Modifier.fillMaxSize()) {
items(state.filteredContacts, key = { "${it.uid}|${it.email}" }) { contact -> items(state.filteredContacts, key = { "${it.uid}|${it.email}" }) { contact ->
ContactListRow( ContactListRow(
@@ -112,6 +114,7 @@ fun ContactsScreen(
} }
} }
} }
}
state.selectedContact?.let { contact -> state.selectedContact?.let { contact ->
ContactDetailSheet( ContactDetailSheet(
@@ -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) { viewModelScope.launch(Dispatchers.IO) {
if (showLoading && _state.value.contacts.isEmpty()) { if (showLoading && _state.value.contacts.isEmpty()) {
_state.update { it.copy(loading = true, error = null) } _state.update { it.copy(loading = true, error = null) }
} else { } else {
_state.update { it.copy(syncing = true, error = null) } _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 -> .onFailure { t ->
_state.update { _state.update {
it.copy( it.copy(
@@ -1,10 +1,13 @@
package ru.forbion.f7cloud.feature.deck package ru.forbion.f7cloud.feature.deck
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState 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.F7OverlayDismissHandler
import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun DeckScreen( fun DeckScreen(
session: AuthSession, session: AuthSession,
@@ -57,6 +61,15 @@ fun DeckScreen(
}, },
) { ) {
val detail = state.boardDetail val detail = state.boardDetail
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) { if (detail != null) {
LazyColumn(modifier = Modifier.fillMaxSize()) { LazyColumn(modifier = Modifier.fillMaxSize()) {
items(detail.stacks, key = { it.id }) { stack -> items(detail.stacks, key = { it.id }) { stack ->
@@ -80,3 +93,4 @@ fun DeckScreen(
} }
} }
} }
}
@@ -24,6 +24,8 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -485,6 +487,8 @@ fun MailScreen(
onOutboxRetry = { vm.retryOutboxMessage(session, it) }, onOutboxRetry = { vm.retryOutboxMessage(session, it) },
onOutboxDelete = { vm.deleteOutboxMessage(session, it) }, onOutboxDelete = { vm.deleteOutboxMessage(session, it) },
onLoadMore = { vm.loadMore(session) }, onLoadMore = { vm.loadMore(session) },
refreshing = state.loading,
onRefresh = { vm.load(session, forceRefresh = true) },
) )
} }
MailNavigationSidebar( MailNavigationSidebar(
@@ -520,6 +524,7 @@ fun MailScreen(
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun MailInboxScreen( private fun MailInboxScreen(
session: AuthSession, session: AuthSession,
@@ -542,6 +547,8 @@ private fun MailInboxScreen(
onOutboxRetry: (Int) -> Unit, onOutboxRetry: (Int) -> Unit,
onOutboxDelete: (Int) -> Unit, onOutboxDelete: (Int) -> Unit,
onLoadMore: () -> Unit, onLoadMore: () -> Unit,
refreshing: Boolean = false,
onRefresh: () -> Unit = {},
) { ) {
val listItems = remember(messages) { buildMailInboxListItems(messages) } val listItems = remember(messages) { buildMailInboxListItems(messages) }
val folderTitle = mailFolderListTitle(selectedFolder, searchQuery, searchParams) val folderTitle = mailFolderListTitle(selectedFolder, searchQuery, searchParams)
@@ -595,11 +602,14 @@ private fun MailInboxScreen(
if (!error.isNullOrBlank()) { if (!error.isNullOrBlank()) {
Text(text = error, color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium) Text(text = error, color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium)
} }
Box( PullToRefreshBox(
isRefreshing = refreshing && !loading,
onRefresh = onRefresh,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.fillMaxWidth(), .fillMaxWidth(),
) { ) {
Box(modifier = Modifier.fillMaxSize()) {
if (loading) { if (loading) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -707,6 +717,7 @@ private fun MailInboxScreen(
} }
} }
} }
}
@Composable @Composable
private fun MailMessageDetailHeader( private fun MailMessageDetailHeader(
@@ -29,6 +29,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -318,11 +320,14 @@ fun TalkScreen(
onCreateRoom = vm::openCreateRoom, onCreateRoom = vm::openCreateRoom,
onToggleFavorite = { vm.toggleFavorite(session, it.token) }, onToggleFavorite = { vm.toggleFavorite(session, it.token) },
onMarkUnread = { vm.markRoomUnread(session, it.token) }, onMarkUnread = { vm.markRoomUnread(session, it.token) },
refreshing = state.loading && state.rooms.isNotEmpty(),
onRefresh = { vm.load(session) },
) )
} }
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun RoomListContent( private fun RoomListContent(
session: AuthSession, session: AuthSession,
@@ -337,6 +342,8 @@ private fun RoomListContent(
onCreateRoom: () -> Unit, onCreateRoom: () -> Unit,
onToggleFavorite: (TalkRoom) -> Unit, onToggleFavorite: (TalkRoom) -> Unit,
onMarkUnread: (TalkRoom) -> Unit, onMarkUnread: (TalkRoom) -> Unit,
refreshing: Boolean = false,
onRefresh: () -> Unit = {},
) { ) {
Column( Column(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -351,6 +358,13 @@ private fun RoomListContent(
onCreateClick = onCreateRoom, onCreateClick = onCreateRoom,
) )
TalkFilterChips(selected = roomFilter, onClear = onClearFilter) TalkFilterChips(selected = roomFilter, onClear = onClearFilter)
PullToRefreshBox(
isRefreshing = refreshing,
onRefresh = onRefresh,
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
) {
if (rooms.isEmpty()) { if (rooms.isEmpty()) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -393,6 +407,7 @@ private fun RoomListContent(
} }
} }
} }
}
@Composable @Composable
private fun ChatContent( private fun ChatContent(
@@ -11,6 +11,8 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -143,6 +145,7 @@ fun TasksScreen(
) )
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun TasksMainContent( private fun TasksMainContent(
session: AuthSession, session: AuthSession,
@@ -190,6 +193,13 @@ private fun TasksMainContent(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
} else { } else {
PullToRefreshBox(
isRefreshing = state.loading,
onRefresh = onReload,
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
) {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -232,3 +242,4 @@ private fun TasksMainContent(
} }
} }
} }
}