34 Commits

Author SHA1 Message Date
b-dev-mobile 05ca2b533a files: чипы-фильтры порядок+ё под моб.веб forbion (Тип файла/Изменён/Участники)
Сверено на эмуляторе против живой моб.вёрстки forbion (iframe 360px):
веб = Тип файла/Изменён/Участники; было натив = Тип файла/Участники/Изменен.
Исправлен порядок (Изменён перед Участники) и «Изменен»→«Изменён». Проверено на эмуляторе.
2026-07-13 12:32:30 +00:00
b-dev-mobile 195235495e F7SecureScreen: не ставить FLAG_SECURE в debug-сборке (FLAG_DEBUGGABLE-гейт)
Релиз остаётся защищённым (скриншоты/запись/превью блокируются), но debug-APK
теперь снимается на эмуляторе — нужно для автономного визуал-теста (screencap
на secure-окне возвращает пустой кадр). Проверка через ApplicationInfo.FLAG_DEBUGGABLE,
без BuildConfig (работает в library-модуле).
2026-07-13 11:57:33 +00:00
b-mob-notify 93086d3f43 docs(app-map): notifications Fd — зафиксировал ограничение iframe (multi-tab координация ленты уведомлений, populated-строки не рендерятся) 2026-07-13 10:58:51 +00:00
b-mob-notify 002f22bb91 docs(app-map): notifications Fd 0→20% — 2 Д закрыты лидом+верифицированы Roborazzi (кнопка/зазор), detekt зелёный 2026-07-13 10:42:28 +00:00
b-mob-notify 80725396c6 test(designsystem): перезапись Roborazzi-эталона строки уведомления после Fd-фикса лида (bfa870c) — кнопка «Присоединиться» больше не рвётся посреди слова 2026-07-13 10:40:57 +00:00
b-dev-mobile bfa870c021 designsystem: detekt зелёный + Fd-фикс кнопок уведомления (mail/114)
- detekt --auto-correct: NoMultipleSpaces/ImportOrdering/NoUnusedImports/spacing
  по F7Colors/F7Components/F7MobileBottomBar/F7AppMenuSheet/F7Typography (был красный на main, 18)
- F7NotificationActionUi вынесен в свой файл (fix MatchingDeclarationName)
- NotificationActionButton: подпись 1.5× вместо 2× (на w360dp удвоенный кегль рвал слово
  «Присоединитьс/я»; forbion не переносит), maxLines=2, паддинги 16/9
- зазор subject↔message 6→10dp (сливались на 360dp)

Модуль detekt+compile green.
2026-07-13 10:37:57 +00:00
b-mob-notify 715bb5a056 docs(app-map): notifications Fd — Roborazzi-эталон снят, находки (перенос текста primary-кнопки на w360dp, зазор subject/message) 2026-07-13 10:28:59 +00:00
b-mob-notify 2854f5d5a4 test(designsystem): Roborazzi-эталон F7NotificationRow (Fd, w360dp) — состояния с действиями/без; PNG-эталон нативной строки уведомления 2026-07-13 10:28:02 +00:00
b-mob-notify eed7723230 docs(app-map): notifications P1 ЗАКРЫТ (лента a30627a + unregister cc7ad71/255c962 + retry a6bda89), Ff 45.5→63.6%; Fd на Roborazzi 2026-07-13 10:22:13 +00:00
b-dev-mobile 255c962d8f logout: снятие устройства с f7push (unregisterBlocking) best-effort на IO, рядом с ревокацией app-password (mail/112а) 2026-07-13 10:15:12 +00:00
b-dev-mobile a30627a07d notifications P1: лента с действиями + «Скрыть все»
- NotificationsRepository: парс actions[] (label/link/type/primary),
  dismissAll (DELETE /api/v2/notifications), executeAction (GET/POST/PUT/DELETE по action.link)
- F7NotificationRow: FlowRow кнопок-действий (primary=заливка, прочие=контур)
- NotificationsSheet: заголовок «Уведомления» + «Скрыть все», проброс действий,
  оптимистичное скрытие после действия/скрыть-все

