design(files): главный экран 1:1 по живой теме forbion (_mobile-files.css)

Источник — мобильный CSS боевого сервера (копия в design-reference).
- 9 фирменных SVG хрома забандлены в assets/chrome/ (офлайн): чекбоксы
  16dp (контур/зелёный), звезда избранного, ⋮, лупа, дом, тумблеры вида,
  цветная папка (закрыт TODO «папка с сервера»).
- Строка списка: высота 44dp, чекбокс темы 16 в колонке 44, иконка 30
  (было 24) + звезда-оверлей у избранных, колонки размера (56, центр)
  и даты (88, вправо, относительное время как в вебе), ⋮ 16 в колонке 32,
  без скругления, выбранная — #ECF9DE.
- Тумблер вида: круг 40, иконка 16 из темы. Крошки: дом 16 (home-bottom),
  все сегменты серые 14/20, шеврон 16. Фильтр-чипы: пилюли 25dp,
  minWidth 86, текст чёрный, фон #FBFBFB + мягкая тень (как в теме).
- Карточка сетки: радиус 12, иконка 80 (было 48), чекбокс/звезда 16 по
  углам, ⋮ 16, фон #FBFBFB.
- Поиск: фон #FBFBFB, локальная лупа из CSS темы, крестик-очистка 20dp.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
b-dev-mobile
2026-07-09 20:07:59 +00:00
parent e5bbf0e123
commit 6895f1bffd
12 changed files with 290 additions and 80 deletions
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<g clip-path="url(#clip0_3969_9720)">
<g clip-path="url(#clip1_3969_9720)">
<rect x="1.75" y="1.75" width="12.5" height="12.5" rx="2.25" stroke="#808080" stroke-width="1.5"/>
</g>
</g>
<defs>
<clipPath id="clip0_3969_9720">
<rect width="16" height="16" fill="white"/>
</clipPath>
<clipPath id="clip1_3969_9720">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 592 B

@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<g clip-path="url(#clip0_3969_9737)">
<g clip-path="url(#clip1_3969_9737)">
<rect x="1.75" y="1.75" width="12.5" height="12.5" rx="2.25" fill="#70B62B" stroke="#70B62B" stroke-width="1.5"/>
<path d="M4 9.14286L7.80954 11.864C7.90955 11.9354 8.04971 11.9006 8.10467 11.7907L12 4" stroke="white" stroke-width="1.5" stroke-linecap="round"/>
</g>
</g>
<defs>
<clipPath id="clip0_3969_9737">
<rect width="16" height="16" fill="white"/>
</clipPath>
<clipPath id="clip1_3969_9737">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 768 B

@@ -0,0 +1,12 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_10995_59807)">
<circle cx="8.5" cy="13.5" r="1.5" transform="rotate(-90 8.5 13.5)" fill="#808080"/>
<circle cx="8.5" cy="8.5" r="1.5" transform="rotate(-90 8.5 8.5)" fill="#808080"/>
<circle cx="8.5" cy="3.5" r="1.5" transform="rotate(-90 8.5 3.5)" fill="#808080"/>
</g>
<defs>
<clipPath id="clip0_10995_59807">
<rect width="16" height="16" fill="white" transform="matrix(0 -1 1 0 0 16)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 538 B

@@ -0,0 +1,17 @@
<svg width="75" height="75" viewBox="0 0 75 75" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_17_4782)">
<circle cx="37.5" cy="37.5" r="37.5" fill="#E6E6E6"/>
<path d="M61.875 32.8754V45.3395C61.875 48.1108 59.6284 50.3574 56.857 50.3574H16.268C13.4966 50.3574 11.25 48.1108 11.25 45.3395V27.2504C11.25 24.479 13.4966 22.2324 16.268 22.2324H37.2965C38.6273 22.2324 39.9037 22.7611 40.8447 23.7021L43.5303 26.3877C44.4713 27.3287 45.7477 27.8574 47.0785 27.8574H56.857C59.6284 27.8574 61.875 30.104 61.875 32.8754Z" fill="#F5F5F5" stroke="black" stroke-width="1.67266"/>
<path d="M18.75 10.9824V50.3574H56.25V10.9824H18.75Z" fill="#F5F5F5" stroke="black" stroke-width="1.67266"/>
<path d="M16.875 14.7324V55.9824H52.5V14.7324H16.875Z" fill="#FCFEFC" stroke="black" stroke-width="1.67266"/>
<path d="M11.25 42.2504V58.4645C11.25 61.2358 13.4966 63.4824 16.268 63.4824H58.732C61.5034 63.4824 63.75 61.2358 63.75 58.4645V36.6254C63.75 33.854 61.5034 31.6074 58.732 31.6074H33.3359C32.0202 31.6074 30.7571 32.1242 29.8186 33.0464L27.0235 35.7934C26.085 36.7157 24.8219 37.2324 23.5062 37.2324H16.268C13.4966 37.2324 11.25 39.479 11.25 42.2504Z" fill="#F5F5F5" stroke="black" stroke-width="1.67266"/>
<path d="M20.625 20.3574H48.75" stroke="#70B62B" stroke-width="1.67266" stroke-linecap="square"/>
<path d="M20.625 24.1074H48.75" stroke="#70B62B" stroke-width="1.67266" stroke-linecap="square"/>
<path d="M20.625 27.8574H48.75" stroke="#70B62B" stroke-width="1.67266" stroke-linecap="square"/>
</g>
<defs>
<clipPath id="clip0_17_4782">
<rect width="75" height="75" rx="15" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,15 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_17_4585)">
<path d="M4.25 2L4.25 6C4.25 6.13807 4.13807 6.25 4 6.25L2 6.25C1.86193 6.25 1.75 6.13807 1.75 6L1.75 2C1.75 1.86193 1.86193 1.75 2 1.75L4 1.75C4.13807 1.75 4.25 1.86193 4.25 2Z" stroke="#808080" stroke-width="1.5"/>
<path d="M4.25 10L4.25 14C4.25 14.1381 4.13807 14.25 4 14.25H2C1.86193 14.25 1.75 14.1381 1.75 14L1.75 10C1.75 9.86193 1.86193 9.75 2 9.75H4C4.13807 9.75 4.25 9.86193 4.25 10Z" stroke="#808080" stroke-width="1.5"/>
<path d="M9.25 2V6C9.25 6.13807 9.13807 6.25 9 6.25L7 6.25C6.86193 6.25 6.75 6.13807 6.75 6L6.75 2C6.75 1.86193 6.86193 1.75 7 1.75L9 1.75C9.13807 1.75 9.25 1.86193 9.25 2Z" stroke="#808080" stroke-width="1.5"/>
<path d="M9.25 10V14C9.25 14.1381 9.13807 14.25 9 14.25H7C6.86193 14.25 6.75 14.1381 6.75 14L6.75 10C6.75 9.86193 6.86193 9.75 7 9.75H9C9.13807 9.75 9.25 9.86193 9.25 10Z" stroke="#808080" stroke-width="1.5"/>
<path d="M14.25 2V6C14.25 6.13807 14.1381 6.25 14 6.25L12 6.25C11.8619 6.25 11.75 6.13807 11.75 6V2C11.75 1.86193 11.8619 1.75 12 1.75L14 1.75C14.1381 1.75 14.25 1.86193 14.25 2Z" stroke="#808080" stroke-width="1.5"/>
<path d="M14.25 10V14C14.25 14.1381 14.1381 14.25 14 14.25H12C11.8619 14.25 11.75 14.1381 11.75 14V10C11.75 9.86193 11.8619 9.75 12 9.75H14C14.1381 9.75 14.25 9.86193 14.25 10Z" stroke="#808080" stroke-width="1.5"/>
</g>
<defs>
<clipPath id="clip0_17_4585">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,21 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1460_24542)">
<g clip-path="url(#clip1_1460_24542)">
<g clip-path="url(#clip2_1460_24542)">
<path d="M10.2102 14.9989V9.10405C10.2102 8.90862 10.1326 8.7212 9.99441 8.58301C9.85623 8.44482 9.6688 8.36719 9.47337 8.36719H6.52592C6.3305 8.36719 6.14307 8.44482 6.00488 8.58301C5.8667 8.7212 5.78906 8.90862 5.78906 9.10405V14.9989" stroke="#808080" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M1.36719 6.89451C1.36714 6.68014 1.41386 6.46833 1.50409 6.27387C1.59432 6.07941 1.72589 5.90697 1.88962 5.76859L7.04766 1.34815C7.31366 1.12334 7.65068 1 7.99895 1C8.34722 1 8.68424 1.12334 8.95024 1.34815L14.1083 5.76859C14.272 5.90697 14.4036 6.07941 14.4938 6.27387C14.584 6.46833 14.6308 6.68014 14.6307 6.89451V13.5263C14.6307 13.9171 14.4754 14.292 14.1991 14.5684C13.9227 14.8447 13.5478 15 13.157 15H2.84091C2.45006 15 2.07521 14.8447 1.79883 14.5684C1.52245 14.292 1.36719 13.9171 1.36719 13.5263V6.89451Z" stroke="#808080" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</g>
</g>
<defs>
<clipPath id="clip0_1460_24542">
<rect width="16" height="16" fill="white"/>
</clipPath>
<clipPath id="clip1_1460_24542">
<rect width="16" height="16" fill="white"/>
</clipPath>
<clipPath id="clip2_1460_24542">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,15 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_5015_21462)">
<rect x="1" y="2" width="3" height="3" fill="#151515"/>
<path d="M6 3.5L14 3.5" stroke="#151515" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="1" y="7" width="3" height="3" fill="#151515"/>
<path d="M6 8.5L14 8.5" stroke="#151515" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="1" y="12" width="3" height="3" fill="#151515"/>
<path d="M6 13.5L14 13.5" stroke="#151515" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_5015_21462">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 749 B

@@ -0,0 +1 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#clip0)"><circle cx="5.5" cy="5.5" r="4.75" stroke="#808080" stroke-width="1.5"/><path d="M8.73541 9L14.7354 15" stroke="#808080" stroke-width="1.5" stroke-linecap="round"/></g><defs><clipPath id="clip0"><rect width="16" height="16" fill="white"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 385 B

@@ -0,0 +1,10 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="#70B62B" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_11908_43900)">
<path d="M6.10476 3.60501C6.94943 2.09034 7.37143 1.33301 8.00276 1.33301C8.63409 1.33301 9.05609 2.09034 9.90076 3.60501L10.1194 3.99701C10.3594 4.42767 10.4794 4.64301 10.6661 4.78501C10.8528 4.92701 11.0861 4.97967 11.5528 5.08501L11.9768 5.18101C13.6168 5.55234 14.4361 5.73767 14.6314 6.36501C14.8261 6.99167 14.2674 7.64567 13.1494 8.95301L12.8601 9.29101C12.5428 9.66234 12.3834 9.84834 12.3121 10.0777C12.2408 10.3077 12.2648 10.5557 12.3128 11.051L12.3568 11.5023C12.5254 13.247 12.6101 14.119 12.0994 14.5063C11.5888 14.8937 10.8208 14.5403 9.28609 13.8337L8.88809 13.651C8.45209 13.4497 8.23409 13.3497 8.00276 13.3497C7.77143 13.3497 7.55343 13.4497 7.11743 13.651L6.72009 13.8337C5.18476 14.5403 4.41676 14.8937 3.90676 14.507C3.39543 14.119 3.48009 13.247 3.64876 11.5023L3.69276 11.0517C3.74076 10.5557 3.76476 10.3077 3.69276 10.0783C3.62209 9.84834 3.46276 9.66234 3.14543 9.29167L2.85609 8.95301C1.73809 7.64634 1.17943 6.99234 1.37409 6.36501C1.56876 5.73767 2.38943 5.55167 4.02943 5.18101L4.45343 5.08501C4.91943 4.97967 5.15209 4.92701 5.33943 4.78501C5.52676 4.64301 5.64609 4.42767 5.88609 3.99701L6.10476 3.60501Z" stroke="#70B62B" stroke-width="1.5"/>
</g>
<defs>
<clipPath id="clip0_11908_43900">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -1,13 +1,16 @@
package ru.forbion.f7cloud.feature.files package ru.forbion.f7cloud.feature.files
object FileIcons { object FileIcons {
/** Локальные SVG хрома Файлов из боевой темы forbion (assets/chrome/, coil-svg). */
fun chromeAsset(name: String): String = "file:///android_asset/chrome/$name"
/** /**
* Иконка типа файла. Типы файлов — ЛОКАЛЬНЫЕ SVG из дизайна (assets/filetypes/, грузятся * Иконка типа файла. Типы файлов — ЛОКАЛЬНЫЕ SVG из дизайна (assets/filetypes/, грузятся
* coil-svg): офлайн + точные иконки макета (раньше тянулись с сервера — аудит: офлайн ломался). * coil-svg): офлайн + точные иконки макета (раньше тянулись с сервера — аудит: офлайн ломался).
* Папка — пока с сервера (иконки папки в наборе дизайна нет; TODO забрать из Figma). * Папка — фирменная folder-icon-colored.svg из боевой темы (тоже локально).
*/ */
fun iconUrl(serverUrl: String, name: String, isDirectory: Boolean): String { fun iconUrl(serverUrl: String, name: String, isDirectory: Boolean): String {
if (isDirectory) return "${serverUrl.trimEnd('/')}/core/img/filetypes/folder.svg" if (isDirectory) return chromeAsset("folder-icon-colored.svg")
return "file:///android_asset/filetypes/${localIcon(name)}.svg" return "file:///android_asset/filetypes/${localIcon(name)}.svg"
} }
@@ -4,34 +4,32 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.filled.ChevronRight
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.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.draw.shadow
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -40,30 +38,59 @@ import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7Radius import ru.forbion.f7cloud.core.designsystem.F7Radius
import ru.forbion.f7cloud.core.designsystem.F7Spacing import ru.forbion.f7cloud.core.designsystem.F7Spacing
// Размеры — из боевой темы forbion (_mobile-files.css), копия один в один.
enum class FilesViewMode { List, Grid } enum class FilesViewMode { List, Grid }
/** Квадратная кнопка-переключатель вида список⇄сетка справа от поиска (макет F-17/F-22). */ /**
* Чекбокс из боевой темы: 16×16, серый контур / зелёная заливка с галкой
* (checkbox-blank-outline-icon.svg / checkbox-marked-icon.svg). Тап-зона задаётся снаружи.
*/
@Composable
fun F7ThemeCheckbox(checked: Boolean, onToggle: () -> Unit, modifier: Modifier = Modifier) {
AsyncImage(
model = FileIcons.chromeAsset(
if (checked) "checkbox-marked-icon.svg" else "checkbox-blank-outline-icon.svg",
),
contentDescription = if (checked) "Снять выбор" else "Выбрать",
modifier = modifier
.size(16.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onToggle,
),
)
}
/** Стиль «пилюли» темы: фон #FBFBFB, рамка #E6E6E6, мягкая тень 0 1px 2px. */
private fun Modifier.f7PillSurface(shape: RoundedCornerShape): Modifier = this
.shadow(1.dp, shape, ambientColor = F7Colors.Border, spotColor = F7Colors.Border)
.clip(shape)
.background(F7Colors.Background)
.border(1.dp, F7Colors.Border, shape)
/** Круглая кнопка-переключатель список⇄сетка справа от поиска (40×40, иконка 16). */
@Composable @Composable
fun FilesViewToggleButton(viewMode: FilesViewMode, onToggle: () -> Unit) { fun FilesViewToggleButton(viewMode: FilesViewMode, onToggle: () -> Unit) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(40.dp) .size(40.dp)
.clip(RoundedCornerShape(F7Radius.md)) .f7PillSurface(RoundedCornerShape(F7Radius.pill))
.background(F7Colors.SecondaryButtonBg)
.border(1.dp, F7Colors.Border, RoundedCornerShape(F7Radius.md))
.clickable(onClick = onToggle), .clickable(onClick = onToggle),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Icon( AsyncImage(
imageVector = if (viewMode == FilesViewMode.List) Icons.Filled.GridView else Icons.AutoMirrored.Filled.List, model = FileIcons.chromeAsset(
if (viewMode == FilesViewMode.List) "grid-files-gray.svg" else "list-view-icon.svg",
),
contentDescription = "Сменить вид", contentDescription = "Сменить вид",
tint = F7Colors.TextSecondary, modifier = Modifier.size(16.dp),
modifier = Modifier.size(20.dp),
) )
} }
} }
/** Хлебные крошки: домой + сегменты пути через ›, прокрутка по горизонтали (макет F-21/F-22). */ /** Хлебные крошки: дом 16 + сегменты (все — серые 14/20) через шеврон 16, h-прокрутка. */
@Composable @Composable
fun FilesBreadcrumb( fun FilesBreadcrumb(
segments: List<String>, segments: List<String>,
@@ -75,23 +102,28 @@ fun FilesBreadcrumb(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.horizontalScroll(rememberScrollState()) .horizontalScroll(rememberScrollState())
.padding(vertical = F7Spacing.sm), .padding(horizontal = F7Spacing.sm, vertical = F7Spacing.xs),
horizontalArrangement = Arrangement.spacedBy(F7Spacing.xs),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon( AsyncImage(
imageVector = Icons.Filled.Home, model = FileIcons.chromeAsset("home-bottom.svg"),
contentDescription = "В начало", contentDescription = "В начало",
tint = F7Colors.TextSecondary,
modifier = Modifier modifier = Modifier
.size(22.dp) .size(16.dp)
.clickable(onClick = onHomeClick), .clickable(onClick = onHomeClick),
) )
segments.forEachIndexed { i, seg -> segments.forEachIndexed { i, seg ->
Text(" ", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary) Icon(
imageVector = Icons.Filled.ChevronRight,
contentDescription = null,
tint = F7Colors.TextSecondary,
modifier = Modifier.size(16.dp),
)
Text( Text(
text = seg, text = seg,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = if (i == segments.lastIndex) F7Colors.TextPrimary else F7Colors.TextSecondary, color = F7Colors.TextSecondary,
maxLines = 1, maxLines = 1,
modifier = Modifier.clickable { onSegmentClick(i) }, modifier = Modifier.clickable { onSegmentClick(i) },
) )
@@ -99,7 +131,7 @@ fun FilesBreadcrumb(
} }
} }
/** Ряд фильтр-чипов: Тип файла / Участники / Изменен (макет F-18/F-22). */ /** Ряд фильтр-чипов: Тип файла / Участники / Изменен (пилюли 25dp, minWidth 86). */
@Composable @Composable
fun FilesFilterChips( fun FilesFilterChips(
onTypeClick: () -> Unit, onTypeClick: () -> Unit,
@@ -111,7 +143,7 @@ fun FilesFilterChips(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.horizontalScroll(rememberScrollState()) .horizontalScroll(rememberScrollState())
.padding(vertical = F7Spacing.xs), .padding(horizontal = F7Spacing.xs, vertical = F7Spacing.xs),
horizontalArrangement = Arrangement.spacedBy(F7Spacing.sm), horizontalArrangement = Arrangement.spacedBy(F7Spacing.sm),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -125,16 +157,21 @@ fun FilesFilterChips(
private fun FilterChip(label: String, onClick: () -> Unit) { private fun FilterChip(label: String, onClick: () -> Unit) {
Box( Box(
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(F7Radius.pill)) .height(25.dp)
.border(1.dp, F7Colors.Border, RoundedCornerShape(F7Radius.pill)) .widthIn(min = 86.dp)
.f7PillSurface(RoundedCornerShape(F7Radius.pill))
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = F7Spacing.md, vertical = F7Spacing.xs), .padding(horizontal = F7Spacing.sm),
contentAlignment = Alignment.Center,
) { ) {
Text(label, style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary) Text(label, style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextPrimary)
} }
} }
/** Карточка сетки: чекбокс + звезда сверху, крупная иконка по центру, имя + ⋮ снизу (макет F-17). */ /**
* Карточка сетки (2 колонки): чекбокс 16 слева-сверху, зелёная звезда 16 справа-сверху
* (только у избранных), иконка 80 по центру, имя 14/20 + ⋮ 16 снизу. Радиус 12, отступ 12.
*/
@Composable @Composable
fun FilesGridCard( fun FilesGridCard(
serverUrl: String, serverUrl: String,
@@ -146,41 +183,40 @@ fun FilesGridCard(
onMenuClick: () -> Unit, onMenuClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val shape = RoundedCornerShape(12.dp)
Column( Column(
modifier = modifier modifier = modifier
.clip(RoundedCornerShape(F7Radius.lg)) .clip(shape)
.background(if (selected) F7Colors.PrimaryLight else F7Colors.Surface) .background(if (selected) F7Colors.PrimaryLight else F7Colors.Background)
.border(1.dp, F7Colors.Border, RoundedCornerShape(F7Radius.lg)) .border(1.dp, F7Colors.Border, shape)
.clickable(onClick = onOpenClick) .clickable(onClick = onOpenClick)
.padding(F7Spacing.md), .padding(12.dp),
) { ) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Checkbox( F7ThemeCheckbox(checked = selected, onToggle = onSelectedChange)
checked = selected,
onCheckedChange = { onSelectedChange() },
colors = CheckboxDefaults.colors(checkedColor = F7Colors.Primary),
)
Spacer(Modifier.width(F7Spacing.sm))
Box(modifier = Modifier.weight(1f)) Box(modifier = Modifier.weight(1f))
Icon( if (favorite) {
imageVector = if (favorite) Icons.Filled.Star else Icons.Filled.StarBorder, AsyncImage(
contentDescription = null, model = FileIcons.chromeAsset("star-green-full.svg"),
tint = if (favorite) F7Colors.Primary else F7Colors.TextMuted, contentDescription = "В избранном",
modifier = Modifier.size(18.dp), modifier = Modifier.size(16.dp),
) )
}
} }
Spacer(Modifier.height(F7Spacing.sm))
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = F7Spacing.lg), .height(80.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
AsyncImage( AsyncImage(
model = FileIcons.iconUrl(serverUrl, item.name, item.isDirectory), model = FileIcons.iconUrl(serverUrl, item.name, item.isDirectory),
contentDescription = null, contentDescription = null,
modifier = Modifier.size(48.dp), modifier = Modifier.size(80.dp),
) )
} }
Spacer(Modifier.height(F7Spacing.sm))
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text( Text(
text = item.name, text = item.name,
@@ -191,12 +227,11 @@ fun FilesGridCard(
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
Icon( AsyncImage(
imageVector = Icons.Filled.MoreVert, model = FileIcons.chromeAsset("dots-vertic-gray.svg"),
contentDescription = "Меню", contentDescription = "Меню",
tint = F7Colors.TextSecondary,
modifier = Modifier modifier = Modifier
.size(18.dp) .size(16.dp)
.clickable(onClick = onMenuClick), .clickable(onClick = onMenuClick),
) )
} }
@@ -221,7 +256,7 @@ fun FilesEmptyState(
Box( Box(
modifier = Modifier modifier = Modifier
.size(72.dp) .size(72.dp)
.clip(RoundedCornerShape(F7Radius.pill)) .clip(CircleShape)
.background(F7Colors.SurfaceMuted), .background(F7Colors.SurfaceMuted),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@@ -66,6 +67,7 @@ import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -559,7 +561,6 @@ fun FilesSearchBar(
onQueryChange: (String) -> Unit, onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val base = serverUrl.trimEnd('/')
Box( Box(
modifier = modifier modifier = modifier
.height(40.dp) .height(40.dp)
@@ -569,7 +570,7 @@ fun FilesSearchBar(
spotColor = Color(0xFFCBCBCB), spotColor = Color(0xFFCBCBCB),
) )
.clip(RoundedCornerShape(100.dp)) .clip(RoundedCornerShape(100.dp))
.background(Color(0xFFFDFDFD)) .background(F7Colors.Background)
.border(1.dp, F7Colors.Border, RoundedCornerShape(100.dp)), .border(1.dp, F7Colors.Border, RoundedCornerShape(100.dp)),
contentAlignment = Alignment.CenterStart, contentAlignment = Alignment.CenterStart,
) { ) {
@@ -581,7 +582,7 @@ fun FilesSearchBar(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
AsyncImage( AsyncImage(
model = "$base/themes/forbion/images/header/search-gray.svg", model = FileIcons.chromeAsset("search-gray.svg"),
contentDescription = null, contentDescription = null,
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
@@ -620,6 +621,16 @@ fun FilesSearchBar(
} }
}, },
) )
if (query.isNotEmpty()) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Очистить поиск",
tint = F7Colors.TextSecondary,
modifier = Modifier
.size(20.dp)
.clickable { onQueryChange("") },
)
}
} }
} }
} }
@@ -793,27 +804,36 @@ fun FilesListRow(
val hasExt = !item.isDirectory && item.name.contains('.') val hasExt = !item.isDirectory && item.name.contains('.')
val baseName = if (hasExt) item.name.substringBeforeLast('.') else item.name val baseName = if (hasExt) item.name.substringBeforeLast('.') else item.name
val extension = if (hasExt && showExtensions) "." + item.name.substringAfterLast('.') else "" val extension = if (hasExt && showExtensions) "." + item.name.substringAfterLast('.') else ""
// Строка 44dp по боевой теме (_mobile-files.css): чекбокс 16 в колонке 44, иконка 30
// (зелёная звезда 16 поверх у избранных), имя, размер 56 по центру, дата 88 вправо, ⋮ в 32.
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(F7Radius.md)) .height(44.dp)
// выбранная строка — светло-зелёный фон (макет F-21)
.background(if (selected) F7Colors.PrimaryLight else Color.Transparent) .background(if (selected) F7Colors.PrimaryLight else Color.Transparent)
.clickable(onClick = onOpenClick) .clickable(onClick = onOpenClick),
.padding(horizontal = F7Spacing.sm, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Checkbox( Box(modifier = Modifier.size(44.dp), contentAlignment = Alignment.Center) {
checked = selected, F7ThemeCheckbox(checked = selected, onToggle = onSelectedChange)
onCheckedChange = { onSelectedChange() }, }
colors = CheckboxDefaults.colors(checkedColor = F7Colors.Primary), Box(modifier = Modifier.size(30.dp)) {
) AsyncImage(
AsyncImage( model = FileIcons.iconUrl(serverUrl, item.name, item.isDirectory),
model = FileIcons.iconUrl(serverUrl, item.name, item.isDirectory), contentDescription = null,
contentDescription = null, modifier = Modifier.size(30.dp),
modifier = Modifier.size(24.dp), contentScale = ContentScale.Fit,
contentScale = ContentScale.Fit, )
) if (item.favorite) {
AsyncImage(
model = FileIcons.chromeAsset("star-green-full.svg"),
contentDescription = "В избранном",
modifier = Modifier
.size(16.dp)
.offset(x = 20.dp, y = (-4).dp),
)
}
}
Text( Text(
text = buildAnnotatedString { text = buildAnnotatedString {
append(baseName) append(baseName)
@@ -823,17 +843,38 @@ fun FilesListRow(
}, },
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.padding(horizontal = F7Spacing.sm), .padding(start = F7Spacing.sm, end = F7Spacing.xs),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = F7Colors.TextPrimary, color = F7Colors.TextPrimary,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
item.size?.takeIf { !item.isDirectory }?.let { size ->
Text(
text = formatFileSize(size),
modifier = Modifier.width(56.dp),
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextPrimary,
maxLines = 1,
textAlign = TextAlign.Center,
)
}
item.lastModified?.let { mtime ->
Text(
text = formatRelativeTime(mtime),
modifier = Modifier.width(88.dp),
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.End,
)
}
Box( Box(
modifier = Modifier modifier = Modifier
.size(36.dp) .width(32.dp)
.clip(RoundedCornerShape(8.dp)) .height(44.dp)
.clickable( .clickable(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null, indication = null,
@@ -845,7 +886,7 @@ fun FilesListRow(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
AsyncImage( AsyncImage(
model = "${serverUrl.trimEnd('/')}/themes/forbion/images/files/dots-vertic-gray.svg", model = FileIcons.chromeAsset("dots-vertic-gray.svg"),
contentDescription = "Действия", contentDescription = "Действия",
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
@@ -854,6 +895,15 @@ fun FilesListRow(
} }
} }
/** Относительное время как в вебе (moment.js): «5 мин. назад», «вчера», иначе дата. */
internal fun formatRelativeTime(epochMillis: Long): String =
android.text.format.DateUtils.getRelativeTimeSpanString(
epochMillis,
System.currentTimeMillis(),
android.text.format.DateUtils.MINUTE_IN_MILLIS,
android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE,
).toString()
@Composable @Composable
fun FileActionMenuPopup( fun FileActionMenuPopup(
expanded: Boolean, expanded: Boolean,