Дизайн Файлов (чанк 3): рескин каркаса главного экрана под макет
CI / verify (push) Failing after 3s
CI / release (push) Has been skipped

- FilesBrowserChrome.kt: поиск-пилюля + переключатель список⇄сетка, хлебные
  крошки (навигация navigateToPath), фильтр-чипы (Тип файла/Участники/Изменен)
  вместо десктопной колоночной шапки, вид СЕТКА (LazyVerticalGrid 2 кол., карточки),
  пустое состояние (иконка+заголовок+подзаголовок).
- Строки списка без разделителей (по макету). Токены F7Spacing/F7Radius.
- material-icons-extended → feature:files (иконки chrome; точные из дизайна — потом).

feature:files компилируется. Логика (загрузка/выбор/открытие) не менялась.
This commit is contained in:
b-dev-mobile
2026-07-08 08:12:52 +00:00
parent 078fffd405
commit f280e19418
5 changed files with 344 additions and 64 deletions
+2
View File
@@ -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 | дизайн-система (аддитивно, пока не используется) | нет: новые токены, поведение не меняется
+1
View File
@@ -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
@@ -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<String>,
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()
}
}
}
@@ -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,
@@ -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
},
)
}
}