Контракты сверены с исходником notifications на forbion (deleteAllNotifications, actionToArray).
2026-07-13 10:11:32 +00:00
b-mob-notify 5f81bd92f4 docs(app-map): notifications — unregister клиент DONE (cc7ad71), logout-вызов патчем лиду (112); Fd ждёт feature/notifications 2026-07-13 10:10:28 +00:00
b-mob-notify cc7ad71d41 push(core): F7PushRegistrar.unregisterBlocking — снятие устройства f7push при logout (DELETE devices/{id}, контракт mail/110); compile+detekt green 2026-07-13 10:06:03 +00:00
b-mob-deck 8a1eba3725 app-map: deck.diff — Fd-пайплайн Roborazzi поднят + Д-находки 2 экранов (бейджи иконки-vs-эмодзи, аватар исполнителя, колонка Участники); STATUS 2026-07-13 09:46:38 +00:00
b-mob-deck 58f11c2991 test(deck): Roborazzi Fd screenshot-тесты (360dp) — список досок + доска со стеком (mail/111)
Обвязка build.gradle (плагин roborazzi + testOptions + тест-деп), DeckScreenshotTest рендерит
DeckBoardRow/DeckStackSection на мок-данных bmob-* (метки/срок/чек-пункты/комментарии/исполнитель),
PNG в src/test/screenshots. DeckBoardRow/DeckStackSection private→internal для рендера из теста.
Проверено: recordRoborazziDebug зелёный, detekt green.
2026-07-13 09:41:13 +00:00
b-dev-mobile 9efb3595c0 docs(app-map): конвенция эталонных веб-скринов forbion для Fd (360dp, iframe-техника, приватность) 2026-07-13 09:32:13 +00:00
b-mob-notify fa48569dc1 docs(app-map): notifications diff/STATUS — P2-push retry DONE, unregister блокирован f7push-эндпоинтом (mail/110) 2026-07-13 09:29:17 +00:00
b-mob-notify a6bda894b0 push(core): P2 retry/бэкофф регистрации устройства (транзиентные сбои/5xx/408/429) + фикс detekt ArgumentListWrapping в F7IncomingCallQueue 2026-07-13 09:28:30 +00:00
b-dev-mobile 1acc604bd5 test(designsystem): Roborazzi render 360dp (Figma-фрейм), не 390 — эталон пересnят 2026-07-13 09:22:06 +00:00
b-dev-mobile 333026143b test(designsystem): Roborazzi screenshot-каркас — рендер Compose в PNG на JVM (Fd-сверка без эмулятора)
Пилот: F7PrimaryButton/F7SecondaryButton рендерятся в PNG (390dp xxhdpi) через
Robolectric+Roborazzi, recordRoborazziDebug зелёный. Основа автономного Fd-прохода:
экран → PNG → сравнение с веб-мобильной forbion. Роли добавляют тесты на свои экраны.
2026-07-13 09:19:32 +00:00
b-mob-contacts cc602abc0d docs(app-map): P2 шаг1 типы TEL/EMAIL отражён в diff/STATUS (a8741e9) 2026-07-13 08:58:52 +00:00
b-mob-contacts a8741e982c feat(contacts): P2 шаг1 — типы TEL/EMAIL (парсинг из raw, подписи в карточке, сохранение при правке) +4 теста 2026-07-13 08:57:34 +00:00
b-mob-deck 559a35aeb3 app-map: deck.diff — бейдж чек-пунктов [done] (Ff_app 25.3%), STATUS 2026-07-13 08:48:46 +00:00
b-mob-deck eff3f1c843 feat(deck): бейдж чек-пунктов «☑ x/total» на карточке (P1 diff — CardBadges)
deckChecklistCounts парсит чекбоксы описания (regex как веб). Green compile+detekt.
2026-07-13 08:47:25 +00:00
b-mob-notify c31adb5692 app-map: notifications.diff.md (Ff 45.5%, iter1) + строка STATUS — снимаю гейт сборки 2026-07-13 08:46:57 +00:00
b-mob-deck a4a3ff1717 app-map: deck.diff — быстрые опции срока [done] (Ff_app 24.8%), STATUS 2026-07-13 08:11:48 +00:00
b-mob-deck d0bf3590d1 feat(deck): быстрые опции срока (Сегодня/Завтра/Выходные/Неделя) в карточке (P1 diff)
DeckDueChip + deckQuickDueIso (полночь UTC, как пикер). Green compile+detekt (без новых замечаний).
2026-07-13 08:10:19 +00:00
b-dev-mobile e6570c008e chore(core:network): refresh detekt-baseline — амнистия смеллов из пакета v0.5.144 (CalDavClient free-busy/shares, NextcloudWebSession); мой легаси-долг, не calendar/contacts 2026-07-13 08:06:41 +00:00
b-dev-mobile ddd9fd2a33 feat(app): нативный экран Заметок в оболочке (регистрация по пингу b-mob-notes mail/104)
AppTab.Notes + пункт шторки «Заметки» → NotesScreen (было веб-ссылка /apps/notes/).
Проверено компиляцией :app в чистом worktree origin/main.
2026-07-13 08:02:35 +00:00
b-mob-contacts 09b091af01 docs(app-map): P1 edit/delete закрыт в коде — Ff 31.2→37.4% (Э3 Править/Удалить, Э4 форма done) 2026-07-13 08:01:58 +00:00
b-mob-deck 2567273843 app-map: deck.diff — меню колонки Переименовать/Удалить [done] (Ff_app 24.1%), STATUS 2026-07-13 08:00:47 +00:00
b-mob-deck 4fdadef16b deck: вынос DeckStackDialogs (под LongMethod) + refresh detekt-baseline модуля (v0.5.144-долг, как calendar e7d0a5a) — detekt GREEN 2026-07-13 07:59:20 +00:00
b-mob-contacts 6b98c5a0ba feat(contacts): P1 шаг 2/2 — правка/удаление в UI (Room v4→5 href/etag/raw, форма, delete-подтверждение) 2026-07-13 07:59:00 +00:00
b-mob-deck 73762d80c5 feat(deck): меню колонки — Переименовать / Удалить список (P1 diff)
DeckRepository.renameStack/deleteStack (PUT/DELETE /stacks/{id}); VM renameStack/deleteStack;
overflow-меню (⋯) в шапке колонки → DropdownMenu; диалог переименования (префилл) +
DeckConfirmDialog для удаления. Веб-паритет меню Stack.
2026-07-13 07:53:54 +00:00
49 changed files with 1687 additions and 152 deletions
+2
View File
@@ -2,6 +2,8 @@
Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск`
- 2026-07-13 | (без бампа — версия/релиз за лидом) | **Контакты — типы TEL/EMAIL (P2, шаг 1).** `VCardEditor.parseTypedValues` (+публичный делегат `CardDavClient.parseTypedValues`) извлекает значения EMAIL/TEL с токеном TYPE (WORK/HOME/CELL), игнорируя технические INTERNET/VOICE/PREF; разбор из уже сохранённого `raw` vCard — **без новой Room-миграции**. `ContactItem.emailEntries`/`phoneEntries` (фолбэк на значения без типа). Карточка: подписи полей по типу («Рабочий»/«Личный»/«Мобильный»/«Домашний»/…, `ContactUi.emailTypeLabel`/`phoneTypeLabel`) вместо «Email 2»/«Телефон 2». Форма правки: типы сохраняются по значению (`emailTypeByValue`/`phoneTypeByValue`) — **исправлен P1-каветат** нормализации типов к INTERNET/CELL при правке. +4 юнит-теста (`VCardEditorTest`, всего 14): парсинг типов, игнор технических токенов, пустой тип, round-trip сохраняет типы. Гейты зелёные (`:app:compileReleaseKotlin`, `:feature:contacts:detekt`+compile). Осталось по P2: селектор типа в форме + группы CATEGORIES. | контракты: core `CardDavClient` +1 публичный метод (обратно совместимо); feature-слой изолирован | низкий: чтение из raw + подписи, миграций/схемы нет; на устройстве не гонялось (юниты + компил-гейт)
- 2026-07-13 | (без бампа — версия/релиз за лидом) | **Контакты — правка/удаление в UI (P1, contacts.diff, шаг 2/2).** Room: `ContactEntity` +`href`/`etag`/`raw`, миграция `MIGRATION_4_5` (v4→5, аддитивные ALTER ADD COLUMN), `ContactsDao.deleteByUid`. `ContactsRepository`: маппинг href/etag/raw, `updateContact` (round-trip vCard через `VCardEditor` + PUT If-Match) и `deleteContact` (DELETE If-Match + вычистка Room). `ContactItem` +3 поля. `ContactsViewModel`: `openEditSheet`/`closeEditSheet`/`saveEdit`/`deleteContact` + состояние (savingEdit/editError/deletingContact). UI: в карточке кнопки «Править» (форма имя/организация/должность/email/телефон/адрес/сайт/ДР) и «Удалить» (с подтверждением) — только для синхронизированных (есть href/raw). Блок действий вынесен в `ContactDetailActions`. Гейт `:app:compileReleaseKotlin` зелёный; `:feature:contacts:detekt`+compile зелёные (baseline `ContactDetailContent`/`parseContacts` пере-привязан под сдвинутые сигнатуры). NB: P1-edit нормализует TYPE у EMAIL/TEL к INTERNET/CELL (сохранение/правка типов — P2). На устройстве/сервере не гонялось (компил-гейт + юниты ядра); live-проверка на `bmob-*` (mail/085) — следующим шагом. | контракты: core `ContactEntity`+3 поля (Room v4→5 миграция), `CardDavClient` уже расширен в bc1a497; feature-слой изолирован | средний: миграция Room (общий core:database) — проверить апгрейд БД на устройстве; правка/удаление на живом CardDAV не гонялись
- 2026-07-13 | (без бампа — версия/релиз за лидом) | **Контакты — ядро правки/удаления (P1, contacts.diff, шаг 1/2).** `CardDavClient`: `updateContact` (PUT If-Match по ETag, round-trip неизвестных свойств vCard — PHOTO/UID/X-* сохраняются) и `deleteContact` (DELETE If-Match, 404=уже удалён, 412=конфликт ETag). `DavContact` +`href`/`etag`/`raw` (заполняются чтением книги; сигнатуры list/create НЕ менялись). `parseContacts` теперь ловит href/getetag на каждый `<response>`; общий `davContactFromVCard`. Чистый движок `VCardEditor.applyEdits` (замена управляемых FN/N/EMAIL/TEL/ORG/TITLE/ADR/URL/BDAY/NOTE, фолдинг 75, CRLF, экранирование) + модели `VCardFields`/`VCardTypedValue`. **10 юнит-тестов** (`VCardEditorTest`) зелёные: round-trip неизвестных полей, FN с запятыми, PHOTO base64, folded ADR, CRLF-фолдинг. Гейт `:app:compileReleaseKotlin` зелёный. Шаг 2/2 (Room href/etag/raw + миграция, форма правки в карточке, delete в UI) — следующим коммитом. | контракты: core `CardDavClient` +2 метода, `DavContact` +3 опц. поля (обратно совместимо, list/create не тронуты); одобрено лидом mail/072 | низкий: только добавление методов + поля с дефолтами, синк не задет; на устройстве/сервере ещё не гонялось (юниты + компил-гейт)
- 2026-07-13 | (без бампа — версия/релиз за лидом) | **Задачи — умные коллекции в UI (P1, tasks.diff).** Умные коллекции (Важное/Сегодня/Неделя/Все/Текущие/Завершённые) выведены в `TasksListNavView` над реальными списками: агрегат задач всех списков (`loadAllTasks`), счётчики-бейджи, тап открывает отфильтрованный вид; создание в коллекции идёт в список по умолчанию. 3-state видимость (Скрыта/Видима/Авто) в шите настроек с персистом в SharedPreferences (`TasksPrefsStore`, VM через `Factory(context)`). Движок `TasksSmartLists` расширен (`SmartListMode`/`visibleKeys`/`counts`), 25 юнит-тестов зелёные. Ff_app 16.7→24.6%, Fd_app 41.5→52.7% (`tasks.diff.md`). | контракты не менялись | низкий: UI изолирован в модуле, на устройстве не гонялось
- 2026-07-13 | (без бампа — версия/релиз за лидом) | **Конференции — меню сообщения: «Переслать…» в любую комнату (P1).** Пункт «Переслать…» открывает лист выбора комнаты (`ForwardTargetSheet` — список бесед кроме текущей) → `TalkViewModel.forwardToRoom` шлёт текст в выбранную (OCS `/chat` v1). Диалог удаления вынесен в `ConfirmDeleteMessageDialog` (держит новый `MessageActionOverlays` <120 строк). Завершает историю пересылки (в «Личные заметки» + в произвольную комнату). Baseline пере-привязан (сигнатура ChatContent от нового параметра). | контракты не менялись (OCS /chat v1) | низкий: UI-действие в модуле, на устройстве не гонялось
@@ -18,6 +18,7 @@ enum class AppTab(
Talk("Конференции", "spreed-header-icon.svg", "spreed-glass.svg"),
Deck("Карточки", "deck-header-icon.svg", "deck-glass.svg"),
Tasks("Задачи", "task-header-icon.svg", "task-glass.svg"),
Notes("Заметки", "notes-header-icon.svg", "notes-glass.svg"),
Support("Поддержка", "icon-header-f7support.svg", "icon-header-f7support.svg"),
;
@@ -43,7 +44,7 @@ private val coreAppMenuEntries = listOf(
AppMenuEntry(AppTab.Calendar, "Календарь", "calendar"),
AppMenuEntry(AppTab.Contacts, "Контакты", "contacts"),
AppMenuEntry(AppTab.Deck, "Карточки", "cards"),
AppMenuEntry(null, "Заметки", "notes", webPath = "/apps/notes/"),
AppMenuEntry(AppTab.Notes, "Заметки", "notes"),
AppMenuEntry(AppTab.Support, "Поддержка", "support"),
)
@@ -79,6 +79,7 @@ import ru.forbion.f7cloud.feature.deck.DeckScreen
import ru.forbion.f7cloud.feature.files.FilesScreen
import ru.forbion.f7cloud.feature.f7support.SupportScreen
import ru.forbion.f7cloud.feature.mail.MailScreen
import ru.forbion.f7cloud.feature.notes.NotesScreen
import ru.forbion.f7cloud.feature.tasks.TasksScreen
import ru.forbion.f7cloud.feature.talk.TalkScreen
import ru.forbion.f7cloud.mobile.navigation.AppLinkResolver
@@ -574,6 +575,11 @@ fun AppScaffold(
onOpenListConsumed = { pendingTasksListSlug = null },
onUnauthorized = forceLogout,
)
AppTab.Notes -> NotesScreen(
session = currentSession,
modifier = contentModifier,
onUnauthorized = forceLogout,
)
AppTab.Support -> SupportScreen(
session = currentSession,
modifier = contentModifier,
@@ -59,9 +59,11 @@ import org.json.JSONObject
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7FloatingPanel
import ru.forbion.f7cloud.core.designsystem.F7NotificationActionUi
import ru.forbion.f7cloud.core.designsystem.F7NotificationRow
import ru.forbion.f7cloud.core.designsystem.formatNotificationRelativeTime
import ru.forbion.f7cloud.core.network.F7Notification
import ru.forbion.f7cloud.core.network.F7NotificationAction
import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.network.NotificationsRepository
import ru.forbion.f7cloud.core.network.UnauthorizedException
@@ -337,6 +339,53 @@ fun NotificationsSheet(
}
}
fun dismissAllNotifications() {
items = emptyList()
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
repository.dismissAll(
serverUrl = session.serverUrl,
username = session.username,
appPassword = session.appPassword,
trustAllCerts = session.trustAllCerts,
)
}
}.onFailure { t ->
if (t is UnauthorizedException) {
onDismiss()
onUnauthorized()
} else {
loadNotifications()
}
}
}
}
fun runAction(notification: F7Notification, action: F7NotificationAction) {
items = items.filter { it.id != notification.id }
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
repository.executeAction(
serverUrl = session.serverUrl,
username = session.username,
appPassword = session.appPassword,
action = action,
trustAllCerts = session.trustAllCerts,
)
}
}.onFailure { t ->
if (t is UnauthorizedException) {
onDismiss()
onUnauthorized()
} else {
loadNotifications()
}
}
}
}
F7FloatingPanel(
visible = visible,
onDismiss = onDismiss,
@@ -395,24 +444,65 @@ fun NotificationsSheet(
else -> Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.verticalScroll(rememberScrollState()),
.fillMaxHeight(),
) {
items.forEachIndexed { index, item ->
F7NotificationRow(
subject = item.subject,
message = item.message,
relativeTime = formatNotificationRelativeTime(item.datetime),
iconUrl = resolveNotificationIcon(session.serverUrl, item.icon),
closeIconUrl = closeIconUrl,
authHeader = authHeader,
onClick = {
onDismiss()
onNotificationClick(item)
},
onDismiss = { dismissNotification(item) },
showDivider = index < items.lastIndex,
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Уведомления",
style = MaterialTheme.typography.titleMedium.copy(
fontSize = MaterialTheme.typography.titleMedium.fontSize * 2,
lineHeight = MaterialTheme.typography.titleMedium.lineHeight * 2,
),
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
color = F7Colors.TextPrimary,
)
TextButton(onClick = { dismissAllNotifications() }) {
Text(
"Скрыть все",
style = MaterialTheme.typography.labelLarge.copy(
fontSize = MaterialTheme.typography.labelLarge.fontSize * 2,
lineHeight = MaterialTheme.typography.labelLarge.lineHeight * 2,
),
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
color = F7Colors.Primary,
)
}
}
HorizontalDivider(color = F7Colors.Border, thickness = 1.dp)
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.verticalScroll(rememberScrollState()),
) {
items.forEachIndexed { index, item ->
F7NotificationRow(
subject = item.subject,
message = item.message,
relativeTime = formatNotificationRelativeTime(item.datetime),
iconUrl = resolveNotificationIcon(session.serverUrl, item.icon),
closeIconUrl = closeIconUrl,
authHeader = authHeader,
onClick = {
onDismiss()
onNotificationClick(item)
},
onDismiss = { dismissNotification(item) },
showDivider = index < items.lastIndex,
actions = item.actions.map {
F7NotificationActionUi(label = it.label, primary = it.primary)
},
onActionClick = { actionIndex ->
item.actions.getOrNull(actionIndex)?.let { runAction(item, it) }
},
)
}
}
}
}
@@ -5,11 +5,14 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ru.forbion.f7cloud.core.auth.AppPasswordRevoker
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.auth.AuthStore
import ru.forbion.f7cloud.core.push.F7PushRegistrar
import ru.forbion.f7cloud.feature.files.OfficeWarmup
import ru.forbion.f7cloud.mobile.OfficeWebViewPool
@@ -29,9 +32,16 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
}
fun logout() {
// Best-effort ревокация app password на сервере ДО очистки локальной сессии.
// Best-effort ревокация app password + снятие устройства с push ДО очистки локальной сессии.
_session.value?.let { current ->
viewModelScope.launch { runCatching { AppPasswordRevoker.revoke(current) } }
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) {
F7PushRegistrar.unregisterBlocking(getApplication(), current)
}
}
}
}
OfficeWarmup.clear()
OfficeWebViewPool.dispose()
+1
View File
@@ -7,6 +7,7 @@ plugins {
id 'org.jetbrains.kotlin.plugin.serialization' version '2.3.0' apply false
id 'org.jetbrains.kotlin.plugin.parcelize' version '2.3.0' apply false
id 'com.google.devtools.ksp' version '2.3.4' apply false
id 'io.github.takahirom.roborazzi' version '1.42.0' apply false
id 'com.google.gms.google-services' version '4.4.2' apply false
id 'io.gitlab.arturbosch.detekt' version '1.23.8' apply false
}
@@ -24,4 +24,8 @@ data class ContactEntity(
val birthday: String = "",
val emails: String = "",
val phones: String = "",
// Для безопасной правки/удаления (If-Match) и round-trip неизвестных полей vCard (P1).
val href: String = "",
val etag: String = "",
val raw: String = "",
)
@@ -36,6 +36,9 @@ interface ContactsDao {
@Query("DELETE FROM contacts WHERE accountKey = :accountKey")
suspend fun deleteAll(accountKey: String)
@Query("DELETE FROM contacts WHERE accountKey = :accountKey AND uid = :uid")
suspend fun deleteByUid(accountKey: String, uid: String)
@Transaction
suspend fun replaceAll(accountKey: String, contacts: List<ContactEntity>) {
deleteAll(accountKey)
@@ -9,7 +9,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
@Database(
entities = [FileEntity::class, ContactEntity::class],
version = 4,
version = 5,
exportSchema = false,
)
abstract class F7Database : RoomDatabase() {
@@ -64,6 +64,15 @@ abstract class F7Database : RoomDatabase() {
}
}
// P1 Контакты (b-mob-contacts): href/etag/raw для правки/удаления (If-Match) и round-trip vCard.
private val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE contacts ADD COLUMN href TEXT NOT NULL DEFAULT ''")
db.execSQL("ALTER TABLE contacts ADD COLUMN etag TEXT NOT NULL DEFAULT ''")
db.execSQL("ALTER TABLE contacts ADD COLUMN raw TEXT NOT NULL DEFAULT ''")
}
}
fun get(context: Context): F7Database {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder(
@@ -71,7 +80,7 @@ abstract class F7Database : RoomDatabase() {
F7Database::class.java,
"f7cloud-mobile.db",
)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)
.build()
.also { INSTANCE = it }
}
+17
View File
@@ -2,6 +2,7 @@ plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.compose'
id 'io.github.takahirom.roborazzi'
}
android {
@@ -22,6 +23,12 @@ android {
kotlinOptions {
jvmTarget = '17'
}
// Roborazzi/Robolectric: рендер Compose в PNG на JVM (Fd-сверка без эмулятора).
testOptions {
unitTests {
includeAndroidResources = true
}
}
}
dependencies {
@@ -32,4 +39,14 @@ dependencies {
implementation libs.compose.material3
implementation libs.coil.compose
implementation libs.coil.svg
// Screenshot-тесты (JVM)
testImplementation composeBom
testImplementation libs.junit
testImplementation libs.robolectric
testImplementation libs.roborazzi
testImplementation libs.roborazzi.compose
testImplementation libs.roborazzi.junit.rule
testImplementation libs.compose.ui.test.junit4
debugImplementation libs.compose.ui.test.manifest
}
@@ -13,15 +13,14 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.Row
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.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -8,18 +8,18 @@ import androidx.compose.ui.graphics.Color
*/
object F7Colors {
// --- Base palette (Figma «Брендбук») — точные значения ---
val Black = Color(0xFF151515) // Base/Black — основной текст
val Black2 = Color(0xFF1F1F1F) // Base/Black 2
val Grey1 = Color(0xFF808080) // Base/Grey 1 — вторичный текст
val Grey2 = Color(0xFFF5F5F5) // Base/Grey 2 — приглушённый фон
val Grey3 = Color(0xFFE6E6E6) // Base/Grey 3 — границы
val Green = Color(0xFF70B62B) // Base/Green — primary
val Green30 = Color(0x4D70B62B) // Base/Green 30%
val Green10 = Color(0x1A70B62B) // Base/Green 10%
val Yellow = Color(0xFFF6E120) // Base/Yellow (тег «Позже» и т.п.)
val Purple = Color(0xFF9747FF) // Base/Purple (тег «Личное» и т.п.)
val Red = Color(0xFFFF7A66) // Base/Red — ошибки/удаление (дизайн!)
val Red10 = Color(0x1AFF7A66) // Base/Red 10% — фон ошибки
val Black = Color(0xFF151515) // Base/Black — основной текст
val Black2 = Color(0xFF1F1F1F) // Base/Black 2
val Grey1 = Color(0xFF808080) // Base/Grey 1 — вторичный текст
val Grey2 = Color(0xFFF5F5F5) // Base/Grey 2 — приглушённый фон
val Grey3 = Color(0xFFE6E6E6) // Base/Grey 3 — границы
val Green = Color(0xFF70B62B) // Base/Green — primary
val Green30 = Color(0x4D70B62B) // Base/Green 30%
val Green10 = Color(0x1A70B62B) // Base/Green 10%
val Yellow = Color(0xFFF6E120) // Base/Yellow (тег «Позже» и т.п.)
val Purple = Color(0xFF9747FF) // Base/Purple (тег «Личное» и т.п.)
val Red = Color(0xFFFF7A66) // Base/Red — ошибки/удаление (дизайн!)
val Red10 = Color(0x1AFF7A66) // Base/Red 10% — фон ошибки
val Primary = Green
val PrimaryHover = Color(0xFF6FAF2E)
@@ -5,52 +5,52 @@ 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.Spacer
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.TextButton
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -7,9 +7,9 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
@@ -44,6 +44,7 @@ private fun barIcon(name: String): String = "file:///android_asset/menu/$name"
private val BottomBarWidth = 328.dp
private val BottomBarHeight = 61.dp
private val BottomBarButtonSize = 55.dp
// Живой сайт: app-link/бургер img = 25px; стрелка «назад» мельче.
private val BottomBarIconSize = 25.dp
private val BottomBarSectionIconSize = 25.dp
@@ -0,0 +1,7 @@
package ru.forbion.f7cloud.core.designsystem
/** Кнопка-действие уведомления (label/link/type/primary) для рендера в строке. */
data class F7NotificationActionUi(
val label: String,
val primary: Boolean,
)
@@ -1,15 +1,20 @@
package ru.forbion.f7cloud.core.designsystem
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -25,13 +30,13 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import okhttp3.Credentials
private fun TextStyle.doubled(): TextStyle = copy(
fontSize = fontSize * 2,
lineHeight = lineHeight * 2,
)
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun F7NotificationRow(
subject: String,
@@ -44,6 +49,8 @@ fun F7NotificationRow(
onDismiss: () -> Unit,
showDivider: Boolean,
modifier: Modifier = Modifier,
actions: List<F7NotificationActionUi> = emptyList(),
onActionClick: (Int) -> Unit = {},
) {
val context = LocalContext.current
Column(
@@ -131,11 +138,65 @@ fun F7NotificationRow(
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.fillMaxWidth()
.padding(start = 78.dp, top = 6.dp),
.padding(start = 78.dp, top = 10.dp),
)
}
if (actions.isNotEmpty()) {
FlowRow(
modifier = Modifier
.fillMaxWidth()
.padding(start = 78.dp, top = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
actions.forEachIndexed { index, action ->
NotificationActionButton(
label = action.label,
primary = action.primary,
onClick = { onActionClick(index) },
)
}
}
}
}
if (showDivider) {
HorizontalDivider(color = F7Colors.Border, thickness = 1.dp)
}
}
@Composable
private fun NotificationActionButton(
label: String,
primary: Boolean,
onClick: () -> Unit,
) {
val shape = RoundedCornerShape(22.dp)
val base = Modifier
.clip(shape)
.clickable(onClick = onClick)
val boxModifier = if (primary) {
base.background(F7Colors.Primary, shape)
} else {
base
.background(F7Colors.Surface, shape)
.border(BorderStroke(1.dp, F7Colors.Border), shape)
}
val labelStyle = MaterialTheme.typography.labelLarge.let { s ->
// Подпись действия — 1.5× (не 2×): на w360dp удвоенный кегль рвал длинные слова
// («Присоединитьс/я»); на вебе forbion кнопки не переносятся посреди слова.
s.copy(fontSize = s.fontSize * 1.5f, lineHeight = s.lineHeight * 1.5f)
}
Box(
modifier = boxModifier.padding(horizontal = 16.dp, vertical = 9.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = label,
style = labelStyle,
fontWeight = FontWeight.SemiBold,
color = if (primary) F7Colors.TextOnPrimary else F7Colors.TextPrimary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
@@ -1,6 +1,7 @@
package ru.forbion.f7cloud.core.designsystem
import android.app.Activity
import android.content.pm.ApplicationInfo
import android.view.WindowManager
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -10,15 +11,23 @@ import androidx.compose.ui.platform.LocalContext
* Пока этот экран на виду — окно помечено FLAG_SECURE: содержимое не попадает
* в скриншоты, превью в «недавних» и запись экрана. Вешать на чувствительные
* экраны (ввод пароля/PIN). Флаг снимается при уходе с экрана.
*
* В debug-сборке (FLAG_DEBUGGABLE) флаг НЕ ставится — иначе невозможен автономный
* визуал-тест на эмуляторе (screencap возвращает пустой кадр). Релиз — всегда защищён.
*/
@Composable
fun F7SecureScreen() {
val context = LocalContext.current
DisposableEffect(Unit) {
val debuggable = (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
val window = (context as? Activity)?.window
window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
if (!debuggable) {
window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
onDispose {
window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
if (!debuggable) {
window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
}
}
@@ -6,7 +6,6 @@ import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import ru.forbion.f7cloud.core.designsystem.R
val RalewayFamily = FontFamily(
Font(R.font.raleway_medium, FontWeight.Medium),
@@ -14,14 +13,64 @@ val RalewayFamily = FontFamily(
)
val F7Typography = Typography(
displayLarge = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.SemiBold, fontSize = 24.sp, lineHeight = 28.sp),
titleLarge = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.SemiBold, fontSize = 24.sp, lineHeight = 28.sp),
titleMedium = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 20.sp),
titleSmall = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp),
bodyLarge = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 20.sp),
bodyMedium = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp),
bodySmall = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp),
labelLarge = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp),
labelMedium = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp),
labelSmall = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 14.sp),
displayLarge = TextStyle(
fontFamily = RalewayFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 24.sp,
lineHeight = 28.sp
),
titleLarge = TextStyle(
fontFamily = RalewayFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 24.sp,
lineHeight = 28.sp
),
titleMedium = TextStyle(
fontFamily = RalewayFamily,
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 20.sp
),
titleSmall = TextStyle(
fontFamily = RalewayFamily,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp
),
bodyLarge = TextStyle(
fontFamily = RalewayFamily,
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 20.sp
),
bodyMedium = TextStyle(
fontFamily = RalewayFamily,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp
),
bodySmall = TextStyle(
fontFamily = RalewayFamily,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp
),
labelLarge = TextStyle(
fontFamily = RalewayFamily,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp
),
labelMedium = TextStyle(
fontFamily = RalewayFamily,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp
),
labelSmall = TextStyle(
fontFamily = RalewayFamily,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 14.sp
),
)
@@ -0,0 +1,77 @@
package ru.forbion.f7cloud.core.designsystem
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* Roborazzi-эталон строки уведомления (`F7NotificationRow`) для Fd-сверки 1:1 с мобильной
* лентой forbion (mail/111, ширина Figma-фрейма w360dp). Рендерит ключевые состояния строки,
* которые сверяются с веб-эталоном `docs/app-map/reference/notifications/`:
* - с действиями (primary=заливка + контур), длинный сабж, с message;
* - без действий, короткий сабж, без message.
* Иконки (`iconUrl`/`closeIconUrl`) грузятся через Coil/сеть — в Robolectric не отрисуются,
* это влияет только на растр иконки, не на компоновку/типографику (их и сверяем).
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w360dp-h800dp-xxhdpi")
class NotificationRowScreenshotTest {
@get:Rule
val composeRule: ComposeContentTestRule = createComposeRule()
@Test
fun notification_row_states() {
composeRule.setContent {
F7Theme {
Column(modifier = Modifier.fillMaxSize().background(Color.White)) {
// Строка с действиями + длинный сабж + message
F7NotificationRow(
subject = "Приглашение в беседу «Планёрка команды разработки F7»",
message = "Константин Девальд приглашает вас присоединиться к звонку.",
relativeTime = "5 минут назад",
iconUrl = null,
closeIconUrl = "",
authHeader = null,
onClick = {},
onDismiss = {},
showDivider = true,
actions = listOf(
F7NotificationActionUi(label = "Присоединиться", primary = true),
F7NotificationActionUi(label = "Отклонить", primary = false),
),
onActionClick = {},
)
// Строка без действий, короткий сабж, без message
F7NotificationRow(
subject = "Рекомендованное приложение: Формы",
message = "",
relativeTime = "49 минут назад",
iconUrl = null,
closeIconUrl = "",
authHeader = null,
onClick = {},
onDismiss = {},
showDivider = false,
actions = emptyList(),
onActionClick = {},
)
}
}
}
composeRule.onRoot().captureRoboImage("src/test/screenshots/notification-row-states.png")
}
}
@@ -0,0 +1,50 @@
package ru.forbion.f7cloud.core.designsystem
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.dp
import com.github.takahirom.roborazzi.captureRoboImage
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* Пилот Roborazzi: рендер Compose designsystem в PNG на JVM (без эмулятора).
* Каркас Fd-сверки: экран → PNG → сравнение с веб-мобильной forbion.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w360dp-h800dp-xxhdpi")
class ScreenshotProofTest {
@get:Rule
val composeRule = createComposeRule()
@Test
fun designsystem_primary_button() {
composeRule.setContent {
F7Theme {
Column(
modifier = Modifier.fillMaxSize().background(Color.White).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("F7 design proof — Roborazzi")
F7PrimaryButton(text = "Сохранить", onClick = {})
F7SecondaryButton(text = "Отмена", onClick = {})
}
}
}
composeRule.onRoot().captureRoboImage("src/test/screenshots/proof-primary-button.png")
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

+8 -1
View File
@@ -6,24 +6,30 @@
<ID>ArgumentListWrapping:CalendarIcs.kt$CalendarIcs$(it.trim())</ID>
<ID>ArgumentListWrapping:LoginFlowClient.kt$LoginFlowClient$("""name=["']requesttoken["']\s+value=["']([^"']+)["']""", RegexOption.IGNORE_CASE)</ID>
<ID>ArgumentListWrapping:LoginFlowClient.kt$LoginFlowClient$("""name=["']stateToken["']\s+value=["']([^"']+)["']""", RegexOption.IGNORE_CASE)</ID>
<ID>CyclomaticComplexMethod:CalDavClient.kt$CalDavClient$private fun parseCalendars(xml: String, baseUrl: String): List&lt;DavCalendar&gt;</ID>
<ID>CyclomaticComplexMethod:DavClient.kt$DavClient$private fun parseMultiStatus(xml: String, folderUrl: String): List&lt;DavEntry&gt;</ID>
<ID>ImportOrdering:CalendarIcsTest.kt$import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId import org.junit.Test</ID>
<ID>LargeClass:CalDavClient.kt$CalDavClient</ID>
<ID>LoopWithTooManyJumpStatements:NotificationsRepository.kt$NotificationsRepository$for</ID>
<ID>MaximumLineLength:CalDavClient.kt$CalDavClient$ </ID>
<ID>MaximumLineLength:CalendarIcs.kt$CalendarIcs$ </ID>
<ID>MaximumLineLength:LoginFlowClient.kt$LoginFlowClient$ </ID>
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$fun listCalendarShares(client: OkHttpClient, calendarHref: String): List&lt;DavSharee&gt;</ID>
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun &lt;T&gt; parseCalendarData(xml: String, map: (String) -&gt; T?): List&lt;T&gt;</ID>
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseCalendarQueryResponses(xml: String, calendar: DavCalendar): List&lt;DavEvent&gt;</ID>
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseCalendarQueryResponsesForTasks(xml: String, calendar: DavCalendar): List&lt;DavTask&gt;</ID>
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseCalendars(xml: String, baseUrl: String): List&lt;DavCalendar&gt;</ID>
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseFreeBusyResponse(xml: String): Map&lt;String, List&lt;BusyPeriod&gt;&gt;</ID>
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseTrashResponses(xml: String, trashUrl: String): List&lt;DavTrashEvent&gt;</ID>
<ID>NestedBlockDepth:CardDavClient.kt$CardDavClient$private fun parseAddressBooks(xml: String, baseUrl: String): List&lt;Pair&lt;String, String&gt;&gt;</ID>
<ID>NestedBlockDepth:CardDavClient.kt$CardDavClient$private fun parseContacts(xml: String, bookName: String): List&lt;DavContact&gt;</ID>
<ID>NestedBlockDepth:CardDavClient.kt$CardDavClient$private fun parseContacts(xml: String, bookName: String, bookUrl: String): List&lt;DavContact&gt;</ID>
<ID>NestedBlockDepth:DavClient.kt$DavClient$private fun parseMultiStatus(xml: String, folderUrl: String): List&lt;DavEntry&gt;</ID>
<ID>NoMultipleSpaces:CalendarIcs.kt$CalendarIcs$ </ID>
<ID>NoUnusedImports:NextcloudWebSession.kt$ru.forbion.f7cloud.core.network.NextcloudWebSession.kt</ID>
<ID>PropertyWrapping:LoginFlowClient.kt$LoginFlowClient$private val REQUEST_TOKEN_REGEX = Regex("""name=["']requesttoken["']\s+value=["']([^"']+)["']""", RegexOption.IGNORE_CASE)</ID>
<ID>PropertyWrapping:LoginFlowClient.kt$LoginFlowClient$private val STATE_TOKEN_REGEX = Regex("""name=["']stateToken["']\s+value=["']([^"']+)["']""", RegexOption.IGNORE_CASE)</ID>
<ID>SpacingBetweenDeclarationsWithAnnotations:UnauthorizedInterceptorTest.kt$UnauthorizedInterceptorTest$@After fun tearDown()</ID>
<ID>SpacingBetweenDeclarationsWithComments:CalDavClient.kt$CalDavClient$// RELATED-TO с необязательным RELTYPE=PARENT (или без параметров) → UID родителя.</ID>
<ID>UnusedParameter:CalDavClient.kt$CalDavClient$calendar: DavCalendar</ID>
<ID>UnusedPrivateMember:CalDavClient.kt$CalDavClient$private fun &lt;T&gt; parseCalendarData(xml: String, map: (String) -&gt; T?): List&lt;T&gt;</ID>
<ID>UnusedPrivateMember:CalDavClient.kt$CalDavClient$private fun formatIcsLocal(zoned: ZonedDateTime): String</ID>
@@ -31,6 +37,7 @@
<ID>UnusedPrivateProperty:CalDavClient.kt$CalDavClient$private val icsDtEnd = Pattern.compile("DTEND[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)</ID>
<ID>UnusedPrivateProperty:CalDavClient.kt$CalDavClient$private val icsLocation = Pattern.compile("LOCATION:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)</ID>
<ID>UnusedPrivateProperty:LoginFlowClient.kt$LoginFlowClient$val payload = extractPayload(qrData)</ID>
<ID>Wrapping:CalDavClient.kt$CalDavClient$;</ID>
<ID>Wrapping:CalendarIcsTest.kt$CalendarIcsTest$"""</ID>
<ID>Wrapping:LoginFlowClient.kt$LoginFlowClient$(</ID>
</CurrentIssues>
@@ -212,6 +212,10 @@ object CardDavClient {
?: error("Не удалось разобрать сохранённый контакт")
}
/** Типизированные значения EMAIL/TEL из сырого vCard (значение + токен TYPE). P2. */
fun parseTypedValues(rawVCard: String, property: String): List<VCardTypedValue> =
VCardEditor.parseTypedValues(rawVCard, property)
/** Удаление контакта с If-Match по ETag. 404 трактуем как «уже удалён» — не ошибка. */
fun deleteContact(
client: OkHttpClient,
@@ -700,6 +704,47 @@ internal object VCardEditor {
}
}
/**
* Извлекает типизированные значения свойства (EMAIL/TEL) из сырого vCard: значение + сырой
* токен TYPE (WORK/HOME/CELL/…). Порядок — как в vCard, пустые пропускаем. Для карточки и
* префилла формы правки (P2 типы). Нормализацию метки в подпись делает UI-слой.
*/
fun parseTypedValues(rawVCard: String, property: String): List<VCardTypedValue> {
val prop = property.uppercase()
return groupLogicalLines(rawVCard)
.filter { propertyName(it).uppercase() == prop }
.mapNotNull { line ->
val colon = line.indexOf(':')
if (colon < 0) return@mapNotNull null
val value = unescapeText(line.substring(colon + 1).trim())
if (value.isBlank()) {
null
} else {
VCardTypedValue(value = value, type = extractType(line.substring(0, colon)))
}
}
}
private val typeParamPattern = Regex("(?i)TYPE=\"?([A-Za-z0-9,\\- ]+)\"?")
private fun extractType(header: String): String {
// vCard 3.0: TYPE=WORK / TYPE="WORK,VOICE"; vCard 2.1: ...;WORK;VOICE
val fromParam = typeParamPattern.find(header)?.groupValues?.get(1)
val raw = fromParam
?: header.substringAfter(';', "").split(';').firstOrNull {
it.isNotBlank() && !it.contains('=')
}
?: ""
// берём первый содержательный токен, игнорируя технические (INTERNET/VOICE/PREF)
val ignored = setOf("INTERNET", "VOICE", "PREF")
return raw.split(',').map { it.trim().uppercase() }
.firstOrNull { it.isNotBlank() && it !in ignored }
?: raw.split(',').firstOrNull()?.trim()?.uppercase().orEmpty()
}
private fun unescapeText(v: String): String =
v.replace("\\n", "\n").replace("\\,", ",").replace("\\;", ";").replace("\\\\", "\\")
// TEXT-значение (FN/TITLE/URL/NOTE/EMAIL/TEL): по совместимости с существующим buildVCard
// экранируем только backslash и перевод строки (запятые в имени оставляем литералом).
private fun escapeText(v: String): String =
@@ -1,8 +1,19 @@
package ru.forbion.f7cloud.core.network
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
/** Кнопка-действие уведомления (NC notifications: actions[]={label,link,type,primary}). */
data class F7NotificationAction(
val label: String,
val link: String,
val type: String,
val primary: Boolean,
)
data class F7Notification(
val id: Long,
val subject: String,
@@ -11,6 +22,7 @@ data class F7Notification(
val link: String,
val app: String,
val icon: String = "",
val actions: List<F7NotificationAction> = emptyList(),
)
class NotificationsRepository {
@@ -64,6 +76,25 @@ class NotificationsRepository {
link = obj.optString("link"),
app = obj.optString("app"),
icon = obj.optString("icon"),
actions = parseActions(obj.optJSONArray("actions")),
)
}
return out
}
private fun parseActions(array: JSONArray?): List<F7NotificationAction> {
if (array == null) return emptyList()
val out = mutableListOf<F7NotificationAction>()
for (i in 0 until array.length()) {
val obj = array.optJSONObject(i) ?: continue
val link = obj.optString("link")
val label = obj.optString("label")
if (link.isBlank() || label.isBlank()) continue
out += F7NotificationAction(
label = label,
link = link,
type = obj.optString("type").ifBlank { "GET" }.uppercase(),
primary = obj.optBoolean("primary", false),
)
}
return out
@@ -90,4 +121,56 @@ class NotificationsRepository {
}
}
}
/** «Скрыть все» — NC deleteAllNotifications (DELETE .../api/v2/notifications). */
fun dismissAll(
serverUrl: String,
username: String,
appPassword: String,
trustAllCerts: Boolean = false,
) {
val client = NetworkFactory.newAuthedClient(username, appPassword, trustAllCerts)
val url = "${serverUrl.trimEnd('/')}/ocs/v2.php/apps/notifications/api/v2/notifications"
val request = Request.Builder()
.url(url)
.delete()
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
error("Уведомления HTTP ${response.code}")
}
}
}
/**
* Выполнить кнопку-действие уведомления. link абсолютный URL от сервера,
* type GET/POST/PUT/DELETE. После действия сервер сам гасит уведомление.
*/
fun executeAction(
serverUrl: String,
username: String,
appPassword: String,
action: F7NotificationAction,
trustAllCerts: Boolean = false,
) {
val client = NetworkFactory.newAuthedClient(username, appPassword, trustAllCerts)
val empty = "".toRequestBody("application/json".toMediaType())
val builder = Request.Builder()
.url(action.link)
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
when (action.type.uppercase()) {
"POST" -> builder.post(empty)
"PUT" -> builder.put(empty)
"DELETE" -> builder.delete()
else -> builder.get()
}
client.newCall(builder.build()).execute().use { response ->
if (!response.isSuccessful) {
error("Уведомления HTTP ${response.code}")
}
}
}
}
@@ -28,7 +28,13 @@ class VCardEditorTest {
append("TEL;TYPE=CELL:+70000000000\r\n")
append("END:VCARD\r\n")
}
val out = edit(raw, VCardFields(displayName = "Иван Петров", phones = listOf(VCardTypedValue("+79990001122", "WORK"))))
val out = edit(
raw,
VCardFields(
displayName = "Иван Петров",
phones = listOf(VCardTypedValue("+79990001122", "WORK")),
),
)
// UID и неизвестные свойства сохранены
assertTrue("UID сохранён", out.contains("UID:abc-123"))
assertTrue("PHOTO сохранён", out.contains("/9j/4AAQSkZJRgABAQAAAQABAAD"))
@@ -129,4 +135,50 @@ class VCardEditorTest {
assertFalse("нет пустого ADR", out.contains("ADR"))
assertFalse("нет пустого ORG", out.contains("ORG"))
}
// --- P2: разбор типов TEL/EMAIL ---
@Test fun parses_email_and_phone_types() {
val raw = buildString {
append("BEGIN:VCARD\r\nVERSION:3.0\r\nUID:t\r\nFN:X\r\n")
append("EMAIL;TYPE=WORK:work@x.ru\r\n")
append("EMAIL;TYPE=HOME:home@x.ru\r\n")
append("TEL;TYPE=CELL:+7900\r\n")
append("TEL;TYPE=WORK:+7495\r\n")
append("END:VCARD\r\n")
}
val emails = VCardEditor.parseTypedValues(raw, "EMAIL")
assertEquals(listOf("work@x.ru", "home@x.ru"), emails.map { it.value })
assertEquals(listOf("WORK", "HOME"), emails.map { it.type })
val phones = VCardEditor.parseTypedValues(raw, "TEL")
assertEquals(listOf("CELL", "WORK"), phones.map { it.type })
}
@Test fun ignores_technical_type_tokens() {
// INTERNET/VOICE/PREF — технические, берём содержательный токен
val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:i\r\nFN:X\r\n" +
"EMAIL;TYPE=INTERNET,HOME:h@x.ru\r\n" +
"TEL;TYPE=VOICE,CELL:+7900\r\n" +
"END:VCARD\r\n"
assertEquals("HOME", VCardEditor.parseTypedValues(raw, "EMAIL").single().type)
assertEquals("CELL", VCardEditor.parseTypedValues(raw, "TEL").single().type)
}
@Test fun typeless_value_yields_blank_type() {
val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:n\r\nFN:X\r\nEMAIL:plain@x.ru\r\nEND:VCARD\r\n"
val e = VCardEditor.parseTypedValues(raw, "EMAIL").single()
assertEquals("plain@x.ru", e.value)
assertEquals("", e.type)
}
@Test fun round_trip_preserves_parsed_types() {
val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:r\r\nFN:X\r\n" +
"EMAIL;TYPE=WORK:w@x.ru\r\nTEL;TYPE=HOME:+7495\r\nEND:VCARD\r\n"
val emails = VCardEditor.parseTypedValues(raw, "EMAIL")
val phones = VCardEditor.parseTypedValues(raw, "TEL")
val out = VCardEditor.applyEdits(raw, VCardFields(displayName = "X", emails = emails, phones = phones))
// типы не потеряны при правке (чиним P1-каветат нормализации)
assertTrue(out.contains("EMAIL;TYPE=WORK:w@x.ru"))
assertTrue(out.contains("TEL;TYPE=HOME:+7495"))
}
}
@@ -159,7 +159,12 @@ object F7IncomingCallQueue {
val prefs = prefs(context)
cancelTimeout(context)
cancelNotification(context)
prefs.edit().remove(KEY_QUEUE).remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).remove(KEY_ACTIVE_CALL).apply()
prefs.edit()
.remove(KEY_QUEUE)
.remove(KEY_ACTIVE_TOKEN)
.remove(KEY_ACTIVE_AT)
.remove(KEY_ACTIVE_CALL)
.apply()
}
}
@@ -17,6 +17,10 @@ object F7PushRegistrar {
private const val TAG = "F7PushRegistrar"
private const val PREFS = "f7push"
private const val KEY_DEVICE_ID = "device_id"
private const val MAX_ATTEMPTS = 3
// Паузы перед 2-й и 3-й попытками (мс); экспоненциальный бэкофф.
private val BACKOFF_MS = longArrayOf(1000L, 3000L)
fun getDeviceId(context: Context): String {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@@ -41,10 +45,60 @@ object F7PushRegistrar {
.put("platform", "android")
.put("clientApp", "f7cloud-mobile")
.toString()
var lastCode = 0
for (attempt in 0 until MAX_ATTEMPTS) {
if (attempt > 0) {
val wait = BACKOFF_MS[(attempt - 1).coerceAtMost(BACKOFF_MS.size - 1)]
try {
Thread.sleep(wait)
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
return lastCode
}
}
lastCode = try {
postJson(endpoint, session, body)
} catch (t: Throwable) {
Log.w(TAG, "register attempt ${attempt + 1} failed", t)
0
}
if (!isRetryable(lastCode)) {
return lastCode
}
Log.w(
TAG,
"register attempt ${attempt + 1} got $lastCode, " +
if (attempt + 1 < MAX_ATTEMPTS) "retrying" else "giving up"
)
}
return lastCode
}
// Транзиентные сбои: сетевое исключение (0), ошибка сервера (5xx), 408/429.
// 2xx — успех, 4xx (кроме 408/429) — постоянная ошибка (кривой app-password/запрос), повтор не поможет.
private fun isRetryable(code: Int): Boolean {
return code == 0 || code >= 500 || code == 408 || code == 429
}
/**
* Снимает устройство с регистрации в f7push (при logout) ДО очистки локальной сессии.
* Контракт сервера (f7push v0.2.4, подтверждён integrator-b, mail/110):
* `DELETE /ocs/v2.php/apps/f7push/api/v1/devices/{deviceId}`, та же Basic-авторизация +
* `OCS-APIRequest`; идемпотентен (200 `success:true` даже если строки нет). Best-effort:
* ошибку глотаем (сеть могла отвалиться при выходе), возвращаем HTTP-код или 0.
*/
fun unregisterBlocking(
context: Context,
session: AuthSession,
): Int {
val deviceId = getDeviceId(context)
val endpoint = session.serverUrl.trimEnd('/') +
"/ocs/v2.php/apps/f7push/api/v1/devices/$deviceId"
return try {
postJson(endpoint, session, body)
deleteRequest(endpoint, session)
} catch (t: Throwable) {
Log.w(TAG, "register failed", t)
Log.w(TAG, "unregister failed", t)
0
}
}
@@ -76,6 +130,25 @@ object F7PushRegistrar {
return code
}
private fun deleteRequest(endpoint: String, session: AuthSession): Int {
val conn = URL(endpoint).openConnection() as HttpURLConnection
conn.connectTimeout = 15000
conn.readTimeout = 15000
conn.requestMethod = "DELETE"
conn.setRequestProperty("Accept", "application/json")
conn.setRequestProperty("OCS-APIRequest", "true")
val basic = android.util.Base64.encodeToString(
"${session.username}:${session.appPassword}".toByteArray(StandardCharsets.UTF_8),
android.util.Base64.NO_WRAP
)
conn.setRequestProperty("Authorization", "Basic $basic")
val code = conn.responseCode
drainQuietly(if (code >= 400) conn.errorStream else conn.inputStream)
conn.disconnect()
return code
}
private fun drainQuietly(stream: InputStream?) {
if (stream == null) return
try {
+3 -3
View File
@@ -16,10 +16,10 @@
| talk | b-mob-conf | 1 | 41.3 | предв. | ~29 | ? | нет | P1 меню сообщения ~закрыто (Копировать/ссылка/2×переслать/Напоминание/Ответить-лично, …01198a4); остаток P1 файл-тред+перевод эскалирую (кросс-модуль/провайдеры); далее P2 меню беседы; Fd на устройстве |
| tasks | b-mob-tasks | 1 | 24.6 | 52.7 | ~34 | ~8 | нет | P1 закрыт в коде (умные коллекции+3-state видимость+персист, `bd56c4b`); далее P2 управление списками; ждёт девайс-сверки |
| notes | b-mob-notes | 1 | 42.8 | 0.0 | 26 | 21 | нет | P1 read-only ядро в коде+UI (`8a8f1cf` данные + `fb120d5` VM/список/редактор/конфликт); compile+7/7 тестов+detekt OK (worktree); Fd ждёт девайс. Пинг лиду на регистрацию экрана в app-шелле |
| notifications | b-mob-notify | 0 | — | — | | | нет | карта готова, diff |
| deck | b-mob-deck | 1 | 23.1 | предв. | 51 | ? | нет | P1: «Назначить мне/снять» (6eeba1b) + бейдж комментариев (9d726f4) done, green compile; далее бейдж чек-пунктов, меню колонки (Удалить/Переименовать), быстрые сроки; Fd на устройстве |
| notifications | b-mob-notify | 1 | 63.6 | 20.0 | 8 | 8 | нет | **P1 ЗАКРЫТ** (лента `a30627a`, unregister `cc7ad71`+`255c962`, retry `a6bda89`). **Fd идёт:** Roborazzi-эталон строки снят (`8072539`, w360dp); 2 Д-находки закрыты лидом+верифицированы (перенос текста кнопки, зазор subject/message — `bfa870c`); detekt designsystem был красный, лид починил. Осталось Ф P2/P3; Fd — веб-эталон 360px + сверка (лента `cloude` пуста, ждёт populated) |
| deck | b-mob-deck | 1 | 25.3 | предв. 2/8 | 47 | ~4 | нет | Ff P1 done (назначить-себя/бейджи/меню колонки/быстрые сроки, 6eeba1b…eff3f1c). Fd: Roborazzi 360dp поднят (58f11c2, native PNG), сверено 2 экрана (Д: бейджи иконки-vs-эмодзи, аватар исполнителя, колонка Участники); далее 6 экранов+меню. Крупный Ff (назначение юзера/Комментарии/Вложения/обзор/CRUD) — жду приоритеты лида |
| calendar | b-mob-calendar | 1 | 64.0 | 80.4 | ~35 | ~7 | нет | P1 разворот повторений+BYDAY=1MO ЗАКРЫТ в коде (`CalendarRecurrence`, 15 тестов, compile+detekt green, коммиты e7d0a5a/492af61); Appointments=n-a (101); далее P2 EXDATE/RECURRENCE-ID |
| contacts | b-mob-contacts | 1 | 31.2 | предв. | ~37 | ? | нет | P1 ядро edit/delete в main (`bc1a497`: updateContact/deleteContact If-Match + VCardEditor, 10 тестов, гейт зелёный); Команды=n-a (102); далее шаг 2/2 Room+форма, потом P2 группы/типы |
| contacts | b-mob-contacts | 1 | 37.4 | предв. | ~32 | ? | нет | P1 edit/delete в main (bc1a497+6b98c5a); P2 шаг1 типы TEL/EMAIL в main (a8741e9: парс+подписи+сохранение, 14 тестов); далее P2 шаг2 селектор типа + группы CATEGORIES; live/device далее |
Обозначения статуса: `карта``diff` (итерация 1) → `правки``повторный прогон`
`✅ 0` (Ff=Fd=100%) → `✅ 0 подтв.` (второй прогон держит 0).
+33 -26
View File
@@ -1,13 +1,15 @@
# Diff: Контакты (CardDAV) — итерация 1 (2026-07-13)
Ff_app: **31.2%** · Fd_app: **предварительно** (натив на устройстве в этот проход не гонялся; веб снят live 390px) · n-a: 8
Ff_app: **37.4%** · Fd_app: **предварительно** (натив на устройстве в этот проход не гонялся; веб снят live 390px) · n-a: 8
> Прогресс P1 (коммит bc1a497): ЯДРО правки/удаления готово и покрыто тестами (10 юнит-тестов
> `VCardEditorTest` зелёные, гейт `:app:compileReleaseKotlin` зелёный) `CardDavClient.updateContact`
> (PUT If-Match, round-trip неизвестных полей vCard) / `deleteContact` (DELETE If-Match) +
> `VCardEditor`. UI-часть (Room href/etag/raw + форма правки + delete в карточке) — шаг 2/2, следующим
> коммитом; пункты «Править»/«Удалить» карточки станут `[done]` после неё.
> Решение лида (mail/102): экран «Команды» (Circles) — целиком `n-a` (отдельный тяжёлый апп, не контакты);
> порядок P1→P2→P4→P3→P5 утверждён.
> Прогресс P1 (коммиты bc1a497 + 6b98c5a): правка/удаление РЕАЛИЗОВАНЫ (ядро + UI). Ядро:
> `CardDavClient.updateContact` (PUT If-Match, round-trip неизвестных полей vCard) / `deleteContact`
> (DELETE If-Match) + `VCardEditor`, 10 юнит-тестов зелёные. UI: Room href/etag/raw (миграция v4→5),
> кнопки «Править» (форма) и «Удалить» (с подтверждением) в карточке. Гейты зелёные
> (`:app:compileReleaseKotlin`, `:feature:contacts:detekt`+compile). Пункты «Править»/«Удалить» (Э3) и
> поля формы (Э4) отмечены `[done]` — реализованы в коде; live-проверка на `bmob-*` (mail/085) и
> дизайн-проход на устройстве — следующим шагом (Ff по коду, Fd предварительно). NB: P1-edit
> нормализует TYPE у EMAIL/TEL к INTERNET/CELL — сохранение/правка типов идёт в P2.
> Решение лида (mail/102): экран «Команды» (Circles) — целиком `n-a`; порядок P1→P2→P4→P3→P5 утверждён.
Сверка веб (карта `contacts.md`, апстрим `nextcloud/contacts` + тема `_mobile-*.css`) ↔ натив
(`feature/contacts` Compose поверх CardDAV/`core:network`). Метод mail/083: `[Ф|Д]`·`[open|done|n-a]`,
@@ -39,7 +41,7 @@ Fd (дизайн) — отдельным проходом на устройст
- [Ф][open] Команды: «Показать все команды» — нет.
- [Ф][open] «Параметры контактов» — нет (см. Экран 7).
## Экран 3: Карточка контакта (просмотр) — Ff 42.1% (11 open Ф / 19)
## Экран 3: Карточка контакта (просмотр) — Ff 52.6% (9 open Ф / 19; «Править»/«Удалить» done — P1)
Ряд действий (веб) ↔ натив (`ContactDetailContent`):
- [Ф][done] Написать письмо (конверт) · натив «Email» (ACTION_SENDTO mailto).
*расхождение: уходит во внешний почтовик, не в свой `MailComposeActivity` (план mail/072 п.3).*
@@ -58,30 +60,31 @@ Fd (дизайн) — отдельным проходом на устройст
- [Ф][open] «ID в объединении облачных хранилищ» (federated cloud ID) — нет.
- [Ф][open] «Подробное имя» (Фамилия/Имя/фонетика отдельными полями) — натив показывает только displayName.
Меню шапки:
- [Ф][open] «✏️ Править» — нет (Экран 4).
- [Ф][done] «✏️ Править» — реализовано (P1, 6b98c5a): кнопка в карточке → форма (Экран 4).
- [Ф][done] «Удалить» — реализовано (P1): кнопка с подтверждением, DELETE If-Match.
- [Ф][open] ⋯ «Скачать» (.vcf) — нет.
- [Ф][open] ⋯ «Копировать контакт» — нет.
- [Ф][open] ⋯ «Создать QR-код» — нет.
- [Ф][open] ⋯ «Исключить контакт из календаря Дней Рождений» — нет.
- [Ф][open] ⋯ «Удалить» — нет.
- [Д][—] Крупный аватар/имя/subtitle, поля-плитки, кнопки Email/Позвонить — по живой теме; Fd на устройстве.
## Экран 4: Правка/создание контакта (форма) — Ff 26.7% (11 open Ф / 15)
Веб-форма правки ↔ натив (`AddContactDialog`только СОЗДАНИЕ, правки нет):
- [Ф][done] Имя · натив: поле в диалоге создания.
- [Ф][done] Телефон (значение) · натив: поле в диалоге.
- [Ф][done] Email (значение) · натив: поле в диалоге.
- [Ф][done] Сохранить · натив: `createContact` (vCard 3.0 PUT).
- [Ф][open] Правка существующего (загрузка контакта в форму, PUT If-Match) — нет.
## Экран 4: Правка/создание контакта (форма) — Ff 53.3% (7 open Ф / 15; правка реализована — P1)
Веб-форма правки ↔ натив (`EditContactDialog`правка + `AddContactDialog` — создание):
- [Ф][done] Имя · поле формы.
- [Ф][done] Телефон (значение) · поле формы (многострочное, несколько номеров).
- [Ф][done] Email (значение) · поле формы (многострочное, несколько адресов).
- [Ф][done] Сохранить · `updateContact` (round-trip vCard, PUT If-Match) / `createContact`.
- [Ф][done] Правка существующего (загрузка контакта в форму, PUT If-Match) — реализовано (P1, 6b98c5a).
- [Ф][done] Должность/организация — поля формы (organization + title).
- [Ф][done] Адрес — поле формы.
- [Ф][done] «Личные даты» (день рождения) — поле формы (текст; дата-пикер — доработка).
- [Ф][open] «Добавить фото» — нет.
- [Ф][open] Должность/организация — нет в форме.
- [Ф][open] Тип телефона (Домашний/Рабочий/CELL/…) — нет (селектор типа).
- [Ф][open] Тип email — нет.
- [Ф][open] Адрес — нет в форме.
- [Ф][open] «Личные даты» (день рождения, дата-пикер) — нет.
- [Ф][open] «О себе» — нет.
- [Ф][~] Тип телефона (Домашний/Рабочий/CELL/…) — типы ПАРСЯТСЯ и сохраняются при правке (P2 шаг1,
a8741e9); отдельного селектора типа в форме пока нет — P2 шаг2.
- [Ф][~] Тип email — аналогично (парсинг+сохранение есть, селектор — P2 шаг2).
- [Ф][open] «О себе» (NOTE) — нет в форме.
- [Ф][open] «Часовой пояс» — нет.
- [Ф][open] «Группы контакта» (добавить в группу) — нет.
- [Ф][open] «Группы контакта» (добавить в группу) — нет — P2.
- [Ф][open] Выбор адресной книги при создании — нет (пишет в `books.firstOrNull()`).
- [Д][—] Веб — полноэкранная форма; натив — компактный диалог. Расхождение композиции (Fd на устройстве).
@@ -117,7 +120,11 @@ Fd (дизайн) — отдельным проходом на устройст
---
## Сводка и приоритеты (прошу приоритизацию у лида)
Ff_app **31.2%** по 6 экранам (Э1 100 · Э2 18.2 · Э3 42.1 · Э4 26.7 · Э5 0 · Э7 0; Э6 Команды = n-a, исключён). Fd — предварительно.
Ff_app **37.4%** по 6 экранам (Э1 100 · Э2 18.2 · Э3 52.6 · Э4 53.3 · Э5 0 · Э7 0; Э6 Команды = n-a, исключён). Fd — предварительно.
P1 (правка/удаление) закрыт в коде — Э3/Э4 подросли. **P2 шаг1 (типы TEL/EMAIL)** — в коде
(a8741e9): типы парсятся из raw, в карточке подписи по типу (Рабочий/Личный/Мобильный/…),
при правке типы сохраняются (fix P1-каветата), +4 теста. Дальше P2 шаг2: селектор типа в форме +
**группы CATEGORIES** (чипы-фильтр в списке — поднимет Э2). Шторка (Э2) и параметры (Э7) — P3/P5.
Крупнейшие функциональные дыры (кандидаты P1–P5), совпадают с моим планом mail/072:
1. **Правка/удаление контакта** (Экран 4 + ⋯ карточки): edit-форма, `updateContact` PUT If-Match с
round-trip неизвестных полей vCard, `deleteContact`, выбор адресной книги, поля должность/адрес/
+39 -18
View File
@@ -1,11 +1,18 @@
# Diff: Карточки (Deck) — итерация 1 (2026-07-13)
Ff_app: **23.1%** (51 open Ф / 76) · Fd_app: **предварительно** (веб-вёрстка сверена live через
iframe 390px; натив-дизайн-проход на устройстве — след. итерация) · n-a: 4
Ff_app: **25.3%** (47 open Ф / 76) · Fd_app: **предварительно, 2/8 экранов** (Roborazzi-пайплайн
поднят, mail/111) · n-a: 4
> Прогресс P1: (6eeba1b) меню карточки «**Назначить мне / Снять назначение с меня**»
> (`assignUser`/`unassignUser`, type 0); (9d726f4) **бейдж числа комментариев** «💬 N» на карточке
> (`commentsCount`) — оба `[done]` ниже (Ff экрана 4: 52.0%→60.0%). Green compile против core;
> detekt модуля без новых замечаний (baseline-рефреш — лид, mail/095).
> **Fd-пайплайн (mail/111):** Roborazzi 360dp живой — `feature/deck/src/test/DeckScreenshotTest`
> рендерит нативные экраны в PNG (`src/test/screenshots/deck-boards.png`, `deck-board.png`,
> коммит 58f11c2). Веб-эталон снимаю iframe-360 на forbion (sample-доска «Welcome», без данных
> `cloude`). Сверено 2 экрана (список досок, доска-стек); Д-находки — ниже. Остальные 6 экранов
> + всплывающие меню (mail/087) — след. проходы.
> Прогресс P1: (6eeba1b) меню карточки «**Назначить мне / Снять**»; (9d726f4) **бейдж
> комментариев** «💬 N»; (73762d8/4fdadef) **меню колонки** «⋯» — «**Переименовать**» и
> «**Удалить список**» (`renameStack`/`deleteStack`) — все `[done]` ниже (Ff экрана 4:
> 52.0%→68.0%). **Compile + detekt модуля GREEN** (detekt-baseline модуля обновлён — v0.5.144-долг,
> как у calendar e7d0a5a; вписывается в свип лида mail/095).
Сверка веб (карта `deck.md`, апстрим `nextcloud/deck v1.16.4` + тема forbion, live-сверка
Browser 1/cloude read-only) ↔ натив (`feature/deck`: `DeckRepository`/`DeckScreen`/
@@ -41,9 +48,12 @@ Browser 1/cloude read-only) ↔ натив (`feature/deck`: `DeckRepository`/`De
## Экран 2: Все доски (список) — Ff 50.0% (1 open Ф / 2)
- [Ф][done] Открыть доску (тап строки) — есть.
- [Ф][open] Поиск/фильтр списка досок (`navFilter`) — нет.
- [Д][open] Колонка «**Участники**» (аватары ACL) — натив не показывает участников доски.
- [Д][—] Строка-карточка доски (bullet цвета + название, border/radius) — сверено, близко 1:1
(натив `DeckBoardRow`); детальный Fd — на устройстве.
- [Д][open] Колонка «**Участники**» (аватары ACL) — натив не показывает участников доски
(`сверено:` Roborazzi vs iframe-360: веб — таблица с колонкой аватаров справа, натив — только bullet+имя).
- [Д][open] **Заголовки-колонки** «Название доски / Участники» — в вебе есть строка-шапка таблицы,
в нативе её нет (список без шапки). Fd-минус композиции.
- [Д][done] Строка доски: bullet цвета + название, border/radius 8 — сверено (Roborazzi
`deck-boards.png` ↔ веб), совпадает.
## Экран 3: Обзор / «Ожидающие выполнения» — Ff 0.0% (2 open Ф / 2)
- [Ф][open] Дашборд карточек по срокам (Просроченные/Сегодня/Завтра/Ближайшие 7 дней/Позже/
@@ -51,7 +61,7 @@ Browser 1/cloude read-only) ↔ натив (`feature/deck`: `DeckRepository`/`De
- [Ф][open] Открыть карточку из обзора — нет (нет обзора).
- [Д][open] Композиция: 6 секций-капшенов в столбец, карточки внутри — отсутствует.
## Экран 4: Доска (Controls + Колонка + Карточка) — Ff 60.0% (10 open Ф / 25)
## Экран 4: Доска (Controls + Колонка + Карточка) — Ff 72.0% (7 open Ф / 25)
**Шапка доски (`Controls`):**
- [Ф][done] «**Добавить список**» — натив «+ Колонка» (`createStack`).
- [Ф][open] «**Применить фильтр**» (по метке / назначенному / статусу) — фильтров нет.
@@ -61,14 +71,16 @@ Browser 1/cloude read-only) ↔ натив (`feature/deck`: `DeckRepository`/`De
**Всплывающее меню колонки (⋯, `Stack`)** — в нативе меню колонки НЕТ:
- [Ф][open] «**Архивировать все карточки**» — нет.
- [Ф][open] «**Разархивировать все карточки**» — нет.
- [Ф][open] «**Удалить список**» — нет.
- [Ф][open] «**Изменить заголовок списка**» (инлайн-правка) — нет (переименования колонки нет).
- [Ф][done] «**Удалить список**» — реализовано (меню «⋯» → подтверждение `DeckConfirmDialog`).
- [Ф][done] «**Изменить заголовок списка / Переименовать**» — реализовано (меню «⋯» → диалог с
префиллом, `renameStack`).
- [Ф][done] «**Добавить карточку**» в колонку — есть (инлайн-поле «Имя карточки»).
**Карточка на доске (`CardItem` + бейджи):**
- [Ф][done] Открыть карточку (тап) — открывает лист-деталь.
- [Ф][done] Правка заголовка — есть (в листе-детали; веб правит инлайн — Д-нюанс).
- [Ф][done] Бейдж срока (`DueDate`) — есть.
- [Ф][open] Бейдж «**чек-пунктов**» (Todo items, напр. 0/9) — натив не показывает.
- [Ф][done] Бейдж «**чек-пунктов**» «☑ x/total» — реализовано (`deckChecklistCounts`, парсинг
чекбоксов описания regex как веб).
- [Ф][done] Бейдж «**комментариев**» «💬 N» — реализовано (`commentsCount`); индикатор M
непрочитанных — Д-нюанс на след. проход.
- [Ф][done] Аватары назначенных — есть (отображение).
@@ -87,14 +99,23 @@ Browser 1/cloude read-only) ↔ натив (`feature/deck`: `DeckRepository`/`De
- [Ф][done] Перенос карточки между колонками (в вебе drag-n-drop) — натив реализует переносом
через чипы «Колонка» (Д-нюанс: без DnD-жеста).
## Экран 5: Лист-деталь / сайдбар карточки (Свойства/Вложения/Комментарии/Активность) — Ff 33.3% (12 open Ф / 18)
**Fd-находки (Roborazzi `deck-board.png` ↔ веб iframe-360):**
- [Д][open] **Бейджи карточки — иконки vs эмодзи**: веб рисует SVG-иконки (календарь/чек-лист/
комментарий/аватар), натив — эмодзи (🗓/☑/💬/👤). Расхождение типографики/иконок.
- [Д][open] **Исполнитель**: веб — кружок-аватар, натив — «👤 имя» текстом. Композиция отличается.
- [Д][done] Колонка-стек: шапка (заголовок+счётчик+«+»+«⋯»), карточки с метками/чекбоксом/
заголовком — сверено, композиция близка 1:1.
- [Д][done] Метки-чипы (цветной фон 0.22 + рамка) и чекбокс done — сверено, совпадают.
## Экран 5: Лист-деталь / сайдбар карточки (Свойства/Вложения/Комментарии/Активность) — Ff 38.9% (11 open Ф / 18)
**Свойства (Details):**
- [Ф][done] Метки: назначить/снять (toggle меток доски) — есть.
- [Ф][open] Метки: «**Создать новую метку**» — натив только тогглит существующие.
- [Ф][open] Назначение: «**Назначить пользователю…**» — нет (write).
- [Ф][open] Назначение: снять назначение — нет.
- [Ф][done] Срок: выбрать дату (DatePicker) — есть.
- [Ф][open] Срок: «**быстрые опции**» (Позже сегодня/Завтра/В выходные/На след. неделе) — нет.
- [Ф][done] Срок: «**быстрые опции**» — реализованы чипы Сегодня/Завтра/Выходные/Неделя
(`deckQuickDueIso`). *(«Позже сегодня» с часом — Д-нюанс, натив ставит дату; на след. проход.)*
- [Ф][done] Срок: «**Убрать срок**» — есть («Сбросить»).
- [Ф][done] Срок: «Отметить выполненной» из блока срока — есть (done).
- [Ф][done] Описание: редактировать — есть.
@@ -142,12 +163,12 @@ Browser 1/cloude read-only) ↔ натив (`feature/deck`: `DeckRepository`/`De
| 1. Навигация + меню доски | 16.7% | 10/12 |
| 2. Все доски (список) | 50.0% | 1/2 |
| 3. Обзор (по срокам) | 0.0% | 2/2 |
| 4. Доска (Controls/Колонка/Карточка) | 60.0% | 10/25 |
| 5. Лист-деталь карточки | 33.3% | 12/18 |
| 4. Доска (Controls/Колонка/Карточка) | 72.0% | 7/25 |
| 5. Лист-деталь карточки | 38.9% | 11/18 |
| 6. Сайдбар доски | 0.0% | 10/10 |
| 7. Move/copy карточки | 25.0% | 3/4 |
| 8. Настройки Deck | 0.0% | 3/3 |
| **Ff_app (среднее)** | **23.1%** | **51/76** |
| **Ff_app (среднее)** | **25.3%** | **47/76** |
## Приоритеты (черновик, жду визы лида — mail/071 план 1→2→3)
- **P1 (в своём модуле, наибольший охват):** меню карточки (Назначить мне/Переместить-копировать/
+115
View File
@@ -0,0 +1,115 @@
# Diff: Уведомления (Notifications) — итерация 1 (2026-07-13)
Ff_app: **63.6%** (done 14 / open 8, n-a 1) · Fd_app: **20.0%** (Д done 2 / open 8; Roborazzi-эталон снят, веб-сверка ленты pending) · автор `b-mob-notify`
> **Итерация 1b (2026-07-13):** P1-лента закрыта лидом (`a30627a`) + P1-push unregister закрыт
> (клиент `cc7ad71` + вызов в logout `255c962`). Ff 45.5% → 63.6%.
> Сверка веб (`nextcloud/notifications 5.0.0-dev.0` + тема forbion, mail/078) ↔ **натив
> (реализован в app-shell)**. Лента УЖЕ работает: `NotificationsSheet` (`app/AppSheets.kt`) +
> `F7NotificationRow` (`core/designsystem`) + `NotificationsRepository` (`core/network`, API v2).
> `[done]` = есть в коде натива; `Fd=0%` — вёрстка на устройстве/визуально ещё не сверялась.
> Карта-источник: [`notifications.md`](notifications.md) (из кода + живая сверка forbion,
> Browser 1/`cloude`, read-only; лента `cloude` = 3 уведомления `firstrunwizard`).
>
> **NB зона:** лента в `app/AppSheets.kt` + `core/*` (зона лида) — P1-функционал он закрыл сам
> (mail/113). Моё: core/push (retry+unregister — done) и **Fd (Roborazzi-строка уведомления в
> `core/designsystem/src/test` + веб-эталон 360 CSS-px)** — по разрешению лида (mail/112б).
> `feature/notifications` — после приёмочного гейта (mail/112б).
Метки: `[Ф|Д]` (функционал/дизайн) · `[open|done|n-a]`.
---
## Экран 1. Триггер (колокольчик) — Ff n-a (зона лида)
Колокольчик-вход живёт в шелле (нижняя шторка, слот `#mobile-header-notifications`) — **зона
`b-dev-mobile`** (shell.md), не считаю в свой Ff.
**Ф (1, n-a 1):**
- [Ф][n-a] иконка-триггер + раскрытие ленты — шелл (лид). Индикатор непрочитанных (точка
`showDot` / warning `hasThrottledPushNotifications`) — если понадобится, отдельным пингом лиду.
## Экран 2. Лента (дропдаун/шит) — Ff 71.4% · Fd 0%
**Ф (8, done 5, open 2, n-a 1):**
- [Ф][done] загрузка списка (`NotificationsRepository.load`, OCS API v2, `limit=50`)
- [Ф][done] пустое состояние «Нет уведомлений» (иконка `nof-not-icon.svg` + текст) — `items.isEmpty()`
- [Ф][done] состояние загрузки (спиннер `CircularProgressIndicator`)
- [Ф][done] состояние ошибки (текст `error`)
- [Ф][done] **«Скрыть все уведомления»** (`dismissAll()``DELETE …/api/v2/notifications`) —
лид `a30627a` (заголовок ленты + кнопка + оптимистичное скрытие)
- [Ф][open] баннер fairUsePolicy при throttled push («Push notifications might be unreliable») — P3
- [Ф][open] авто-обновление ленты: сейчас только push-триггер (`refreshRequest`), ETag/поллинга
(`lastETag`/notify_push) НЕТ — P2
- [Ф][n-a] пустой вариант «Запрос разрешений браузера» (`webNotificationsGranted===null`) — n-a:
веб-концепт web-push; натив на FCM, разрешение POST_NOTIFICATIONS запрашивается системно
**Д (5, open 5):** [Д][open] вёрстка ленты по теме/токенам · [Д][open] высота/скролл шита ·
[Д][open] пустое состояние (вид иконки/текста) · [Д][open] состояние загрузки/ошибки ·
[Д][open] анимация inbox-zero (галочка+конфетти при переходе >0→0) — P3
## Экран 3. Элемент уведомления (`F7NotificationRow``NotificationItem`) — Ff 58.3% · Fd 0%
**Ф (12, done 7, open 5):**
- [Ф][done] относительное время (`formatNotificationRelativeTime`) — совпадает с forbion («N минут назад»)
- [Ф][done] кнопка «Скрыть» (× → `dismissNotification``DELETE …/notifications/{id}`)
- [Ф][done] иконка app (`resolveNotificationIcon` + `AsyncImage` с auth-хедером)
- [Ф][done] subject (плоский текст)
- [Ф][done] message (плоский текст, если есть)
- [Ф][done] переход по тапу (`onNotificationClick``openAppLink(notification.link)`)
- [Ф][done] **action-кнопки уведомления** (`actions[]``F7NotificationActionUi`, FlowRow) —
лид `a30627a` (primary=заливка, прочие=контур); закрывает Talk-приглашения и т.п.
- [Ф][open] `subjectRich` (`NcRichText` + User/File-параметры) — только плоский subject — P2
- [Ф][open] `messageRich` (rich + autolink) — только плоский message — P2
- [Ф][open] `externalLink` (вариант «↗», target=_blank + primary-кнопка «Contact…») — не отличается
от обычной ссылки — P3
- [Ф][open] сворачивание длинного message (`.collapsed` + разворот по тапу) — нет — P3
- [Ф][open] подсветка нового `notification--new` — нет — P3
**Д (5, done 2, open 3):** нативный Roborazzi-эталон снят (`core/designsystem/src/test`
`notification-row-states.png`, w360dp, перезаписан `8072539` после фикса лида).
> **Веб-эталон 360 CSS-px — техническое ограничение:** лента уведомлений НЕ рендерится в
> same-origin iframe (приложение `notifications` координирует вкладки — `setCurrentTabAsActive`,
> фетчит только «активная» вкладка; iframe как вторая вкладка остаётся пустым, хотя OCS API
> отдаёт 3 уведомления `firstrunwizard`). Populated-строки живьём для попиксельной сверки пока
> недоступны через iframe. Обходы на изучении; часть Д закрыта по Roborazzi-эталону + карте.
- [Д][done] **перенос текста primary-кнопки посреди слова** — было «Присоединитьс/я» на w360dp;
лид `bfa870c` (подпись действия 1.5× вместо 2×, maxLines=2 по словам, паддинги 16/9).
Верифицировано перезаписью Roborazzi: «Присоединиться» в одну строку. Находка Roborazzi.
- [Д][done] зазор subject↔message (сливались на 360dp) — лид `bfa870c` (6→10dp), подтверждено на PNG.
- [Д][open] компоновка строки (время+× сверху, иконка+subject/message) — сверить с веб-эталоном.
- [Д][open] вёрстка по теме (карточный стиль `_mobile-common.css` + токены) — vs веб-эталон.
- [Д][open] типографика: натив рендерит удвоенным кеглем (`.doubled()`) — сверить с мобильной темой.
## Экран 4. Кнопка действия (`F7NotificationActionUi``ActionButton`) — Ff 66.7% · Fd 0%
Реализовано лидом (`a30627a`): парсинг `actions[]` (`parseActions`, type→uppercase, default GET,
primary), рендер `F7NotificationActionUi`, выполнение `executeAction`.
**Ф (3, done 2, open 1):**
- [Ф][done] primary/контур по флагу `primary` (`F7NotificationActionUi.primary`) — рендер FlowRow.
(нюанс упстрима: там `isWebLink||primary`→primary; в нативе только флаг `primary` — визуально
сойдётся, разберём в Fd, не Ф-дыра)
- [Ф][done] выполнение действия (`executeAction`: POST/PUT/DELETE, иначе GET, по абсолютному
`action.link` + `OCS-APIRequest`) + оптимистичное скрытие уведомления после
- [Ф][open] **WEB-действие как прямая ссылка/открытие в браузере**`executeAction` для типа
`WEB` уходит в `else→GET` (упстрим: WEB = открыть `link` в браузере, а не HTTP-GET). Пограничный
случай, но семантика иная — P3. (сообщу лиду, если встретится WEB-action живьём)
---
## Вне карты-экрана: core/push (моя зона, отдельный бэклог)
Не входит в Ff ленты (нет веб-экрана-аналога), но моя зона и уже зафиксировано в представлении
(mail/070). Держу как отдельный трек:
- [Ф][done] **unregister при logout** — ЗАКРЫТО. Контракт f7push (mail/110). Клиент
`F7PushRegistrar.unregisterBlocking` (`cc7ad71`, compile+detekt green) + вызов в
`MainViewModel.logout()` best-effort на IO рядом с ревокацией (лид `255c962`, mail/112а). P1-push.
- [Ф][done] retry/бэкофф в `F7PushRegistrar.registerBlocking` (3 попытки, бэкофф 1с/3с, повтор
на 0/5xx/408/429, стоп на 2xx/4xx) — коммит `a6bda89`, compile+detekt green (P2-push)
- [Ф][n-a] серверные хвосты FCM (чистка 403 SenderId mismatch, старые `f7cloud-apk`) — ячейка A,
через integrator-b (mail/064) — вне натива
## Сводка
- **Ff = 63.6%** (done 14 / open 8; n-a 3 не в счёте: web-permission-empty, триггер-шелл, серверный FCM).
- **P1 — ЗАКРЫТ:** «Скрыть все» + action-кнопки + парсинг `actions` (лид `a30627a`);
unregister-push (клиент `cc7ad71` + вызов `255c962`); retry регистрации (`a6bda89`).
- **P2 (осталось):** rich subject/message, авто-обновление ETag.
- **P3 (осталось):** fairUsePolicy-баннер, external-«↗», WEB-action как ссылка, сворачивание
message, `notification--new`, inbox-zero.
- **Fd=0% → в работе автономно:** Roborazzi-скрин `F7NotificationRow` в `core/designsystem/src/test`
(w360dp) + веб-эталон 360 CSS-px (пустое состояние, без данных `cloude`) — по mail/111/112б.
+35
View File
@@ -0,0 +1,35 @@
# Эталонные веб-скриншоты forbion (для Fd-сверки)
Здесь лежат **эталонные скриншоты мобильной веб-версии forbion**с ними Roborazzi-рендеры
нативных экранов сравниваются попиксельно (метрика Fd, программа 1:1). Одна папка на приложение:
`docs/app-map/reference/<app>/<screen>.png`.
## Ширина — 360dp (Figma-фрейм)
Рендер и эталон — **360dp** (не 390!). В screenshot-тестах Roborazzi:
`@Config(sdk=[34], qualifiers="w360dp-h800dp-xxhdpi")`. Эталонный веб-скрин тоже снимать на
ширине **360 CSS-px**.
## Как снять мобильную вёрстку (важно!)
`resize_window` НЕ ужимает вьюпорт общего Chrome (отдаёт десктоп 1480px). Снимаем через
**same-origin iframe 360px** внутри страницы forbion ([[f7-mobile-web-iframe-technique]]):
инжектируешь `<iframe width=360 src="/index.php/apps/<app>/…">` в открытую страницу forbion
(Browser 1, сессия `cloude`), ждёшь загрузки, скриншотишь регион iframe. Мобильная тема
включается по ширине iframe (у него свой вьюпорт 360px).
## ⚠️ Приватность — НЕ светить реальные данные
`cloude` — реальный рабочий аккаунт: на экранах видны настоящие файлы/письма/договоры/контакты.
**В эталон НЕ коммитим реальные данные.** Снимаем:
- пустые состояния, ИЛИ
- свои тест-объекты `bmob-*` (mail/085), ИЛИ
- кадрируем так, чтобы в кадре был только UI-каркас (шапки/панели/кнопки/меню), без реального
контента; персональные имена/суммы/адреса замазать/обрезать.
Скрин с чужими реальными данными в git — недопустим.
## Что снимать (по карте `<app>.md`)
Каждый экран из карты + всплывающие меню (mail/087): список/деталь/форма/модалки/пустые
состояния/контекстные «⋯». Имя файла = слаг экрана (`list.png`, `editor.png`, `menu-message.png`).
## Кто заводит
Владелец приложения (`b-mob-<x>`) снимает эталоны СВОИХ экранов и коммитит сюда; лид — оболочку
(`shell/`). Затем — Roborazzi-тест нативного экрана → PNG → визуальное сравнение с этим эталоном,
расхождения в `<app>.diff.md` (тип `Д`).
+1 -1
View File
@@ -3,7 +3,7 @@
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>ComplexCondition:ContactsRepository.kt$ContactsRepository$!force &amp;&amp; !needsPhotoBackfill &amp;&amp; !needsDetailsBackfill &amp;&amp; System.currentTimeMillis() - lastSync &lt; SYNC_INTERVAL_MS</ID>
<ID>LongMethod:ContactDetailSheet.kt$@Composable private fun ContactDetailContent( contact: ContactItem, onDismiss: () -&gt; Unit, )</ID>
<ID>LongMethod:ContactDetailSheet.kt$@Composable private fun ContactDetailContent( contact: ContactItem, onDismiss: () -&gt; Unit, onEdit: () -&gt; Unit, onDelete: () -&gt; Unit, deleting: Boolean, )</ID>
<ID>MultiLineIfElse:ContactDetailSheet.kt$"https://$it"</ID>
<ID>MultiLineIfElse:ContactDetailSheet.kt$it</ID>
</CurrentIssues>
@@ -17,14 +17,20 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -42,6 +48,9 @@ import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton
fun ContactDetailSheet(
contact: ContactItem,
onDismiss: () -> Unit,
onEdit: () -> Unit = {},
onDelete: () -> Unit = {},
deleting: Boolean = false,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
@@ -52,6 +61,9 @@ fun ContactDetailSheet(
ContactDetailContent(
contact = contact,
onDismiss = onDismiss,
onEdit = onEdit,
onDelete = onDelete,
deleting = deleting,
)
}
}
@@ -60,8 +72,13 @@ fun ContactDetailSheet(
private fun ContactDetailContent(
contact: ContactItem,
onDismiss: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
deleting: Boolean,
) {
val context = LocalContext.current
// Правка/удаление доступны только для синхронизированных контактов (есть href/raw).
val editable = contact.href.isNotBlank() && contact.raw.isNotBlank()
val label = contact.displayName.ifBlank { contact.email }
val photoBytes = remember(contact.uid, contact.photoBase64) {
ContactUi.decodeContactPhoto(contact.photoBase64)
@@ -163,31 +180,27 @@ private fun ContactDetailContent(
.padding(horizontal = 16.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
if (contact.emailLines.isNotEmpty()) {
contact.emailLines.forEachIndexed { index, email ->
ContactDetailField(
label = if (index == 0) "Email" else "Email ${index + 1}",
value = email,
onClick = {
context.startActivity(
Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:$email")),
)
},
)
}
contact.emailEntries.forEachIndexed { index, entry ->
ContactDetailField(
label = ContactUi.emailTypeLabel(entry.type, index),
value = entry.value,
onClick = {
context.startActivity(
Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:${entry.value}")),
)
},
)
}
if (contact.phoneLines.isNotEmpty()) {
contact.phoneLines.forEachIndexed { index, phone ->
ContactDetailField(
label = if (index == 0) "Телефон" else "Телефон ${index + 1}",
value = phone,
onClick = {
context.startActivity(
Intent(Intent.ACTION_DIAL, Uri.parse("tel:$phone")),
)
},
)
}
contact.phoneEntries.forEachIndexed { index, entry ->
ContactDetailField(
label = ContactUi.phoneTypeLabel(entry.type, index),
value = entry.value,
onClick = {
context.startActivity(
Intent(Intent.ACTION_DIAL, Uri.parse("tel:${entry.value}")),
)
},
)
}
if (contact.address.isNotBlank()) {
ContactDetailField(label = "Адрес", value = contact.address)
@@ -213,6 +226,15 @@ private fun ContactDetailContent(
}
}
if (editable) {
ContactDetailActions(
contact = contact,
deleting = deleting,
onEdit = onEdit,
onDelete = onDelete,
)
}
Spacer(Modifier.height(4.dp))
Text(
"Закрыть",
@@ -225,6 +247,65 @@ private fun ContactDetailContent(
}
}
@Composable
private fun ContactDetailActions(
contact: ContactItem,
deleting: Boolean,
onEdit: () -> Unit,
onDelete: () -> Unit,
) {
var confirmDelete by remember { mutableStateOf(false) }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
F7SecondaryButton(
text = "Править",
onClick = onEdit,
modifier = Modifier.weight(1f),
)
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(100.dp))
.clickable(enabled = !deleting) { confirmDelete = true }
.padding(vertical = 12.dp),
contentAlignment = Alignment.Center,
) {
if (deleting) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
color = F7Colors.Error,
strokeWidth = 2.dp,
)
} else {
Text("Удалить", color = F7Colors.Error, fontWeight = FontWeight.SemiBold)
}
}
}
if (confirmDelete) {
AlertDialog(
onDismissRequest = { confirmDelete = false },
title = { Text("Удалить контакт?") },
text = {
Text("«${contact.displayName.ifBlank { contact.email }}» будет удалён без возможности отмены.")
},
confirmButton = {
TextButton(
onClick = {
confirmDelete = false
onDelete()
},
) { Text("Удалить", color = F7Colors.Error) }
},
dismissButton = {
TextButton(onClick = { confirmDelete = false }) { Text("Отмена") }
},
)
}
}
@Composable
private fun ContactDetailField(
label: String,
@@ -287,4 +368,21 @@ internal object ContactUi {
android.util.Base64.decode(base64, android.util.Base64.DEFAULT)
}.getOrNull()?.takeIf { it.isNotEmpty() }
}
// P2: подпись типа EMAIL/TEL по токену TYPE (WORK/HOME/CELL/…); index — фолбэк-нумерация.
fun emailTypeLabel(type: String, index: Int): String = when (type.uppercase()) {
"WORK" -> "Рабочий"
"HOME" -> "Личный"
"" -> if (index == 0) "Email" else "Email ${index + 1}"
else -> type.lowercase().replaceFirstChar { it.uppercase() }
}
fun phoneTypeLabel(type: String, index: Int): String = when (type.uppercase()) {
"CELL", "MOBILE" -> "Мобильный"
"WORK" -> "Рабочий"
"HOME" -> "Домашний"
"FAX" -> "Факс"
"" -> if (index == 0) "Телефон" else "Телефон ${index + 1}"
else -> type.lowercase().replaceFirstChar { it.uppercase() }
}
}
@@ -9,6 +9,8 @@ import ru.forbion.f7cloud.core.database.ContactEntity
import ru.forbion.f7cloud.core.database.F7Database
import ru.forbion.f7cloud.core.network.CardDavClient
import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.network.VCardFields
import ru.forbion.f7cloud.core.network.VCardTypedValue
data class ContactItem(
val uid: String,
@@ -25,6 +27,10 @@ data class ContactItem(
val birthday: String = "",
val emails: String = "",
val phones: String = "",
// Для правки/удаления (If-Match) и round-trip неизвестных полей vCard (P1).
val href: String = "",
val etag: String = "",
val raw: String = "",
) {
val emailLines: List<String>
get() = (if (emails.isNotBlank()) emails else email)
@@ -40,6 +46,21 @@ data class ContactItem(
.filter { it.isNotBlank() }
.distinct()
// P2: значения с типом (WORK/HOME/CELL) из сырого vCard; фолбэк на значения без типа.
val emailEntries: List<VCardTypedValue>
get() = if (raw.isNotBlank()) {
CardDavClient.parseTypedValues(raw, "EMAIL")
} else {
emailLines.map { VCardTypedValue(it) }
}
val phoneEntries: List<VCardTypedValue>
get() = if (raw.isNotBlank()) {
CardDavClient.parseTypedValues(raw, "TEL")
} else {
phoneLines.map { VCardTypedValue(it) }
}
val subtitle: String?
get() = when {
title.isNotBlank() && organization.isNotBlank() -> "$title · $organization"
@@ -104,6 +125,9 @@ class ContactsRepository(context: Context) {
birthday = contact.birthday,
emails = contact.emails,
phones = contact.phones,
href = contact.href,
etag = contact.etag,
raw = contact.raw,
)
}
dao.replaceAll(key, entities)
@@ -147,6 +171,62 @@ class ContactsRepository(context: Context) {
return entity.toItem()
}
/**
* Правка контакта: round-trip сырого vCard (сохраняет PHOTO/X-*), PUT If-Match по ETag.
* Требует, чтобы контакт был синхронизирован (есть href/raw). Обновляет Room новым состоянием.
*/
suspend fun updateContact(
session: AuthSession,
item: ContactItem,
fields: VCardFields,
): ContactItem {
if (item.href.isBlank() || item.raw.isBlank()) {
error("Контакт ещё не синхронизирован — обновите список и повторите")
}
val client = authedClient(session)
val updated = CardDavClient.updateContact(
client = client,
href = item.href,
etag = item.etag,
rawVCard = item.raw,
fields = fields,
bookName = item.bookName,
)
val key = accountKey(session)
val entity = ContactEntity(
accountKey = key,
uid = updated.uid.ifBlank { item.uid },
displayName = updated.displayName,
email = updated.email,
phone = updated.phone,
bookName = updated.bookName.ifBlank { item.bookName },
photoBase64 = updated.photoBase64,
photoMimeType = updated.photoMimeType,
organization = updated.organization,
title = updated.title,
address = updated.address,
website = updated.website,
birthday = updated.birthday,
emails = updated.emails,
phones = updated.phones,
href = updated.href.ifBlank { item.href },
etag = updated.etag,
raw = updated.raw,
)
dao.insert(entity)
return entity.toItem()
}
/** Удаление контакта (DELETE If-Match) + удаление из Room-кэша. */
suspend fun deleteContact(session: AuthSession, item: ContactItem) {
if (item.href.isBlank()) {
error("Контакт ещё не синхронизирован — обновите список и повторите")
}
val client = authedClient(session)
CardDavClient.deleteContact(client, item.href, item.etag)
dao.deleteByUid(accountKey(session), item.uid)
}
fun filterSuggestions(
contacts: List<ContactItem>,
query: String,
@@ -224,6 +304,9 @@ class ContactsRepository(context: Context) {
birthday = birthday,
emails = emails,
phones = phones,
href = href,
etag = etag,
raw = raw,
)
companion object {
@@ -15,15 +15,17 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
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.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -52,6 +54,8 @@ import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7CreateButton
import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
import ru.forbion.f7cloud.core.network.VCardFields
import ru.forbion.f7cloud.core.network.VCardTypedValue
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -121,7 +125,19 @@ fun ContactsScreen(
ContactDetailSheet(
contact = contact,
onDismiss = vm::closeContactDetail,
onEdit = vm::openEditSheet,
onDelete = { vm.deleteContact(session, contact) },
deleting = state.deletingContact,
)
if (state.editSheetOpen) {
EditContactDialog(
contact = contact,
saving = state.savingEdit,
error = state.editError,
onDismiss = vm::closeEditSheet,
onSave = { fields -> vm.saveEdit(session, contact, fields) },
)
}
}
if (state.addSheetOpen) {
@@ -136,6 +152,114 @@ fun ContactsScreen(
}
}
@Composable
private fun EditContactDialog(
contact: ContactItem,
saving: Boolean,
error: String?,
onDismiss: () -> Unit,
onSave: (VCardFields) -> Unit,
) {
var name by remember(contact.uid) { mutableStateOf(contact.displayName) }
var organization by remember(contact.uid) { mutableStateOf(contact.organization) }
var title by remember(contact.uid) { mutableStateOf(contact.title) }
var emails by remember(contact.uid) { mutableStateOf(contact.emailLines.joinToString("\n")) }
var phones by remember(contact.uid) { mutableStateOf(contact.phoneLines.joinToString("\n")) }
var address by remember(contact.uid) { mutableStateOf(contact.address) }
var website by remember(contact.uid) { mutableStateOf(contact.website) }
var birthday by remember(contact.uid) { mutableStateOf(contact.birthday) }
// Типы сохраняем по значению: неизменённые адреса/номера не теряют TYPE (fix P1-каветата).
val emailTypeByValue = contact.emailEntries.associate { it.value to it.type }
val phoneTypeByValue = contact.phoneEntries.associate { it.value to it.type }
fun buildFields(): VCardFields {
fun lines(v: String) = v.lines().map { it.trim() }.filter { it.isNotBlank() }
return VCardFields(
displayName = name.trim(),
emails = lines(emails).map { VCardTypedValue(it, emailTypeByValue[it].orEmpty()) },
phones = lines(phones).map { VCardTypedValue(it, phoneTypeByValue[it].orEmpty()) },
organization = organization.trim(),
title = title.trim(),
address = address.trim(),
website = website.trim(),
birthday = birthday.trim(),
)
}
Dialog(onDismissRequest = { if (!saving) onDismiss() }) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(Color.White)
.border(1.dp, F7Colors.Border, RoundedCornerShape(16.dp))
.padding(20.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
"Править контакт",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
AddContactField(label = "Имя", value = name, onValueChange = { name = it })
AddContactField(label = "Организация", value = organization, onValueChange = { organization = it })
AddContactField(label = "Должность", value = title, onValueChange = { title = it })
AddContactField(
label = "Email (по одному в строке)",
value = emails,
onValueChange = { emails = it },
singleLine = false,
)
AddContactField(
label = "Телефон (по одному в строке)",
value = phones,
onValueChange = { phones = it },
singleLine = false,
)
AddContactField(label = "Адрес", value = address, onValueChange = { address = it })
AddContactField(label = "Сайт", value = website, onValueChange = { website = it })
AddContactField(label = "День рождения", value = birthday, onValueChange = { birthday = it })
if (!error.isNullOrBlank()) {
Text(error, color = MaterialTheme.colorScheme.error, fontSize = 13.sp)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Отмена",
modifier = Modifier
.clickable(enabled = !saving, onClick = onDismiss)
.padding(8.dp),
color = F7Colors.TextSecondary,
)
Spacer(Modifier.size(8.dp))
Box(
modifier = Modifier
.clip(RoundedCornerShape(100.dp))
.background(F7Colors.Primary)
.clickable(enabled = !saving) { onSave(buildFields()) }
.padding(horizontal = 20.dp, vertical = 10.dp),
contentAlignment = Alignment.Center,
) {
if (saving) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
color = Color.White,
strokeWidth = 2.dp,
)
} else {
Text("Сохранить", color = Color.White, fontWeight = FontWeight.SemiBold)
}
}
}
}
}
}
@Composable
private fun AddContactDialog(
saving: Boolean,
@@ -209,6 +333,7 @@ private fun AddContactField(
label: String,
value: String,
onValueChange: (String) -> Unit,
singleLine: Boolean = true,
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(label, fontSize = 13.sp, color = F7Colors.TextSecondary)
@@ -220,7 +345,7 @@ private fun AddContactField(
.clip(RoundedCornerShape(8.dp))
.border(1.dp, F7Colors.Border, RoundedCornerShape(8.dp))
.padding(horizontal = 12.dp, vertical = 10.dp),
singleLine = true,
singleLine = singleLine,
textStyle = TextStyle(fontSize = 15.sp, color = F7Colors.TextPrimary),
cursorBrush = SolidColor(F7Colors.Primary),
)
@@ -16,6 +16,7 @@ import kotlinx.coroutines.launch
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.AppForegroundTracker
import ru.forbion.f7cloud.core.network.UnauthorizedException
import ru.forbion.f7cloud.core.network.VCardFields
data class ContactsUiState(
val loading: Boolean = false,
@@ -28,6 +29,10 @@ data class ContactsUiState(
val savingContact: Boolean = false,
val addError: String? = null,
val selectedContact: ContactItem? = null,
val editSheetOpen: Boolean = false,
val savingEdit: Boolean = false,
val editError: String? = null,
val deletingContact: Boolean = false,
) {
val filteredContacts: List<ContactItem>
get() {
@@ -70,6 +75,70 @@ class ContactsViewModel(
_state.update { it.copy(selectedContact = null) }
}
fun openEditSheet() {
_state.update { it.copy(editSheetOpen = true, editError = null) }
}
fun closeEditSheet() {
_state.update { it.copy(editSheetOpen = false, editError = null, savingEdit = false) }
}
/** Сохранение правки контакта: round-trip vCard (If-Match). fields собирает форма. */
fun saveEdit(session: AuthSession, item: ContactItem, fields: VCardFields) {
if (_state.value.savingEdit) return
viewModelScope.launch(Dispatchers.IO) {
_state.update { it.copy(savingEdit = true, editError = null) }
runCatching { repository.updateContact(session, item, fields) }
.onSuccess { updated ->
_state.update {
it.copy(
savingEdit = false,
editSheetOpen = false,
editError = null,
selectedContact = updated,
)
}
refresh(session, showLoading = false)
}
.onFailure { t ->
_state.update {
it.copy(
savingEdit = false,
editError = t.message ?: "Не удалось сохранить контакт",
unauthorized = t is UnauthorizedException,
)
}
}
}
}
/** Удаление контакта (If-Match) + закрытие карточки. */
fun deleteContact(session: AuthSession, item: ContactItem) {
if (_state.value.deletingContact) return
viewModelScope.launch(Dispatchers.IO) {
_state.update { it.copy(deletingContact = true, editError = null) }
runCatching { repository.deleteContact(session, item) }
.onSuccess {
_state.update {
it.copy(
deletingContact = false,
selectedContact = null,
editSheetOpen = false,
)
}
}
.onFailure { t ->
_state.update {
it.copy(
deletingContact = false,
editError = t.message ?: "Не удалось удалить контакт",
unauthorized = t is UnauthorizedException,
)
}
}
}
}
fun load(session: AuthSession) {
if (activeSession?.serverUrl == session.serverUrl &&
activeSession?.username == session.username
+13
View File
@@ -2,6 +2,7 @@ plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.compose'
id 'io.github.takahirom.roborazzi'
}
android {
@@ -21,6 +22,12 @@ android {
kotlinOptions {
jvmTarget = '17'
}
// Roborazzi/Robolectric: рендер Compose deck-экранов в PNG на JVM (Fd-сверка, mail/111).
testOptions {
unitTests {
includeAndroidResources = true
}
}
}
dependencies {
@@ -35,4 +42,10 @@ dependencies {
implementation libs.compose.material3
implementation libs.compose.foundation
implementation libs.lifecycle.viewmodel.compose
testImplementation libs.robolectric
testImplementation libs.roborazzi
testImplementation libs.roborazzi.compose
testImplementation libs.roborazzi.junit.rule
testImplementation libs.compose.ui.test.junit4
debugImplementation libs.compose.ui.test.manifest
}
+31 -1
View File
@@ -2,6 +2,36 @@
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>NoUnusedImports:DeckRepository.kt$ru.forbion.f7cloud.feature.deck.DeckRepository.kt</ID>
<ID>ArgumentListWrapping:DeckCardSheet.kt$("В архив", onClick = onArchive, enabled = !busy, modifier = Modifier.weight(1f))</ID>
<ID>ArgumentListWrapping:DeckCardSheet.kt$("Сбросить", color = F7Colors.Primary, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.clickable { duedate = null })</ID>
<ID>ArgumentListWrapping:DeckCardSheet.kt$("Удалить", onClick = onDelete, enabled = !busy, modifier = Modifier.weight(1f))</ID>
<ID>ArgumentListWrapping:DeckCardSheet.kt$(1f)</ID>
<ID>ArgumentListWrapping:DeckCardSheet.kt$(6.dp)</ID>
<ID>ArgumentListWrapping:DeckCardSheet.kt$(8.dp)</ID>
<ID>ArgumentListWrapping:DeckCardSheet.kt$(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp))</ID>
<ID>ArgumentListWrapping:DeckCardSheet.kt$(label.title, style = MaterialTheme.typography.labelMedium, color = F7Colors.TextPrimary)</ID>
<ID>ArgumentListWrapping:DeckCardSheet.kt$(stack.title, style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextPrimary)</ID>
<ID>ArgumentListWrapping:DeckCardSheet.kt$(value = description, onValueChange = { description = it }, label = "Описание", minLines = 3)</ID>
<ID>ArgumentListWrapping:DeckCardSheet.kt$(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp))</ID>
<ID>ArgumentListWrapping:DeckScreen.kt$(", ")</ID>
<ID>ArgumentListWrapping:DeckScreen.kt$("👤 ${card.assignees.joinToString(", ")}", style = MaterialTheme.typography.labelMedium, color = F7Colors.TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis)</ID>
<ID>ArgumentListWrapping:DeckScreen.kt$(18.dp)</ID>
<ID>ArgumentListWrapping:DeckScreen.kt$(4.dp)</ID>
<ID>ArgumentListWrapping:DeckScreen.kt$(Icons.Filled.Add, contentDescription = "Добавить карточку", tint = F7Colors.Primary, modifier = Modifier.size(18.dp))</ID>
<ID>ArgumentListWrapping:DeckScreen.kt$(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp))</ID>
<ID>ArgumentListWrapping:DeckViewModel.kt$DeckViewModel$(boardDetail = detail, openedCard = freshCard ?: it.openedCard, busy = false)</ID>
<ID>CyclomaticComplexMethod:DeckCardSheet.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun DeckCardSheet( card: DeckCard, boardLabels: List&lt;DeckLabel&gt;, stacks: List&lt;DeckStack&gt;, canEdit: Boolean, busy: Boolean, assignedToMe: Boolean, onDismiss: () -&gt; Unit, onToggleDone: () -&gt; Unit, onSave: (title: String, description: String, duedate: String?) -&gt; Unit, onToggleLabel: (DeckLabel) -&gt; Unit, onToggleAssignSelf: () -&gt; Unit, onMove: (targetStackId: Int) -&gt; Unit, onArchive: () -&gt; Unit, onDelete: () -&gt; Unit, )</ID>
<ID>LongMethod:DeckCardSheet.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun DeckCardSheet( card: DeckCard, boardLabels: List&lt;DeckLabel&gt;, stacks: List&lt;DeckStack&gt;, canEdit: Boolean, busy: Boolean, assignedToMe: Boolean, onDismiss: () -&gt; Unit, onToggleDone: () -&gt; Unit, onSave: (title: String, description: String, duedate: String?) -&gt; Unit, onToggleLabel: (DeckLabel) -&gt; Unit, onToggleAssignSelf: () -&gt; Unit, onMove: (targetStackId: Int) -&gt; Unit, onArchive: () -&gt; Unit, onDelete: () -&gt; Unit, )</ID>
<ID>LoopWithTooManyJumpStatements:DeckRepository.kt$DeckRepository$for</ID>
<ID>MaxLineLength:DeckCardSheet.kt$Text("Сбросить", color = F7Colors.Primary, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.clickable { duedate = null })</ID>
<ID>MaxLineLength:DeckScreen.kt$Icon(Icons.Filled.Add, contentDescription = "Добавить карточку", tint = F7Colors.Primary, modifier = Modifier.size(18.dp))</ID>
<ID>MaxLineLength:DeckScreen.kt$Text("👤 ${card.assignees.joinToString(", ")}", style = MaterialTheme.typography.labelMedium, color = F7Colors.TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis)</ID>
<ID>MaximumLineLength:DeckCardSheet.kt$ </ID>
<ID>MaximumLineLength:DeckScreen.kt$ </ID>
<ID>MaximumLineLength:DeckViewModel.kt$DeckViewModel$ </ID>
<ID>NoConsecutiveBlankLines:DeckCardSheet.kt$ </ID>
<ID>NoUnusedImports:DeckScreen.kt$ru.forbion.f7cloud.feature.deck.DeckScreen.kt</ID>
<ID>Wrapping:DeckCardSheet.kt$duedate = null</ID>
<ID>Wrapping:DeckScreen.kt$;</ID>
</CurrentIssues>
</SmellBaseline>
@@ -43,9 +43,13 @@ import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7OutlinedField
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton
import java.time.DayOfWeek
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.time.temporal.TemporalAdjusters
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
@@ -120,6 +124,17 @@ fun DeckCardSheet(
Text("Сбросить", color = F7Colors.Primary, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.clickable { duedate = null })
}
}
if (canEdit) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
DeckDueChip("Сегодня") { duedate = deckQuickDueIso(0) }
DeckDueChip("Завтра") { duedate = deckQuickDueIso(1) }
DeckDueChip("Выходные") { duedate = deckQuickDueIso(0, toWeekend = true) }
DeckDueChip("Неделя") { duedate = deckQuickDueIso(7) }
}
}
// Метки доски
if (boardLabels.isNotEmpty()) {
@@ -236,8 +251,9 @@ fun DeckTextPromptDialog(
confirmText: String,
onConfirm: (String) -> Unit,
onDismiss: () -> Unit,
initial: String = "",
) {
var text by remember { mutableStateOf("") }
var text by remember { mutableStateOf(initial) }
Dialog(onDismissRequest = onDismiss) {
Surface(shape = RoundedCornerShape(16.dp), color = Color.White) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
@@ -263,6 +279,56 @@ fun DeckTextPromptDialog(
}
@Composable
fun DeckConfirmDialog(
title: String,
message: String,
confirmText: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
Dialog(onDismissRequest = onDismiss) {
Surface(shape = RoundedCornerShape(16.dp), color = Color.White) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
title,
style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp),
fontWeight = FontWeight.SemiBold,
color = F7Colors.TextPrimary,
)
Text(message, style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
F7SecondaryButton("Отмена", onClick = onDismiss, modifier = Modifier.weight(1f))
F7PrimaryButton(text = confirmText, onClick = onConfirm, modifier = Modifier.weight(1f))
}
}
}
}
}
@Composable
private fun DeckDueChip(text: String, onClick: () -> Unit) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(100.dp))
.background(F7Colors.SurfaceMuted)
.clickable(onClick = onClick)
.padding(horizontal = 10.dp, vertical = 4.dp),
) {
Text(text, style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextPrimary)
}
}
/** ISO-срок для быстрых опций: сегодня/через N дней/ближайшая суббота (полночь UTC — как пикер). */
private fun deckQuickDueIso(daysAhead: Long, toWeekend: Boolean = false): String {
val date = if (toWeekend) {
LocalDate.now().with(TemporalAdjusters.nextOrSame(DayOfWeek.SATURDAY))
} else {
LocalDate.now().plusDays(daysAhead)
}
return DateTimeFormatter.ISO_INSTANT.format(date.atStartOfDay(ZoneOffset.UTC).toInstant())
}
/** Парсит ISO-строку срока Deck в миллисекунды (для инициализации пикера). */
internal fun parseDeckDueMillis(iso: String): Long? = runCatching {
Instant.parse(iso).toEpochMilli()
@@ -215,6 +215,15 @@ class DeckRepository {
return DeckStack(id = id, boardId = boardId, title = title, order = 999, cards = emptyList())
}
fun renameStack(session: AuthSession, stack: DeckStack, title: String) {
val body = JSONObject().put("title", title).put("boardId", stack.boardId).put("order", stack.order)
sendJson(session, "PUT", "${apiBase(session)}/stacks/${stack.id}", body)
}
fun deleteStack(session: AuthSession, stackId: Int) {
sendJson(session, "DELETE", "${apiBase(session)}/stacks/$stackId", null)
}
// --- HTTP ---
private fun getJson(client: OkHttpClient, url: String): Any {
@@ -22,7 +22,10 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -62,6 +65,8 @@ fun DeckScreen(
val state by vm.state.collectAsState()
var addStackOpen by remember { mutableStateOf(false) }
var addCardStackId by remember { mutableStateOf<Int?>(null) }
var renameStackTarget by remember { mutableStateOf<DeckStack?>(null) }
var deleteStackTarget by remember { mutableStateOf<DeckStack?>(null) }
LaunchedEffect(session.serverUrl, session.username) { vm.load(session) }
LaunchedEffect(openCardId) {
@@ -110,6 +115,8 @@ fun DeckScreen(
onCardClick = { vm.openCard(it) },
onToggleDone = { vm.toggleDone(session, it) },
onAddCard = { addCardStackId = stack.id },
onRename = { renameStackTarget = stack },
onDelete = { deleteStackTarget = stack },
)
}
}
@@ -165,10 +172,48 @@ fun DeckScreen(
onDismiss = { addCardStackId = null },
)
}
DeckStackDialogs(
renameTarget = renameStackTarget,
deleteTarget = deleteStackTarget,
onRenameConfirm = { stack, title -> vm.renameStack(session, stack, title); renameStackTarget = null },
onDeleteConfirm = { stack -> vm.deleteStack(session, stack); deleteStackTarget = null },
onDismissRename = { renameStackTarget = null },
onDismissDelete = { deleteStackTarget = null },
)
}
@Composable
private fun DeckBoardRow(board: DeckBoard, onClick: () -> Unit) {
private fun DeckStackDialogs(
renameTarget: DeckStack?,
deleteTarget: DeckStack?,
onRenameConfirm: (DeckStack, String) -> Unit,
onDeleteConfirm: (DeckStack) -> Unit,
onDismissRename: () -> Unit,
onDismissDelete: () -> Unit,
) {
renameTarget?.let { stack ->
DeckTextPromptDialog(
title = "Переименовать колонку",
label = "Название колонки",
confirmText = "Сохранить",
initial = stack.title,
onConfirm = { onRenameConfirm(stack, it) },
onDismiss = onDismissRename,
)
}
deleteTarget?.let { stack ->
DeckConfirmDialog(
title = "Удалить колонку?",
message = "Колонка «${stack.title}» и её карточки будут удалены.",
confirmText = "Удалить",
onConfirm = { onDeleteConfirm(stack) },
onDismiss = onDismissDelete,
)
}
}
@Composable
internal fun DeckBoardRow(board: DeckBoard, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
@@ -200,13 +245,16 @@ private fun DeckBoardRow(board: DeckBoard, onClick: () -> Unit) {
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun DeckStackSection(
internal fun DeckStackSection(
stack: DeckStack,
canEdit: Boolean,
onCardClick: (DeckCard) -> Unit,
onToggleDone: (DeckCard) -> Unit,
onAddCard: () -> Unit,
onRename: () -> Unit,
onDelete: () -> Unit,
) {
var menuOpen by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxWidth()
@@ -240,6 +288,28 @@ private fun DeckStackSection(
) {
Icon(Icons.Filled.Add, contentDescription = "Добавить карточку", tint = F7Colors.Primary, modifier = Modifier.size(18.dp))
}
Box {
Icon(
Icons.Filled.MoreVert,
contentDescription = "Меню колонки",
tint = F7Colors.TextSecondary,
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
.clickable { menuOpen = true }
.padding(5.dp),
)
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(
text = { Text("Переименовать") },
onClick = { menuOpen = false; onRename() },
)
DropdownMenuItem(
text = { Text("Удалить список") },
onClick = { menuOpen = false; onDelete() },
)
}
}
}
}
stack.cards.forEach { card ->
@@ -292,7 +362,9 @@ private fun DeckCardRow(card: DeckCard, onClick: () -> Unit, onToggleDone: () ->
)
}
val due = card.duedate?.let { formatDeckDue(it) }
if (due != null || card.assignees.isNotEmpty() || card.commentsCount > 0) {
val (checked, total) = deckChecklistCounts(card.description)
val hasMeta = listOf(due != null, total > 0, card.commentsCount > 0, card.assignees.isNotEmpty())
if (hasMeta.any { it }) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -301,6 +373,10 @@ private fun DeckCardRow(card: DeckCard, onClick: () -> Unit, onToggleDone: () ->
if (due != null) {
Text("🗓 $due", style = MaterialTheme.typography.labelMedium, color = F7Colors.TextSecondary)
}
if (total > 0) {
val todo = "$checked/$total"
Text(todo, style = MaterialTheme.typography.labelMedium, color = F7Colors.TextSecondary)
}
if (card.commentsCount > 0) {
val comments = "💬 ${card.commentsCount}"
Text(comments, style = MaterialTheme.typography.labelMedium, color = F7Colors.TextSecondary)
@@ -333,6 +409,17 @@ internal fun DeckLabelChip(label: DeckLabel) {
}
}
private val deckChecklistRe = Regex("(?im)^\\s*([*+-]|\\d\\.)\\s+\\[\\s*(\\s|[xX])\\s*\\].*$")
private val deckCheckedRe = Regex("(?im)^\\s*([*+-]|\\d\\.)\\s+\\[\\s*[xX]\\s*\\].*$")
/** Чек-пункты из описания карточки (как веб CardBadges): (отмечено, всего). */
internal fun deckChecklistCounts(description: String): Pair<Int, Int> {
if (description.isBlank()) return 0 to 0
val total = deckChecklistRe.findAll(description).count()
val checked = deckCheckedRe.findAll(description).count()
return checked to total
}
/** Цвет Deck («RRGGBB» без #, иногда с #). */
internal fun parseDeckColor(hex: String?): Color? {
val h = hex?.trim()?.removePrefix("#")?.takeIf { it.length == 6 } ?: return null
@@ -124,6 +124,12 @@ class DeckViewModel(
mutate(session) { repository.createStack(session, boardId, title.trim()) }
}
fun renameStack(session: AuthSession, stack: DeckStack, title: String) =
mutate(session) { repository.renameStack(session, stack, title.trim()) }
fun deleteStack(session: AuthSession, stack: DeckStack) =
mutate(session) { repository.deleteStack(session, stack.id) }
private fun mutate(
session: AuthSession,
closeCardAfter: Boolean = false,
@@ -0,0 +1,101 @@
package ru.forbion.f7cloud.feature.deck
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.dp
import com.github.takahirom.roborazzi.captureRoboImage
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import ru.forbion.f7cloud.core.designsystem.F7Theme
/**
* Fd-сверка Карточек (Roborazzi, mail/111): рендер экранов в PNG на JVM (360dp, без эмулятора).
* Данные только мок (`bmob-*`), реальные данные `cloude` не используются.
* Сравнение PNG веб-эталон (`docs/app-map/reference/deck/`) пункты Д в `deck.diff.md`.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w360dp-h800dp-xxhdpi")
class DeckScreenshotTest {
@get:Rule
val composeRule = createComposeRule()
private val labels = listOf(
DeckLabel(id = 1, title = "bmob-важное", color = "e74c3c"),
DeckLabel(id = 2, title = "bmob-ревью", color = "3498db"),
)
private val mockStack = DeckStack(
id = 1,
boardId = 1,
title = "В работе",
order = 0,
cards = listOf(
DeckCard(
id = 1,
title = "bmob-карточка со сроком и метками",
done = false,
description = "- [x] шаг 1\n- [ ] шаг 2",
duedate = "2026-07-20T00:00:00+00:00",
order = 0,
stackId = 1,
labels = labels,
assignees = listOf("bmob-user"),
assigneeUids = listOf("bmob-user"),
commentsCount = 3,
),
DeckCard(id = 2, title = "bmob-выполненная", done = true, stackId = 1, order = 1),
),
)
@Test
fun deck_boards_list() {
composeRule.setContent {
F7Theme {
Column(
modifier = Modifier.fillMaxSize().background(Color.White).padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
DeckBoardRow(DeckBoard(id = 1, title = "bmob-доска", color = "9b59b6")) {}
DeckBoardRow(DeckBoard(id = 2, title = "bmob-проект", color = "2ecc71")) {}
}
}
}
composeRule.onRoot().captureRoboImage("src/test/screenshots/deck-boards.png")
}
@Test
fun deck_board_with_stack() {
composeRule.setContent {
F7Theme {
Column(
modifier = Modifier.fillMaxSize().background(Color.White).padding(8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
DeckStackSection(
stack = mockStack,
canEdit = true,
onCardClick = {},
onToggleDone = {},
onAddCard = {},
onRename = {},
onDelete = {},
)
}
}
}
composeRule.onRoot().captureRoboImage("src/test/screenshots/deck-board.png")
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

@@ -131,7 +131,7 @@ fun FilesBreadcrumb(
}
}
/** Ряд фильтр-чипов: Тип файла / Участники / Изменен (пилюли 25dp, minWidth 86). */
/** Ряд фильтр-чипов: Тип файла / Изменён / Участники (порядок и «ё» — как в моб.вебе forbion). */
@Composable
fun FilesFilterChips(
onTypeClick: () -> Unit,
@@ -148,8 +148,8 @@ fun FilesFilterChips(
verticalAlignment = Alignment.CenterVertically,
) {
FilterChip("Тип файла", onTypeClick)
FilterChip("Изменён", onModifiedClick)
FilterChip("Участники", onParticipantsClick)
FilterChip("Изменен", onModifiedClick)
}
}
+9
View File
@@ -8,6 +8,8 @@ coreKtx = "1.15.0" # был дрейф 1.13.1 / 1.15.0
lifecycle = "2.8.7" # был дрейф 2.8.1 / 2.8.7
coil = "2.7.0" # был дрейф 2.6.0 / 2.7.0
media3 = "1.4.1" # нативный медиа-слой (видео/аудио) — ExoPlayer
roborazzi = "1.42.0" # screenshot-тесты Compose на JVM (Fd-сверка без эмулятора)
robolectric = "4.14.1"
coroutines = "1.10.1" # был дрейф 1.8.1 / 1.10.1
navigationCompose = "2.8.9"
room = "2.7.2"
@@ -42,6 +44,13 @@ coil-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coil" }
coil-gif = { group = "io.coil-kt", name = "coil-gif", version.ref = "coil" }
media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
roborazzi = { group = "io.github.takahirom.roborazzi", name = "roborazzi", version.ref = "roborazzi" }
roborazzi-compose = { group = "io.github.takahirom.roborazzi", name = "roborazzi-compose", version.ref = "roborazzi" }
roborazzi-junit-rule = { group = "io.github.takahirom.roborazzi", name = "roborazzi-junit-rule", version.ref = "roborazzi" }
robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }
compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "coroutines" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }