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 | (без бампа — версия/релиз за лидом) | **Контакты — ядро правки/удаления (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 | (без бампа — версия/релиз за лидом) | **Задачи — умные коллекции в 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-действие в модуле, на устройстве не гонялось - 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"), Talk("Конференции", "spreed-header-icon.svg", "spreed-glass.svg"),
Deck("Карточки", "deck-header-icon.svg", "deck-glass.svg"), Deck("Карточки", "deck-header-icon.svg", "deck-glass.svg"),
Tasks("Задачи", "task-header-icon.svg", "task-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"), Support("Поддержка", "icon-header-f7support.svg", "icon-header-f7support.svg"),
; ;
@@ -43,7 +44,7 @@ private val coreAppMenuEntries = listOf(
AppMenuEntry(AppTab.Calendar, "Календарь", "calendar"), AppMenuEntry(AppTab.Calendar, "Календарь", "calendar"),
AppMenuEntry(AppTab.Contacts, "Контакты", "contacts"), AppMenuEntry(AppTab.Contacts, "Контакты", "contacts"),
AppMenuEntry(AppTab.Deck, "Карточки", "cards"), AppMenuEntry(AppTab.Deck, "Карточки", "cards"),
AppMenuEntry(null, "Заметки", "notes", webPath = "/apps/notes/"), AppMenuEntry(AppTab.Notes, "Заметки", "notes"),
AppMenuEntry(AppTab.Support, "Поддержка", "support"), 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.files.FilesScreen
import ru.forbion.f7cloud.feature.f7support.SupportScreen import ru.forbion.f7cloud.feature.f7support.SupportScreen
import ru.forbion.f7cloud.feature.mail.MailScreen 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.tasks.TasksScreen
import ru.forbion.f7cloud.feature.talk.TalkScreen import ru.forbion.f7cloud.feature.talk.TalkScreen
import ru.forbion.f7cloud.mobile.navigation.AppLinkResolver import ru.forbion.f7cloud.mobile.navigation.AppLinkResolver
@@ -574,6 +575,11 @@ fun AppScaffold(
onOpenListConsumed = { pendingTasksListSlug = null }, onOpenListConsumed = { pendingTasksListSlug = null },
onUnauthorized = forceLogout, onUnauthorized = forceLogout,
) )
AppTab.Notes -> NotesScreen(
session = currentSession,
modifier = contentModifier,
onUnauthorized = forceLogout,
)
AppTab.Support -> SupportScreen( AppTab.Support -> SupportScreen(
session = currentSession, session = currentSession,
modifier = contentModifier, modifier = contentModifier,
@@ -59,9 +59,11 @@ import org.json.JSONObject
import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.F7Colors import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7FloatingPanel 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.F7NotificationRow
import ru.forbion.f7cloud.core.designsystem.formatNotificationRelativeTime import ru.forbion.f7cloud.core.designsystem.formatNotificationRelativeTime
import ru.forbion.f7cloud.core.network.F7Notification 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.NetworkFactory
import ru.forbion.f7cloud.core.network.NotificationsRepository import ru.forbion.f7cloud.core.network.NotificationsRepository
import ru.forbion.f7cloud.core.network.UnauthorizedException 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( F7FloatingPanel(
visible = visible, visible = visible,
onDismiss = onDismiss, onDismiss = onDismiss,
@@ -395,7 +444,41 @@ fun NotificationsSheet(
else -> Column( else -> Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.fillMaxHeight() .fillMaxHeight(),
) {
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()), .verticalScroll(rememberScrollState()),
) { ) {
items.forEachIndexed { index, item -> items.forEachIndexed { index, item ->
@@ -412,9 +495,16 @@ fun NotificationsSheet(
}, },
onDismiss = { dismissNotification(item) }, onDismiss = { dismissNotification(item) },
showDivider = index < items.lastIndex, 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 androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ru.forbion.f7cloud.core.auth.AppPasswordRevoker import ru.forbion.f7cloud.core.auth.AppPasswordRevoker
import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.auth.AuthStore 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.feature.files.OfficeWarmup
import ru.forbion.f7cloud.mobile.OfficeWebViewPool import ru.forbion.f7cloud.mobile.OfficeWebViewPool
@@ -29,9 +32,16 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
} }
fun logout() { fun logout() {
// Best-effort ревокация app password на сервере ДО очистки локальной сессии. // Best-effort ревокация app password + снятие устройства с push ДО очистки локальной сессии.
_session.value?.let { current -> _session.value?.let { current ->
viewModelScope.launch { runCatching { AppPasswordRevoker.revoke(current) } } viewModelScope.launch { runCatching { AppPasswordRevoker.revoke(current) } }
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) {
F7PushRegistrar.unregisterBlocking(getApplication(), current)
}
}
}
} }
OfficeWarmup.clear() OfficeWarmup.clear()
OfficeWebViewPool.dispose() 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.serialization' version '2.3.0' apply false
id 'org.jetbrains.kotlin.plugin.parcelize' 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 '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 'com.google.gms.google-services' version '4.4.2' apply false
id 'io.gitlab.arturbosch.detekt' version '1.23.8' apply false id 'io.gitlab.arturbosch.detekt' version '1.23.8' apply false
} }
@@ -24,4 +24,8 @@ data class ContactEntity(
val birthday: String = "", val birthday: String = "",
val emails: String = "", val emails: String = "",
val phones: 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") @Query("DELETE FROM contacts WHERE accountKey = :accountKey")
suspend fun deleteAll(accountKey: String) suspend fun deleteAll(accountKey: String)
@Query("DELETE FROM contacts WHERE accountKey = :accountKey AND uid = :uid")
suspend fun deleteByUid(accountKey: String, uid: String)
@Transaction @Transaction
suspend fun replaceAll(accountKey: String, contacts: List<ContactEntity>) { suspend fun replaceAll(accountKey: String, contacts: List<ContactEntity>) {
deleteAll(accountKey) deleteAll(accountKey)
@@ -9,7 +9,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
@Database( @Database(
entities = [FileEntity::class, ContactEntity::class], entities = [FileEntity::class, ContactEntity::class],
version = 4, version = 5,
exportSchema = false, exportSchema = false,
) )
abstract class F7Database : RoomDatabase() { 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 { fun get(context: Context): F7Database {
return INSTANCE ?: synchronized(this) { return INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder( INSTANCE ?: Room.databaseBuilder(
@@ -71,7 +80,7 @@ abstract class F7Database : RoomDatabase() {
F7Database::class.java, F7Database::class.java,
"f7cloud-mobile.db", "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() .build()
.also { INSTANCE = it } .also { INSTANCE = it }
} }
+17
View File
@@ -2,6 +2,7 @@ plugins {
id 'com.android.library' id 'com.android.library'
id 'org.jetbrains.kotlin.android' id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.compose' id 'org.jetbrains.kotlin.plugin.compose'
id 'io.github.takahirom.roborazzi'
} }
android { android {
@@ -22,6 +23,12 @@ android {
kotlinOptions { kotlinOptions {
jvmTarget = '17' jvmTarget = '17'
} }
// Roborazzi/Robolectric: рендер Compose в PNG на JVM (Fd-сверка без эмулятора).
testOptions {
unitTests {
includeAndroidResources = true
}
}
} }
dependencies { dependencies {
@@ -32,4 +39,14 @@ dependencies {
implementation libs.compose.material3 implementation libs.compose.material3
implementation libs.coil.compose implementation libs.coil.compose
implementation libs.coil.svg 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -5,52 +5,52 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape 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.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.TextButton
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.ui.Alignment
import androidx.compose.foundation.text.KeyboardOptions 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.Key
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type 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.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.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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable 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 BottomBarWidth = 328.dp
private val BottomBarHeight = 61.dp private val BottomBarHeight = 61.dp
private val BottomBarButtonSize = 55.dp private val BottomBarButtonSize = 55.dp
// Живой сайт: app-link/бургер img = 25px; стрелка «назад» мельче. // Живой сайт: app-link/бургер img = 25px; стрелка «назад» мельче.
private val BottomBarIconSize = 25.dp private val BottomBarIconSize = 25.dp
private val BottomBarSectionIconSize = 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 package ru.forbion.f7cloud.core.designsystem
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -25,13 +30,13 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.request.ImageRequest import coil.request.ImageRequest
import okhttp3.Credentials
private fun TextStyle.doubled(): TextStyle = copy( private fun TextStyle.doubled(): TextStyle = copy(
fontSize = fontSize * 2, fontSize = fontSize * 2,
lineHeight = lineHeight * 2, lineHeight = lineHeight * 2,
) )
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun F7NotificationRow( fun F7NotificationRow(
subject: String, subject: String,
@@ -44,6 +49,8 @@ fun F7NotificationRow(
onDismiss: () -> Unit, onDismiss: () -> Unit,
showDivider: Boolean, showDivider: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
actions: List<F7NotificationActionUi> = emptyList(),
onActionClick: (Int) -> Unit = {},
) { ) {
val context = LocalContext.current val context = LocalContext.current
Column( Column(
@@ -131,11 +138,65 @@ fun F7NotificationRow(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .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) { if (showDivider) {
HorizontalDivider(color = F7Colors.Border, thickness = 1.dp) 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 package ru.forbion.f7cloud.core.designsystem
import android.app.Activity import android.app.Activity
import android.content.pm.ApplicationInfo
import android.view.WindowManager import android.view.WindowManager
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
@@ -10,15 +11,23 @@ import androidx.compose.ui.platform.LocalContext
* Пока этот экран на виду — окно помечено FLAG_SECURE: содержимое не попадает * Пока этот экран на виду — окно помечено FLAG_SECURE: содержимое не попадает
* в скриншоты, превью в «недавних» и запись экрана. Вешать на чувствительные * в скриншоты, превью в «недавних» и запись экрана. Вешать на чувствительные
* экраны (ввод пароля/PIN). Флаг снимается при уходе с экрана. * экраны (ввод пароля/PIN). Флаг снимается при уходе с экрана.
*
* В debug-сборке (FLAG_DEBUGGABLE) флаг НЕ ставится — иначе невозможен автономный
* визуал-тест на эмуляторе (screencap возвращает пустой кадр). Релиз — всегда защищён.
*/ */
@Composable @Composable
fun F7SecureScreen() { fun F7SecureScreen() {
val context = LocalContext.current val context = LocalContext.current
DisposableEffect(Unit) { DisposableEffect(Unit) {
val debuggable = (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
val window = (context as? Activity)?.window val window = (context as? Activity)?.window
if (!debuggable) {
window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE) window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
onDispose { onDispose {
if (!debuggable) {
window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) 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.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import ru.forbion.f7cloud.core.designsystem.R
val RalewayFamily = FontFamily( val RalewayFamily = FontFamily(
Font(R.font.raleway_medium, FontWeight.Medium), Font(R.font.raleway_medium, FontWeight.Medium),
@@ -14,14 +13,64 @@ val RalewayFamily = FontFamily(
) )
val F7Typography = Typography( val F7Typography = Typography(
displayLarge = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.SemiBold, fontSize = 24.sp, lineHeight = 28.sp), displayLarge = TextStyle(
titleLarge = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.SemiBold, fontSize = 24.sp, lineHeight = 28.sp), fontFamily = RalewayFamily,
titleMedium = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 20.sp), fontWeight = FontWeight.SemiBold,
titleSmall = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp), fontSize = 24.sp,
bodyLarge = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 20.sp), lineHeight = 28.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), titleLarge = TextStyle(
labelLarge = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp), fontFamily = RalewayFamily,
labelMedium = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp), fontWeight = FontWeight.SemiBold,
labelSmall = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 14.sp), 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: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=["']requesttoken["']\s+value=["']([^"']+)["']""", RegexOption.IGNORE_CASE)</ID>
<ID>ArgumentListWrapping:LoginFlowClient.kt$LoginFlowClient$("""name=["']stateToken["']\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>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>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>LargeClass:CalDavClient.kt$CalDavClient</ID>
<ID>LoopWithTooManyJumpStatements:NotificationsRepository.kt$NotificationsRepository$for</ID> <ID>LoopWithTooManyJumpStatements:NotificationsRepository.kt$NotificationsRepository$for</ID>
<ID>MaximumLineLength:CalDavClient.kt$CalDavClient$ </ID>
<ID>MaximumLineLength:CalendarIcs.kt$CalendarIcs$ </ID> <ID>MaximumLineLength:CalendarIcs.kt$CalendarIcs$ </ID>
<ID>MaximumLineLength:LoginFlowClient.kt$LoginFlowClient$ </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 &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 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 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 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: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 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>NestedBlockDepth:DavClient.kt$DavClient$private fun parseMultiStatus(xml: String, folderUrl: String): List&lt;DavEntry&gt;</ID>
<ID>NoMultipleSpaces:CalendarIcs.kt$CalendarIcs$ </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 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>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>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>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 &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> <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 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: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>UnusedPrivateProperty:LoginFlowClient.kt$LoginFlowClient$val payload = extractPayload(qrData)</ID>
<ID>Wrapping:CalDavClient.kt$CalDavClient$;</ID>
<ID>Wrapping:CalendarIcsTest.kt$CalendarIcsTest$"""</ID> <ID>Wrapping:CalendarIcsTest.kt$CalendarIcsTest$"""</ID>
<ID>Wrapping:LoginFlowClient.kt$LoginFlowClient$(</ID> <ID>Wrapping:LoginFlowClient.kt$LoginFlowClient$(</ID>
</CurrentIssues> </CurrentIssues>
@@ -212,6 +212,10 @@ object CardDavClient {
?: error("Не удалось разобрать сохранённый контакт") ?: error("Не удалось разобрать сохранённый контакт")
} }
/** Типизированные значения EMAIL/TEL из сырого vCard (значение + токен TYPE). P2. */
fun parseTypedValues(rawVCard: String, property: String): List<VCardTypedValue> =
VCardEditor.parseTypedValues(rawVCard, property)
/** Удаление контакта с If-Match по ETag. 404 трактуем как «уже удалён» — не ошибка. */ /** Удаление контакта с If-Match по ETag. 404 трактуем как «уже удалён» — не ошибка. */
fun deleteContact( fun deleteContact(
client: OkHttpClient, 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 // TEXT-значение (FN/TITLE/URL/NOTE/EMAIL/TEL): по совместимости с существующим buildVCard
// экранируем только backslash и перевод строки (запятые в имени оставляем литералом). // экранируем только backslash и перевод строки (запятые в имени оставляем литералом).
private fun escapeText(v: String): String = private fun escapeText(v: String): String =
@@ -1,8 +1,19 @@
package ru.forbion.f7cloud.core.network package ru.forbion.f7cloud.core.network
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject 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( data class F7Notification(
val id: Long, val id: Long,
val subject: String, val subject: String,
@@ -11,6 +22,7 @@ data class F7Notification(
val link: String, val link: String,
val app: String, val app: String,
val icon: String = "", val icon: String = "",
val actions: List<F7NotificationAction> = emptyList(),
) )
class NotificationsRepository { class NotificationsRepository {
@@ -64,6 +76,25 @@ class NotificationsRepository {
link = obj.optString("link"), link = obj.optString("link"),
app = obj.optString("app"), app = obj.optString("app"),
icon = obj.optString("icon"), 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 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("TEL;TYPE=CELL:+70000000000\r\n")
append("END:VCARD\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 и неизвестные свойства сохранены // UID и неизвестные свойства сохранены
assertTrue("UID сохранён", out.contains("UID:abc-123")) assertTrue("UID сохранён", out.contains("UID:abc-123"))
assertTrue("PHOTO сохранён", out.contains("/9j/4AAQSkZJRgABAQAAAQABAAD")) assertTrue("PHOTO сохранён", out.contains("/9j/4AAQSkZJRgABAQAAAQABAAD"))
@@ -129,4 +135,50 @@ class VCardEditorTest {
assertFalse("нет пустого ADR", out.contains("ADR")) assertFalse("нет пустого ADR", out.contains("ADR"))
assertFalse("нет пустого ORG", out.contains("ORG")) 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) val prefs = prefs(context)
cancelTimeout(context) cancelTimeout(context)
cancelNotification(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 TAG = "F7PushRegistrar"
private const val PREFS = "f7push" private const val PREFS = "f7push"
private const val KEY_DEVICE_ID = "device_id" 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 { fun getDeviceId(context: Context): String {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@@ -41,10 +45,60 @@ object F7PushRegistrar {
.put("platform", "android") .put("platform", "android")
.put("clientApp", "f7cloud-mobile") .put("clientApp", "f7cloud-mobile")
.toString() .toString()
return try {
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) postJson(endpoint, session, body)
} catch (t: Throwable) { } catch (t: Throwable) {
Log.w(TAG, "register failed", t) 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 {
deleteRequest(endpoint, session)
} catch (t: Throwable) {
Log.w(TAG, "unregister failed", t)
0 0
} }
} }
@@ -76,6 +130,25 @@ object F7PushRegistrar {
return code 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?) { private fun drainQuietly(stream: InputStream?) {
if (stream == null) return if (stream == null) return
try { try {
+3 -3
View File
@@ -16,10 +16,10 @@
| talk | b-mob-conf | 1 | 41.3 | предв. | ~29 | ? | нет | P1 меню сообщения ~закрыто (Копировать/ссылка/2×переслать/Напоминание/Ответить-лично, …01198a4); остаток P1 файл-тред+перевод эскалирую (кросс-модуль/провайдеры); далее P2 меню беседы; Fd на устройстве | | 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 управление списками; ждёт девайс-сверки | | 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-шелле | | 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 | | 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 | 23.1 | предв. | 51 | ? | нет | P1: «Назначить мне/снять» (6eeba1b) + бейдж комментариев (9d726f4) done, green compile; далее бейдж чек-пунктов, меню колонки (Удалить/Переименовать), быстрые сроки; Fd на устройстве | | 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 | | 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) → `правки``повторный прогон` Обозначения статуса: `карта``diff` (итерация 1) → `правки``повторный прогон`
`✅ 0` (Ff=Fd=100%) → `✅ 0 подтв.` (второй прогон держит 0). `✅ 0` (Ff=Fd=100%) → `✅ 0 подтв.` (второй прогон держит 0).
+33 -26
View File
@@ -1,13 +1,15 @@
# Diff: Контакты (CardDAV) — итерация 1 (2026-07-13) # 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 юнит-тестов > Прогресс P1 (коммиты bc1a497 + 6b98c5a): правка/удаление РЕАЛИЗОВАНЫ (ядро + UI). Ядро:
> `VCardEditorTest` зелёные, гейт `:app:compileReleaseKotlin` зелёный) `CardDavClient.updateContact` > `CardDavClient.updateContact` (PUT If-Match, round-trip неизвестных полей vCard) / `deleteContact`
> (PUT If-Match, round-trip неизвестных полей vCard) / `deleteContact` (DELETE If-Match) + > (DELETE If-Match) + `VCardEditor`, 10 юнит-тестов зелёные. UI: Room href/etag/raw (миграция v4→5),
> `VCardEditor`. UI-часть (Room href/etag/raw + форма правки + delete в карточке) — шаг 2/2, следующим > кнопки «Править» (форма) и «Удалить» (с подтверждением) в карточке. Гейты зелёные
> коммитом; пункты «Править»/«Удалить» карточки станут `[done]` после неё. > (`:app:compileReleaseKotlin`, `:feature:contacts:detekt`+compile). Пункты «Править»/«Удалить» (Э3) и
> Решение лида (mail/102): экран «Команды» (Circles) — целиком `n-a` (отдельный тяжёлый апп, не контакты); > поля формы (Э4) отмечены `[done]` — реализованы в коде; live-проверка на `bmob-*` (mail/085) и
> порядок P1→P2→P4→P3→P5 утверждён. > дизайн-проход на устройстве — следующим шагом (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`) ↔ натив Сверка веб (карта `contacts.md`, апстрим `nextcloud/contacts` + тема `_mobile-*.css`) ↔ натив
(`feature/contacts` Compose поверх CardDAV/`core:network`). Метод mail/083: `[Ф|Д]`·`[open|done|n-a]`, (`feature/contacts` Compose поверх CardDAV/`core:network`). Метод mail/083: `[Ф|Д]`·`[open|done|n-a]`,
@@ -39,7 +41,7 @@ Fd (дизайн) — отдельным проходом на устройст
- [Ф][open] Команды: «Показать все команды» — нет. - [Ф][open] Команды: «Показать все команды» — нет.
- [Ф][open] «Параметры контактов» — нет (см. Экран 7). - [Ф][open] «Параметры контактов» — нет (см. Экран 7).
## Экран 3: Карточка контакта (просмотр) — Ff 42.1% (11 open Ф / 19) ## Экран 3: Карточка контакта (просмотр) — Ff 52.6% (9 open Ф / 19; «Править»/«Удалить» done — P1)
Ряд действий (веб) ↔ натив (`ContactDetailContent`): Ряд действий (веб) ↔ натив (`ContactDetailContent`):
- [Ф][done] Написать письмо (конверт) · натив «Email» (ACTION_SENDTO mailto). - [Ф][done] Написать письмо (конверт) · натив «Email» (ACTION_SENDTO mailto).
*расхождение: уходит во внешний почтовик, не в свой `MailComposeActivity` (план mail/072 п.3).* *расхождение: уходит во внешний почтовик, не в свой `MailComposeActivity` (план mail/072 п.3).*
@@ -58,30 +60,31 @@ Fd (дизайн) — отдельным проходом на устройст
- [Ф][open] «ID в объединении облачных хранилищ» (federated cloud ID) — нет. - [Ф][open] «ID в объединении облачных хранилищ» (federated cloud ID) — нет.
- [Ф][open] «Подробное имя» (Фамилия/Имя/фонетика отдельными полями) — натив показывает только displayName. - [Ф][open] «Подробное имя» (Фамилия/Имя/фонетика отдельными полями) — натив показывает только displayName.
Меню шапки: Меню шапки:
- [Ф][open] «✏️ Править» — нет (Экран 4). - [Ф][done] «✏️ Править» — реализовано (P1, 6b98c5a): кнопка в карточке → форма (Экран 4).
- [Ф][done] «Удалить» — реализовано (P1): кнопка с подтверждением, DELETE If-Match.
- [Ф][open] ⋯ «Скачать» (.vcf) — нет. - [Ф][open] ⋯ «Скачать» (.vcf) — нет.
- [Ф][open] ⋯ «Копировать контакт» — нет. - [Ф][open] ⋯ «Копировать контакт» — нет.
- [Ф][open] ⋯ «Создать QR-код» — нет. - [Ф][open] ⋯ «Создать QR-код» — нет.
- [Ф][open] ⋯ «Исключить контакт из календаря Дней Рождений» — нет. - [Ф][open] ⋯ «Исключить контакт из календаря Дней Рождений» — нет.
- [Ф][open] ⋯ «Удалить» — нет.
- [Д][—] Крупный аватар/имя/subtitle, поля-плитки, кнопки Email/Позвонить — по живой теме; Fd на устройстве. - [Д][—] Крупный аватар/имя/subtitle, поля-плитки, кнопки Email/Позвонить — по живой теме; Fd на устройстве.
## Экран 4: Правка/создание контакта (форма) — Ff 26.7% (11 open Ф / 15) ## Экран 4: Правка/создание контакта (форма) — Ff 53.3% (7 open Ф / 15; правка реализована — P1)
Веб-форма правки ↔ натив (`AddContactDialog`только СОЗДАНИЕ, правки нет): Веб-форма правки ↔ натив (`EditContactDialog`правка + `AddContactDialog` — создание):
- [Ф][done] Имя · натив: поле в диалоге создания. - [Ф][done] Имя · поле формы.
- [Ф][done] Телефон (значение) · натив: поле в диалоге. - [Ф][done] Телефон (значение) · поле формы (многострочное, несколько номеров).
- [Ф][done] Email (значение) · натив: поле в диалоге. - [Ф][done] Email (значение) · поле формы (многострочное, несколько адресов).
- [Ф][done] Сохранить · натив: `createContact` (vCard 3.0 PUT). - [Ф][done] Сохранить · `updateContact` (round-trip vCard, PUT If-Match) / `createContact`.
- [Ф][open] Правка существующего (загрузка контакта в форму, PUT If-Match) — нет. - [Ф][done] Правка существующего (загрузка контакта в форму, PUT If-Match) — реализовано (P1, 6b98c5a).
- [Ф][done] Должность/организация — поля формы (organization + title).
- [Ф][done] Адрес — поле формы.
- [Ф][done] «Личные даты» (день рождения) — поле формы (текст; дата-пикер — доработка).
- [Ф][open] «Добавить фото» — нет. - [Ф][open] «Добавить фото» — нет.
- [Ф][open] Должность/организация — нет в форме. - [Ф][~] Тип телефона (Домашний/Рабочий/CELL/…) — типы ПАРСЯТСЯ и сохраняются при правке (P2 шаг1,
- [Ф][open] Тип телефона (Домашний/Рабочий/CELL/…) — нет (селектор типа). a8741e9); отдельного селектора типа в форме пока нет — P2 шаг2.
- [Ф][open] Тип email — нет. - [Ф][~] Тип email — аналогично (парсинг+сохранение есть, селектор — P2 шаг2).
- [Ф][open] Адрес — нет в форме. - [Ф][open] «О себе» (NOTE) — нет в форме.
- [Ф][open] «Личные даты» (день рождения, дата-пикер) — нет.
- [Ф][open] «О себе» — нет.
- [Ф][open] «Часовой пояс» — нет. - [Ф][open] «Часовой пояс» — нет.
- [Ф][open] «Группы контакта» (добавить в группу) — нет. - [Ф][open] «Группы контакта» (добавить в группу) — нет — P2.
- [Ф][open] Выбор адресной книги при создании — нет (пишет в `books.firstOrNull()`). - [Ф][open] Выбор адресной книги при создании — нет (пишет в `books.firstOrNull()`).
- [Д][—] Веб — полноэкранная форма; натив — компактный диалог. Расхождение композиции (Fd на устройстве). - [Д][—] Веб — полноэкранная форма; натив — компактный диалог. Расхождение композиции (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: Крупнейшие функциональные дыры (кандидаты P1–P5), совпадают с моим планом mail/072:
1. **Правка/удаление контакта** (Экран 4 + ⋯ карточки): edit-форма, `updateContact` PUT If-Match с 1. **Правка/удаление контакта** (Экран 4 + ⋯ карточки): edit-форма, `updateContact` PUT If-Match с
round-trip неизвестных полей vCard, `deleteContact`, выбор адресной книги, поля должность/адрес/ round-trip неизвестных полей vCard, `deleteContact`, выбор адресной книги, поля должность/адрес/
+39 -18
View File
@@ -1,11 +1,18 @@
# Diff: Карточки (Deck) — итерация 1 (2026-07-13) # Diff: Карточки (Deck) — итерация 1 (2026-07-13)
Ff_app: **23.1%** (51 open Ф / 76) · Fd_app: **предварительно** (веб-вёрстка сверена live через Ff_app: **25.3%** (47 open Ф / 76) · Fd_app: **предварительно, 2/8 экранов** (Roborazzi-пайплайн
iframe 390px; натив-дизайн-проход на устройстве — след. итерация) · n-a: 4 поднят, mail/111) · n-a: 4
> Прогресс P1: (6eeba1b) меню карточки «**Назначить мне / Снять назначение с меня**» > **Fd-пайплайн (mail/111):** Roborazzi 360dp живой — `feature/deck/src/test/DeckScreenshotTest`
> (`assignUser`/`unassignUser`, type 0); (9d726f4) **бейдж числа комментариев** «💬 N» на карточке > рендерит нативные экраны в PNG (`src/test/screenshots/deck-boards.png`, `deck-board.png`,
> (`commentsCount`) — оба `[done]` ниже (Ff экрана 4: 52.0%→60.0%). Green compile против core; > коммит 58f11c2). Веб-эталон снимаю iframe-360 на forbion (sample-доска «Welcome», без данных
> detekt модуля без новых замечаний (baseline-рефреш — лид, mail/095). > `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-сверка Сверка веб (карта `deck.md`, апстрим `nextcloud/deck v1.16.4` + тема forbion, live-сверка
Browser 1/cloude read-only) ↔ натив (`feature/deck`: `DeckRepository`/`DeckScreen`/ 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) ## Экран 2: Все доски (список) — Ff 50.0% (1 open Ф / 2)
- [Ф][done] Открыть доску (тап строки) — есть. - [Ф][done] Открыть доску (тап строки) — есть.
- [Ф][open] Поиск/фильтр списка досок (`navFilter`) — нет. - [Ф][open] Поиск/фильтр списка досок (`navFilter`) — нет.
- [Д][open] Колонка «**Участники**» (аватары ACL) — натив не показывает участников доски. - [Д][open] Колонка «**Участники**» (аватары ACL) — натив не показывает участников доски
- [Д][—] Строка-карточка доски (bullet цвета + название, border/radius) — сверено, близко 1:1 (`сверено:` Roborazzi vs iframe-360: веб — таблица с колонкой аватаров справа, натив — только bullet+имя).
(натив `DeckBoardRow`); детальный Fd — на устройстве. - [Д][open] **Заголовки-колонки** «Название доски / Участники» — в вебе есть строка-шапка таблицы,
в нативе её нет (список без шапки). Fd-минус композиции.
- [Д][done] Строка доски: bullet цвета + название, border/radius 8 — сверено (Roborazzi
`deck-boards.png` ↔ веб), совпадает.
## Экран 3: Обзор / «Ожидающие выполнения» — Ff 0.0% (2 open Ф / 2) ## Экран 3: Обзор / «Ожидающие выполнения» — Ff 0.0% (2 open Ф / 2)
- [Ф][open] Дашборд карточек по срокам (Просроченные/Сегодня/Завтра/Ближайшие 7 дней/Позже/ - [Ф][open] Дашборд карточек по срокам (Просроченные/Сегодня/Завтра/Ближайшие 7 дней/Позже/
@@ -51,7 +61,7 @@ Browser 1/cloude read-only) ↔ натив (`feature/deck`: `DeckRepository`/`De
- [Ф][open] Открыть карточку из обзора — нет (нет обзора). - [Ф][open] Открыть карточку из обзора — нет (нет обзора).
- [Д][open] Композиция: 6 секций-капшенов в столбец, карточки внутри — отсутствует. - [Д][open] Композиция: 6 секций-капшенов в столбец, карточки внутри — отсутствует.
## Экран 4: Доска (Controls + Колонка + Карточка) — Ff 60.0% (10 open Ф / 25) ## Экран 4: Доска (Controls + Колонка + Карточка) — Ff 72.0% (7 open Ф / 25)
**Шапка доски (`Controls`):** **Шапка доски (`Controls`):**
- [Ф][done] «**Добавить список**» — натив «+ Колонка» (`createStack`). - [Ф][done] «**Добавить список**» — натив «+ Колонка» (`createStack`).
- [Ф][open] «**Применить фильтр**» (по метке / назначенному / статусу) — фильтров нет. - [Ф][open] «**Применить фильтр**» (по метке / назначенному / статусу) — фильтров нет.
@@ -61,14 +71,16 @@ Browser 1/cloude read-only) ↔ натив (`feature/deck`: `DeckRepository`/`De
**Всплывающее меню колонки (⋯, `Stack`)** — в нативе меню колонки НЕТ: **Всплывающее меню колонки (⋯, `Stack`)** — в нативе меню колонки НЕТ:
- [Ф][open] «**Архивировать все карточки**» — нет. - [Ф][open] «**Архивировать все карточки**» — нет.
- [Ф][open] «**Разархивировать все карточки**» — нет. - [Ф][open] «**Разархивировать все карточки**» — нет.
- [Ф][open] «**Удалить список**» — нет. - [Ф][done] «**Удалить список**» — реализовано (меню «⋯» → подтверждение `DeckConfirmDialog`).
- [Ф][open] «**Изменить заголовок списка**» (инлайн-правка) — нет (переименования колонки нет). - [Ф][done] «**Изменить заголовок списка / Переименовать**» — реализовано (меню «⋯» → диалог с
префиллом, `renameStack`).
- [Ф][done] «**Добавить карточку**» в колонку — есть (инлайн-поле «Имя карточки»). - [Ф][done] «**Добавить карточку**» в колонку — есть (инлайн-поле «Имя карточки»).
**Карточка на доске (`CardItem` + бейджи):** **Карточка на доске (`CardItem` + бейджи):**
- [Ф][done] Открыть карточку (тап) — открывает лист-деталь. - [Ф][done] Открыть карточку (тап) — открывает лист-деталь.
- [Ф][done] Правка заголовка — есть (в листе-детали; веб правит инлайн — Д-нюанс). - [Ф][done] Правка заголовка — есть (в листе-детали; веб правит инлайн — Д-нюанс).
- [Ф][done] Бейдж срока (`DueDate`) — есть. - [Ф][done] Бейдж срока (`DueDate`) — есть.
- [Ф][open] Бейдж «**чек-пунктов**» (Todo items, напр. 0/9) — натив не показывает. - [Ф][done] Бейдж «**чек-пунктов**» «☑ x/total» — реализовано (`deckChecklistCounts`, парсинг
чекбоксов описания regex как веб).
- [Ф][done] Бейдж «**комментариев**» «💬 N» — реализовано (`commentsCount`); индикатор M - [Ф][done] Бейдж «**комментариев**» «💬 N» — реализовано (`commentsCount`); индикатор M
непрочитанных — Д-нюанс на след. проход. непрочитанных — Д-нюанс на след. проход.
- [Ф][done] Аватары назначенных — есть (отображение). - [Ф][done] Аватары назначенных — есть (отображение).
@@ -87,14 +99,23 @@ Browser 1/cloude read-only) ↔ натив (`feature/deck`: `DeckRepository`/`De
- [Ф][done] Перенос карточки между колонками (в вебе drag-n-drop) — натив реализует переносом - [Ф][done] Перенос карточки между колонками (в вебе drag-n-drop) — натив реализует переносом
через чипы «Колонка» (Д-нюанс: без DnD-жеста). через чипы «Колонка» (Д-нюанс: без 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):** **Свойства (Details):**
- [Ф][done] Метки: назначить/снять (toggle меток доски) — есть. - [Ф][done] Метки: назначить/снять (toggle меток доски) — есть.
- [Ф][open] Метки: «**Создать новую метку**» — натив только тогглит существующие. - [Ф][open] Метки: «**Создать новую метку**» — натив только тогглит существующие.
- [Ф][open] Назначение: «**Назначить пользователю…**» — нет (write). - [Ф][open] Назначение: «**Назначить пользователю…**» — нет (write).
- [Ф][open] Назначение: снять назначение — нет. - [Ф][open] Назначение: снять назначение — нет.
- [Ф][done] Срок: выбрать дату (DatePicker) — есть. - [Ф][done] Срок: выбрать дату (DatePicker) — есть.
- [Ф][open] Срок: «**быстрые опции**» (Позже сегодня/Завтра/В выходные/На след. неделе) — нет. - [Ф][done] Срок: «**быстрые опции**» — реализованы чипы Сегодня/Завтра/Выходные/Неделя
(`deckQuickDueIso`). *(«Позже сегодня» с часом — Д-нюанс, натив ставит дату; на след. проход.)*
- [Ф][done] Срок: «**Убрать срок**» — есть («Сбросить»). - [Ф][done] Срок: «**Убрать срок**» — есть («Сбросить»).
- [Ф][done] Срок: «Отметить выполненной» из блока срока — есть (done). - [Ф][done] Срок: «Отметить выполненной» из блока срока — есть (done).
- [Ф][done] Описание: редактировать — есть. - [Ф][done] Описание: редактировать — есть.
@@ -142,12 +163,12 @@ Browser 1/cloude read-only) ↔ натив (`feature/deck`: `DeckRepository`/`De
| 1. Навигация + меню доски | 16.7% | 10/12 | | 1. Навигация + меню доски | 16.7% | 10/12 |
| 2. Все доски (список) | 50.0% | 1/2 | | 2. Все доски (список) | 50.0% | 1/2 |
| 3. Обзор (по срокам) | 0.0% | 2/2 | | 3. Обзор (по срокам) | 0.0% | 2/2 |
| 4. Доска (Controls/Колонка/Карточка) | 60.0% | 10/25 | | 4. Доска (Controls/Колонка/Карточка) | 72.0% | 7/25 |
| 5. Лист-деталь карточки | 33.3% | 12/18 | | 5. Лист-деталь карточки | 38.9% | 11/18 |
| 6. Сайдбар доски | 0.0% | 10/10 | | 6. Сайдбар доски | 0.0% | 10/10 |
| 7. Move/copy карточки | 25.0% | 3/4 | | 7. Move/copy карточки | 25.0% | 3/4 |
| 8. Настройки Deck | 0.0% | 3/3 | | 8. Настройки Deck | 0.0% | 3/3 |
| **Ff_app (среднее)** | **23.1%** | **51/76** | | **Ff_app (среднее)** | **25.3%** | **47/76** |
## Приоритеты (черновик, жду визы лида — mail/071 план 1→2→3) ## Приоритеты (черновик, жду визы лида — mail/071 план 1→2→3)
- **P1 (в своём модуле, наибольший охват):** меню карточки (Назначить мне/Переместить-копировать/ - **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/> <ManuallySuppressedIssues/>
<CurrentIssues> <CurrentIssues>
<ID>ComplexCondition:ContactsRepository.kt$ContactsRepository$!force &amp;&amp; !needsPhotoBackfill &amp;&amp; !needsDetailsBackfill &amp;&amp; System.currentTimeMillis() - lastSync &lt; SYNC_INTERVAL_MS</ID> <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$"https://$it"</ID>
<ID>MultiLineIfElse:ContactDetailSheet.kt$it</ID> <ID>MultiLineIfElse:ContactDetailSheet.kt$it</ID>
</CurrentIssues> </CurrentIssues>
@@ -17,14 +17,20 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -42,6 +48,9 @@ import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton
fun ContactDetailSheet( fun ContactDetailSheet(
contact: ContactItem, contact: ContactItem,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onEdit: () -> Unit = {},
onDelete: () -> Unit = {},
deleting: Boolean = false,
) { ) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet( ModalBottomSheet(
@@ -52,6 +61,9 @@ fun ContactDetailSheet(
ContactDetailContent( ContactDetailContent(
contact = contact, contact = contact,
onDismiss = onDismiss, onDismiss = onDismiss,
onEdit = onEdit,
onDelete = onDelete,
deleting = deleting,
) )
} }
} }
@@ -60,8 +72,13 @@ fun ContactDetailSheet(
private fun ContactDetailContent( private fun ContactDetailContent(
contact: ContactItem, contact: ContactItem,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
deleting: Boolean,
) { ) {
val context = LocalContext.current val context = LocalContext.current
// Правка/удаление доступны только для синхронизированных контактов (есть href/raw).
val editable = contact.href.isNotBlank() && contact.raw.isNotBlank()
val label = contact.displayName.ifBlank { contact.email } val label = contact.displayName.ifBlank { contact.email }
val photoBytes = remember(contact.uid, contact.photoBase64) { val photoBytes = remember(contact.uid, contact.photoBase64) {
ContactUi.decodeContactPhoto(contact.photoBase64) ContactUi.decodeContactPhoto(contact.photoBase64)
@@ -163,32 +180,28 @@ private fun ContactDetailContent(
.padding(horizontal = 16.dp, vertical = 4.dp), .padding(horizontal = 16.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(0.dp), verticalArrangement = Arrangement.spacedBy(0.dp),
) { ) {
if (contact.emailLines.isNotEmpty()) { contact.emailEntries.forEachIndexed { index, entry ->
contact.emailLines.forEachIndexed { index, email ->
ContactDetailField( ContactDetailField(
label = if (index == 0) "Email" else "Email ${index + 1}", label = ContactUi.emailTypeLabel(entry.type, index),
value = email, value = entry.value,
onClick = { onClick = {
context.startActivity( context.startActivity(
Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:$email")), Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:${entry.value}")),
) )
}, },
) )
} }
} contact.phoneEntries.forEachIndexed { index, entry ->
if (contact.phoneLines.isNotEmpty()) {
contact.phoneLines.forEachIndexed { index, phone ->
ContactDetailField( ContactDetailField(
label = if (index == 0) "Телефон" else "Телефон ${index + 1}", label = ContactUi.phoneTypeLabel(entry.type, index),
value = phone, value = entry.value,
onClick = { onClick = {
context.startActivity( context.startActivity(
Intent(Intent.ACTION_DIAL, Uri.parse("tel:$phone")), Intent(Intent.ACTION_DIAL, Uri.parse("tel:${entry.value}")),
) )
}, },
) )
} }
}
if (contact.address.isNotBlank()) { if (contact.address.isNotBlank()) {
ContactDetailField(label = "Адрес", value = contact.address) 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)) Spacer(Modifier.height(4.dp))
Text( 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 @Composable
private fun ContactDetailField( private fun ContactDetailField(
label: String, label: String,
@@ -287,4 +368,21 @@ internal object ContactUi {
android.util.Base64.decode(base64, android.util.Base64.DEFAULT) android.util.Base64.decode(base64, android.util.Base64.DEFAULT)
}.getOrNull()?.takeIf { it.isNotEmpty() } }.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.database.F7Database
import ru.forbion.f7cloud.core.network.CardDavClient import ru.forbion.f7cloud.core.network.CardDavClient
import ru.forbion.f7cloud.core.network.NetworkFactory import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.network.VCardFields
import ru.forbion.f7cloud.core.network.VCardTypedValue
data class ContactItem( data class ContactItem(
val uid: String, val uid: String,
@@ -25,6 +27,10 @@ data class ContactItem(
val birthday: String = "", val birthday: String = "",
val emails: String = "", val emails: String = "",
val phones: String = "", val phones: String = "",
// Для правки/удаления (If-Match) и round-trip неизвестных полей vCard (P1).
val href: String = "",
val etag: String = "",
val raw: String = "",
) { ) {
val emailLines: List<String> val emailLines: List<String>
get() = (if (emails.isNotBlank()) emails else email) get() = (if (emails.isNotBlank()) emails else email)
@@ -40,6 +46,21 @@ data class ContactItem(
.filter { it.isNotBlank() } .filter { it.isNotBlank() }
.distinct() .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? val subtitle: String?
get() = when { get() = when {
title.isNotBlank() && organization.isNotBlank() -> "$title · $organization" title.isNotBlank() && organization.isNotBlank() -> "$title · $organization"
@@ -104,6 +125,9 @@ class ContactsRepository(context: Context) {
birthday = contact.birthday, birthday = contact.birthday,
emails = contact.emails, emails = contact.emails,
phones = contact.phones, phones = contact.phones,
href = contact.href,
etag = contact.etag,
raw = contact.raw,
) )
} }
dao.replaceAll(key, entities) dao.replaceAll(key, entities)
@@ -147,6 +171,62 @@ class ContactsRepository(context: Context) {
return entity.toItem() 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( fun filterSuggestions(
contacts: List<ContactItem>, contacts: List<ContactItem>,
query: String, query: String,
@@ -224,6 +304,9 @@ class ContactsRepository(context: Context) {
birthday = birthday, birthday = birthday,
emails = emails, emails = emails,
phones = phones, phones = phones,
href = href,
etag = etag,
raw = raw,
) )
companion object { companion object {
@@ -15,15 +15,17 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -52,6 +54,8 @@ import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.F7Colors import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7CreateButton import ru.forbion.f7cloud.core.designsystem.F7CreateButton
import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
import ru.forbion.f7cloud.core.network.VCardFields
import ru.forbion.f7cloud.core.network.VCardTypedValue
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -121,7 +125,19 @@ fun ContactsScreen(
ContactDetailSheet( ContactDetailSheet(
contact = contact, contact = contact,
onDismiss = vm::closeContactDetail, 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) { 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 @Composable
private fun AddContactDialog( private fun AddContactDialog(
saving: Boolean, saving: Boolean,
@@ -209,6 +333,7 @@ private fun AddContactField(
label: String, label: String,
value: String, value: String,
onValueChange: (String) -> Unit, onValueChange: (String) -> Unit,
singleLine: Boolean = true,
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(label, fontSize = 13.sp, color = F7Colors.TextSecondary) Text(label, fontSize = 13.sp, color = F7Colors.TextSecondary)
@@ -220,7 +345,7 @@ private fun AddContactField(
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.border(1.dp, F7Colors.Border, RoundedCornerShape(8.dp)) .border(1.dp, F7Colors.Border, RoundedCornerShape(8.dp))
.padding(horizontal = 12.dp, vertical = 10.dp), .padding(horizontal = 12.dp, vertical = 10.dp),
singleLine = true, singleLine = singleLine,
textStyle = TextStyle(fontSize = 15.sp, color = F7Colors.TextPrimary), textStyle = TextStyle(fontSize = 15.sp, color = F7Colors.TextPrimary),
cursorBrush = SolidColor(F7Colors.Primary), cursorBrush = SolidColor(F7Colors.Primary),
) )
@@ -16,6 +16,7 @@ import kotlinx.coroutines.launch
import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.AppForegroundTracker import ru.forbion.f7cloud.core.designsystem.AppForegroundTracker
import ru.forbion.f7cloud.core.network.UnauthorizedException import ru.forbion.f7cloud.core.network.UnauthorizedException
import ru.forbion.f7cloud.core.network.VCardFields
data class ContactsUiState( data class ContactsUiState(
val loading: Boolean = false, val loading: Boolean = false,
@@ -28,6 +29,10 @@ data class ContactsUiState(
val savingContact: Boolean = false, val savingContact: Boolean = false,
val addError: String? = null, val addError: String? = null,
val selectedContact: ContactItem? = 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> val filteredContacts: List<ContactItem>
get() { get() {
@@ -70,6 +75,70 @@ class ContactsViewModel(
_state.update { it.copy(selectedContact = null) } _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) { fun load(session: AuthSession) {
if (activeSession?.serverUrl == session.serverUrl && if (activeSession?.serverUrl == session.serverUrl &&
activeSession?.username == session.username activeSession?.username == session.username
+13
View File
@@ -2,6 +2,7 @@ plugins {
id 'com.android.library' id 'com.android.library'
id 'org.jetbrains.kotlin.android' id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.compose' id 'org.jetbrains.kotlin.plugin.compose'
id 'io.github.takahirom.roborazzi'
} }
android { android {
@@ -21,6 +22,12 @@ android {
kotlinOptions { kotlinOptions {
jvmTarget = '17' jvmTarget = '17'
} }
// Roborazzi/Robolectric: рендер Compose deck-экранов в PNG на JVM (Fd-сверка, mail/111).
testOptions {
unitTests {
includeAndroidResources = true
}
}
} }
dependencies { dependencies {
@@ -35,4 +42,10 @@ dependencies {
implementation libs.compose.material3 implementation libs.compose.material3
implementation libs.compose.foundation implementation libs.compose.foundation
implementation libs.lifecycle.viewmodel.compose 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> <SmellBaseline>
<ManuallySuppressedIssues/> <ManuallySuppressedIssues/>
<CurrentIssues> <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> </CurrentIssues>
</SmellBaseline> </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.F7OutlinedField
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton
import java.time.DayOfWeek
import java.time.Instant import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.temporal.TemporalAdjusters
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
@@ -120,6 +124,17 @@ fun DeckCardSheet(
Text("Сбросить", color = F7Colors.Primary, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.clickable { duedate = null }) 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()) { if (boardLabels.isNotEmpty()) {
@@ -236,8 +251,9 @@ fun DeckTextPromptDialog(
confirmText: String, confirmText: String,
onConfirm: (String) -> Unit, onConfirm: (String) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
initial: String = "",
) { ) {
var text by remember { mutableStateOf("") } var text by remember { mutableStateOf(initial) }
Dialog(onDismissRequest = onDismiss) { Dialog(onDismissRequest = onDismiss) {
Surface(shape = RoundedCornerShape(16.dp), color = Color.White) { Surface(shape = RoundedCornerShape(16.dp), color = Color.White) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { 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 в миллисекунды (для инициализации пикера). */ /** Парсит ISO-строку срока Deck в миллисекунды (для инициализации пикера). */
internal fun parseDeckDueMillis(iso: String): Long? = runCatching { internal fun parseDeckDueMillis(iso: String): Long? = runCatching {
Instant.parse(iso).toEpochMilli() Instant.parse(iso).toEpochMilli()
@@ -215,6 +215,15 @@ class DeckRepository {
return DeckStack(id = id, boardId = boardId, title = title, order = 999, cards = emptyList()) 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 --- // --- HTTP ---
private fun getJson(client: OkHttpClient, url: String): Any { 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.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -62,6 +65,8 @@ fun DeckScreen(
val state by vm.state.collectAsState() val state by vm.state.collectAsState()
var addStackOpen by remember { mutableStateOf(false) } var addStackOpen by remember { mutableStateOf(false) }
var addCardStackId by remember { mutableStateOf<Int?>(null) } 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(session.serverUrl, session.username) { vm.load(session) }
LaunchedEffect(openCardId) { LaunchedEffect(openCardId) {
@@ -110,6 +115,8 @@ fun DeckScreen(
onCardClick = { vm.openCard(it) }, onCardClick = { vm.openCard(it) },
onToggleDone = { vm.toggleDone(session, it) }, onToggleDone = { vm.toggleDone(session, it) },
onAddCard = { addCardStackId = stack.id }, onAddCard = { addCardStackId = stack.id },
onRename = { renameStackTarget = stack },
onDelete = { deleteStackTarget = stack },
) )
} }
} }
@@ -165,10 +172,48 @@ fun DeckScreen(
onDismiss = { addCardStackId = null }, 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 @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( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -200,13 +245,16 @@ private fun DeckBoardRow(board: DeckBoard, onClick: () -> Unit) {
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
private fun DeckStackSection( internal fun DeckStackSection(
stack: DeckStack, stack: DeckStack,
canEdit: Boolean, canEdit: Boolean,
onCardClick: (DeckCard) -> Unit, onCardClick: (DeckCard) -> Unit,
onToggleDone: (DeckCard) -> Unit, onToggleDone: (DeckCard) -> Unit,
onAddCard: () -> Unit, onAddCard: () -> Unit,
onRename: () -> Unit,
onDelete: () -> Unit,
) { ) {
var menuOpen by remember { mutableStateOf(false) }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -240,6 +288,28 @@ private fun DeckStackSection(
) { ) {
Icon(Icons.Filled.Add, contentDescription = "Добавить карточку", tint = F7Colors.Primary, modifier = Modifier.size(18.dp)) 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 -> stack.cards.forEach { card ->
@@ -292,7 +362,9 @@ private fun DeckCardRow(card: DeckCard, onClick: () -> Unit, onToggleDone: () ->
) )
} }
val due = card.duedate?.let { formatDeckDue(it) } 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( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -301,6 +373,10 @@ private fun DeckCardRow(card: DeckCard, onClick: () -> Unit, onToggleDone: () ->
if (due != null) { if (due != null) {
Text("🗓 $due", style = MaterialTheme.typography.labelMedium, color = F7Colors.TextSecondary) 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) { if (card.commentsCount > 0) {
val comments = "💬 ${card.commentsCount}" val comments = "💬 ${card.commentsCount}"
Text(comments, style = MaterialTheme.typography.labelMedium, color = F7Colors.TextSecondary) 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» без #, иногда с #). */ /** Цвет Deck («RRGGBB» без #, иногда с #). */
internal fun parseDeckColor(hex: String?): Color? { internal fun parseDeckColor(hex: String?): Color? {
val h = hex?.trim()?.removePrefix("#")?.takeIf { it.length == 6 } ?: return null 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()) } 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( private fun mutate(
session: AuthSession, session: AuthSession,
closeCardAfter: Boolean = false, 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 @Composable
fun FilesFilterChips( fun FilesFilterChips(
onTypeClick: () -> Unit, onTypeClick: () -> Unit,
@@ -148,8 +148,8 @@ fun FilesFilterChips(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
FilterChip("Тип файла", onTypeClick) FilterChip("Тип файла", onTypeClick)
FilterChip("Изменён", onModifiedClick)
FilterChip("Участники", onParticipantsClick) 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 lifecycle = "2.8.7" # был дрейф 2.8.1 / 2.8.7
coil = "2.7.0" # был дрейф 2.6.0 / 2.7.0 coil = "2.7.0" # был дрейф 2.6.0 / 2.7.0
media3 = "1.4.1" # нативный медиа-слой (видео/аудио) — ExoPlayer 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 coroutines = "1.10.1" # был дрейф 1.8.1 / 1.10.1
navigationCompose = "2.8.9" navigationCompose = "2.8.9"
room = "2.7.2" 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" } coil-gif = { group = "io.coil-kt", name = "coil-gif", version.ref = "coil" }
media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" } media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
media3-ui = { group = "androidx.media3", name = "media3-ui", 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-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" } 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" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }