Compare commits
34 Commits
e3725b7339
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 05ca2b533a | |||
| 195235495e | |||
| 93086d3f43 | |||
| 002f22bb91 | |||
| 80725396c6 | |||
| bfa870c021 | |||
| 715bb5a056 | |||
| 2854f5d5a4 | |||
| eed7723230 | |||
| 255c962d8f | |||
| a30627a07d | |||
| 5f81bd92f4 | |||
| cc7ad71d41 | |||
| 8a1eba3725 | |||
| 58f11c2991 | |||
| 9efb3595c0 | |||
| fa48569dc1 | |||
| a6bda894b0 | |||
| 1acc604bd5 | |||
| 333026143b | |||
| cc602abc0d | |||
| a8741e982c | |||
| 559a35aeb3 | |||
| eff3f1c843 | |||
| c31adb5692 | |||
| a4a3ff1717 | |||
| d0bf3590d1 | |||
| e6570c008e | |||
| ddd9fd2a33 | |||
| 09b091af01 | |||
| 2567273843 | |||
| 4fdadef16b | |||
| 6b98c5a0ba | |||
| 73762d80c5 |
@@ -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()
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-2
@@ -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
|
||||||
|
|||||||
+15
-15
@@ -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
|
||||||
|
|||||||
+2
-1
@@ -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
|
||||||
|
|||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package ru.forbion.f7cloud.core.designsystem
|
||||||
|
|
||||||
|
/** Кнопка-действие уведомления (label/link/type/primary) для рендера в строке. */
|
||||||
|
data class F7NotificationActionUi(
|
||||||
|
val label: String,
|
||||||
|
val primary: Boolean,
|
||||||
|
)
|
||||||
+63
-2
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+60
-11
@@ -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
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
+77
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
@@ -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 |
@@ -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<DavCalendar></ID>
|
||||||
<ID>CyclomaticComplexMethod:DavClient.kt$DavClient$private fun parseMultiStatus(xml: String, folderUrl: String): List<DavEntry></ID>
|
<ID>CyclomaticComplexMethod:DavClient.kt$DavClient$private fun parseMultiStatus(xml: String, folderUrl: String): List<DavEntry></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<DavSharee></ID>
|
||||||
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun <T> parseCalendarData(xml: String, map: (String) -> T?): List<T></ID>
|
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun <T> parseCalendarData(xml: String, map: (String) -> T?): List<T></ID>
|
||||||
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseCalendarQueryResponses(xml: String, calendar: DavCalendar): List<DavEvent></ID>
|
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseCalendarQueryResponses(xml: String, calendar: DavCalendar): List<DavEvent></ID>
|
||||||
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseCalendarQueryResponsesForTasks(xml: String, calendar: DavCalendar): List<DavTask></ID>
|
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseCalendarQueryResponsesForTasks(xml: String, calendar: DavCalendar): List<DavTask></ID>
|
||||||
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseCalendars(xml: String, baseUrl: String): List<DavCalendar></ID>
|
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseCalendars(xml: String, baseUrl: String): List<DavCalendar></ID>
|
||||||
|
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseFreeBusyResponse(xml: String): Map<String, List<BusyPeriod>></ID>
|
||||||
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseTrashResponses(xml: String, trashUrl: String): List<DavTrashEvent></ID>
|
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseTrashResponses(xml: String, trashUrl: String): List<DavTrashEvent></ID>
|
||||||
<ID>NestedBlockDepth:CardDavClient.kt$CardDavClient$private fun parseAddressBooks(xml: String, baseUrl: String): List<Pair<String, String>></ID>
|
<ID>NestedBlockDepth:CardDavClient.kt$CardDavClient$private fun parseAddressBooks(xml: String, baseUrl: String): List<Pair<String, String>></ID>
|
||||||
<ID>NestedBlockDepth:CardDavClient.kt$CardDavClient$private fun parseContacts(xml: String, bookName: String): List<DavContact></ID>
|
<ID>NestedBlockDepth:CardDavClient.kt$CardDavClient$private fun parseContacts(xml: String, bookName: String, bookUrl: String): List<DavContact></ID>
|
||||||
<ID>NestedBlockDepth:DavClient.kt$DavClient$private fun parseMultiStatus(xml: String, folderUrl: String): List<DavEntry></ID>
|
<ID>NestedBlockDepth:DavClient.kt$DavClient$private fun parseMultiStatus(xml: String, folderUrl: String): List<DavEntry></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 <T> parseCalendarData(xml: String, map: (String) -> T?): List<T></ID>
|
<ID>UnusedPrivateMember:CalDavClient.kt$CalDavClient$private fun <T> parseCalendarData(xml: String, map: (String) -> T?): List<T></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 {
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
@@ -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 (в своём модуле, наибольший охват):** меню карточки (Назначить мне/Переместить-копировать/
|
||||||
|
|||||||
@@ -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б.
|
||||||
@@ -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` (тип `Д`).
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<ManuallySuppressedIssues/>
|
<ManuallySuppressedIssues/>
|
||||||
<CurrentIssues>
|
<CurrentIssues>
|
||||||
<ID>ComplexCondition:ContactsRepository.kt$ContactsRepository$!force && !needsPhotoBackfill && !needsDetailsBackfill && System.currentTimeMillis() - lastSync < SYNC_INTERVAL_MS</ID>
|
<ID>ComplexCondition:ContactsRepository.kt$ContactsRepository$!force && !needsPhotoBackfill && !needsDetailsBackfill && System.currentTimeMillis() - lastSync < SYNC_INTERVAL_MS</ID>
|
||||||
<ID>LongMethod:ContactDetailSheet.kt$@Composable private fun ContactDetailContent( contact: ContactItem, onDismiss: () -> Unit, )</ID>
|
<ID>LongMethod:ContactDetailSheet.kt$@Composable private fun ContactDetailContent( contact: ContactItem, onDismiss: () -> Unit, onEdit: () -> Unit, onDelete: () -> 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>
|
||||||
|
|||||||
+110
-12
@@ -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() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+83
@@ -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 {
|
||||||
|
|||||||
+128
-3
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
+69
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<DeckLabel>, stacks: List<DeckStack>, canEdit: Boolean, busy: Boolean, assignedToMe: Boolean, onDismiss: () -> Unit, onToggleDone: () -> Unit, onSave: (title: String, description: String, duedate: String?) -> Unit, onToggleLabel: (DeckLabel) -> Unit, onToggleAssignSelf: () -> Unit, onMove: (targetStackId: Int) -> Unit, onArchive: () -> Unit, onDelete: () -> Unit, )</ID>
|
||||||
|
<ID>LongMethod:DeckCardSheet.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun DeckCardSheet( card: DeckCard, boardLabels: List<DeckLabel>, stacks: List<DeckStack>, canEdit: Boolean, busy: Boolean, assignedToMe: Boolean, onDismiss: () -> Unit, onToggleDone: () -> Unit, onSave: (title: String, description: String, duedate: String?) -> Unit, onToggleLabel: (DeckLabel) -> Unit, onToggleAssignSelf: () -> Unit, onMove: (targetStackId: Int) -> Unit, onArchive: () -> Unit, onDelete: () -> 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user