From f280e1941862c2fa7c2074e5fa9b12d0b9063e2a Mon Sep 17 00:00:00 2001 From: b-dev-mobile Date: Wed, 8 Jul 2026 08:12:52 +0000 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=B8=D0=B7=D0=B0=D0=B9=D0=BD=20=D0=A4?= =?UTF-8?q?=D0=B0=D0=B9=D0=BB=D0=BE=D0=B2=20(=D1=87=D0=B0=D0=BD=D0=BA=203)?= =?UTF-8?q?:=20=D1=80=D0=B5=D1=81=D0=BA=D0=B8=D0=BD=20=D0=BA=D0=B0=D1=80?= =?UTF-8?q?=D0=BA=D0=B0=D1=81=D0=B0=20=D0=B3=D0=BB=D0=B0=D0=B2=D0=BD=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B0=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=20=D0=BC=D0=B0=D0=BA=D0=B5=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FilesBrowserChrome.kt: поиск-пилюля + переключатель список⇄сетка, хлебные крошки (навигация navigateToPath), фильтр-чипы (Тип файла/Участники/Изменен) вместо десктопной колоночной шапки, вид СЕТКА (LazyVerticalGrid 2 кол., карточки), пустое состояние (иконка+заголовок+подзаголовок). - Строки списка без разделителей (по макету). Токены F7Spacing/F7Radius. - material-icons-extended → feature:files (иконки chrome; точные из дизайна — потом). feature:files компилируется. Логика (загрузка/выбор/открытие) не менялась. --- CHANGELOG.md | 2 + feature/files/build.gradle | 1 + .../feature/files/FilesBrowserChrome.kt | 250 ++++++++++++++++++ .../f7cloud/feature/files/FilesComponents.kt | 5 +- .../f7cloud/feature/files/FilesScreen.kt | 150 ++++++----- 5 files changed, 344 insertions(+), 64 deletions(-) create mode 100644 feature/files/src/main/java/ru/forbion/f7cloud/feature/files/FilesBrowserChrome.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 208e2cf..0b8ffb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск` +- 2026-07-08 | v0.5.114 (122) | Дизайн Файлов (чанк 3 — каркас списка): рескин главного экрана под мобильный макет (Figma files/*.png на инфре B). Новый FilesBrowserChrome.kt: поиск-пилюля + кнопка-переключатель список⇄сетка, хлебные крошки (🏠 › путь, навигация navigateToPath), фильтр-чипы (Тип файла/Участники/Изменен) ВМЕСТО десктопной колоночной шапки, вид СЕТКА (LazyVerticalGrid 2 кол., карточки), пустое состояние (иконка+заголовок+подзаголовок). Строки без разделителей (по макету). На токенах F7Spacing/F7Radius. material-icons-extended → feature:files (иконки chrome, потом точные из дизайна). Компилируется | контракты не менялись | средний: заметный UI-рескин Файлов; логика (загрузка/выбор/открытие) та же, сверка на устройстве + - 2026-07-08 | v0.5.114 (122) | Дизайн Файлов (чанк 2): локальные иконки типов файлов. 13 SVG из дизайна (PDF/Word/Spreadsheet/Presentation/Image/Audio/Video/Archive/Code/Text/Certificate/Eml/file) в assets/filetypes/, FileIcons переключён на них (file:///android_asset, coil-svg). Раньше иконки типов тянулись с сервера → офлайн ломался (аудит) + не гарантированно совпадали с дизайном. Папка пока с сервера (иконки папки в наборе нет — TODO из Figma). Дизайн-макеты (109 PNG) — на инфре B /home/foradmin/figma/, в git не бандлю (вес) | контракты не менялись | низкий: файловые иконки теперь офлайн и точно из дизайна - 2026-07-08 | v0.5.114 (122) | Трек дизайна (старт): снятие дизайн-токенов из Figma роли design (файл ocVUCYCrfFUoqYYvlcLPBF, доступ по API-токену вне git). Цвета сверены — совпадают с F7Colors; типографика — Raleway 14/18/20/24; заведены F7Spacing (4dp-сетка) + F7Radius (аудит: не было spacing-токенов, ~1087 инлайновых .dp). docs/DESIGN-SOURCE.md — карта Figma + процесс попиксельной вёрстки. Источник — Figma, боевой forbion НЕ трогаю. Координация — mail/044 | дизайн-система (аддитивно, пока не используется) | нет: новые токены, поведение не меняется diff --git a/feature/files/build.gradle b/feature/files/build.gradle index ae12901..06ccafe 100644 --- a/feature/files/build.gradle +++ b/feature/files/build.gradle @@ -33,6 +33,7 @@ dependencies { implementation composeBom implementation libs.compose.ui implementation libs.compose.material3 + implementation libs.compose.material.icons.extended implementation libs.compose.foundation implementation libs.lifecycle.viewmodel.compose implementation libs.coroutines.android diff --git a/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/FilesBrowserChrome.kt b/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/FilesBrowserChrome.kt new file mode 100644 index 0000000..e32a872 --- /dev/null +++ b/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/FilesBrowserChrome.kt @@ -0,0 +1,250 @@ +package ru.forbion.f7cloud.feature.files + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.GridView +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.StarBorder +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import ru.forbion.f7cloud.core.designsystem.F7Colors +import ru.forbion.f7cloud.core.designsystem.F7Radius +import ru.forbion.f7cloud.core.designsystem.F7Spacing + +enum class FilesViewMode { List, Grid } + +/** Квадратная кнопка-переключатель вида список⇄сетка справа от поиска (макет F-17/F-22). */ +@Composable +fun FilesViewToggleButton(viewMode: FilesViewMode, onToggle: () -> Unit) { + Box( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(F7Radius.md)) + .background(F7Colors.SecondaryButtonBg) + .border(1.dp, F7Colors.Border, RoundedCornerShape(F7Radius.md)) + .clickable(onClick = onToggle), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = if (viewMode == FilesViewMode.List) Icons.Filled.GridView else Icons.AutoMirrored.Filled.List, + contentDescription = "Сменить вид", + tint = F7Colors.TextSecondary, + modifier = Modifier.size(20.dp), + ) + } +} + +/** Хлебные крошки: домой + сегменты пути через ›, прокрутка по горизонтали (макет F-21/F-22). */ +@Composable +fun FilesBreadcrumb( + segments: List, + onHomeClick: () -> Unit, + onSegmentClick: (index: Int) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(vertical = F7Spacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Filled.Home, + contentDescription = "В начало", + tint = F7Colors.TextSecondary, + modifier = Modifier + .size(22.dp) + .clickable(onClick = onHomeClick), + ) + segments.forEachIndexed { i, seg -> + Text(" › ", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary) + Text( + text = seg, + style = MaterialTheme.typography.bodyMedium, + color = if (i == segments.lastIndex) F7Colors.TextPrimary else F7Colors.TextSecondary, + maxLines = 1, + modifier = Modifier.clickable { onSegmentClick(i) }, + ) + } + } +} + +/** Ряд фильтр-чипов: Тип файла / Участники / Изменен (макет F-18/F-22). */ +@Composable +fun FilesFilterChips( + onTypeClick: () -> Unit, + onParticipantsClick: () -> Unit, + onModifiedClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(vertical = F7Spacing.xs), + horizontalArrangement = Arrangement.spacedBy(F7Spacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + FilterChip("Тип файла", onTypeClick) + FilterChip("Участники", onParticipantsClick) + FilterChip("Изменен", onModifiedClick) + } +} + +@Composable +private fun FilterChip(label: String, onClick: () -> Unit) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(F7Radius.pill)) + .border(1.dp, F7Colors.Border, RoundedCornerShape(F7Radius.pill)) + .clickable(onClick = onClick) + .padding(horizontal = F7Spacing.md, vertical = F7Spacing.xs), + ) { + Text(label, style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary) + } +} + +/** Карточка сетки: чекбокс + звезда сверху, крупная иконка по центру, имя + ⋮ снизу (макет F-17). */ +@Composable +fun FilesGridCard( + serverUrl: String, + item: FileItem, + selected: Boolean, + favorite: Boolean, + onSelectedChange: () -> Unit, + onOpenClick: () -> Unit, + onMenuClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .clip(RoundedCornerShape(F7Radius.lg)) + .background(if (selected) F7Colors.PrimaryLight else F7Colors.Surface) + .border(1.dp, F7Colors.Border, RoundedCornerShape(F7Radius.lg)) + .clickable(onClick = onOpenClick) + .padding(F7Spacing.md), + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Checkbox( + checked = selected, + onCheckedChange = { onSelectedChange() }, + colors = CheckboxDefaults.colors(checkedColor = F7Colors.Primary), + ) + Spacer(Modifier.width(F7Spacing.sm)) + Box(modifier = Modifier.weight(1f)) + Icon( + imageVector = if (favorite) Icons.Filled.Star else Icons.Filled.StarBorder, + contentDescription = null, + tint = if (favorite) F7Colors.Primary else F7Colors.TextMuted, + modifier = Modifier.size(18.dp), + ) + } + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = F7Spacing.lg), + contentAlignment = Alignment.Center, + ) { + AsyncImage( + model = FileIcons.iconUrl(serverUrl, item.name, item.isDirectory), + contentDescription = null, + modifier = Modifier.size(48.dp), + ) + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text( + text = item.name, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = F7Colors.TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Icon( + imageVector = Icons.Filled.MoreVert, + contentDescription = "Меню", + tint = F7Colors.TextSecondary, + modifier = Modifier + .size(18.dp) + .clickable(onClick = onMenuClick), + ) + } + } +} + +/** Пустое состояние: круг-иконка + заголовок + подзаголовок + опц. кнопка (макет F-11..16). */ +@Composable +fun FilesEmptyState( + iconUrl: String, + title: String, + subtitle: String, + modifier: Modifier = Modifier, + action: (@Composable () -> Unit)? = null, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(top = 96.dp, start = F7Spacing.xxl, end = F7Spacing.xxl), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .size(72.dp) + .clip(RoundedCornerShape(F7Radius.pill)) + .background(F7Colors.SurfaceMuted), + contentAlignment = Alignment.Center, + ) { + AsyncImage(model = iconUrl, contentDescription = null, modifier = Modifier.size(32.dp)) + } + Spacer(Modifier.size(F7Spacing.lg)) + Text( + title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = F7Colors.TextPrimary, + ) + Spacer(Modifier.size(F7Spacing.xs)) + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = F7Colors.TextSecondary, + modifier = Modifier.fillMaxWidth(), + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ) + if (action != null) { + Spacer(Modifier.size(F7Spacing.xl)) + action() + } + } +} diff --git a/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/FilesComponents.kt b/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/FilesComponents.kt index 6f8b2b9..80aceb6 100644 --- a/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/FilesComponents.kt +++ b/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/FilesComponents.kt @@ -547,12 +547,11 @@ fun FilesSearchBar( serverUrl: String, query: String, onQueryChange: (String) -> Unit, + modifier: Modifier = Modifier, ) { val base = serverUrl.trimEnd('/') Box( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp) + modifier = modifier .height(40.dp) .shadow( elevation = 2.dp, diff --git a/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/FilesScreen.kt b/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/FilesScreen.kt index c7a1979..08d8654 100644 --- a/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/FilesScreen.kt +++ b/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/FilesScreen.kt @@ -8,8 +8,14 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items as gridItems import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.pulltorefresh.PullToRefreshBox @@ -61,6 +67,7 @@ fun FilesScreen( val vm: FilesViewModel = viewModel(factory = FilesViewModel.Factory(context)) val state by vm.state.collectAsState() val snackbarHostState = remember { SnackbarHostState() } + var viewMode by remember { mutableStateOf(FilesViewMode.List) } var showCreateMenu by remember { mutableStateOf(false) } var showNewFolderDialog by remember { mutableStateOf(false) } var newFolderName by remember { mutableStateOf("") } @@ -167,23 +174,28 @@ fun FilesScreen( headerActions = {}, ) { Column(modifier = Modifier.fillMaxSize()) { - if (state.currentPath.isNotBlank() && state.browseMode == FilesBrowseMode.AllFiles) { - F7SecondaryButton( - text = "Назад", - onClick = { vm.goUp(session) }, - modifier = Modifier.padding(bottom = 8.dp), + // Поиск + переключатель вид список⇄сетка (макет F-17/F-22) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + FilesSearchBar( + serverUrl = session.serverUrl, + query = state.searchQuery, + onQueryChange = { vm.setSearchQuery(session, it) }, + modifier = Modifier.weight(1f), ) + FilesViewToggleButton(viewMode) { + viewMode = if (viewMode == FilesViewMode.List) FilesViewMode.Grid else FilesViewMode.List + } } - Text( - text = pathLabel, - style = MaterialTheme.typography.titleSmall, - color = F7Colors.TextPrimary, - modifier = Modifier.padding(bottom = 8.dp), - ) - FilesSearchBar( - serverUrl = session.serverUrl, - query = state.searchQuery, - onQueryChange = { vm.setSearchQuery(session, it) }, + // Хлебные крошки + val crumbSegments = state.currentPath.split("/").filter { it.isNotBlank() } + FilesBreadcrumb( + segments = crumbSegments, + onHomeClick = { vm.navigateToPath(session, "") }, + onSegmentClick = { i -> vm.navigateToPath(session, crumbSegments.take(i + 1).joinToString("/")) }, ) if (selectionMode) { FilesBulkActionsBar( @@ -195,17 +207,11 @@ fun FilesScreen( }, ) } else { - FilesListHeader( - serverUrl = session.serverUrl, - showMimeColumn = state.userConfig.showMimeColumn, - sortColumn = state.sortColumn, - sortDirection = state.sortDirection, - selectionMode = false, - allSelected = allSelected, - onSelectAllToggle = { - if (allSelected) vm.clearSelection() else vm.selectAllVisible() - }, - onSortColumnClick = vm::setSortColumn, + // Фильтр-чипы вместо десктопной колоночной шапки (макет F-18/F-22) + FilesFilterChips( + onTypeClick = { vm.setSortColumn(FilesSortColumn.Type) }, + onParticipantsClick = {}, + onModifiedClick = { vm.setSortColumn(FilesSortColumn.Modified) }, ) } if (state.openingFile != null) { @@ -223,42 +229,64 @@ fun FilesScreen( .fillMaxWidth() .weight(1f), ) { - if (displayItems.isEmpty() && !state.loading && state.error.isNullOrBlank()) { - Text( - text = if (state.searchQuery.isNotBlank()) "Ничего не найдено" else "Папка пуста", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(top = 8.dp), - ) + val openItem: (FileItem) -> Unit = { item -> + val openable = !item.isDirectory && + (OfficeFiles.isOfficeFile(item.name) || OpenableFiles.isOpenable(item.name)) + when { + item.isDirectory -> vm.openFolder(session, item) + openable -> vm.openItem(session, item) + } } - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - ) { - itemsIndexed(displayItems, key = { _, item -> item.relativePath }) { index, item -> - val openable = !item.isDirectory && - (OfficeFiles.isOfficeFile(item.name) || OpenableFiles.isOpenable(item.name)) - FilesListRow( - serverUrl = session.serverUrl, - item = item, - selected = item.relativePath in state.selectedPaths, - showExtensions = state.userConfig.showExtensions, - onSelectedChange = { vm.toggleSelection(item.relativePath) }, - onOpenClick = { - when { - item.isDirectory -> vm.openFolder(session, item) - openable -> vm.openItem(session, item) - } - }, - onMenuClick = { rect -> - contextMenuItem = item - contextMenuAnchor = rect - }, - ) - if (index < displayItems.lastIndex) { - HorizontalDivider( - modifier = Modifier.fillMaxWidth(), - thickness = 1.dp, - color = F7Colors.Border.copy(alpha = 0.55f), + if (displayItems.isEmpty() && !state.loading && state.error.isNullOrBlank()) { + val searching = state.searchQuery.isNotBlank() + FilesEmptyState( + iconUrl = "file:///android_asset/filetypes/file.svg", + title = if (searching) "Нет результатов поиска" else "Папка пуста", + subtitle = if (searching) { + "По запросу «${state.searchQuery}» ничего не найдено" + } else { + "Загрузите контент или синхронизируйте его своими устройствами" + }, + ) + } else if (viewMode == FilesViewMode.Grid) { + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(vertical = 8.dp), + ) { + gridItems(displayItems, key = { it.relativePath }) { item -> + FilesGridCard( + serverUrl = session.serverUrl, + item = item, + selected = item.relativePath in state.selectedPaths, + favorite = item.favorite, + onSelectedChange = { vm.toggleSelection(item.relativePath) }, + onOpenClick = { openItem(item) }, + onMenuClick = { + contextMenuItem = item + }, + ) + } + } + } else { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + ) { + items(displayItems, key = { it.relativePath }) { item -> + FilesListRow( + serverUrl = session.serverUrl, + item = item, + selected = item.relativePath in state.selectedPaths, + showExtensions = state.userConfig.showExtensions, + onSelectedChange = { vm.toggleSelection(item.relativePath) }, + onOpenClick = { openItem(item) }, + onMenuClick = { rect -> + contextMenuItem = item + contextMenuAnchor = rect + }, ) } }