Compare commits
43 Commits
e7d0a5a9ee
...
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 | |||
| e3725b7339 | |||
| fb120d5795 | |||
| 7be35490e4 | |||
| 492af6157e | |||
| 2819359b2b | |||
| b818032f68 | |||
| af25ee8e6d | |||
| 9d726f4549 | |||
| 01198a4d14 |
@@ -2,8 +2,11 @@
|
||||
|
||||
Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск`
|
||||
|
||||
- 2026-07-13 | (без бампа — версия/релиз за лидом) | **Контакты — типы TEL/EMAIL (P2, шаг 1).** `VCardEditor.parseTypedValues` (+публичный делегат `CardDavClient.parseTypedValues`) извлекает значения EMAIL/TEL с токеном TYPE (WORK/HOME/CELL), игнорируя технические INTERNET/VOICE/PREF; разбор из уже сохранённого `raw` vCard — **без новой Room-миграции**. `ContactItem.emailEntries`/`phoneEntries` (фолбэк на значения без типа). Карточка: подписи полей по типу («Рабочий»/«Личный»/«Мобильный»/«Домашний»/…, `ContactUi.emailTypeLabel`/`phoneTypeLabel`) вместо «Email 2»/«Телефон 2». Форма правки: типы сохраняются по значению (`emailTypeByValue`/`phoneTypeByValue`) — **исправлен P1-каветат** нормализации типов к INTERNET/CELL при правке. +4 юнит-теста (`VCardEditorTest`, всего 14): парсинг типов, игнор технических токенов, пустой тип, round-trip сохраняет типы. Гейты зелёные (`:app:compileReleaseKotlin`, `:feature:contacts:detekt`+compile). Осталось по P2: селектор типа в форме + группы CATEGORIES. | контракты: core `CardDavClient` +1 публичный метод (обратно совместимо); feature-слой изолирован | низкий: чтение из raw + подписи, миграций/схемы нет; на устройстве не гонялось (юниты + компил-гейт)
|
||||
- 2026-07-13 | (без бампа — версия/релиз за лидом) | **Контакты — правка/удаление в UI (P1, contacts.diff, шаг 2/2).** Room: `ContactEntity` +`href`/`etag`/`raw`, миграция `MIGRATION_4_5` (v4→5, аддитивные ALTER ADD COLUMN), `ContactsDao.deleteByUid`. `ContactsRepository`: маппинг href/etag/raw, `updateContact` (round-trip vCard через `VCardEditor` + PUT If-Match) и `deleteContact` (DELETE If-Match + вычистка Room). `ContactItem` +3 поля. `ContactsViewModel`: `openEditSheet`/`closeEditSheet`/`saveEdit`/`deleteContact` + состояние (savingEdit/editError/deletingContact). UI: в карточке кнопки «Править» (форма имя/организация/должность/email/телефон/адрес/сайт/ДР) и «Удалить» (с подтверждением) — только для синхронизированных (есть href/raw). Блок действий вынесен в `ContactDetailActions`. Гейт `:app:compileReleaseKotlin` зелёный; `:feature:contacts:detekt`+compile зелёные (baseline `ContactDetailContent`/`parseContacts` пере-привязан под сдвинутые сигнатуры). NB: P1-edit нормализует TYPE у EMAIL/TEL к INTERNET/CELL (сохранение/правка типов — P2). На устройстве/сервере не гонялось (компил-гейт + юниты ядра); live-проверка на `bmob-*` (mail/085) — следующим шагом. | контракты: core `ContactEntity`+3 поля (Room v4→5 миграция), `CardDavClient` уже расширен в bc1a497; feature-слой изолирован | средний: миграция Room (общий core:database) — проверить апгрейд БД на устройстве; правка/удаление на живом CardDAV не гонялись
|
||||
- 2026-07-13 | (без бампа — версия/релиз за лидом) | **Контакты — ядро правки/удаления (P1, contacts.diff, шаг 1/2).** `CardDavClient`: `updateContact` (PUT If-Match по ETag, round-trip неизвестных свойств vCard — PHOTO/UID/X-* сохраняются) и `deleteContact` (DELETE If-Match, 404=уже удалён, 412=конфликт ETag). `DavContact` +`href`/`etag`/`raw` (заполняются чтением книги; сигнатуры list/create НЕ менялись). `parseContacts` теперь ловит href/getetag на каждый `<response>`; общий `davContactFromVCard`. Чистый движок `VCardEditor.applyEdits` (замена управляемых FN/N/EMAIL/TEL/ORG/TITLE/ADR/URL/BDAY/NOTE, фолдинг 75, CRLF, экранирование) + модели `VCardFields`/`VCardTypedValue`. **10 юнит-тестов** (`VCardEditorTest`) зелёные: round-trip неизвестных полей, FN с запятыми, PHOTO base64, folded ADR, CRLF-фолдинг. Гейт `:app:compileReleaseKotlin` зелёный. Шаг 2/2 (Room href/etag/raw + миграция, форма правки в карточке, delete в UI) — следующим коммитом. | контракты: core `CardDavClient` +2 метода, `DavContact` +3 опц. поля (обратно совместимо, list/create не тронуты); одобрено лидом mail/072 | низкий: только добавление методов + поля с дефолтами, синк не задет; на устройстве/сервере ещё не гонялось (юниты + компил-гейт)
|
||||
- 2026-07-13 | (без бампа — версия/релиз за лидом) | **Задачи — умные коллекции в UI (P1, tasks.diff).** Умные коллекции (Важное/Сегодня/Неделя/Все/Текущие/Завершённые) выведены в `TasksListNavView` над реальными списками: агрегат задач всех списков (`loadAllTasks`), счётчики-бейджи, тап открывает отфильтрованный вид; создание в коллекции идёт в список по умолчанию. 3-state видимость (Скрыта/Видима/Авто) в шите настроек с персистом в SharedPreferences (`TasksPrefsStore`, VM через `Factory(context)`). Движок `TasksSmartLists` расширен (`SmartListMode`/`visibleKeys`/`counts`), 25 юнит-тестов зелёные. Ff_app 16.7→24.6%, Fd_app 41.5→52.7% (`tasks.diff.md`). | контракты не менялись | низкий: UI изолирован в модуле, на устройстве не гонялось
|
||||
- 2026-07-13 | (без бампа — версия/релиз за лидом) | **Конференции — меню сообщения: «Переслать…» в любую комнату (P1).** Пункт «Переслать…» открывает лист выбора комнаты (`ForwardTargetSheet` — список бесед кроме текущей) → `TalkViewModel.forwardToRoom` шлёт текст в выбранную (OCS `/chat` v1). Диалог удаления вынесен в `ConfirmDeleteMessageDialog` (держит новый `MessageActionOverlays` <120 строк). Завершает историю пересылки (в «Личные заметки» + в произвольную комнату). Baseline пере-привязан (сигнатура ChatContent от нового параметра). | контракты не менялись (OCS /chat v1) | низкий: UI-действие в модуле, на устройстве не гонялось
|
||||
- 2026-07-13 | (без бампа — версия/релиз за лидом) | **Конференции — меню сообщения: «Ответить лично» + рефактор (P1).** Пункт «Ответить лично» в меню сообщения (для чужих сообщений): `TalkViewModel.replyPrivately` открывает/создаёт 1:1 с автором через существующий `createOneToOneRoom` (OCS spreed v4, сервер идемпотентен) и `openRoom`. Заодно рефактор: блок оверлеев сообщения (лист действий + диалоги правки/удаления) вынесен из `ChatContent` в отдельный `MessageActionOverlays` — `ChatContent` LongMethod 267→174, cyclomatic 29→<25 (перестал плодить baseline-churn на каждый пункт меню). Baseline пере-привязан (сигнатура ChatContent сдвинулась от нового параметра); новый код чист (компиляция+detekt зелёные). | контракты не менялись (OCS spreed v4/v1) | низкий: изолированное UI-действие + чистый рефактор, на устройстве не гонялось
|
||||
- 2026-07-13 | (без бампа — версия/релиз за лидом) | **Конференции — меню сообщения: напоминание (P1).** Пункт «Напоминание» в `TalkMessageActionSheet` с пресетами «Сегодня» (18:00) / «Завтра» (08:00) / «Выходные» (сб 08:00) / «Неделя» (пн 08:00): `TalkRepository.setReminder` (OCS `POST /chat/{token}/{messageId}/reminder`, timestamp сек) + `TalkViewModel.setMessageReminder`/`reminderTimestamp` + enum `ReminderPreset`. Закрывает пункт «Напоминание» дыр меню сообщения (talk.diff.md). Baseline `feature/talk` пере-привязан (сигнатура ChatContent + импорт сдвинули существующие записи; новый код чист, компиляция+detekt зелёные). | контракты не менялись (OCS spreed v1) | низкий: изолированное UI-действие, на устройстве не гонялось
|
||||
- 2026-07-13 | (без бампа — версия/релиз за лидом) | **Конференции — меню сообщения, P1 итерации 1:1 (talk.diff.md).** В `TalkMessageActionSheet` добавлены пункты «Копировать» (текст → буфер), «Копировать ссылку» (`TalkUrls.messageLink` = `/call/<token>#message_<id>` → буфер) и «Переслать в «Личные заметки»» (`TalkViewModel.forwardToNoteToSelf` — шлёт текст в комнату note-to-self, spreed type 6, через существующий OCS `/chat` v1). Закрывает часть дыр меню сообщения из `docs/app-map/talk.diff.md` (Копировать/Копировать ссылку/частично Переслать). Обновлён `feature/talk/detekt-baseline.xml` — модуль был detekt-красным ДО моей правки (устаревший baseline от чужих коммитов v0.5.133+); мои новые строки чисты (компиляция+detekt зелёные), рефреш лишь пере-привязал существующие записи к сдвинутым сигнатурам. | контракты не менялись (та же OCS /chat v1) | низкий: изолированные UI-действия в своём модуле, на устройстве не гонялось
|
||||
|
||||
@@ -18,6 +18,7 @@ enum class AppTab(
|
||||
Talk("Конференции", "spreed-header-icon.svg", "spreed-glass.svg"),
|
||||
Deck("Карточки", "deck-header-icon.svg", "deck-glass.svg"),
|
||||
Tasks("Задачи", "task-header-icon.svg", "task-glass.svg"),
|
||||
Notes("Заметки", "notes-header-icon.svg", "notes-glass.svg"),
|
||||
Support("Поддержка", "icon-header-f7support.svg", "icon-header-f7support.svg"),
|
||||
;
|
||||
|
||||
@@ -43,7 +44,7 @@ private val coreAppMenuEntries = listOf(
|
||||
AppMenuEntry(AppTab.Calendar, "Календарь", "calendar"),
|
||||
AppMenuEntry(AppTab.Contacts, "Контакты", "contacts"),
|
||||
AppMenuEntry(AppTab.Deck, "Карточки", "cards"),
|
||||
AppMenuEntry(null, "Заметки", "notes", webPath = "/apps/notes/"),
|
||||
AppMenuEntry(AppTab.Notes, "Заметки", "notes"),
|
||||
AppMenuEntry(AppTab.Support, "Поддержка", "support"),
|
||||
)
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ import ru.forbion.f7cloud.feature.deck.DeckScreen
|
||||
import ru.forbion.f7cloud.feature.files.FilesScreen
|
||||
import ru.forbion.f7cloud.feature.f7support.SupportScreen
|
||||
import ru.forbion.f7cloud.feature.mail.MailScreen
|
||||
import ru.forbion.f7cloud.feature.notes.NotesScreen
|
||||
import ru.forbion.f7cloud.feature.tasks.TasksScreen
|
||||
import ru.forbion.f7cloud.feature.talk.TalkScreen
|
||||
import ru.forbion.f7cloud.mobile.navigation.AppLinkResolver
|
||||
@@ -574,6 +575,11 @@ fun AppScaffold(
|
||||
onOpenListConsumed = { pendingTasksListSlug = null },
|
||||
onUnauthorized = forceLogout,
|
||||
)
|
||||
AppTab.Notes -> NotesScreen(
|
||||
session = currentSession,
|
||||
modifier = contentModifier,
|
||||
onUnauthorized = forceLogout,
|
||||
)
|
||||
AppTab.Support -> SupportScreen(
|
||||
session = currentSession,
|
||||
modifier = contentModifier,
|
||||
|
||||
@@ -59,9 +59,11 @@ import org.json.JSONObject
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||
import ru.forbion.f7cloud.core.designsystem.F7FloatingPanel
|
||||
import ru.forbion.f7cloud.core.designsystem.F7NotificationActionUi
|
||||
import ru.forbion.f7cloud.core.designsystem.F7NotificationRow
|
||||
import ru.forbion.f7cloud.core.designsystem.formatNotificationRelativeTime
|
||||
import ru.forbion.f7cloud.core.network.F7Notification
|
||||
import ru.forbion.f7cloud.core.network.F7NotificationAction
|
||||
import ru.forbion.f7cloud.core.network.NetworkFactory
|
||||
import ru.forbion.f7cloud.core.network.NotificationsRepository
|
||||
import ru.forbion.f7cloud.core.network.UnauthorizedException
|
||||
@@ -337,6 +339,53 @@ fun NotificationsSheet(
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissAllNotifications() {
|
||||
items = emptyList()
|
||||
scope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
repository.dismissAll(
|
||||
serverUrl = session.serverUrl,
|
||||
username = session.username,
|
||||
appPassword = session.appPassword,
|
||||
trustAllCerts = session.trustAllCerts,
|
||||
)
|
||||
}
|
||||
}.onFailure { t ->
|
||||
if (t is UnauthorizedException) {
|
||||
onDismiss()
|
||||
onUnauthorized()
|
||||
} else {
|
||||
loadNotifications()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun runAction(notification: F7Notification, action: F7NotificationAction) {
|
||||
items = items.filter { it.id != notification.id }
|
||||
scope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
repository.executeAction(
|
||||
serverUrl = session.serverUrl,
|
||||
username = session.username,
|
||||
appPassword = session.appPassword,
|
||||
action = action,
|
||||
trustAllCerts = session.trustAllCerts,
|
||||
)
|
||||
}
|
||||
}.onFailure { t ->
|
||||
if (t is UnauthorizedException) {
|
||||
onDismiss()
|
||||
onUnauthorized()
|
||||
} else {
|
||||
loadNotifications()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
F7FloatingPanel(
|
||||
visible = visible,
|
||||
onDismiss = onDismiss,
|
||||
@@ -395,24 +444,65 @@ fun NotificationsSheet(
|
||||
else -> Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
.fillMaxHeight(),
|
||||
) {
|
||||
items.forEachIndexed { index, item ->
|
||||
F7NotificationRow(
|
||||
subject = item.subject,
|
||||
message = item.message,
|
||||
relativeTime = formatNotificationRelativeTime(item.datetime),
|
||||
iconUrl = resolveNotificationIcon(session.serverUrl, item.icon),
|
||||
closeIconUrl = closeIconUrl,
|
||||
authHeader = authHeader,
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onNotificationClick(item)
|
||||
},
|
||||
onDismiss = { dismissNotification(item) },
|
||||
showDivider = index < items.lastIndex,
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"Уведомления",
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
fontSize = MaterialTheme.typography.titleMedium.fontSize * 2,
|
||||
lineHeight = MaterialTheme.typography.titleMedium.lineHeight * 2,
|
||||
),
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
|
||||
color = F7Colors.TextPrimary,
|
||||
)
|
||||
TextButton(onClick = { dismissAllNotifications() }) {
|
||||
Text(
|
||||
"Скрыть все",
|
||||
style = MaterialTheme.typography.labelLarge.copy(
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize * 2,
|
||||
lineHeight = MaterialTheme.typography.labelLarge.lineHeight * 2,
|
||||
),
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
|
||||
color = F7Colors.Primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = F7Colors.Border, thickness = 1.dp)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
items.forEachIndexed { index, item ->
|
||||
F7NotificationRow(
|
||||
subject = item.subject,
|
||||
message = item.message,
|
||||
relativeTime = formatNotificationRelativeTime(item.datetime),
|
||||
iconUrl = resolveNotificationIcon(session.serverUrl, item.icon),
|
||||
closeIconUrl = closeIconUrl,
|
||||
authHeader = authHeader,
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onNotificationClick(item)
|
||||
},
|
||||
onDismiss = { dismissNotification(item) },
|
||||
showDivider = index < items.lastIndex,
|
||||
actions = item.actions.map {
|
||||
F7NotificationActionUi(label = it.label, primary = it.primary)
|
||||
},
|
||||
onActionClick = { actionIndex ->
|
||||
item.actions.getOrNull(actionIndex)?.let { runAction(item, it) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,14 @@ import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.forbion.f7cloud.core.auth.AppPasswordRevoker
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
import ru.forbion.f7cloud.core.auth.AuthStore
|
||||
import ru.forbion.f7cloud.core.push.F7PushRegistrar
|
||||
import ru.forbion.f7cloud.feature.files.OfficeWarmup
|
||||
import ru.forbion.f7cloud.mobile.OfficeWebViewPool
|
||||
|
||||
@@ -29,9 +32,16 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
// Best-effort ревокация app password на сервере ДО очистки локальной сессии.
|
||||
// Best-effort ревокация app password + снятие устройства с push ДО очистки локальной сессии.
|
||||
_session.value?.let { current ->
|
||||
viewModelScope.launch { runCatching { AppPasswordRevoker.revoke(current) } }
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
F7PushRegistrar.unregisterBlocking(getApplication(), current)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
OfficeWarmup.clear()
|
||||
OfficeWebViewPool.dispose()
|
||||
|
||||
@@ -7,6 +7,7 @@ plugins {
|
||||
id 'org.jetbrains.kotlin.plugin.serialization' version '2.3.0' apply false
|
||||
id 'org.jetbrains.kotlin.plugin.parcelize' version '2.3.0' apply false
|
||||
id 'com.google.devtools.ksp' version '2.3.4' apply false
|
||||
id 'io.github.takahirom.roborazzi' version '1.42.0' apply false
|
||||
id 'com.google.gms.google-services' version '4.4.2' apply false
|
||||
id 'io.gitlab.arturbosch.detekt' version '1.23.8' apply false
|
||||
}
|
||||
|
||||
@@ -24,4 +24,8 @@ data class ContactEntity(
|
||||
val birthday: String = "",
|
||||
val emails: String = "",
|
||||
val phones: String = "",
|
||||
// Для безопасной правки/удаления (If-Match) и round-trip неизвестных полей vCard (P1).
|
||||
val href: String = "",
|
||||
val etag: String = "",
|
||||
val raw: String = "",
|
||||
)
|
||||
|
||||
@@ -36,6 +36,9 @@ interface ContactsDao {
|
||||
@Query("DELETE FROM contacts WHERE accountKey = :accountKey")
|
||||
suspend fun deleteAll(accountKey: String)
|
||||
|
||||
@Query("DELETE FROM contacts WHERE accountKey = :accountKey AND uid = :uid")
|
||||
suspend fun deleteByUid(accountKey: String, uid: String)
|
||||
|
||||
@Transaction
|
||||
suspend fun replaceAll(accountKey: String, contacts: List<ContactEntity>) {
|
||||
deleteAll(accountKey)
|
||||
|
||||
@@ -9,7 +9,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
@Database(
|
||||
entities = [FileEntity::class, ContactEntity::class],
|
||||
version = 4,
|
||||
version = 5,
|
||||
exportSchema = false,
|
||||
)
|
||||
abstract class F7Database : RoomDatabase() {
|
||||
@@ -64,6 +64,15 @@ abstract class F7Database : RoomDatabase() {
|
||||
}
|
||||
}
|
||||
|
||||
// P1 Контакты (b-mob-contacts): href/etag/raw для правки/удаления (If-Match) и round-trip vCard.
|
||||
private val MIGRATION_4_5 = object : Migration(4, 5) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE contacts ADD COLUMN href TEXT NOT NULL DEFAULT ''")
|
||||
db.execSQL("ALTER TABLE contacts ADD COLUMN etag TEXT NOT NULL DEFAULT ''")
|
||||
db.execSQL("ALTER TABLE contacts ADD COLUMN raw TEXT NOT NULL DEFAULT ''")
|
||||
}
|
||||
}
|
||||
|
||||
fun get(context: Context): F7Database {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: Room.databaseBuilder(
|
||||
@@ -71,7 +80,7 @@ abstract class F7Database : RoomDatabase() {
|
||||
F7Database::class.java,
|
||||
"f7cloud-mobile.db",
|
||||
)
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)
|
||||
.build()
|
||||
.also { INSTANCE = it }
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ plugins {
|
||||
id 'com.android.library'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
id 'org.jetbrains.kotlin.plugin.compose'
|
||||
id 'io.github.takahirom.roborazzi'
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -22,6 +23,12 @@ android {
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
// Roborazzi/Robolectric: рендер Compose в PNG на JVM (Fd-сверка без эмулятора).
|
||||
testOptions {
|
||||
unitTests {
|
||||
includeAndroidResources = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -32,4 +39,14 @@ dependencies {
|
||||
implementation libs.compose.material3
|
||||
implementation libs.coil.compose
|
||||
implementation libs.coil.svg
|
||||
|
||||
// Screenshot-тесты (JVM)
|
||||
testImplementation composeBom
|
||||
testImplementation libs.junit
|
||||
testImplementation libs.robolectric
|
||||
testImplementation libs.roborazzi
|
||||
testImplementation libs.roborazzi.compose
|
||||
testImplementation libs.roborazzi.junit.rule
|
||||
testImplementation libs.compose.ui.test.junit4
|
||||
debugImplementation libs.compose.ui.test.manifest
|
||||
}
|
||||
|
||||
+1
-2
@@ -13,15 +13,14 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
|
||||
@@ -8,18 +8,18 @@ import androidx.compose.ui.graphics.Color
|
||||
*/
|
||||
object F7Colors {
|
||||
// --- Base palette (Figma «Брендбук») — точные значения ---
|
||||
val Black = Color(0xFF151515) // Base/Black — основной текст
|
||||
val Black2 = Color(0xFF1F1F1F) // Base/Black 2
|
||||
val Grey1 = Color(0xFF808080) // Base/Grey 1 — вторичный текст
|
||||
val Grey2 = Color(0xFFF5F5F5) // Base/Grey 2 — приглушённый фон
|
||||
val Grey3 = Color(0xFFE6E6E6) // Base/Grey 3 — границы
|
||||
val Green = Color(0xFF70B62B) // Base/Green — primary
|
||||
val Green30 = Color(0x4D70B62B) // Base/Green 30%
|
||||
val Green10 = Color(0x1A70B62B) // Base/Green 10%
|
||||
val Yellow = Color(0xFFF6E120) // Base/Yellow (тег «Позже» и т.п.)
|
||||
val Purple = Color(0xFF9747FF) // Base/Purple (тег «Личное» и т.п.)
|
||||
val Red = Color(0xFFFF7A66) // Base/Red — ошибки/удаление (дизайн!)
|
||||
val Red10 = Color(0x1AFF7A66) // Base/Red 10% — фон ошибки
|
||||
val Black = Color(0xFF151515) // Base/Black — основной текст
|
||||
val Black2 = Color(0xFF1F1F1F) // Base/Black 2
|
||||
val Grey1 = Color(0xFF808080) // Base/Grey 1 — вторичный текст
|
||||
val Grey2 = Color(0xFFF5F5F5) // Base/Grey 2 — приглушённый фон
|
||||
val Grey3 = Color(0xFFE6E6E6) // Base/Grey 3 — границы
|
||||
val Green = Color(0xFF70B62B) // Base/Green — primary
|
||||
val Green30 = Color(0x4D70B62B) // Base/Green 30%
|
||||
val Green10 = Color(0x1A70B62B) // Base/Green 10%
|
||||
val Yellow = Color(0xFFF6E120) // Base/Yellow (тег «Позже» и т.п.)
|
||||
val Purple = Color(0xFF9747FF) // Base/Purple (тег «Личное» и т.п.)
|
||||
val Red = Color(0xFFFF7A66) // Base/Red — ошибки/удаление (дизайн!)
|
||||
val Red10 = Color(0x1AFF7A66) // Base/Red 10% — фон ошибки
|
||||
|
||||
val Primary = Green
|
||||
val PrimaryHover = Color(0xFF6FAF2E)
|
||||
|
||||
+15
-15
@@ -5,52 +5,52 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
+2
-1
@@ -7,9 +7,9 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -44,6 +44,7 @@ private fun barIcon(name: String): String = "file:///android_asset/menu/$name"
|
||||
private val BottomBarWidth = 328.dp
|
||||
private val BottomBarHeight = 61.dp
|
||||
private val BottomBarButtonSize = 55.dp
|
||||
|
||||
// Живой сайт: app-link/бургер img = 25px; стрелка «назад» мельче.
|
||||
private val BottomBarIconSize = 25.dp
|
||||
private val BottomBarSectionIconSize = 25.dp
|
||||
|
||||
+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
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -25,13 +30,13 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import okhttp3.Credentials
|
||||
|
||||
private fun TextStyle.doubled(): TextStyle = copy(
|
||||
fontSize = fontSize * 2,
|
||||
lineHeight = lineHeight * 2,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun F7NotificationRow(
|
||||
subject: String,
|
||||
@@ -44,6 +49,8 @@ fun F7NotificationRow(
|
||||
onDismiss: () -> Unit,
|
||||
showDivider: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
actions: List<F7NotificationActionUi> = emptyList(),
|
||||
onActionClick: (Int) -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
Column(
|
||||
@@ -131,11 +138,65 @@ fun F7NotificationRow(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 78.dp, top = 6.dp),
|
||||
.padding(start = 78.dp, top = 10.dp),
|
||||
)
|
||||
}
|
||||
if (actions.isNotEmpty()) {
|
||||
FlowRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 78.dp, top = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
actions.forEachIndexed { index, action ->
|
||||
NotificationActionButton(
|
||||
label = action.label,
|
||||
primary = action.primary,
|
||||
onClick = { onActionClick(index) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showDivider) {
|
||||
HorizontalDivider(color = F7Colors.Border, thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotificationActionButton(
|
||||
label: String,
|
||||
primary: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val shape = RoundedCornerShape(22.dp)
|
||||
val base = Modifier
|
||||
.clip(shape)
|
||||
.clickable(onClick = onClick)
|
||||
val boxModifier = if (primary) {
|
||||
base.background(F7Colors.Primary, shape)
|
||||
} else {
|
||||
base
|
||||
.background(F7Colors.Surface, shape)
|
||||
.border(BorderStroke(1.dp, F7Colors.Border), shape)
|
||||
}
|
||||
val labelStyle = MaterialTheme.typography.labelLarge.let { s ->
|
||||
// Подпись действия — 1.5× (не 2×): на w360dp удвоенный кегль рвал длинные слова
|
||||
// («Присоединитьс/я»); на вебе forbion кнопки не переносятся посреди слова.
|
||||
s.copy(fontSize = s.fontSize * 1.5f, lineHeight = s.lineHeight * 1.5f)
|
||||
}
|
||||
Box(
|
||||
modifier = boxModifier.padding(horizontal = 16.dp, vertical = 9.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = labelStyle,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = if (primary) F7Colors.TextOnPrimary else F7Colors.TextPrimary,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+11
-2
@@ -1,6 +1,7 @@
|
||||
package ru.forbion.f7cloud.core.designsystem
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
@@ -10,15 +11,23 @@ import androidx.compose.ui.platform.LocalContext
|
||||
* Пока этот экран на виду — окно помечено FLAG_SECURE: содержимое не попадает
|
||||
* в скриншоты, превью в «недавних» и запись экрана. Вешать на чувствительные
|
||||
* экраны (ввод пароля/PIN). Флаг снимается при уходе с экрана.
|
||||
*
|
||||
* В debug-сборке (FLAG_DEBUGGABLE) флаг НЕ ставится — иначе невозможен автономный
|
||||
* визуал-тест на эмуляторе (screencap возвращает пустой кадр). Релиз — всегда защищён.
|
||||
*/
|
||||
@Composable
|
||||
fun F7SecureScreen() {
|
||||
val context = LocalContext.current
|
||||
DisposableEffect(Unit) {
|
||||
val debuggable = (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
|
||||
val window = (context as? Activity)?.window
|
||||
window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
if (!debuggable) {
|
||||
window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
onDispose {
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
if (!debuggable) {
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import ru.forbion.f7cloud.core.designsystem.R
|
||||
|
||||
val RalewayFamily = FontFamily(
|
||||
Font(R.font.raleway_medium, FontWeight.Medium),
|
||||
@@ -14,14 +13,64 @@ val RalewayFamily = FontFamily(
|
||||
)
|
||||
|
||||
val F7Typography = Typography(
|
||||
displayLarge = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.SemiBold, fontSize = 24.sp, lineHeight = 28.sp),
|
||||
titleLarge = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.SemiBold, fontSize = 24.sp, lineHeight = 28.sp),
|
||||
titleMedium = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 20.sp),
|
||||
titleSmall = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp),
|
||||
bodyLarge = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 20.sp),
|
||||
bodyMedium = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp),
|
||||
bodySmall = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp),
|
||||
labelLarge = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp),
|
||||
labelMedium = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp),
|
||||
labelSmall = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 14.sp),
|
||||
displayLarge = TextStyle(
|
||||
fontFamily = RalewayFamily,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 24.sp,
|
||||
lineHeight = 28.sp
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = RalewayFamily,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 24.sp,
|
||||
lineHeight = 28.sp
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontFamily = RalewayFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 20.sp
|
||||
),
|
||||
titleSmall = TextStyle(
|
||||
fontFamily = RalewayFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = RalewayFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 20.sp
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontFamily = RalewayFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp
|
||||
),
|
||||
bodySmall = TextStyle(
|
||||
fontFamily = RalewayFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp
|
||||
),
|
||||
labelLarge = TextStyle(
|
||||
fontFamily = RalewayFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp
|
||||
),
|
||||
labelMedium = TextStyle(
|
||||
fontFamily = RalewayFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = RalewayFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 14.sp
|
||||
),
|
||||
)
|
||||
|
||||
+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:LoginFlowClient.kt$LoginFlowClient$("""name=["']requesttoken["']\s+value=["']([^"']+)["']""", RegexOption.IGNORE_CASE)</ID>
|
||||
<ID>ArgumentListWrapping:LoginFlowClient.kt$LoginFlowClient$("""name=["']stateToken["']\s+value=["']([^"']+)["']""", RegexOption.IGNORE_CASE)</ID>
|
||||
<ID>CyclomaticComplexMethod:CalDavClient.kt$CalDavClient$private fun parseCalendars(xml: String, baseUrl: String): List<DavCalendar></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>LargeClass:CalDavClient.kt$CalDavClient</ID>
|
||||
<ID>LoopWithTooManyJumpStatements:NotificationsRepository.kt$NotificationsRepository$for</ID>
|
||||
<ID>MaximumLineLength:CalDavClient.kt$CalDavClient$ </ID>
|
||||
<ID>MaximumLineLength:CalendarIcs.kt$CalendarIcs$ </ID>
|
||||
<ID>MaximumLineLength:LoginFlowClient.kt$LoginFlowClient$ </ID>
|
||||
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$fun listCalendarShares(client: OkHttpClient, calendarHref: String): List<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 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 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: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>NoMultipleSpaces:CalendarIcs.kt$CalendarIcs$ </ID>
|
||||
<ID>NoUnusedImports:NextcloudWebSession.kt$ru.forbion.f7cloud.core.network.NextcloudWebSession.kt</ID>
|
||||
<ID>PropertyWrapping:LoginFlowClient.kt$LoginFlowClient$private val REQUEST_TOKEN_REGEX = Regex("""name=["']requesttoken["']\s+value=["']([^"']+)["']""", RegexOption.IGNORE_CASE)</ID>
|
||||
<ID>PropertyWrapping:LoginFlowClient.kt$LoginFlowClient$private val STATE_TOKEN_REGEX = Regex("""name=["']stateToken["']\s+value=["']([^"']+)["']""", RegexOption.IGNORE_CASE)</ID>
|
||||
<ID>SpacingBetweenDeclarationsWithAnnotations:UnauthorizedInterceptorTest.kt$UnauthorizedInterceptorTest$@After fun tearDown()</ID>
|
||||
<ID>SpacingBetweenDeclarationsWithComments:CalDavClient.kt$CalDavClient$// RELATED-TO с необязательным RELTYPE=PARENT (или без параметров) → UID родителя.</ID>
|
||||
<ID>UnusedParameter:CalDavClient.kt$CalDavClient$calendar: DavCalendar</ID>
|
||||
<ID>UnusedPrivateMember:CalDavClient.kt$CalDavClient$private fun <T> parseCalendarData(xml: String, map: (String) -> T?): List<T></ID>
|
||||
<ID>UnusedPrivateMember:CalDavClient.kt$CalDavClient$private fun formatIcsLocal(zoned: ZonedDateTime): String</ID>
|
||||
@@ -31,6 +37,7 @@
|
||||
<ID>UnusedPrivateProperty:CalDavClient.kt$CalDavClient$private val icsDtEnd = Pattern.compile("DTEND[^:]*:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)</ID>
|
||||
<ID>UnusedPrivateProperty:CalDavClient.kt$CalDavClient$private val icsLocation = Pattern.compile("LOCATION:([^\\r\\n]+)", Pattern.CASE_INSENSITIVE)</ID>
|
||||
<ID>UnusedPrivateProperty:LoginFlowClient.kt$LoginFlowClient$val payload = extractPayload(qrData)</ID>
|
||||
<ID>Wrapping:CalDavClient.kt$CalDavClient$;</ID>
|
||||
<ID>Wrapping:CalendarIcsTest.kt$CalendarIcsTest$"""</ID>
|
||||
<ID>Wrapping:LoginFlowClient.kt$LoginFlowClient$(</ID>
|
||||
</CurrentIssues>
|
||||
|
||||
@@ -212,6 +212,10 @@ object CardDavClient {
|
||||
?: error("Не удалось разобрать сохранённый контакт")
|
||||
}
|
||||
|
||||
/** Типизированные значения EMAIL/TEL из сырого vCard (значение + токен TYPE). P2. */
|
||||
fun parseTypedValues(rawVCard: String, property: String): List<VCardTypedValue> =
|
||||
VCardEditor.parseTypedValues(rawVCard, property)
|
||||
|
||||
/** Удаление контакта с If-Match по ETag. 404 трактуем как «уже удалён» — не ошибка. */
|
||||
fun deleteContact(
|
||||
client: OkHttpClient,
|
||||
@@ -700,6 +704,47 @@ internal object VCardEditor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает типизированные значения свойства (EMAIL/TEL) из сырого vCard: значение + сырой
|
||||
* токен TYPE (WORK/HOME/CELL/…). Порядок — как в vCard, пустые пропускаем. Для карточки и
|
||||
* префилла формы правки (P2 типы). Нормализацию метки в подпись делает UI-слой.
|
||||
*/
|
||||
fun parseTypedValues(rawVCard: String, property: String): List<VCardTypedValue> {
|
||||
val prop = property.uppercase()
|
||||
return groupLogicalLines(rawVCard)
|
||||
.filter { propertyName(it).uppercase() == prop }
|
||||
.mapNotNull { line ->
|
||||
val colon = line.indexOf(':')
|
||||
if (colon < 0) return@mapNotNull null
|
||||
val value = unescapeText(line.substring(colon + 1).trim())
|
||||
if (value.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
VCardTypedValue(value = value, type = extractType(line.substring(0, colon)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val typeParamPattern = Regex("(?i)TYPE=\"?([A-Za-z0-9,\\- ]+)\"?")
|
||||
|
||||
private fun extractType(header: String): String {
|
||||
// vCard 3.0: TYPE=WORK / TYPE="WORK,VOICE"; vCard 2.1: ...;WORK;VOICE
|
||||
val fromParam = typeParamPattern.find(header)?.groupValues?.get(1)
|
||||
val raw = fromParam
|
||||
?: header.substringAfter(';', "").split(';').firstOrNull {
|
||||
it.isNotBlank() && !it.contains('=')
|
||||
}
|
||||
?: ""
|
||||
// берём первый содержательный токен, игнорируя технические (INTERNET/VOICE/PREF)
|
||||
val ignored = setOf("INTERNET", "VOICE", "PREF")
|
||||
return raw.split(',').map { it.trim().uppercase() }
|
||||
.firstOrNull { it.isNotBlank() && it !in ignored }
|
||||
?: raw.split(',').firstOrNull()?.trim()?.uppercase().orEmpty()
|
||||
}
|
||||
|
||||
private fun unescapeText(v: String): String =
|
||||
v.replace("\\n", "\n").replace("\\,", ",").replace("\\;", ";").replace("\\\\", "\\")
|
||||
|
||||
// TEXT-значение (FN/TITLE/URL/NOTE/EMAIL/TEL): по совместимости с существующим buildVCard
|
||||
// экранируем только backslash и перевод строки (запятые в имени оставляем литералом).
|
||||
private fun escapeText(v: String): String =
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
package ru.forbion.f7cloud.core.network
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/** Кнопка-действие уведомления (NC notifications: actions[]={label,link,type,primary}). */
|
||||
data class F7NotificationAction(
|
||||
val label: String,
|
||||
val link: String,
|
||||
val type: String,
|
||||
val primary: Boolean,
|
||||
)
|
||||
|
||||
data class F7Notification(
|
||||
val id: Long,
|
||||
val subject: String,
|
||||
@@ -11,6 +22,7 @@ data class F7Notification(
|
||||
val link: String,
|
||||
val app: String,
|
||||
val icon: String = "",
|
||||
val actions: List<F7NotificationAction> = emptyList(),
|
||||
)
|
||||
|
||||
class NotificationsRepository {
|
||||
@@ -64,6 +76,25 @@ class NotificationsRepository {
|
||||
link = obj.optString("link"),
|
||||
app = obj.optString("app"),
|
||||
icon = obj.optString("icon"),
|
||||
actions = parseActions(obj.optJSONArray("actions")),
|
||||
)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun parseActions(array: JSONArray?): List<F7NotificationAction> {
|
||||
if (array == null) return emptyList()
|
||||
val out = mutableListOf<F7NotificationAction>()
|
||||
for (i in 0 until array.length()) {
|
||||
val obj = array.optJSONObject(i) ?: continue
|
||||
val link = obj.optString("link")
|
||||
val label = obj.optString("label")
|
||||
if (link.isBlank() || label.isBlank()) continue
|
||||
out += F7NotificationAction(
|
||||
label = label,
|
||||
link = link,
|
||||
type = obj.optString("type").ifBlank { "GET" }.uppercase(),
|
||||
primary = obj.optBoolean("primary", false),
|
||||
)
|
||||
}
|
||||
return out
|
||||
@@ -90,4 +121,56 @@ class NotificationsRepository {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** «Скрыть все» — NC deleteAllNotifications (DELETE .../api/v2/notifications). */
|
||||
fun dismissAll(
|
||||
serverUrl: String,
|
||||
username: String,
|
||||
appPassword: String,
|
||||
trustAllCerts: Boolean = false,
|
||||
) {
|
||||
val client = NetworkFactory.newAuthedClient(username, appPassword, trustAllCerts)
|
||||
val url = "${serverUrl.trimEnd('/')}/ocs/v2.php/apps/notifications/api/v2/notifications"
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.delete()
|
||||
.header("OCS-APIRequest", "true")
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
error("Уведомления HTTP ${response.code}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполнить кнопку-действие уведомления. link — абсолютный URL от сервера,
|
||||
* type — GET/POST/PUT/DELETE. После действия сервер сам гасит уведомление.
|
||||
*/
|
||||
fun executeAction(
|
||||
serverUrl: String,
|
||||
username: String,
|
||||
appPassword: String,
|
||||
action: F7NotificationAction,
|
||||
trustAllCerts: Boolean = false,
|
||||
) {
|
||||
val client = NetworkFactory.newAuthedClient(username, appPassword, trustAllCerts)
|
||||
val empty = "".toRequestBody("application/json".toMediaType())
|
||||
val builder = Request.Builder()
|
||||
.url(action.link)
|
||||
.header("OCS-APIRequest", "true")
|
||||
.header("Accept", "application/json")
|
||||
when (action.type.uppercase()) {
|
||||
"POST" -> builder.post(empty)
|
||||
"PUT" -> builder.put(empty)
|
||||
"DELETE" -> builder.delete()
|
||||
else -> builder.get()
|
||||
}
|
||||
client.newCall(builder.build()).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
error("Уведомления HTTP ${response.code}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,13 @@ class VCardEditorTest {
|
||||
append("TEL;TYPE=CELL:+70000000000\r\n")
|
||||
append("END:VCARD\r\n")
|
||||
}
|
||||
val out = edit(raw, VCardFields(displayName = "Иван Петров", phones = listOf(VCardTypedValue("+79990001122", "WORK"))))
|
||||
val out = edit(
|
||||
raw,
|
||||
VCardFields(
|
||||
displayName = "Иван Петров",
|
||||
phones = listOf(VCardTypedValue("+79990001122", "WORK")),
|
||||
),
|
||||
)
|
||||
// UID и неизвестные свойства сохранены
|
||||
assertTrue("UID сохранён", out.contains("UID:abc-123"))
|
||||
assertTrue("PHOTO сохранён", out.contains("/9j/4AAQSkZJRgABAQAAAQABAAD"))
|
||||
@@ -129,4 +135,50 @@ class VCardEditorTest {
|
||||
assertFalse("нет пустого ADR", out.contains("ADR"))
|
||||
assertFalse("нет пустого ORG", out.contains("ORG"))
|
||||
}
|
||||
|
||||
// --- P2: разбор типов TEL/EMAIL ---
|
||||
|
||||
@Test fun parses_email_and_phone_types() {
|
||||
val raw = buildString {
|
||||
append("BEGIN:VCARD\r\nVERSION:3.0\r\nUID:t\r\nFN:X\r\n")
|
||||
append("EMAIL;TYPE=WORK:work@x.ru\r\n")
|
||||
append("EMAIL;TYPE=HOME:home@x.ru\r\n")
|
||||
append("TEL;TYPE=CELL:+7900\r\n")
|
||||
append("TEL;TYPE=WORK:+7495\r\n")
|
||||
append("END:VCARD\r\n")
|
||||
}
|
||||
val emails = VCardEditor.parseTypedValues(raw, "EMAIL")
|
||||
assertEquals(listOf("work@x.ru", "home@x.ru"), emails.map { it.value })
|
||||
assertEquals(listOf("WORK", "HOME"), emails.map { it.type })
|
||||
val phones = VCardEditor.parseTypedValues(raw, "TEL")
|
||||
assertEquals(listOf("CELL", "WORK"), phones.map { it.type })
|
||||
}
|
||||
|
||||
@Test fun ignores_technical_type_tokens() {
|
||||
// INTERNET/VOICE/PREF — технические, берём содержательный токен
|
||||
val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:i\r\nFN:X\r\n" +
|
||||
"EMAIL;TYPE=INTERNET,HOME:h@x.ru\r\n" +
|
||||
"TEL;TYPE=VOICE,CELL:+7900\r\n" +
|
||||
"END:VCARD\r\n"
|
||||
assertEquals("HOME", VCardEditor.parseTypedValues(raw, "EMAIL").single().type)
|
||||
assertEquals("CELL", VCardEditor.parseTypedValues(raw, "TEL").single().type)
|
||||
}
|
||||
|
||||
@Test fun typeless_value_yields_blank_type() {
|
||||
val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:n\r\nFN:X\r\nEMAIL:plain@x.ru\r\nEND:VCARD\r\n"
|
||||
val e = VCardEditor.parseTypedValues(raw, "EMAIL").single()
|
||||
assertEquals("plain@x.ru", e.value)
|
||||
assertEquals("", e.type)
|
||||
}
|
||||
|
||||
@Test fun round_trip_preserves_parsed_types() {
|
||||
val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:r\r\nFN:X\r\n" +
|
||||
"EMAIL;TYPE=WORK:w@x.ru\r\nTEL;TYPE=HOME:+7495\r\nEND:VCARD\r\n"
|
||||
val emails = VCardEditor.parseTypedValues(raw, "EMAIL")
|
||||
val phones = VCardEditor.parseTypedValues(raw, "TEL")
|
||||
val out = VCardEditor.applyEdits(raw, VCardFields(displayName = "X", emails = emails, phones = phones))
|
||||
// типы не потеряны при правке (чиним P1-каветат нормализации)
|
||||
assertTrue(out.contains("EMAIL;TYPE=WORK:w@x.ru"))
|
||||
assertTrue(out.contains("TEL;TYPE=HOME:+7495"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,7 +159,12 @@ object F7IncomingCallQueue {
|
||||
val prefs = prefs(context)
|
||||
cancelTimeout(context)
|
||||
cancelNotification(context)
|
||||
prefs.edit().remove(KEY_QUEUE).remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).remove(KEY_ACTIVE_CALL).apply()
|
||||
prefs.edit()
|
||||
.remove(KEY_QUEUE)
|
||||
.remove(KEY_ACTIVE_TOKEN)
|
||||
.remove(KEY_ACTIVE_AT)
|
||||
.remove(KEY_ACTIVE_CALL)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,10 @@ object F7PushRegistrar {
|
||||
private const val TAG = "F7PushRegistrar"
|
||||
private const val PREFS = "f7push"
|
||||
private const val KEY_DEVICE_ID = "device_id"
|
||||
private const val MAX_ATTEMPTS = 3
|
||||
|
||||
// Паузы перед 2-й и 3-й попытками (мс); экспоненциальный бэкофф.
|
||||
private val BACKOFF_MS = longArrayOf(1000L, 3000L)
|
||||
|
||||
fun getDeviceId(context: Context): String {
|
||||
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
@@ -41,10 +45,60 @@ object F7PushRegistrar {
|
||||
.put("platform", "android")
|
||||
.put("clientApp", "f7cloud-mobile")
|
||||
.toString()
|
||||
|
||||
var lastCode = 0
|
||||
for (attempt in 0 until MAX_ATTEMPTS) {
|
||||
if (attempt > 0) {
|
||||
val wait = BACKOFF_MS[(attempt - 1).coerceAtMost(BACKOFF_MS.size - 1)]
|
||||
try {
|
||||
Thread.sleep(wait)
|
||||
} catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
return lastCode
|
||||
}
|
||||
}
|
||||
lastCode = try {
|
||||
postJson(endpoint, session, body)
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "register attempt ${attempt + 1} failed", t)
|
||||
0
|
||||
}
|
||||
if (!isRetryable(lastCode)) {
|
||||
return lastCode
|
||||
}
|
||||
Log.w(
|
||||
TAG,
|
||||
"register attempt ${attempt + 1} got $lastCode, " +
|
||||
if (attempt + 1 < MAX_ATTEMPTS) "retrying" else "giving up"
|
||||
)
|
||||
}
|
||||
return lastCode
|
||||
}
|
||||
|
||||
// Транзиентные сбои: сетевое исключение (0), ошибка сервера (5xx), 408/429.
|
||||
// 2xx — успех, 4xx (кроме 408/429) — постоянная ошибка (кривой app-password/запрос), повтор не поможет.
|
||||
private fun isRetryable(code: Int): Boolean {
|
||||
return code == 0 || code >= 500 || code == 408 || code == 429
|
||||
}
|
||||
|
||||
/**
|
||||
* Снимает устройство с регистрации в f7push (при logout) ДО очистки локальной сессии.
|
||||
* Контракт сервера (f7push v0.2.4, подтверждён integrator-b, mail/110):
|
||||
* `DELETE /ocs/v2.php/apps/f7push/api/v1/devices/{deviceId}`, та же Basic-авторизация +
|
||||
* `OCS-APIRequest`; идемпотентен (200 `success:true` даже если строки нет). Best-effort:
|
||||
* ошибку глотаем (сеть могла отвалиться при выходе), возвращаем HTTP-код или 0.
|
||||
*/
|
||||
fun unregisterBlocking(
|
||||
context: Context,
|
||||
session: AuthSession,
|
||||
): Int {
|
||||
val deviceId = getDeviceId(context)
|
||||
val endpoint = session.serverUrl.trimEnd('/') +
|
||||
"/ocs/v2.php/apps/f7push/api/v1/devices/$deviceId"
|
||||
return try {
|
||||
postJson(endpoint, session, body)
|
||||
deleteRequest(endpoint, session)
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "register failed", t)
|
||||
Log.w(TAG, "unregister failed", t)
|
||||
0
|
||||
}
|
||||
}
|
||||
@@ -76,6 +130,25 @@ object F7PushRegistrar {
|
||||
return code
|
||||
}
|
||||
|
||||
private fun deleteRequest(endpoint: String, session: AuthSession): Int {
|
||||
val conn = URL(endpoint).openConnection() as HttpURLConnection
|
||||
conn.connectTimeout = 15000
|
||||
conn.readTimeout = 15000
|
||||
conn.requestMethod = "DELETE"
|
||||
conn.setRequestProperty("Accept", "application/json")
|
||||
conn.setRequestProperty("OCS-APIRequest", "true")
|
||||
val basic = android.util.Base64.encodeToString(
|
||||
"${session.username}:${session.appPassword}".toByteArray(StandardCharsets.UTF_8),
|
||||
android.util.Base64.NO_WRAP
|
||||
)
|
||||
conn.setRequestProperty("Authorization", "Basic $basic")
|
||||
|
||||
val code = conn.responseCode
|
||||
drainQuietly(if (code >= 400) conn.errorStream else conn.inputStream)
|
||||
conn.disconnect()
|
||||
return code
|
||||
}
|
||||
|
||||
private fun drainQuietly(stream: InputStream?) {
|
||||
if (stream == null) return
|
||||
try {
|
||||
|
||||
@@ -13,13 +13,13 @@
|
||||
| shell | b-dev-mobile | 0 | — | — | — | — | — | карта |
|
||||
| files | b-mob-files | 1 | — | — | 4 | ? | нет | правки D4→D3→D2→D1 |
|
||||
| mail | b-mob-mail | 1 | — | — | ~4 | ~2 | нет | правка P1 черновики |
|
||||
| talk | b-mob-conf | 1 | 40.7 | предв. | ~30 | ? | нет | P1: меню сообщения Копировать/ссылка/переслать/Напоминание/Ответить-лично (…a60fd78); далее файл-тред/перевод, затем 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 управление списками; ждёт девайс-сверки |
|
||||
| notes | b-mob-notes | 1 | 21.7 | 0.0 | 34 | 21 | нет | P1 слой данных в коде+юнит-тест (`8a8f1cf`: Notes API v1 клиент/репо/ETag-кэш/конфликт 412); скелет заведён лидом (`133d80d`); далее P1-UI (экраны/редактор/диалог) |
|
||||
| notifications | b-mob-notify | 0 | — | — | — | — | нет | карта готова, diff |
|
||||
| deck | b-mob-deck | 1 | 22.6 | предв. | 52 | ? | нет | P1 начат: «Назначить мне/снять» done (6eeba1b, green compile); далее бейджи чек-пунктов/комментариев, меню колонки (Удалить/Переименовать), быстрые сроки; Fd на устройстве |
|
||||
| calendar | b-mob-calendar | 1 | 53.3 | 67.0 | ~37 | ~7 | нет | P1 разворот повторений ЗАКРЫТ в коде (`CalendarRecurrence`+11 тестов, compile+detekt green); NB: обновил протухший detekt-baseline модуля (v0.5.144-долг, не мой); далее P2 EXDATE/RECURRENCE-ID; Appointments — вопрос n-a лиду |
|
||||
| contacts | b-mob-contacts | 1 | 26.7 | предв. | ~40 | ? | нет | diff выложен (7 экранов); P1 — edit/delete (core-правка одобрена 072), далее группы/типы |
|
||||
| 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 | 1 | 63.6 | 20.0 | 8 | 8 | нет | **P1 ЗАКРЫТ** (лента `a30627a`, unregister `cc7ad71`+`255c962`, retry `a6bda89`). **Fd идёт:** Roborazzi-эталон строки снят (`8072539`, w360dp); 2 Д-находки закрыты лидом+верифицированы (перенос текста кнопки, зазор subject/message — `bfa870c`); detekt designsystem был красный, лид починил. Осталось Ф P2/P3; Fd — веб-эталон 360px + сверка (лента `cloude` пуста, ждёт populated) |
|
||||
| deck | b-mob-deck | 1 | 25.3 | предв. 2/8 | 47 | ~4 | нет | Ff P1 done (назначить-себя/бейджи/меню колонки/быстрые сроки, 6eeba1b…eff3f1c). Fd: Roborazzi 360dp поднят (58f11c2, native PNG), сверено 2 экрана (Д: бейджи иконки-vs-эмодзи, аватар исполнителя, колонка Участники); далее 6 экранов+меню. Крупный Ff (назначение юзера/Комментарии/Вложения/обзор/CRUD) — жду приоритеты лида |
|
||||
| calendar | b-mob-calendar | 1 | 64.0 | 80.4 | ~35 | ~7 | нет | P1 разворот повторений+BYDAY=1MO ЗАКРЫТ в коде (`CalendarRecurrence`, 15 тестов, compile+detekt green, коммиты e7d0a5a/492af61); Appointments=n-a (101); далее P2 EXDATE/RECURRENCE-ID |
|
||||
| contacts | b-mob-contacts | 1 | 37.4 | предв. | ~32 | ? | нет | P1 edit/delete в main (bc1a497+6b98c5a); P2 шаг1 типы TEL/EMAIL в main (a8741e9: парс+подписи+сохранение, 14 тестов); далее P2 шаг2 селектор типа + группы CATEGORIES; live/device далее |
|
||||
|
||||
Обозначения статуса: `карта` → `diff` (итерация 1) → `правки` → `повторный прогон` →
|
||||
`✅ 0` (Ff=Fd=100%) → `✅ 0 подтв.` (второй прогон держит 0).
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
# Diff: Календарь (Calendar) — итерация 1 (2026-07-13)
|
||||
|
||||
Ff_app: **53.3%** · Fd_app: **67.0%** (среднее по 6 экранам, невзвешенно) · n-a: 0
|
||||
_(без экрана Appointments, если владелец сочтёт его мобильно-неприменимым: Ff_app **64.0%** /
|
||||
Fd_app **80.4%** по 5 экранам — вопрос лиду в конце.)_
|
||||
Ff_app: **64.0%** · Fd_app: **80.4%** (среднее по 5 экранам, невзвешенно) · n-a: Экран 5
|
||||
Appointments (решение лида mail/101 — организаторская фича, мобильно-неприменима, вне знаменателя).
|
||||
|
||||
> **P1 закрыт в коде** (итерация правок 1): разворот повторяющихся событий реализован —
|
||||
> `CalendarRecurrence` (генератор оккуренций RRULE на лету, без кэша и без `<c:expand>`,
|
||||
@@ -91,8 +90,11 @@ mail/085) добавлю следующим проходом — этот про
|
||||
[Д][done] секция импорта · [Д][open] отсутствуют секции General/Editing/Availability/CalDAV ·
|
||||
[Д][open] порядок и группировка пунктов по теме.
|
||||
|
||||
## Экран 5. Appointments / Расписание встреч — Ff 0.0% · Fd 0.0%
|
||||
Ф (10, open 10) — **раздел отсутствует в нативе целиком**:
|
||||
## Экран 5. Appointments / Расписание встреч — `n-a` (вне метрики, mail/101)
|
||||
Решение лида: организаторская фича (публикация слотов, внешние букинги) — десктоп/веб-воркфлоу,
|
||||
на мобильном клиенте неприменима, тянет тяжёлый UI. Экран целиком `n-a`, из знаменателя исключён,
|
||||
остаётся в финальном списке с обоснованием.
|
||||
Ф (10, все `n-a`) — раздел отсутствует в нативе (осознанно):
|
||||
- [Ф][open] список расписаний встреч · [Ф][open] «Создать расписание встреч»
|
||||
- [Ф][open] поля: имя/описание/местоположение/длительность/инкремент
|
||||
- [Ф][open] «Календарь бронирования» · [Ф][open] «Календари для проверки конфликтов»
|
||||
@@ -100,8 +102,7 @@ mail/085) добавлю следующим проходом — этот про
|
||||
- [Ф][open] «Создать комнату Talk» для встречи · [Ф][open] публичная страница бронирования
|
||||
(внешний гость: выбор слота → Подтвердить)
|
||||
Д (n/a пока — экран не реализован).
|
||||
> ВОПРОС ЛИДУ: Appointments мобильно-применимы или помечаем `n-a` (организаторская фича, чаще
|
||||
> десктоп)? От ответа зависит, входит ли экран в знаменатель (см. шапку — две метрики).
|
||||
> Решено (mail/101): `n-a` — вне знаменателя.
|
||||
|
||||
## Экран 6. Окно события — Ff 83.3% · Fd 92.0%
|
||||
Ф (30, open 5):
|
||||
@@ -148,8 +149,9 @@ pill-поля 40dp, шапка с крестиком/галочкой, высо
|
||||
## Предлагаемый порядок правок (жду приоритизацию лида)
|
||||
- **P1 — Разворот повторений в сетке/списке** (Экран 1, долг №2): ✅ **СДЕЛАНО в коде**
|
||||
(`CalendarRecurrence` + `eventOccursOnDay`, mail/072-совместимо: развёртка на лету, без кэша
|
||||
и `<c:expand>`; +11 тестов). Остаётся девайс-сверка (подтверждающий прогон). NB: `BYDAY=1MO`
|
||||
(месячный n-й день недели) в модели `RecurrenceRule` ещё не хранится — уходит в P2.
|
||||
и `<c:expand>`; +11 тестов). ✅ **`BYDAY=1MO` тоже починен** (коммит 492af61): `RecurrenceRule`
|
||||
хранит/сериализует ординал (раньше терялся в `toRrule`) + разворот n-го дня месяца +4 теста.
|
||||
Остаётся девайс-сверка (подтверждающий прогон).
|
||||
- **P2 — EXDATE + RECURRENCE-ID** (Экран 6, долг №3): чтение EXDATE (не показывать удалённые
|
||||
оккуренции) → выбор области правки/удаления «это / это и будущие» → запись без затирания серии.
|
||||
- **P3 — Настройки** (Экран 4): вынести веб-параметры (номера недель/выходные/задачи-в-календаре/
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Diff: Контакты (CardDAV) — итерация 1 (2026-07-13)
|
||||
Ff_app: **26.7%** · Fd_app: **предварительно** (натив на устройстве в этот проход не гонялся; веб снят live 390px) · n-a: 3
|
||||
Ff_app: **37.4%** · Fd_app: **предварительно** (натив на устройстве в этот проход не гонялся; веб снят live 390px) · n-a: 8
|
||||
|
||||
> Прогресс P1 (коммиты bc1a497 + 6b98c5a): правка/удаление РЕАЛИЗОВАНЫ (ядро + UI). Ядро:
|
||||
> `CardDavClient.updateContact` (PUT If-Match, round-trip неизвестных полей vCard) / `deleteContact`
|
||||
> (DELETE If-Match) + `VCardEditor`, 10 юнит-тестов зелёные. UI: Room href/etag/raw (миграция v4→5),
|
||||
> кнопки «Править» (форма) и «Удалить» (с подтверждением) в карточке. Гейты зелёные
|
||||
> (`:app:compileReleaseKotlin`, `:feature:contacts:detekt`+compile). Пункты «Править»/«Удалить» (Э3) и
|
||||
> поля формы (Э4) отмечены `[done]` — реализованы в коде; live-проверка на `bmob-*` (mail/085) и
|
||||
> дизайн-проход на устройстве — следующим шагом (Ff по коду, Fd предварительно). NB: P1-edit
|
||||
> нормализует TYPE у EMAIL/TEL к INTERNET/CELL — сохранение/правка типов идёт в P2.
|
||||
> Решение лида (mail/102): экран «Команды» (Circles) — целиком `n-a`; порядок P1→P2→P4→P3→P5 утверждён.
|
||||
|
||||
Сверка веб (карта `contacts.md`, апстрим `nextcloud/contacts` + тема `_mobile-*.css`) ↔ натив
|
||||
(`feature/contacts` Compose поверх CardDAV/`core:network`). Метод mail/083: `[Ф|Д]`·`[open|done|n-a]`,
|
||||
@@ -31,7 +41,7 @@ Fd (дизайн) — отдельным проходом на устройст
|
||||
- [Ф][open] Команды: «Показать все команды» — нет.
|
||||
- [Ф][open] «Параметры контактов» — нет (см. Экран 7).
|
||||
|
||||
## Экран 3: Карточка контакта (просмотр) — Ff 42.1% (11 open Ф / 19)
|
||||
## Экран 3: Карточка контакта (просмотр) — Ff 52.6% (9 open Ф / 19; «Править»/«Удалить» done — P1)
|
||||
Ряд действий (веб) ↔ натив (`ContactDetailContent`):
|
||||
- [Ф][done] Написать письмо (конверт) · натив «Email» (ACTION_SENDTO mailto).
|
||||
*расхождение: уходит во внешний почтовик, не в свой `MailComposeActivity` (план mail/072 п.3).*
|
||||
@@ -50,30 +60,31 @@ Fd (дизайн) — отдельным проходом на устройст
|
||||
- [Ф][open] «ID в объединении облачных хранилищ» (federated cloud ID) — нет.
|
||||
- [Ф][open] «Подробное имя» (Фамилия/Имя/фонетика отдельными полями) — натив показывает только displayName.
|
||||
Меню шапки:
|
||||
- [Ф][open] «✏️ Править» — нет (Экран 4).
|
||||
- [Ф][done] «✏️ Править» — реализовано (P1, 6b98c5a): кнопка в карточке → форма (Экран 4).
|
||||
- [Ф][done] «Удалить» — реализовано (P1): кнопка с подтверждением, DELETE If-Match.
|
||||
- [Ф][open] ⋯ «Скачать» (.vcf) — нет.
|
||||
- [Ф][open] ⋯ «Копировать контакт» — нет.
|
||||
- [Ф][open] ⋯ «Создать QR-код» — нет.
|
||||
- [Ф][open] ⋯ «Исключить контакт из календаря Дней Рождений» — нет.
|
||||
- [Ф][open] ⋯ «Удалить» — нет.
|
||||
- [Д][—] Крупный аватар/имя/subtitle, поля-плитки, кнопки Email/Позвонить — по живой теме; Fd на устройстве.
|
||||
|
||||
## Экран 4: Правка/создание контакта (форма) — Ff 26.7% (11 open Ф / 15)
|
||||
Веб-форма правки ↔ натив (`AddContactDialog` — только СОЗДАНИЕ, правки нет):
|
||||
- [Ф][done] Имя · натив: поле в диалоге создания.
|
||||
- [Ф][done] Телефон (значение) · натив: поле в диалоге.
|
||||
- [Ф][done] Email (значение) · натив: поле в диалоге.
|
||||
- [Ф][done] Сохранить · натив: `createContact` (vCard 3.0 PUT).
|
||||
- [Ф][open] Правка существующего (загрузка контакта в форму, PUT If-Match) — нет.
|
||||
## Экран 4: Правка/создание контакта (форма) — Ff 53.3% (7 open Ф / 15; правка реализована — P1)
|
||||
Веб-форма правки ↔ натив (`EditContactDialog` — правка + `AddContactDialog` — создание):
|
||||
- [Ф][done] Имя · поле формы.
|
||||
- [Ф][done] Телефон (значение) · поле формы (многострочное, несколько номеров).
|
||||
- [Ф][done] Email (значение) · поле формы (многострочное, несколько адресов).
|
||||
- [Ф][done] Сохранить · `updateContact` (round-trip vCard, PUT If-Match) / `createContact`.
|
||||
- [Ф][done] Правка существующего (загрузка контакта в форму, PUT If-Match) — реализовано (P1, 6b98c5a).
|
||||
- [Ф][done] Должность/организация — поля формы (organization + title).
|
||||
- [Ф][done] Адрес — поле формы.
|
||||
- [Ф][done] «Личные даты» (день рождения) — поле формы (текст; дата-пикер — доработка).
|
||||
- [Ф][open] «Добавить фото» — нет.
|
||||
- [Ф][open] Должность/организация — нет в форме.
|
||||
- [Ф][open] Тип телефона (Домашний/Рабочий/CELL/…) — нет (селектор типа).
|
||||
- [Ф][open] Тип email — нет.
|
||||
- [Ф][open] Адрес — нет в форме.
|
||||
- [Ф][open] «Личные даты» (день рождения, дата-пикер) — нет.
|
||||
- [Ф][open] «О себе» — нет.
|
||||
- [Ф][~] Тип телефона (Домашний/Рабочий/CELL/…) — типы ПАРСЯТСЯ и сохраняются при правке (P2 шаг1,
|
||||
a8741e9); отдельного селектора типа в форме пока нет — P2 шаг2.
|
||||
- [Ф][~] Тип email — аналогично (парсинг+сохранение есть, селектор — P2 шаг2).
|
||||
- [Ф][open] «О себе» (NOTE) — нет в форме.
|
||||
- [Ф][open] «Часовой пояс» — нет.
|
||||
- [Ф][open] «Группы контакта» (добавить в группу) — нет.
|
||||
- [Ф][open] «Группы контакта» (добавить в группу) — нет — P2.
|
||||
- [Ф][open] Выбор адресной книги при создании — нет (пишет в `books.firstOrNull()`).
|
||||
- [Д][—] Веб — полноэкранная форма; натив — компактный диалог. Расхождение композиции (Fd на устройстве).
|
||||
|
||||
@@ -84,19 +95,20 @@ Fd (дизайн) — отдельным проходом на устройст
|
||||
- [Ф][open] Разворачивание/сворачивание узлов (`^N`).
|
||||
- [Ф][open] Переход к карточке из узла.
|
||||
|
||||
## Экран 6: Команда (например «Тест») — Ff 0.0% (0 open Ф / 5; n-a 3)
|
||||
Отсутствует в нативе:
|
||||
- [Ф][open] Просмотр команды (название/владелец/участники).
|
||||
- [Ф][open] «✏️ Править» команду.
|
||||
- [Ф][open] «Копировать ссылку».
|
||||
- [Ф][open] «Добавить» участника.
|
||||
- [Ф][open] Список участников (роль «Владелец» и т.д.).
|
||||
- [Ф][n-a] Создать «Папка» — зона `feature/files` (создаётся из Файлов).
|
||||
## Экран 6: Команда (Circles/Teams) — n-a целиком (0 применимых Ф; n-a 8) — исключён из среднего
|
||||
Решение лида (mail/102): отдельного экрана команд в мобильных Контактах v1 НЕ делаем — Circles это
|
||||
самостоятельный тяжёлый апп (членство/уровни/шары), не контакты. Весь экран `n-a`:
|
||||
- [Ф][n-a] Просмотр команды (название/владелец/участники).
|
||||
- [Ф][n-a] «✏️ Править» команду.
|
||||
- [Ф][n-a] «Копировать ссылку».
|
||||
- [Ф][n-a] «Добавить» участника.
|
||||
- [Ф][n-a] Список участников.
|
||||
- [Ф][n-a] Создать «Папка» — зона `feature/files`.
|
||||
- [Ф][n-a] Создать «Конференции» — зона `feature/talk`.
|
||||
- [Ф][n-a] Создать «Календарь» — зона `feature/calendar`.
|
||||
> n-a обоснование: три «Создать …» из карточки команды — кросс-модульные создающие потоки чужих
|
||||
> модулей, достижимы из их приложений; в контактах мобильно-неприменимы. Команды (Circles/Teams)
|
||||
> целиком — кандидат на согласование с лидом: нужен ли отдельный экран команд в мобильных Контактах.
|
||||
> Обоснование: Circles = отдельное приложение (мобильно — не в скоупе Контактов v1); три «Создать …»
|
||||
> — кросс-модульные создающие потоки чужих модулей. NB: «команды» ≠ «группы контактов»: группы
|
||||
> (CATEGORIES→чипы) остаются в скоупе (P2).
|
||||
|
||||
## Экран 7: Параметры контактов (модальный шит) — Ff 0.0% (5 open Ф / 5)
|
||||
Отсутствует в нативе:
|
||||
@@ -108,7 +120,11 @@ Fd (дизайн) — отдельным проходом на устройст
|
||||
|
||||
---
|
||||
## Сводка и приоритеты (прошу приоритизацию у лида)
|
||||
Ff_app **26.7%** по 7 экранам (Э1 100 · Э2 18.2 · Э3 42.1 · Э4 26.7 · Э5/6/7 0). Fd — предварительно.
|
||||
Ff_app **37.4%** по 6 экранам (Э1 100 · Э2 18.2 · Э3 52.6 · Э4 53.3 · Э5 0 · Э7 0; Э6 Команды = n-a, исключён). Fd — предварительно.
|
||||
P1 (правка/удаление) закрыт в коде — Э3/Э4 подросли. **P2 шаг1 (типы TEL/EMAIL)** — в коде
|
||||
(a8741e9): типы парсятся из raw, в карточке подписи по типу (Рабочий/Личный/Мобильный/…),
|
||||
при правке типы сохраняются (fix P1-каветата), +4 теста. Дальше P2 шаг2: селектор типа в форме +
|
||||
**группы CATEGORIES** (чипы-фильтр в списке — поднимет Э2). Шторка (Э2) и параметры (Э7) — P3/P5.
|
||||
Крупнейшие функциональные дыры (кандидаты P1–P5), совпадают с моим планом mail/072:
|
||||
1. **Правка/удаление контакта** (Экран 4 + ⋯ карточки): edit-форма, `updateContact` PUT If-Match с
|
||||
round-trip неизвестных полей vCard, `deleteContact`, выбор адресной книги, поля должность/адрес/
|
||||
@@ -122,12 +138,11 @@ Ff_app **26.7%** по 7 экранам (Э1 100 · Э2 18.2 · Э3 42.1 · Э4 2
|
||||
5. **Параметры** (Экран 7): сортировка, импорт, управление адресными книгами.
|
||||
Прочее: соц.сеть/cloud-ID/QR/скачать/копировать в карточке; оргструктура-граф; экран команд (см. n-a).
|
||||
|
||||
## Вопросы к лиду (b-dev-mobile)
|
||||
- Порядок P1–P5? Мой дефолт (не жду ответа, начинаю с P1 edit/delete — целиком мой модуль +
|
||||
твоё уже-данное добро на core-правку): 1→2→(4 кросс-модуль)→3→5.
|
||||
- **Команды (Circles/Teams)** (Экран 6) — нужен ли отдельный экран команд в мобильных Контактах,
|
||||
или считаем n-a целиком? Три «Создать …» я пометил n-a (чужие модули) — подтверди.
|
||||
- **Оргструктура** (Экран 5) — в скоуп мобильных Контактов или n-a?
|
||||
## Вопросы к лиду (b-dev-mobile) — РЕШЕНО (mail/102)
|
||||
- ✅ Порядок P1→P2→P4→P3→P5 — утверждён.
|
||||
- ✅ **Команды (Circles/Teams)** (Экран 6) — `n-a` целиком (учтено выше).
|
||||
- ⏳ **Оргструктура** (Экран 5) — лид пока не высказался; держу `open` (не занижаю знаменатель
|
||||
односторонним n-a). Аналогична Circles по «тяжести» — вероятный кандидат в n-a, жду слова.
|
||||
- Fd: гоню отдельным проходом на устройстве после ревью-сборки — сборка/версия по гейту (084) за тобой.
|
||||
|
||||
## Live-запись (mail/085, ограждения)
|
||||
|
||||
+41
-18
@@ -1,10 +1,18 @@
|
||||
# Diff: Карточки (Deck) — итерация 1 (2026-07-13)
|
||||
Ff_app: **22.6%** (52 open Ф / 76) · Fd_app: **предварительно** (веб-вёрстка сверена live через
|
||||
iframe 390px; натив-дизайн-проход на устройстве — след. итерация) · n-a: 4
|
||||
Ff_app: **25.3%** (47 open Ф / 76) · Fd_app: **предварительно, 2/8 экранов** (Roborazzi-пайплайн
|
||||
поднят, mail/111) · n-a: 4
|
||||
|
||||
> Прогресс P1 (коммит 6eeba1b): реализовано меню карточки «**Назначить мне / Снять назначение с
|
||||
> меня**» (`assignUser`/`unassignUser`, type 0) — отмечено `[done]` ниже (Ff экрана 4: 52.0%→56.0%).
|
||||
> Компилируется green против core; detekt модуля без новых замечаний (baseline-рефреш — лид, mail/095).
|
||||
> **Fd-пайплайн (mail/111):** Roborazzi 360dp живой — `feature/deck/src/test/DeckScreenshotTest`
|
||||
> рендерит нативные экраны в PNG (`src/test/screenshots/deck-boards.png`, `deck-board.png`,
|
||||
> коммит 58f11c2). Веб-эталон снимаю iframe-360 на forbion (sample-доска «Welcome», без данных
|
||||
> `cloude`). Сверено 2 экрана (список досок, доска-стек); Д-находки — ниже. Остальные 6 экранов
|
||||
> + всплывающие меню (mail/087) — след. проходы.
|
||||
|
||||
> Прогресс P1: (6eeba1b) меню карточки «**Назначить мне / Снять**»; (9d726f4) **бейдж
|
||||
> комментариев** «💬 N»; (73762d8/4fdadef) **меню колонки** «⋯» — «**Переименовать**» и
|
||||
> «**Удалить список**» (`renameStack`/`deleteStack`) — все `[done]` ниже (Ff экрана 4:
|
||||
> 52.0%→68.0%). **Compile + detekt модуля GREEN** (detekt-baseline модуля обновлён — v0.5.144-долг,
|
||||
> как у calendar e7d0a5a; вписывается в свип лида mail/095).
|
||||
|
||||
Сверка веб (карта `deck.md`, апстрим `nextcloud/deck v1.16.4` + тема forbion, live-сверка
|
||||
Browser 1/cloude read-only) ↔ натив (`feature/deck`: `DeckRepository`/`DeckScreen`/
|
||||
@@ -40,9 +48,12 @@ Browser 1/cloude read-only) ↔ натив (`feature/deck`: `DeckRepository`/`De
|
||||
## Экран 2: Все доски (список) — Ff 50.0% (1 open Ф / 2)
|
||||
- [Ф][done] Открыть доску (тап строки) — есть.
|
||||
- [Ф][open] Поиск/фильтр списка досок (`navFilter`) — нет.
|
||||
- [Д][open] Колонка «**Участники**» (аватары ACL) — натив не показывает участников доски.
|
||||
- [Д][—] Строка-карточка доски (bullet цвета + название, border/radius) — сверено, близко 1:1
|
||||
(натив `DeckBoardRow`); детальный Fd — на устройстве.
|
||||
- [Д][open] Колонка «**Участники**» (аватары ACL) — натив не показывает участников доски
|
||||
(`сверено:` Roborazzi vs iframe-360: веб — таблица с колонкой аватаров справа, натив — только bullet+имя).
|
||||
- [Д][open] **Заголовки-колонки** «Название доски / Участники» — в вебе есть строка-шапка таблицы,
|
||||
в нативе её нет (список без шапки). Fd-минус композиции.
|
||||
- [Д][done] Строка доски: bullet цвета + название, border/radius 8 — сверено (Roborazzi
|
||||
`deck-boards.png` ↔ веб), совпадает.
|
||||
|
||||
## Экран 3: Обзор / «Ожидающие выполнения» — Ff 0.0% (2 open Ф / 2)
|
||||
- [Ф][open] Дашборд карточек по срокам (Просроченные/Сегодня/Завтра/Ближайшие 7 дней/Позже/
|
||||
@@ -50,7 +61,7 @@ Browser 1/cloude read-only) ↔ натив (`feature/deck`: `DeckRepository`/`De
|
||||
- [Ф][open] Открыть карточку из обзора — нет (нет обзора).
|
||||
- [Д][open] Композиция: 6 секций-капшенов в столбец, карточки внутри — отсутствует.
|
||||
|
||||
## Экран 4: Доска (Controls + Колонка + Карточка) — Ff 56.0% (11 open Ф / 25)
|
||||
## Экран 4: Доска (Controls + Колонка + Карточка) — Ff 72.0% (7 open Ф / 25)
|
||||
**Шапка доски (`Controls`):**
|
||||
- [Ф][done] «**Добавить список**» — натив «+ Колонка» (`createStack`).
|
||||
- [Ф][open] «**Применить фильтр**» (по метке / назначенному / статусу) — фильтров нет.
|
||||
@@ -60,15 +71,18 @@ Browser 1/cloude read-only) ↔ натив (`feature/deck`: `DeckRepository`/`De
|
||||
**Всплывающее меню колонки (⋯, `Stack`)** — в нативе меню колонки НЕТ:
|
||||
- [Ф][open] «**Архивировать все карточки**» — нет.
|
||||
- [Ф][open] «**Разархивировать все карточки**» — нет.
|
||||
- [Ф][open] «**Удалить список**» — нет.
|
||||
- [Ф][open] «**Изменить заголовок списка**» (инлайн-правка) — нет (переименования колонки нет).
|
||||
- [Ф][done] «**Удалить список**» — реализовано (меню «⋯» → подтверждение `DeckConfirmDialog`).
|
||||
- [Ф][done] «**Изменить заголовок списка / Переименовать**» — реализовано (меню «⋯» → диалог с
|
||||
префиллом, `renameStack`).
|
||||
- [Ф][done] «**Добавить карточку**» в колонку — есть (инлайн-поле «Имя карточки»).
|
||||
**Карточка на доске (`CardItem` + бейджи):**
|
||||
- [Ф][done] Открыть карточку (тап) — открывает лист-деталь.
|
||||
- [Ф][done] Правка заголовка — есть (в листе-детали; веб правит инлайн — Д-нюанс).
|
||||
- [Ф][done] Бейдж срока (`DueDate`) — есть.
|
||||
- [Ф][open] Бейдж «**чек-пунктов**» (Todo items, напр. 0/9) — натив не показывает.
|
||||
- [Ф][open] Бейдж «**комментариев**» (N, M непрочитанных) — не показывает.
|
||||
- [Ф][done] Бейдж «**чек-пунктов**» «☑ x/total» — реализовано (`deckChecklistCounts`, парсинг
|
||||
чекбоксов описания regex как веб).
|
||||
- [Ф][done] Бейдж «**комментариев**» «💬 N» — реализовано (`commentsCount`); индикатор M
|
||||
непрочитанных — Д-нюанс на след. проход.
|
||||
- [Ф][done] Аватары назначенных — есть (отображение).
|
||||
**Всплывающее меню карточки (⋯ / long-press, `CardMenuEntries`)** — в нативе overflow-меню НЕТ
|
||||
(тап открывает лист; часть действий — кнопками листа):
|
||||
@@ -85,14 +99,23 @@ Browser 1/cloude read-only) ↔ натив (`feature/deck`: `DeckRepository`/`De
|
||||
- [Ф][done] Перенос карточки между колонками (в вебе drag-n-drop) — натив реализует переносом
|
||||
через чипы «Колонка» (Д-нюанс: без DnD-жеста).
|
||||
|
||||
## Экран 5: Лист-деталь / сайдбар карточки (Свойства/Вложения/Комментарии/Активность) — Ff 33.3% (12 open Ф / 18)
|
||||
**Fd-находки (Roborazzi `deck-board.png` ↔ веб iframe-360):**
|
||||
- [Д][open] **Бейджи карточки — иконки vs эмодзи**: веб рисует SVG-иконки (календарь/чек-лист/
|
||||
комментарий/аватар), натив — эмодзи (🗓/☑/💬/👤). Расхождение типографики/иконок.
|
||||
- [Д][open] **Исполнитель**: веб — кружок-аватар, натив — «👤 имя» текстом. Композиция отличается.
|
||||
- [Д][done] Колонка-стек: шапка (заголовок+счётчик+«+»+«⋯»), карточки с метками/чекбоксом/
|
||||
заголовком — сверено, композиция близка 1:1.
|
||||
- [Д][done] Метки-чипы (цветной фон 0.22 + рамка) и чекбокс done — сверено, совпадают.
|
||||
|
||||
## Экран 5: Лист-деталь / сайдбар карточки (Свойства/Вложения/Комментарии/Активность) — Ff 38.9% (11 open Ф / 18)
|
||||
**Свойства (Details):**
|
||||
- [Ф][done] Метки: назначить/снять (toggle меток доски) — есть.
|
||||
- [Ф][open] Метки: «**Создать новую метку**» — натив только тогглит существующие.
|
||||
- [Ф][open] Назначение: «**Назначить пользователю…**» — нет (write).
|
||||
- [Ф][open] Назначение: снять назначение — нет.
|
||||
- [Ф][done] Срок: выбрать дату (DatePicker) — есть.
|
||||
- [Ф][open] Срок: «**быстрые опции**» (Позже сегодня/Завтра/В выходные/На след. неделе) — нет.
|
||||
- [Ф][done] Срок: «**быстрые опции**» — реализованы чипы Сегодня/Завтра/Выходные/Неделя
|
||||
(`deckQuickDueIso`). *(«Позже сегодня» с часом — Д-нюанс, натив ставит дату; на след. проход.)*
|
||||
- [Ф][done] Срок: «**Убрать срок**» — есть («Сбросить»).
|
||||
- [Ф][done] Срок: «Отметить выполненной» из блока срока — есть (done).
|
||||
- [Ф][done] Описание: редактировать — есть.
|
||||
@@ -140,12 +163,12 @@ Browser 1/cloude read-only) ↔ натив (`feature/deck`: `DeckRepository`/`De
|
||||
| 1. Навигация + меню доски | 16.7% | 10/12 |
|
||||
| 2. Все доски (список) | 50.0% | 1/2 |
|
||||
| 3. Обзор (по срокам) | 0.0% | 2/2 |
|
||||
| 4. Доска (Controls/Колонка/Карточка) | 56.0% | 11/25 |
|
||||
| 5. Лист-деталь карточки | 33.3% | 12/18 |
|
||||
| 4. Доска (Controls/Колонка/Карточка) | 72.0% | 7/25 |
|
||||
| 5. Лист-деталь карточки | 38.9% | 11/18 |
|
||||
| 6. Сайдбар доски | 0.0% | 10/10 |
|
||||
| 7. Move/copy карточки | 25.0% | 3/4 |
|
||||
| 8. Настройки Deck | 0.0% | 3/3 |
|
||||
| **Ff_app (среднее)** | **22.6%** | **52/76** |
|
||||
| **Ff_app (среднее)** | **25.3%** | **47/76** |
|
||||
|
||||
## Приоритеты (черновик, жду визы лида — mail/071 план 1→2→3)
|
||||
- **P1 (в своём модуле, наибольший охват):** меню карточки (Назначить мне/Переместить-копировать/
|
||||
|
||||
+43
-39
@@ -1,18 +1,22 @@
|
||||
# Diff: Заметки (Notes) — итерация 1 (2026-07-13)
|
||||
|
||||
Ff_app: **21.7%** · Fd_app: **0.0%** (среднее по 6 экранам) · n-a: 0
|
||||
Ff_app: **42.8%** · Fd_app: **0.0%** (среднее по 6 экранам; Fd ждёт девайс-сверки) · n-a: 0
|
||||
|
||||
> ✅ **Скелет модуля заведён лидом** (`133d80d`, mail/096). **Итерация 1a — слой данных
|
||||
> реализован и верифицирован** (коммит `8a8f1cf`): пункты, помеченные `[done]` ниже = логика в
|
||||
> коде + юнит-тест (7/7, MockWebServer), **UI-экраны (список/редактор/диалог/навигация) —
|
||||
> следующий заход P1-UI**, девайс/визуал-сверка позже. Fd=0% пока держится (ни один экран
|
||||
> визуально не собран). Пишущее проверено ЮНИТАМИ, не на forbion (mail/073).
|
||||
> ✅ **Скелет заведён лидом** (`133d80d`). **Итерация 1a — слой данных** (`8a8f1cf`) +
|
||||
> **1b — UI read-only ядра** (`fb120d5`). `[done]` = в коде + верифицировано (compile + 7/7
|
||||
> юнит-тестов MockWebServer + detekt, изолированный worktree); **Fd=0% пока держится — экраны
|
||||
> собраны, но девайс/визуал-сверки ещё не было**. Пишущее проверено ЮНИТАМИ, не на forbion (mail/073).
|
||||
>
|
||||
> **Что готово в коде (1a):** `NotesApiClient` (Notes API v1: list exclude=content+If-None-Match,
|
||||
> get, create, saveContent с `If-Match`→412 `NoteConflictException`, setFavorite/Category/rename,
|
||||
> delete) · `NotesRepository` (offline-first список: 304/сеть→кэш) · `NotesCache` (дисковый
|
||||
> ETag-кэш) · `Note` модель. Осталось для полного P1: UI-экраны + `/autotitle` + `undo` +
|
||||
> категории-CRUD (`PATCH/DELETE /category`) + settings API.
|
||||
> **Готово в коде (1a данные + 1b UI):**
|
||||
> - Данные: `NotesApiClient` (v1: list exclude=content+If-None-Match, get, create, saveContent
|
||||
> `If-Match`→412 `NoteConflictException`, setFavorite/Category/rename, delete) · `NotesRepository`
|
||||
> (offline-first: 304/сеть→кэш) · `NotesCache` (дисковый ETag) · `Note`.
|
||||
> - UI: `NotesViewModel` (список избранное↑+поиск, открытие/создание/сохранение/удаление,
|
||||
> разрешение конфликта явным выбором) · `NotesScreen` (список+поиск+пусто-состояние Welcome+
|
||||
> диалог конфликта) · `NoteEditorScreen` (plain markdown + debounce-автосейв + read-only).
|
||||
> - **Осталось для полного P1:** регистрация экрана в app-шелле (переключение веб-пункта меню
|
||||
> «Заметки» на нативный — зона лида, app/; пинг отправлен). **P2:** rich-редактор/preview,
|
||||
> категории-навигация+CRUD, autotitle, undo, settings, шаринг + попиксельная вёрстка (Fd).
|
||||
|
||||
Сверка веб (`nextcloud/notes v5.0.0` + тема forbion) ↔ натив (**отсутствует**). Карта-источник:
|
||||
[`notes.md`](notes.md) (снята из кода + визуально сверена на forbion, Browser 1, read-only).
|
||||
@@ -23,17 +27,17 @@ Notes API v1 + ETag-кэш, план 1→2→3 (mail/071).
|
||||
|
||||
---
|
||||
|
||||
## Экран 1. Список заметок (главный) — Ff 20% · Fd 0%
|
||||
**Ф (10, open 8):**
|
||||
- [Ф][done] создать заметку (`NotesApiClient.create`, юнит-тест) — UI-кнопка следующим заходом
|
||||
- [Ф][open] поле «Искать заметки» (фильтр по заголовку) + очистка — P2
|
||||
- [Ф][done] загрузка заметки (`get`) + списка (`listNotes` offline-first) — данные готовы; UI строки P1-UI
|
||||
## Экран 1. Список заметок (главный) — Ff 50% · Fd 0%
|
||||
**Ф (10, open 5):**
|
||||
- [Ф][done] создать заметку (`NotesApiClient.create` + кнопка `F7CreateButton` в `NotesScreen`)
|
||||
- [Ф][done] поле «Искать заметки» (фильтр по заголовку, `visibleNotes`) — UI собран
|
||||
- [Ф][done] загрузка заметки (`get`) + списка (`listNotes` offline-first) + строки `F7ListCard`
|
||||
- [Ф][open] индикатор «расшарена» на строке — P3
|
||||
- [Ф][open] меню строки «⋯» (см. Экран 2) — P1
|
||||
- [Ф][open] меню строки «⋯» полное (сейчас на строке только избранное+тап; ⋯-поповер) — P2
|
||||
- [Ф][open] группировка по таймслотам (сегодня/недавние/…) — P2
|
||||
- [Ф][open] группировка по категориям (капшены) — P2
|
||||
- [Ф][open] сортировка «избранное вверх» + по дате изменения — P2
|
||||
- [Ф][open] пустое состояние Welcome (текст + «Новая заметка» + «создать образец») — P2
|
||||
- [Ф][done] сортировка «избранное вверх» + по дате изменения (`visibleNotes`)
|
||||
- [Ф][done] пустое состояние Welcome (`NotesEmptyState`: текст + «Новая заметка»)
|
||||
- [Ф][open] «Найти во всех категориях» (сброс фильтра при пустом поиске в категории) — P3
|
||||
- (состояние загрузки «Загрузка …» — Д)
|
||||
|
||||
@@ -55,30 +59,30 @@ Notes API v1 + ETag-кэш, план 1→2→3 (mail/071).
|
||||
**Д (3, open 3):** [Д][open] состав/порядок пунктов меню · [Д][open] иконки пунктов ·
|
||||
[Д][open] инлайн-поля (переименование/категория)
|
||||
|
||||
## Экран 3. Редактор заметки (rich / plain / preview) — Ff 10% · Fd 0%
|
||||
**Ф (10, open 9):**
|
||||
- [Ф][open] rich-редактор (@nextcloud/text) — P1
|
||||
## Экран 3. Редактор заметки (rich / plain / preview) — Ff 40% · Fd 0%
|
||||
**Ф (10, open 6):**
|
||||
- [Ф][open] rich-редактор (@nextcloud/text) — P2
|
||||
- [Ф][open] панель форматирования: жирный/курсив/цитата/моно/очистить/список/нумерованный/
|
||||
заголовок/крупный заголовок/ссылка (всплывающая тулбар-панель, mail/087) — P2
|
||||
- [Ф][open] plain-режим — P2
|
||||
- [Ф][done] plain-режим (`NoteEditorScreen`, `F7OutlinedField` markdown) — UI собран
|
||||
- [Ф][open] тумблер «Просмотр»/«Правка» (preview⇄edit) — P2
|
||||
- [Ф][open] тумблер «Полный экран» — P3
|
||||
- [Ф][done] автосохранение с `If-Match: "<etag>"` (`saveContent`, юнит-тест 412) — данные готовы; UI-редактор P1-UI
|
||||
- [Ф][done] автосохранение с `If-Match: "<etag>"` (debounce → `saveContent`, юнит-тест 412)
|
||||
- [Ф][open] автозаголовок из первой строки (`PUT /autotitle`) — P2
|
||||
- [Ф][open] состояние read-only заметки (индикатор, блок правки) — P2
|
||||
- [Ф][open] «Сохранение не удалось. Нажмите, чтобы повторить» (ручной ретрай) — P2
|
||||
- [Ф][open] плейсхолдеры «Write …» / «Empty note» — P3
|
||||
- [Ф][done] состояние read-only заметки (блок правки + подпись поля)
|
||||
- [Ф][open] «Сохранение не удалось. Нажмите, чтобы повторить» (ошибка в state, кнопки ретрая нет) — P2
|
||||
- [Ф][done] плейсхолдер поля (подпись `F7OutlinedField`)
|
||||
|
||||
**Д (5, open 5):** [Д][open] вёрстка редактора · [Д][open] перенос тулбара форматирования на
|
||||
мобильном (`.text-menubar__entries` flex-wrap) · [Д][open] типографика тела · [Д][open] меню
|
||||
действий редактора (preview/fullscreen) · [Д][open] индикатор read-only
|
||||
|
||||
## Экран 4. Диалог конфликта версий — Ff 33.3% · Fd 0%
|
||||
**Ф (3, open 2):**
|
||||
## Экран 4. Диалог конфликта версий — Ff 100% · Fd 0%
|
||||
**Ф (3, open 0):**
|
||||
- [Ф][done] детект конфликта (412 на `If-Match` → `NoteConflictException` + перечитка серверной
|
||||
версии, юнит-тест) — логика готова; UI-диалог P1-UI
|
||||
- [Ф][open] «Использовать версию с сервера» — P1
|
||||
- [Ф][open] «Использовать текущую версию» — P1
|
||||
версии, юнит-тест)
|
||||
- [Ф][done] «Использовать версию с сервера» (`F7AlertDialog` dismiss → `resolveConflictUseServer`)
|
||||
- [Ф][done] «Использовать текущую версию» (`F7AlertDialog` confirm → `resolveConflictKeepMine`)
|
||||
- (⚠ обязателен — НЕ молчаливая перезапись, согласовано mail/071 п.2)
|
||||
|
||||
**Д (1, open 1):** [Д][open] вёрстка диалога конфликта
|
||||
@@ -114,17 +118,17 @@ Notes API v1 + ETag-кэш, план 1→2→3 (mail/071).
|
||||
## Сводка
|
||||
| Экран | Ф всего | Ф open | Ff% | Д всего | Д open | Fd% |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1. Список | 10 | 8 | 20.0% | 6 | 6 | 0% |
|
||||
| 1. Список | 10 | 5 | 50.0% | 6 | 6 | 0% |
|
||||
| 2. Меню «⋯» | 6 | 2 | 66.7% | 3 | 3 | 0% |
|
||||
| 3. Редактор | 10 | 9 | 10.0% | 5 | 5 | 0% |
|
||||
| 4. Конфликт | 3 | 2 | 33.3% | 1 | 1 | 0% |
|
||||
| 3. Редактор | 10 | 6 | 40.0% | 5 | 5 | 0% |
|
||||
| 4. Конфликт | 3 | 0 | 100% | 1 | 1 | 0% |
|
||||
| 5. Категории | 8 | 8 | 0% | 4 | 4 | 0% |
|
||||
| 6. Настройки | 5 | 5 | 0% | 2 | 2 | 0% |
|
||||
| **Итого** | **42** | **34** | **19.0%** | **21** | **21** | **0%** |
|
||||
| **Итого** | **42** | **26** | **38.1%** | **21** | **21** | **0%** |
|
||||
|
||||
**Ff_app = 21.7%** (среднее по 6 экранам: 20+66.7+10+33.3+0+0) · **Fd_app = 0.0%** (UI не собран).
|
||||
Абсолютно: 8 из 42 Ф закрыты в коде (слой данных), 34 open. Fd — весь бэклог (нет ни одного
|
||||
собранного экрана).
|
||||
**Ff_app = 42.8%** (среднее по 6 экранам: 50+66.7+40+100+0+0) · **Fd_app = 0.0%** (экраны
|
||||
собраны, но девайс/визуал-сверки ещё не было). Абсолютно: 16 из 42 Ф закрыты в коде+UI, 26 open.
|
||||
Fd — весь бэклог до попиксельной сверки на устройстве.
|
||||
|
||||
## Предлагаемый порядок реализации (план 1→2→3, mail/071)
|
||||
- **P1 — read-only ядро:** список заметок (открыть/просмотр), rich-редактор, автосейв `If-Match`,
|
||||
|
||||
@@ -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` (тип `Д`).
|
||||
@@ -1,9 +1,11 @@
|
||||
# Diff: Конференции (Talk) — итерация 1 (2026-07-13)
|
||||
Ff_app: **40.7%** · Fd_app: **предварительно** (только сайдбар сверен live; дизайн-проход на устройстве — след. итерация) · n-a: 4
|
||||
Ff_app: **41.3%** · Fd_app: **предварительно** (только сайдбар сверен live; дизайн-проход на устройстве — след. итерация) · n-a: 4
|
||||
|
||||
> Прогресс P1 (коммиты 59543a7, bbaaf7a, a60fd78): реализованы меню сообщения «Копировать»,
|
||||
> «Копировать ссылку», «Переслать в «Личные заметки»», «Напоминание» (пресеты), «Ответить лично»
|
||||
> — `[done]` ниже (Ff экрана «Беседа» 38.1%→59.1%).
|
||||
> Прогресс P1 (59543a7, bbaaf7a, a60fd78, 01198a4): меню сообщения — «Копировать», «Копировать
|
||||
> ссылку», «Переслать в «Личные заметки»», «Переслать…» (в любую комнату), «Напоминание»
|
||||
> (пресеты), «Ответить лично» — `[done]` ниже (Ff экрана «Беседа» 38.1%→63.6%). Остаток P1:
|
||||
> «Перейти к файлу/треду» (кросс-модуль files + тредов в нативе нет), «Перевод» (нужны провайдеры
|
||||
> перевода на сервере — под вопросом/возможно n-a) — эскалирую лиду.
|
||||
|
||||
Сверка веб (карта `talk.md`, апстрим `spreed v22.0.12` + тема) ↔ натив (`feature/talk` Compose на
|
||||
OCS /api/v4; звонки — `vendor/talk-android` v23). Метод mail/083: `[Ф|Д]`·`[open|done|n-a]`,
|
||||
@@ -42,7 +44,7 @@ Ff/Fd на экран. Дизайн (Fd) сверяю на устройстве
|
||||
- Мастер создания: [Ф][done] имя/тип/аватар; [Ф][open] описание, защита паролем, «Разрешить
|
||||
гостям по ссылке» — в нативе минимальный (имя+тип). *(учтено в open выше как часть «создать».)*
|
||||
|
||||
## Экран 3: Беседа + композер + меню сообщения — Ff 59.1% (9 open Ф / 22)
|
||||
## Экран 3: Беседа + композер + меню сообщения — Ff 63.6% (8 open Ф / 22)
|
||||
Композер:
|
||||
- [Ф][done] Отправить сообщение · «Отправить».
|
||||
- [Ф][done] Эмодзи · «Смайлики».
|
||||
@@ -60,7 +62,7 @@ Ff/Fd на экран. Дизайн (Fd) сверяю на устройстве
|
||||
- [Ф][done] **Ответить лично** (Reply privately) — реализовано (a60fd78): открывает/создаёт 1:1 с автором.
|
||||
- [Ф][done] **Переслать в «Личные заметки»** — реализовано (59543a7); пересылка в произвольную
|
||||
комнату (выбор комнаты) — ещё open, вынес отдельным пунктом ниже.
|
||||
- [Ф][open] Переслать в произвольную комнату (выбор из списка) — нет (пока только note-to-self).
|
||||
- [Ф][done] Переслать в произвольную комнату (выбор из списка) — реализовано (01198a4, ForwardTargetSheet).
|
||||
- [Ф][done] **Копировать сообщение** (Copy message → буфер) — реализовано (59543a7).
|
||||
- [Ф][done] **Копировать ссылку** (Copy message link → буфер) — реализовано (59543a7).
|
||||
- [Ф][done] **Напоминание** (Set reminder — сегодня/завтра/выходные/неделя) — реализовано (bbaaf7a);
|
||||
|
||||
+49
-4
@@ -83,13 +83,58 @@ object CalendarRecurrence {
|
||||
upTo: LocalDate,
|
||||
onOccurrence: (LocalDate) -> Boolean,
|
||||
) {
|
||||
if (rule.freq == "WEEKLY" && rule.byDays.isNotEmpty()) {
|
||||
forEachWeeklyByDay(rule, start, upTo, onOccurrence)
|
||||
} else {
|
||||
forEachSimpleStep(rule, start, upTo, onOccurrence)
|
||||
when {
|
||||
rule.freq == "MONTHLY" && rule.monthlyOrdinal != null && rule.byDays.isNotEmpty() ->
|
||||
forEachMonthlyNthWeekday(rule, start, upTo, onOccurrence)
|
||||
rule.freq == "WEEKLY" && rule.byDays.isNotEmpty() ->
|
||||
forEachWeeklyByDay(rule, start, upTo, onOccurrence)
|
||||
else -> forEachSimpleStep(rule, start, upTo, onOccurrence)
|
||||
}
|
||||
}
|
||||
|
||||
/** MONTHLY с BYDAY=<n><день>: n-й (или последний при -1) день недели каждого интервал-месяца. */
|
||||
private inline fun forEachMonthlyNthWeekday(
|
||||
rule: RecurrenceRule,
|
||||
start: LocalDate,
|
||||
upTo: LocalDate,
|
||||
onOccurrence: (LocalDate) -> Boolean,
|
||||
) {
|
||||
val interval = rule.interval.coerceAtLeast(1)
|
||||
val (until, maxCount) = bounds(rule)
|
||||
val ordinal = rule.monthlyOrdinal ?: return
|
||||
val dow = RecurrenceRule.ICS_DAYS.firstOrNull { it in rule.byDays }?.let { icsDayToDow(it) } ?: return
|
||||
val baseMonth = java.time.YearMonth.from(start)
|
||||
var emitted = 0
|
||||
var n = 0L
|
||||
while (n < MAX_STEPS) {
|
||||
val month = baseMonth.plusMonths(n * interval)
|
||||
val occ = nthWeekdayOfMonth(month, dow, ordinal)
|
||||
n++
|
||||
if (occ == null || occ.isBefore(start)) {
|
||||
if (month.atEndOfMonth().isAfter(upTo)) return
|
||||
continue
|
||||
}
|
||||
val pastUntil = until != null && occ.isAfter(until)
|
||||
if (occ.isAfter(upTo) || pastUntil || emitted >= maxCount) return
|
||||
emitted++
|
||||
if (!onOccurrence(occ)) return
|
||||
}
|
||||
}
|
||||
|
||||
/** n-й день недели [dow] месяца [month]; ordinal>0 — с начала, -1 — последний; null если нет. */
|
||||
private fun nthWeekdayOfMonth(month: java.time.YearMonth, dow: DayOfWeek, ordinal: Int): LocalDate? {
|
||||
if (ordinal < 0) {
|
||||
val last = month.atEndOfMonth()
|
||||
val shift = (last.dayOfWeek.value - dow.value + 7) % 7
|
||||
val candidate = last.minusDays(shift.toLong())
|
||||
return if (java.time.YearMonth.from(candidate) == month) candidate else null
|
||||
}
|
||||
val first = month.atDay(1)
|
||||
val shift = (dow.value - first.dayOfWeek.value + 7) % 7
|
||||
val candidate = first.plusDays(shift.toLong() + (ordinal - 1) * 7L)
|
||||
return if (java.time.YearMonth.from(candidate) == month) candidate else null
|
||||
}
|
||||
|
||||
/** WEEKLY с BYDAY: раскрываем выбранные дни недели в каждом интервал-блоке недель. */
|
||||
private inline fun forEachWeeklyByDay(
|
||||
rule: RecurrenceRule,
|
||||
|
||||
+14
-3
@@ -9,6 +9,8 @@ data class RecurrenceRule(
|
||||
val interval: Int = 1,
|
||||
/** Дни недели для WEEKLY: MO..SU (порядок как в ICS). */
|
||||
val byDays: Set<String> = emptySet(),
|
||||
/** Для MONTHLY с BYDAY=<n><день>: ординал (1..5 = n-й, -1 = последний). null — по числу месяца. */
|
||||
val monthlyOrdinal: Int? = null,
|
||||
val endType: EndType = EndType.NEVER,
|
||||
val untilDate: LocalDate? = null,
|
||||
val count: Int = 10,
|
||||
@@ -18,7 +20,10 @@ data class RecurrenceRule(
|
||||
fun toRrule(): String = buildString {
|
||||
append("FREQ=$freq")
|
||||
if (interval > 1) append(";INTERVAL=$interval")
|
||||
if (freq == "WEEKLY" && byDays.isNotEmpty()) {
|
||||
if (freq == "MONTHLY" && monthlyOrdinal != null && byDays.isNotEmpty()) {
|
||||
// Месячный n-й день недели, напр. BYDAY=1MO (первый понедельник) — раньше терялся.
|
||||
append(";BYDAY=$monthlyOrdinal${ICS_DAYS.first { it in byDays }}")
|
||||
} else if (freq == "WEEKLY" && byDays.isNotEmpty()) {
|
||||
append(";BYDAY=${ICS_DAYS.filter { it in byDays }.joinToString(",")}")
|
||||
}
|
||||
when (endType) {
|
||||
@@ -35,6 +40,7 @@ data class RecurrenceRule(
|
||||
"FR" to "Пт", "SA" to "Сб", "SU" to "Вс",
|
||||
)
|
||||
private val UNTIL_FMT = DateTimeFormatter.BASIC_ISO_DATE
|
||||
private val BYDAY_ORDINAL = Regex("^(-?\\d+)(MO|TU|WE|TH|FR|SA|SU)$")
|
||||
|
||||
/** Парсинг RRULE-строки; null для пустой/неразбираемой. */
|
||||
fun parse(rrule: String): RecurrenceRule? {
|
||||
@@ -50,11 +56,16 @@ data class RecurrenceRule(
|
||||
runCatching { LocalDate.parse(raw.take(8), UNTIL_FMT) }.getOrNull()
|
||||
}
|
||||
val count = parts["COUNT"]?.toIntOrNull()
|
||||
val bydayTokens = parts["BYDAY"]?.split(',')?.map { it.trim().uppercase() }.orEmpty()
|
||||
// Ординал для MONTHLY (BYDAY=1MO/-1FR): берём из первого токена с числовым префиксом.
|
||||
val monthlyOrdinal = bydayTokens
|
||||
.takeIf { freq == "MONTHLY" }
|
||||
?.firstNotNullOfOrNull { BYDAY_ORDINAL.find(it)?.groupValues?.get(1)?.toIntOrNull() }
|
||||
return RecurrenceRule(
|
||||
freq = freq,
|
||||
interval = parts["INTERVAL"]?.toIntOrNull()?.coerceAtLeast(1) ?: 1,
|
||||
byDays = parts["BYDAY"]?.split(',')?.map { it.trim().takeLast(2).uppercase() }
|
||||
?.filter { it in ICS_DAYS }?.toSet().orEmpty(),
|
||||
byDays = bydayTokens.map { it.takeLast(2) }.filter { it in ICS_DAYS }.toSet(),
|
||||
monthlyOrdinal = monthlyOrdinal,
|
||||
endType = when {
|
||||
until != null -> EndType.UNTIL
|
||||
count != null -> EndType.COUNT
|
||||
|
||||
+25
@@ -80,6 +80,31 @@ class CalendarRecurrenceTest {
|
||||
assertFalse(occurs("FREQ=WEEKLY", "2026-07-01", "2026-07-10", span = 2, allDay = true))
|
||||
}
|
||||
|
||||
@Test fun monthly_nth_weekday_first_monday() {
|
||||
// первый понедельник: июль'26 = 06, авг = 03, сен = 07
|
||||
val r = "FREQ=MONTHLY;BYDAY=1MO"
|
||||
assertTrue(occurs(r, "2026-07-06", "2026-07-06"))
|
||||
assertTrue(occurs(r, "2026-07-06", "2026-08-03"))
|
||||
assertTrue(occurs(r, "2026-07-06", "2026-09-07"))
|
||||
assertFalse("второй понедельник — нет", occurs(r, "2026-07-06", "2026-07-13"))
|
||||
assertFalse("число месяца, не день недели", occurs(r, "2026-07-06", "2026-08-06"))
|
||||
}
|
||||
|
||||
@Test fun monthly_last_friday() {
|
||||
// последняя пятница: июль'26 = 31, авг = 28
|
||||
val r = "FREQ=MONTHLY;BYDAY=-1FR"
|
||||
assertTrue(occurs(r, "2026-07-31", "2026-07-31"))
|
||||
assertTrue(occurs(r, "2026-07-31", "2026-08-28"))
|
||||
assertFalse(occurs(r, "2026-07-31", "2026-08-21"))
|
||||
}
|
||||
|
||||
@Test fun byday_ordinal_round_trips_through_rrule() {
|
||||
// раньше toRrule терял ординал (1MO → MO); теперь сохраняет.
|
||||
assertEquals("FREQ=MONTHLY;BYDAY=1MO", rule("FREQ=MONTHLY;BYDAY=1MO").toRrule())
|
||||
assertEquals("FREQ=MONTHLY;BYDAY=-1FR", rule("FREQ=MONTHLY;BYDAY=-1FR").toRrule())
|
||||
assertEquals(1, rule("FREQ=MONTHLY;BYDAY=1MO").monthlyOrdinal)
|
||||
}
|
||||
|
||||
@Test fun range_listing() {
|
||||
val starts = CalendarRecurrence.occurrenceStartsInRange(
|
||||
rule = rule("FREQ=WEEKLY"),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<ManuallySuppressedIssues/>
|
||||
<CurrentIssues>
|
||||
<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$it</ID>
|
||||
</CurrentIssues>
|
||||
|
||||
+122
-24
@@ -17,14 +17,20 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -42,6 +48,9 @@ import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton
|
||||
fun ContactDetailSheet(
|
||||
contact: ContactItem,
|
||||
onDismiss: () -> Unit,
|
||||
onEdit: () -> Unit = {},
|
||||
onDelete: () -> Unit = {},
|
||||
deleting: Boolean = false,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
ModalBottomSheet(
|
||||
@@ -52,6 +61,9 @@ fun ContactDetailSheet(
|
||||
ContactDetailContent(
|
||||
contact = contact,
|
||||
onDismiss = onDismiss,
|
||||
onEdit = onEdit,
|
||||
onDelete = onDelete,
|
||||
deleting = deleting,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -60,8 +72,13 @@ fun ContactDetailSheet(
|
||||
private fun ContactDetailContent(
|
||||
contact: ContactItem,
|
||||
onDismiss: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
deleting: Boolean,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
// Правка/удаление доступны только для синхронизированных контактов (есть href/raw).
|
||||
val editable = contact.href.isNotBlank() && contact.raw.isNotBlank()
|
||||
val label = contact.displayName.ifBlank { contact.email }
|
||||
val photoBytes = remember(contact.uid, contact.photoBase64) {
|
||||
ContactUi.decodeContactPhoto(contact.photoBase64)
|
||||
@@ -163,31 +180,27 @@ private fun ContactDetailContent(
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
) {
|
||||
if (contact.emailLines.isNotEmpty()) {
|
||||
contact.emailLines.forEachIndexed { index, email ->
|
||||
ContactDetailField(
|
||||
label = if (index == 0) "Email" else "Email ${index + 1}",
|
||||
value = email,
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:$email")),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
contact.emailEntries.forEachIndexed { index, entry ->
|
||||
ContactDetailField(
|
||||
label = ContactUi.emailTypeLabel(entry.type, index),
|
||||
value = entry.value,
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:${entry.value}")),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (contact.phoneLines.isNotEmpty()) {
|
||||
contact.phoneLines.forEachIndexed { index, phone ->
|
||||
ContactDetailField(
|
||||
label = if (index == 0) "Телефон" else "Телефон ${index + 1}",
|
||||
value = phone,
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_DIAL, Uri.parse("tel:$phone")),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
contact.phoneEntries.forEachIndexed { index, entry ->
|
||||
ContactDetailField(
|
||||
label = ContactUi.phoneTypeLabel(entry.type, index),
|
||||
value = entry.value,
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_DIAL, Uri.parse("tel:${entry.value}")),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (contact.address.isNotBlank()) {
|
||||
ContactDetailField(label = "Адрес", value = contact.address)
|
||||
@@ -213,6 +226,15 @@ private fun ContactDetailContent(
|
||||
}
|
||||
}
|
||||
|
||||
if (editable) {
|
||||
ContactDetailActions(
|
||||
contact = contact,
|
||||
deleting = deleting,
|
||||
onEdit = onEdit,
|
||||
onDelete = onDelete,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"Закрыть",
|
||||
@@ -225,6 +247,65 @@ private fun ContactDetailContent(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactDetailActions(
|
||||
contact: ContactItem,
|
||||
deleting: Boolean,
|
||||
onEdit: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
var confirmDelete by remember { mutableStateOf(false) }
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
F7SecondaryButton(
|
||||
text = "Править",
|
||||
onClick = onEdit,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(100.dp))
|
||||
.clickable(enabled = !deleting) { confirmDelete = true }
|
||||
.padding(vertical = 12.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (deleting) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
color = F7Colors.Error,
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
Text("Удалить", color = F7Colors.Error, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmDelete) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmDelete = false },
|
||||
title = { Text("Удалить контакт?") },
|
||||
text = {
|
||||
Text("«${contact.displayName.ifBlank { contact.email }}» будет удалён без возможности отмены.")
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
confirmDelete = false
|
||||
onDelete()
|
||||
},
|
||||
) { Text("Удалить", color = F7Colors.Error) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { confirmDelete = false }) { Text("Отмена") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactDetailField(
|
||||
label: String,
|
||||
@@ -287,4 +368,21 @@ internal object ContactUi {
|
||||
android.util.Base64.decode(base64, android.util.Base64.DEFAULT)
|
||||
}.getOrNull()?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
// P2: подпись типа EMAIL/TEL по токену TYPE (WORK/HOME/CELL/…); index — фолбэк-нумерация.
|
||||
fun emailTypeLabel(type: String, index: Int): String = when (type.uppercase()) {
|
||||
"WORK" -> "Рабочий"
|
||||
"HOME" -> "Личный"
|
||||
"" -> if (index == 0) "Email" else "Email ${index + 1}"
|
||||
else -> type.lowercase().replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
|
||||
fun phoneTypeLabel(type: String, index: Int): String = when (type.uppercase()) {
|
||||
"CELL", "MOBILE" -> "Мобильный"
|
||||
"WORK" -> "Рабочий"
|
||||
"HOME" -> "Домашний"
|
||||
"FAX" -> "Факс"
|
||||
"" -> if (index == 0) "Телефон" else "Телефон ${index + 1}"
|
||||
else -> type.lowercase().replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
}
|
||||
|
||||
+83
@@ -9,6 +9,8 @@ import ru.forbion.f7cloud.core.database.ContactEntity
|
||||
import ru.forbion.f7cloud.core.database.F7Database
|
||||
import ru.forbion.f7cloud.core.network.CardDavClient
|
||||
import ru.forbion.f7cloud.core.network.NetworkFactory
|
||||
import ru.forbion.f7cloud.core.network.VCardFields
|
||||
import ru.forbion.f7cloud.core.network.VCardTypedValue
|
||||
|
||||
data class ContactItem(
|
||||
val uid: String,
|
||||
@@ -25,6 +27,10 @@ data class ContactItem(
|
||||
val birthday: String = "",
|
||||
val emails: String = "",
|
||||
val phones: String = "",
|
||||
// Для правки/удаления (If-Match) и round-trip неизвестных полей vCard (P1).
|
||||
val href: String = "",
|
||||
val etag: String = "",
|
||||
val raw: String = "",
|
||||
) {
|
||||
val emailLines: List<String>
|
||||
get() = (if (emails.isNotBlank()) emails else email)
|
||||
@@ -40,6 +46,21 @@ data class ContactItem(
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
|
||||
// P2: значения с типом (WORK/HOME/CELL) из сырого vCard; фолбэк на значения без типа.
|
||||
val emailEntries: List<VCardTypedValue>
|
||||
get() = if (raw.isNotBlank()) {
|
||||
CardDavClient.parseTypedValues(raw, "EMAIL")
|
||||
} else {
|
||||
emailLines.map { VCardTypedValue(it) }
|
||||
}
|
||||
|
||||
val phoneEntries: List<VCardTypedValue>
|
||||
get() = if (raw.isNotBlank()) {
|
||||
CardDavClient.parseTypedValues(raw, "TEL")
|
||||
} else {
|
||||
phoneLines.map { VCardTypedValue(it) }
|
||||
}
|
||||
|
||||
val subtitle: String?
|
||||
get() = when {
|
||||
title.isNotBlank() && organization.isNotBlank() -> "$title · $organization"
|
||||
@@ -104,6 +125,9 @@ class ContactsRepository(context: Context) {
|
||||
birthday = contact.birthday,
|
||||
emails = contact.emails,
|
||||
phones = contact.phones,
|
||||
href = contact.href,
|
||||
etag = contact.etag,
|
||||
raw = contact.raw,
|
||||
)
|
||||
}
|
||||
dao.replaceAll(key, entities)
|
||||
@@ -147,6 +171,62 @@ class ContactsRepository(context: Context) {
|
||||
return entity.toItem()
|
||||
}
|
||||
|
||||
/**
|
||||
* Правка контакта: round-trip сырого vCard (сохраняет PHOTO/X-*), PUT If-Match по ETag.
|
||||
* Требует, чтобы контакт был синхронизирован (есть href/raw). Обновляет Room новым состоянием.
|
||||
*/
|
||||
suspend fun updateContact(
|
||||
session: AuthSession,
|
||||
item: ContactItem,
|
||||
fields: VCardFields,
|
||||
): ContactItem {
|
||||
if (item.href.isBlank() || item.raw.isBlank()) {
|
||||
error("Контакт ещё не синхронизирован — обновите список и повторите")
|
||||
}
|
||||
val client = authedClient(session)
|
||||
val updated = CardDavClient.updateContact(
|
||||
client = client,
|
||||
href = item.href,
|
||||
etag = item.etag,
|
||||
rawVCard = item.raw,
|
||||
fields = fields,
|
||||
bookName = item.bookName,
|
||||
)
|
||||
val key = accountKey(session)
|
||||
val entity = ContactEntity(
|
||||
accountKey = key,
|
||||
uid = updated.uid.ifBlank { item.uid },
|
||||
displayName = updated.displayName,
|
||||
email = updated.email,
|
||||
phone = updated.phone,
|
||||
bookName = updated.bookName.ifBlank { item.bookName },
|
||||
photoBase64 = updated.photoBase64,
|
||||
photoMimeType = updated.photoMimeType,
|
||||
organization = updated.organization,
|
||||
title = updated.title,
|
||||
address = updated.address,
|
||||
website = updated.website,
|
||||
birthday = updated.birthday,
|
||||
emails = updated.emails,
|
||||
phones = updated.phones,
|
||||
href = updated.href.ifBlank { item.href },
|
||||
etag = updated.etag,
|
||||
raw = updated.raw,
|
||||
)
|
||||
dao.insert(entity)
|
||||
return entity.toItem()
|
||||
}
|
||||
|
||||
/** Удаление контакта (DELETE If-Match) + удаление из Room-кэша. */
|
||||
suspend fun deleteContact(session: AuthSession, item: ContactItem) {
|
||||
if (item.href.isBlank()) {
|
||||
error("Контакт ещё не синхронизирован — обновите список и повторите")
|
||||
}
|
||||
val client = authedClient(session)
|
||||
CardDavClient.deleteContact(client, item.href, item.etag)
|
||||
dao.deleteByUid(accountKey(session), item.uid)
|
||||
}
|
||||
|
||||
fun filterSuggestions(
|
||||
contacts: List<ContactItem>,
|
||||
query: String,
|
||||
@@ -224,6 +304,9 @@ class ContactsRepository(context: Context) {
|
||||
birthday = birthday,
|
||||
emails = emails,
|
||||
phones = phones,
|
||||
href = href,
|
||||
etag = etag,
|
||||
raw = raw,
|
||||
)
|
||||
|
||||
companion object {
|
||||
|
||||
+128
-3
@@ -15,15 +15,17 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -52,6 +54,8 @@ import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||
import ru.forbion.f7cloud.core.designsystem.F7CreateButton
|
||||
import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
|
||||
import ru.forbion.f7cloud.core.network.VCardFields
|
||||
import ru.forbion.f7cloud.core.network.VCardTypedValue
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -121,7 +125,19 @@ fun ContactsScreen(
|
||||
ContactDetailSheet(
|
||||
contact = contact,
|
||||
onDismiss = vm::closeContactDetail,
|
||||
onEdit = vm::openEditSheet,
|
||||
onDelete = { vm.deleteContact(session, contact) },
|
||||
deleting = state.deletingContact,
|
||||
)
|
||||
if (state.editSheetOpen) {
|
||||
EditContactDialog(
|
||||
contact = contact,
|
||||
saving = state.savingEdit,
|
||||
error = state.editError,
|
||||
onDismiss = vm::closeEditSheet,
|
||||
onSave = { fields -> vm.saveEdit(session, contact, fields) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.addSheetOpen) {
|
||||
@@ -136,6 +152,114 @@ fun ContactsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditContactDialog(
|
||||
contact: ContactItem,
|
||||
saving: Boolean,
|
||||
error: String?,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (VCardFields) -> Unit,
|
||||
) {
|
||||
var name by remember(contact.uid) { mutableStateOf(contact.displayName) }
|
||||
var organization by remember(contact.uid) { mutableStateOf(contact.organization) }
|
||||
var title by remember(contact.uid) { mutableStateOf(contact.title) }
|
||||
var emails by remember(contact.uid) { mutableStateOf(contact.emailLines.joinToString("\n")) }
|
||||
var phones by remember(contact.uid) { mutableStateOf(contact.phoneLines.joinToString("\n")) }
|
||||
var address by remember(contact.uid) { mutableStateOf(contact.address) }
|
||||
var website by remember(contact.uid) { mutableStateOf(contact.website) }
|
||||
var birthday by remember(contact.uid) { mutableStateOf(contact.birthday) }
|
||||
|
||||
// Типы сохраняем по значению: неизменённые адреса/номера не теряют TYPE (fix P1-каветата).
|
||||
val emailTypeByValue = contact.emailEntries.associate { it.value to it.type }
|
||||
val phoneTypeByValue = contact.phoneEntries.associate { it.value to it.type }
|
||||
|
||||
fun buildFields(): VCardFields {
|
||||
fun lines(v: String) = v.lines().map { it.trim() }.filter { it.isNotBlank() }
|
||||
return VCardFields(
|
||||
displayName = name.trim(),
|
||||
emails = lines(emails).map { VCardTypedValue(it, emailTypeByValue[it].orEmpty()) },
|
||||
phones = lines(phones).map { VCardTypedValue(it, phoneTypeByValue[it].orEmpty()) },
|
||||
organization = organization.trim(),
|
||||
title = title.trim(),
|
||||
address = address.trim(),
|
||||
website = website.trim(),
|
||||
birthday = birthday.trim(),
|
||||
)
|
||||
}
|
||||
|
||||
Dialog(onDismissRequest = { if (!saving) onDismiss() }) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(Color.White)
|
||||
.border(1.dp, F7Colors.Border, RoundedCornerShape(16.dp))
|
||||
.padding(20.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
"Править контакт",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
AddContactField(label = "Имя", value = name, onValueChange = { name = it })
|
||||
AddContactField(label = "Организация", value = organization, onValueChange = { organization = it })
|
||||
AddContactField(label = "Должность", value = title, onValueChange = { title = it })
|
||||
AddContactField(
|
||||
label = "Email (по одному в строке)",
|
||||
value = emails,
|
||||
onValueChange = { emails = it },
|
||||
singleLine = false,
|
||||
)
|
||||
AddContactField(
|
||||
label = "Телефон (по одному в строке)",
|
||||
value = phones,
|
||||
onValueChange = { phones = it },
|
||||
singleLine = false,
|
||||
)
|
||||
AddContactField(label = "Адрес", value = address, onValueChange = { address = it })
|
||||
AddContactField(label = "Сайт", value = website, onValueChange = { website = it })
|
||||
AddContactField(label = "День рождения", value = birthday, onValueChange = { birthday = it })
|
||||
if (!error.isNullOrBlank()) {
|
||||
Text(error, color = MaterialTheme.colorScheme.error, fontSize = 13.sp)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"Отмена",
|
||||
modifier = Modifier
|
||||
.clickable(enabled = !saving, onClick = onDismiss)
|
||||
.padding(8.dp),
|
||||
color = F7Colors.TextSecondary,
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(100.dp))
|
||||
.background(F7Colors.Primary)
|
||||
.clickable(enabled = !saving) { onSave(buildFields()) }
|
||||
.padding(horizontal = 20.dp, vertical = 10.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (saving) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
color = Color.White,
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
Text("Сохранить", color = Color.White, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddContactDialog(
|
||||
saving: Boolean,
|
||||
@@ -209,6 +333,7 @@ private fun AddContactField(
|
||||
label: String,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
singleLine: Boolean = true,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(label, fontSize = 13.sp, color = F7Colors.TextSecondary)
|
||||
@@ -220,7 +345,7 @@ private fun AddContactField(
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.border(1.dp, F7Colors.Border, RoundedCornerShape(8.dp))
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
singleLine = true,
|
||||
singleLine = singleLine,
|
||||
textStyle = TextStyle(fontSize = 15.sp, color = F7Colors.TextPrimary),
|
||||
cursorBrush = SolidColor(F7Colors.Primary),
|
||||
)
|
||||
|
||||
+69
@@ -16,6 +16,7 @@ import kotlinx.coroutines.launch
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
import ru.forbion.f7cloud.core.designsystem.AppForegroundTracker
|
||||
import ru.forbion.f7cloud.core.network.UnauthorizedException
|
||||
import ru.forbion.f7cloud.core.network.VCardFields
|
||||
|
||||
data class ContactsUiState(
|
||||
val loading: Boolean = false,
|
||||
@@ -28,6 +29,10 @@ data class ContactsUiState(
|
||||
val savingContact: Boolean = false,
|
||||
val addError: String? = null,
|
||||
val selectedContact: ContactItem? = null,
|
||||
val editSheetOpen: Boolean = false,
|
||||
val savingEdit: Boolean = false,
|
||||
val editError: String? = null,
|
||||
val deletingContact: Boolean = false,
|
||||
) {
|
||||
val filteredContacts: List<ContactItem>
|
||||
get() {
|
||||
@@ -70,6 +75,70 @@ class ContactsViewModel(
|
||||
_state.update { it.copy(selectedContact = null) }
|
||||
}
|
||||
|
||||
fun openEditSheet() {
|
||||
_state.update { it.copy(editSheetOpen = true, editError = null) }
|
||||
}
|
||||
|
||||
fun closeEditSheet() {
|
||||
_state.update { it.copy(editSheetOpen = false, editError = null, savingEdit = false) }
|
||||
}
|
||||
|
||||
/** Сохранение правки контакта: round-trip vCard (If-Match). fields собирает форма. */
|
||||
fun saveEdit(session: AuthSession, item: ContactItem, fields: VCardFields) {
|
||||
if (_state.value.savingEdit) return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
_state.update { it.copy(savingEdit = true, editError = null) }
|
||||
runCatching { repository.updateContact(session, item, fields) }
|
||||
.onSuccess { updated ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
savingEdit = false,
|
||||
editSheetOpen = false,
|
||||
editError = null,
|
||||
selectedContact = updated,
|
||||
)
|
||||
}
|
||||
refresh(session, showLoading = false)
|
||||
}
|
||||
.onFailure { t ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
savingEdit = false,
|
||||
editError = t.message ?: "Не удалось сохранить контакт",
|
||||
unauthorized = t is UnauthorizedException,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Удаление контакта (If-Match) + закрытие карточки. */
|
||||
fun deleteContact(session: AuthSession, item: ContactItem) {
|
||||
if (_state.value.deletingContact) return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
_state.update { it.copy(deletingContact = true, editError = null) }
|
||||
runCatching { repository.deleteContact(session, item) }
|
||||
.onSuccess {
|
||||
_state.update {
|
||||
it.copy(
|
||||
deletingContact = false,
|
||||
selectedContact = null,
|
||||
editSheetOpen = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
.onFailure { t ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
deletingContact = false,
|
||||
editError = t.message ?: "Не удалось удалить контакт",
|
||||
unauthorized = t is UnauthorizedException,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun load(session: AuthSession) {
|
||||
if (activeSession?.serverUrl == session.serverUrl &&
|
||||
activeSession?.username == session.username
|
||||
|
||||
@@ -2,6 +2,7 @@ plugins {
|
||||
id 'com.android.library'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
id 'org.jetbrains.kotlin.plugin.compose'
|
||||
id 'io.github.takahirom.roborazzi'
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -21,6 +22,12 @@ android {
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
// Roborazzi/Robolectric: рендер Compose deck-экранов в PNG на JVM (Fd-сверка, mail/111).
|
||||
testOptions {
|
||||
unitTests {
|
||||
includeAndroidResources = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -35,4 +42,10 @@ dependencies {
|
||||
implementation libs.compose.material3
|
||||
implementation libs.compose.foundation
|
||||
implementation libs.lifecycle.viewmodel.compose
|
||||
testImplementation libs.robolectric
|
||||
testImplementation libs.roborazzi
|
||||
testImplementation libs.roborazzi.compose
|
||||
testImplementation libs.roborazzi.junit.rule
|
||||
testImplementation libs.compose.ui.test.junit4
|
||||
debugImplementation libs.compose.ui.test.manifest
|
||||
}
|
||||
|
||||
@@ -2,6 +2,36 @@
|
||||
<SmellBaseline>
|
||||
<ManuallySuppressedIssues/>
|
||||
<CurrentIssues>
|
||||
<ID>NoUnusedImports:DeckRepository.kt$ru.forbion.f7cloud.feature.deck.DeckRepository.kt</ID>
|
||||
<ID>ArgumentListWrapping:DeckCardSheet.kt$("В архив", onClick = onArchive, enabled = !busy, modifier = Modifier.weight(1f))</ID>
|
||||
<ID>ArgumentListWrapping:DeckCardSheet.kt$("Сбросить", color = F7Colors.Primary, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.clickable { duedate = null })</ID>
|
||||
<ID>ArgumentListWrapping:DeckCardSheet.kt$("Удалить", onClick = onDelete, enabled = !busy, modifier = Modifier.weight(1f))</ID>
|
||||
<ID>ArgumentListWrapping:DeckCardSheet.kt$(1f)</ID>
|
||||
<ID>ArgumentListWrapping:DeckCardSheet.kt$(6.dp)</ID>
|
||||
<ID>ArgumentListWrapping:DeckCardSheet.kt$(8.dp)</ID>
|
||||
<ID>ArgumentListWrapping:DeckCardSheet.kt$(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp))</ID>
|
||||
<ID>ArgumentListWrapping:DeckCardSheet.kt$(label.title, style = MaterialTheme.typography.labelMedium, color = F7Colors.TextPrimary)</ID>
|
||||
<ID>ArgumentListWrapping:DeckCardSheet.kt$(stack.title, style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextPrimary)</ID>
|
||||
<ID>ArgumentListWrapping:DeckCardSheet.kt$(value = description, onValueChange = { description = it }, label = "Описание", minLines = 3)</ID>
|
||||
<ID>ArgumentListWrapping:DeckCardSheet.kt$(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp))</ID>
|
||||
<ID>ArgumentListWrapping:DeckScreen.kt$(", ")</ID>
|
||||
<ID>ArgumentListWrapping:DeckScreen.kt$("👤 ${card.assignees.joinToString(", ")}", style = MaterialTheme.typography.labelMedium, color = F7Colors.TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis)</ID>
|
||||
<ID>ArgumentListWrapping:DeckScreen.kt$(18.dp)</ID>
|
||||
<ID>ArgumentListWrapping:DeckScreen.kt$(4.dp)</ID>
|
||||
<ID>ArgumentListWrapping:DeckScreen.kt$(Icons.Filled.Add, contentDescription = "Добавить карточку", tint = F7Colors.Primary, modifier = Modifier.size(18.dp))</ID>
|
||||
<ID>ArgumentListWrapping:DeckScreen.kt$(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp))</ID>
|
||||
<ID>ArgumentListWrapping:DeckViewModel.kt$DeckViewModel$(boardDetail = detail, openedCard = freshCard ?: it.openedCard, busy = false)</ID>
|
||||
<ID>CyclomaticComplexMethod:DeckCardSheet.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun DeckCardSheet( card: DeckCard, boardLabels: List<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>
|
||||
</SmellBaseline>
|
||||
|
||||
@@ -43,9 +43,13 @@ import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||
import ru.forbion.f7cloud.core.designsystem.F7OutlinedField
|
||||
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
|
||||
import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton
|
||||
import java.time.DayOfWeek
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.temporal.TemporalAdjusters
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
@@ -120,6 +124,17 @@ fun DeckCardSheet(
|
||||
Text("Сбросить", color = F7Colors.Primary, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.clickable { duedate = null })
|
||||
}
|
||||
}
|
||||
if (canEdit) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
DeckDueChip("Сегодня") { duedate = deckQuickDueIso(0) }
|
||||
DeckDueChip("Завтра") { duedate = deckQuickDueIso(1) }
|
||||
DeckDueChip("Выходные") { duedate = deckQuickDueIso(0, toWeekend = true) }
|
||||
DeckDueChip("Неделя") { duedate = deckQuickDueIso(7) }
|
||||
}
|
||||
}
|
||||
|
||||
// Метки доски
|
||||
if (boardLabels.isNotEmpty()) {
|
||||
@@ -236,8 +251,9 @@ fun DeckTextPromptDialog(
|
||||
confirmText: String,
|
||||
onConfirm: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
initial: String = "",
|
||||
) {
|
||||
var text by remember { mutableStateOf("") }
|
||||
var text by remember { mutableStateOf(initial) }
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Surface(shape = RoundedCornerShape(16.dp), color = Color.White) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
@@ -263,6 +279,56 @@ fun DeckTextPromptDialog(
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun DeckConfirmDialog(
|
||||
title: String,
|
||||
message: String,
|
||||
confirmText: String,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Surface(shape = RoundedCornerShape(16.dp), color = Color.White) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = F7Colors.TextPrimary,
|
||||
)
|
||||
Text(message, style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
F7SecondaryButton("Отмена", onClick = onDismiss, modifier = Modifier.weight(1f))
|
||||
F7PrimaryButton(text = confirmText, onClick = onConfirm, modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeckDueChip(text: String, onClick: () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(100.dp))
|
||||
.background(F7Colors.SurfaceMuted)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(text, style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
/** ISO-срок для быстрых опций: сегодня/через N дней/ближайшая суббота (полночь UTC — как пикер). */
|
||||
private fun deckQuickDueIso(daysAhead: Long, toWeekend: Boolean = false): String {
|
||||
val date = if (toWeekend) {
|
||||
LocalDate.now().with(TemporalAdjusters.nextOrSame(DayOfWeek.SATURDAY))
|
||||
} else {
|
||||
LocalDate.now().plusDays(daysAhead)
|
||||
}
|
||||
return DateTimeFormatter.ISO_INSTANT.format(date.atStartOfDay(ZoneOffset.UTC).toInstant())
|
||||
}
|
||||
|
||||
/** Парсит ISO-строку срока Deck в миллисекунды (для инициализации пикера). */
|
||||
internal fun parseDeckDueMillis(iso: String): Long? = runCatching {
|
||||
Instant.parse(iso).toEpochMilli()
|
||||
|
||||
@@ -100,6 +100,7 @@ class DeckRepository {
|
||||
labels = parseLabels(card.optJSONArray("labels")),
|
||||
assignees = parseAssignees(card.optJSONArray("assignedUsers")),
|
||||
assigneeUids = parseAssigneeUids(card.optJSONArray("assignedUsers")),
|
||||
commentsCount = card.optInt("commentsCount", 0),
|
||||
)
|
||||
|
||||
private fun parseLabels(array: JSONArray?): List<DeckLabel> {
|
||||
@@ -214,6 +215,15 @@ class DeckRepository {
|
||||
return DeckStack(id = id, boardId = boardId, title = title, order = 999, cards = emptyList())
|
||||
}
|
||||
|
||||
fun renameStack(session: AuthSession, stack: DeckStack, title: String) {
|
||||
val body = JSONObject().put("title", title).put("boardId", stack.boardId).put("order", stack.order)
|
||||
sendJson(session, "PUT", "${apiBase(session)}/stacks/${stack.id}", body)
|
||||
}
|
||||
|
||||
fun deleteStack(session: AuthSession, stackId: Int) {
|
||||
sendJson(session, "DELETE", "${apiBase(session)}/stacks/$stackId", null)
|
||||
}
|
||||
|
||||
// --- HTTP ---
|
||||
|
||||
private fun getJson(client: OkHttpClient, url: String): Any {
|
||||
@@ -287,6 +297,7 @@ data class DeckCard(
|
||||
val labels: List<DeckLabel> = emptyList(),
|
||||
val assignees: List<String> = emptyList(),
|
||||
val assigneeUids: List<String> = emptyList(),
|
||||
val commentsCount: Int = 0,
|
||||
)
|
||||
|
||||
data class DeckBoardDetail(
|
||||
|
||||
@@ -22,7 +22,10 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -62,6 +65,8 @@ fun DeckScreen(
|
||||
val state by vm.state.collectAsState()
|
||||
var addStackOpen by remember { mutableStateOf(false) }
|
||||
var addCardStackId by remember { mutableStateOf<Int?>(null) }
|
||||
var renameStackTarget by remember { mutableStateOf<DeckStack?>(null) }
|
||||
var deleteStackTarget by remember { mutableStateOf<DeckStack?>(null) }
|
||||
|
||||
LaunchedEffect(session.serverUrl, session.username) { vm.load(session) }
|
||||
LaunchedEffect(openCardId) {
|
||||
@@ -110,6 +115,8 @@ fun DeckScreen(
|
||||
onCardClick = { vm.openCard(it) },
|
||||
onToggleDone = { vm.toggleDone(session, it) },
|
||||
onAddCard = { addCardStackId = stack.id },
|
||||
onRename = { renameStackTarget = stack },
|
||||
onDelete = { deleteStackTarget = stack },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -165,10 +172,48 @@ fun DeckScreen(
|
||||
onDismiss = { addCardStackId = null },
|
||||
)
|
||||
}
|
||||
DeckStackDialogs(
|
||||
renameTarget = renameStackTarget,
|
||||
deleteTarget = deleteStackTarget,
|
||||
onRenameConfirm = { stack, title -> vm.renameStack(session, stack, title); renameStackTarget = null },
|
||||
onDeleteConfirm = { stack -> vm.deleteStack(session, stack); deleteStackTarget = null },
|
||||
onDismissRename = { renameStackTarget = null },
|
||||
onDismissDelete = { deleteStackTarget = null },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeckBoardRow(board: DeckBoard, onClick: () -> Unit) {
|
||||
private fun DeckStackDialogs(
|
||||
renameTarget: DeckStack?,
|
||||
deleteTarget: DeckStack?,
|
||||
onRenameConfirm: (DeckStack, String) -> Unit,
|
||||
onDeleteConfirm: (DeckStack) -> Unit,
|
||||
onDismissRename: () -> Unit,
|
||||
onDismissDelete: () -> Unit,
|
||||
) {
|
||||
renameTarget?.let { stack ->
|
||||
DeckTextPromptDialog(
|
||||
title = "Переименовать колонку",
|
||||
label = "Название колонки",
|
||||
confirmText = "Сохранить",
|
||||
initial = stack.title,
|
||||
onConfirm = { onRenameConfirm(stack, it) },
|
||||
onDismiss = onDismissRename,
|
||||
)
|
||||
}
|
||||
deleteTarget?.let { stack ->
|
||||
DeckConfirmDialog(
|
||||
title = "Удалить колонку?",
|
||||
message = "Колонка «${stack.title}» и её карточки будут удалены.",
|
||||
confirmText = "Удалить",
|
||||
onConfirm = { onDeleteConfirm(stack) },
|
||||
onDismiss = onDismissDelete,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DeckBoardRow(board: DeckBoard, onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -200,13 +245,16 @@ private fun DeckBoardRow(board: DeckBoard, onClick: () -> Unit) {
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun DeckStackSection(
|
||||
internal fun DeckStackSection(
|
||||
stack: DeckStack,
|
||||
canEdit: Boolean,
|
||||
onCardClick: (DeckCard) -> Unit,
|
||||
onToggleDone: (DeckCard) -> Unit,
|
||||
onAddCard: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -240,6 +288,28 @@ private fun DeckStackSection(
|
||||
) {
|
||||
Icon(Icons.Filled.Add, contentDescription = "Добавить карточку", tint = F7Colors.Primary, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
Box {
|
||||
Icon(
|
||||
Icons.Filled.MoreVert,
|
||||
contentDescription = "Меню колонки",
|
||||
tint = F7Colors.TextSecondary,
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.clip(CircleShape)
|
||||
.clickable { menuOpen = true }
|
||||
.padding(5.dp),
|
||||
)
|
||||
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Переименовать") },
|
||||
onClick = { menuOpen = false; onRename() },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Удалить список") },
|
||||
onClick = { menuOpen = false; onDelete() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
stack.cards.forEach { card ->
|
||||
@@ -292,7 +362,9 @@ private fun DeckCardRow(card: DeckCard, onClick: () -> Unit, onToggleDone: () ->
|
||||
)
|
||||
}
|
||||
val due = card.duedate?.let { formatDeckDue(it) }
|
||||
if (due != null || card.assignees.isNotEmpty()) {
|
||||
val (checked, total) = deckChecklistCounts(card.description)
|
||||
val hasMeta = listOf(due != null, total > 0, card.commentsCount > 0, card.assignees.isNotEmpty())
|
||||
if (hasMeta.any { it }) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
@@ -301,6 +373,14 @@ private fun DeckCardRow(card: DeckCard, onClick: () -> Unit, onToggleDone: () ->
|
||||
if (due != null) {
|
||||
Text("🗓 $due", style = MaterialTheme.typography.labelMedium, color = F7Colors.TextSecondary)
|
||||
}
|
||||
if (total > 0) {
|
||||
val todo = "☑ $checked/$total"
|
||||
Text(todo, style = MaterialTheme.typography.labelMedium, color = F7Colors.TextSecondary)
|
||||
}
|
||||
if (card.commentsCount > 0) {
|
||||
val comments = "💬 ${card.commentsCount}"
|
||||
Text(comments, style = MaterialTheme.typography.labelMedium, color = F7Colors.TextSecondary)
|
||||
}
|
||||
if (card.assignees.isNotEmpty()) {
|
||||
Text("👤 ${card.assignees.joinToString(", ")}", style = MaterialTheme.typography.labelMedium, color = F7Colors.TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
@@ -329,6 +409,17 @@ internal fun DeckLabelChip(label: DeckLabel) {
|
||||
}
|
||||
}
|
||||
|
||||
private val deckChecklistRe = Regex("(?im)^\\s*([*+-]|\\d\\.)\\s+\\[\\s*(\\s|[xX])\\s*\\].*$")
|
||||
private val deckCheckedRe = Regex("(?im)^\\s*([*+-]|\\d\\.)\\s+\\[\\s*[xX]\\s*\\].*$")
|
||||
|
||||
/** Чек-пункты из описания карточки (как веб CardBadges): (отмечено, всего). */
|
||||
internal fun deckChecklistCounts(description: String): Pair<Int, Int> {
|
||||
if (description.isBlank()) return 0 to 0
|
||||
val total = deckChecklistRe.findAll(description).count()
|
||||
val checked = deckCheckedRe.findAll(description).count()
|
||||
return checked to total
|
||||
}
|
||||
|
||||
/** Цвет Deck («RRGGBB» без #, иногда с #). */
|
||||
internal fun parseDeckColor(hex: String?): Color? {
|
||||
val h = hex?.trim()?.removePrefix("#")?.takeIf { it.length == 6 } ?: return null
|
||||
|
||||
@@ -124,6 +124,12 @@ class DeckViewModel(
|
||||
mutate(session) { repository.createStack(session, boardId, title.trim()) }
|
||||
}
|
||||
|
||||
fun renameStack(session: AuthSession, stack: DeckStack, title: String) =
|
||||
mutate(session) { repository.renameStack(session, stack, title.trim()) }
|
||||
|
||||
fun deleteStack(session: AuthSession, stack: DeckStack) =
|
||||
mutate(session) { repository.deleteStack(session, stack.id) }
|
||||
|
||||
private fun mutate(
|
||||
session: AuthSession,
|
||||
closeCardAfter: Boolean = false,
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package ru.forbion.f7cloud.feature.deck
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Theme
|
||||
|
||||
/**
|
||||
* Fd-сверка Карточек (Roborazzi, mail/111): рендер экранов в PNG на JVM (360dp, без эмулятора).
|
||||
* Данные — только мок (`bmob-*`), реальные данные `cloude` не используются.
|
||||
* Сравнение PNG ↔ веб-эталон (`docs/app-map/reference/deck/`) → пункты Д в `deck.diff.md`.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w360dp-h800dp-xxhdpi")
|
||||
class DeckScreenshotTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
private val labels = listOf(
|
||||
DeckLabel(id = 1, title = "bmob-важное", color = "e74c3c"),
|
||||
DeckLabel(id = 2, title = "bmob-ревью", color = "3498db"),
|
||||
)
|
||||
|
||||
private val mockStack = DeckStack(
|
||||
id = 1,
|
||||
boardId = 1,
|
||||
title = "В работе",
|
||||
order = 0,
|
||||
cards = listOf(
|
||||
DeckCard(
|
||||
id = 1,
|
||||
title = "bmob-карточка со сроком и метками",
|
||||
done = false,
|
||||
description = "- [x] шаг 1\n- [ ] шаг 2",
|
||||
duedate = "2026-07-20T00:00:00+00:00",
|
||||
order = 0,
|
||||
stackId = 1,
|
||||
labels = labels,
|
||||
assignees = listOf("bmob-user"),
|
||||
assigneeUids = listOf("bmob-user"),
|
||||
commentsCount = 3,
|
||||
),
|
||||
DeckCard(id = 2, title = "bmob-выполненная", done = true, stackId = 1, order = 1),
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun deck_boards_list() {
|
||||
composeRule.setContent {
|
||||
F7Theme {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().background(Color.White).padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
DeckBoardRow(DeckBoard(id = 1, title = "bmob-доска", color = "9b59b6")) {}
|
||||
DeckBoardRow(DeckBoard(id = 2, title = "bmob-проект", color = "2ecc71")) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
composeRule.onRoot().captureRoboImage("src/test/screenshots/deck-boards.png")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deck_board_with_stack() {
|
||||
composeRule.setContent {
|
||||
F7Theme {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().background(Color.White).padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
DeckStackSection(
|
||||
stack = mockStack,
|
||||
canEdit = true,
|
||||
onCardClick = {},
|
||||
onToggleDone = {},
|
||||
onAddCard = {},
|
||||
onRename = {},
|
||||
onDelete = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
composeRule.onRoot().captureRoboImage("src/test/screenshots/deck-board.png")
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
@@ -131,7 +131,7 @@ fun FilesBreadcrumb(
|
||||
}
|
||||
}
|
||||
|
||||
/** Ряд фильтр-чипов: Тип файла / Участники / Изменен (пилюли 25dp, minWidth 86). */
|
||||
/** Ряд фильтр-чипов: Тип файла / Изменён / Участники (порядок и «ё» — как в моб.вебе forbion). */
|
||||
@Composable
|
||||
fun FilesFilterChips(
|
||||
onTypeClick: () -> Unit,
|
||||
@@ -148,8 +148,8 @@ fun FilesFilterChips(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
FilterChip("Тип файла", onTypeClick)
|
||||
FilterChip("Изменён", onModifiedClick)
|
||||
FilterChip("Участники", onParticipantsClick)
|
||||
FilterChip("Изменен", onModifiedClick)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package ru.forbion.f7cloud.feature.notes
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import kotlinx.coroutines.delay
|
||||
import ru.forbion.f7cloud.core.designsystem.F7HeaderActionButton
|
||||
import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
|
||||
import ru.forbion.f7cloud.core.designsystem.F7OutlinedField
|
||||
|
||||
private const val AUTOSAVE_DEBOUNCE_MS = 1200L
|
||||
|
||||
/**
|
||||
* Редактор заметки (P1, простой markdown). Автосохранение с debounce → PUT с `If-Match`
|
||||
* (конфликт версий обрабатывает [NotesViewModel]). Rich-режим/preview/полноэкранный — P2
|
||||
* (карта app-map/notes.md).
|
||||
*/
|
||||
@Composable
|
||||
fun NoteEditorScreen(
|
||||
note: Note,
|
||||
saving: Boolean,
|
||||
onContentSave: (String) -> Unit,
|
||||
onToggleFavorite: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var content by remember(note.id) { mutableStateOf(note.content) }
|
||||
|
||||
// Автосейв: сохраняем через паузу после последнего изменения, если текст отличается.
|
||||
LaunchedEffect(content) {
|
||||
if (content != note.content) {
|
||||
delay(AUTOSAVE_DEBOUNCE_MS)
|
||||
onContentSave(content)
|
||||
}
|
||||
}
|
||||
|
||||
F7ModuleScreen(
|
||||
title = note.title.ifBlank { "Новая заметка" },
|
||||
modifier = modifier,
|
||||
loading = false,
|
||||
headerActions = {
|
||||
F7HeaderActionButton(text = if (note.favorite) "★" else "☆", onClick = onToggleFavorite)
|
||||
F7HeaderActionButton(text = "Удалить", onClick = onDelete)
|
||||
F7HeaderActionButton(text = if (saving) "Сохранение…" else "Готово", onClick = {
|
||||
if (content != note.content) onContentSave(content)
|
||||
onBack()
|
||||
}, enabled = !saving)
|
||||
},
|
||||
) {
|
||||
F7OutlinedField(
|
||||
value = content,
|
||||
onValueChange = { if (!note.readonly) content = it },
|
||||
label = if (note.readonly) "Только для чтения" else "Заметка",
|
||||
minLines = 12,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package ru.forbion.f7cloud.feature.notes
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
import ru.forbion.f7cloud.core.designsystem.F7AlertDialog
|
||||
import ru.forbion.f7cloud.core.designsystem.F7CreateButton
|
||||
import ru.forbion.f7cloud.core.designsystem.F7ListCard
|
||||
import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
|
||||
import ru.forbion.f7cloud.core.designsystem.F7OutlinedField
|
||||
import ru.forbion.f7cloud.core.designsystem.F7SolidPrimaryButton
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Spacing
|
||||
|
||||
/**
|
||||
* Экран «Заметки» (P1): список с поиском и пустым состоянием, открытие заметки в редакторе,
|
||||
* диалог конфликта версий. Состав/поведение — по карте app-map/notes.md.
|
||||
*/
|
||||
@Composable
|
||||
fun NotesScreen(
|
||||
session: AuthSession,
|
||||
modifier: Modifier = Modifier,
|
||||
createRequest: Int = 0,
|
||||
onUnauthorized: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val vm: NotesViewModel = viewModel(factory = NotesViewModel.Factory(context))
|
||||
val state by vm.state.collectAsState()
|
||||
|
||||
LaunchedEffect(session.serverUrl, session.username, session.davUserId) { vm.load(session) }
|
||||
LaunchedEffect(createRequest) { if (createRequest > 0) vm.createNote(session) }
|
||||
LaunchedEffect(state.unauthorized) { if (state.unauthorized) onUnauthorized() }
|
||||
|
||||
val open = state.openNote
|
||||
if (open != null) {
|
||||
NoteEditorScreen(
|
||||
note = open,
|
||||
saving = state.saving,
|
||||
onContentSave = { vm.saveNote(session, it) },
|
||||
onToggleFavorite = { vm.toggleFavorite(session, open) },
|
||||
onDelete = { vm.deleteNote(session, open) },
|
||||
onBack = { vm.closeNote() },
|
||||
modifier = modifier,
|
||||
)
|
||||
ConflictDialog(
|
||||
conflict = state.conflict,
|
||||
onKeepMine = { vm.resolveConflictKeepMine(session) },
|
||||
onUseServer = { vm.resolveConflictUseServer() },
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
F7ModuleScreen(
|
||||
title = "Заметки",
|
||||
modifier = modifier,
|
||||
loading = state.loading,
|
||||
error = state.error,
|
||||
onRefresh = { vm.load(session) },
|
||||
headerActions = { F7CreateButton(onClick = { vm.createNote(session) }) },
|
||||
) {
|
||||
F7OutlinedField(
|
||||
value = state.searchQuery,
|
||||
onValueChange = vm::setSearch,
|
||||
label = "Искать заметки",
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = F7Spacing.md),
|
||||
)
|
||||
|
||||
if (state.isEmpty) {
|
||||
NotesEmptyState(onNewNote = { vm.createNote(session) })
|
||||
} else {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(F7Spacing.sm)) {
|
||||
state.visibleNotes.forEach { note ->
|
||||
NoteRow(
|
||||
note = note,
|
||||
onOpen = { vm.openNote(session, note.id) },
|
||||
onToggleFavorite = { vm.toggleFavorite(session, note) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ConflictDialog(
|
||||
conflict = state.conflict,
|
||||
onKeepMine = { vm.resolveConflictKeepMine(session) },
|
||||
onUseServer = { vm.resolveConflictUseServer() },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NoteRow(note: Note, onOpen: () -> Unit, onToggleFavorite: () -> Unit) {
|
||||
F7ListCard(onClick = onOpen) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = if (note.favorite) "★" else "☆",
|
||||
modifier = Modifier.clickable(onClick = onToggleFavorite).padding(end = F7Spacing.sm),
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = note.title.ifBlank { "Без названия" },
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (note.category.isNotBlank()) {
|
||||
Text(text = note.category, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotesEmptyState(onNewNote: () -> Unit) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxWidth().padding(F7Spacing.lg),
|
||||
) {
|
||||
Text(text = "Заметки", fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(F7Spacing.sm))
|
||||
Text(text = "Начните писать заметку, нажав «Новая заметка».")
|
||||
Spacer(Modifier.height(F7Spacing.md))
|
||||
F7SolidPrimaryButton(text = "Новая заметка", onClick = onNewNote)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConflictDialog(conflict: NoteConflictException?, onKeepMine: () -> Unit, onUseServer: () -> Unit) {
|
||||
if (conflict == null) return
|
||||
F7AlertDialog(
|
||||
title = "Конфликт версий",
|
||||
onDismiss = onUseServer,
|
||||
confirmText = "Оставить мою версию",
|
||||
onConfirm = onKeepMine,
|
||||
dismissText = "Версия сервера",
|
||||
) {
|
||||
Text("Заметка была изменена в другой сессии. Выберите, какую версию сохранить.")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package ru.forbion.f7cloud.feature.notes
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
import ru.forbion.f7cloud.core.network.UnauthorizedException
|
||||
|
||||
/**
|
||||
* Состояние экрана Заметок (P1). Список сортируется «избранное вверх», затем по дате
|
||||
* изменения; поиск фильтрует по заголовку (карта app-map/notes.md).
|
||||
*/
|
||||
data class NotesUiState(
|
||||
val loading: Boolean = false,
|
||||
val saving: Boolean = false,
|
||||
val notes: List<Note> = emptyList(),
|
||||
val searchQuery: String = "",
|
||||
val openNote: Note? = null,
|
||||
val conflict: NoteConflictException? = null,
|
||||
val error: String? = null,
|
||||
val unauthorized: Boolean = false,
|
||||
) {
|
||||
/** Отфильтрованный по поиску и отсортированный список для отображения. */
|
||||
val visibleNotes: List<Note>
|
||||
get() = notes
|
||||
.filter { searchQuery.isBlank() || it.title.contains(searchQuery, ignoreCase = true) }
|
||||
.sortedWith(compareByDescending<Note> { it.favorite }.thenByDescending { it.modified })
|
||||
|
||||
val isEmpty: Boolean get() = !loading && notes.isEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* ViewModel Заметок (P1): список/открытие/сохранение (с разрешением конфликта версий),
|
||||
* избранное, удаление, создание. Пишет через [NotesRepository]; конфликт `If-Match` (412)
|
||||
* поднимается в состояние и разрешается ЯВНЫМ выбором пользователя — не молчаливо (mail/071).
|
||||
*/
|
||||
class NotesViewModel(
|
||||
private val repository: NotesRepository = NotesRepository(),
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(NotesUiState())
|
||||
val state: StateFlow<NotesUiState> = _state.asStateFlow()
|
||||
|
||||
fun load(session: AuthSession) {
|
||||
_state.update { it.copy(loading = true, error = null) }
|
||||
launchGuarded {
|
||||
val notes = repository.listNotes(session)
|
||||
_state.update { it.copy(loading = false, notes = notes) }
|
||||
}
|
||||
}
|
||||
|
||||
fun setSearch(query: String) = _state.update { it.copy(searchQuery = query) }
|
||||
|
||||
fun openNote(session: AuthSession, id: Long) {
|
||||
launchGuarded {
|
||||
val note = repository.getNote(session, id)
|
||||
_state.update { it.copy(openNote = note) }
|
||||
}
|
||||
}
|
||||
|
||||
fun closeNote() = _state.update { it.copy(openNote = null) }
|
||||
|
||||
fun createNote(session: AuthSession) {
|
||||
_state.update { it.copy(saving = true, error = null) }
|
||||
launchGuarded {
|
||||
val note = repository.createNote(session, content = "")
|
||||
_state.update { it.copy(saving = false, openNote = note, notes = upsert(it.notes, note)) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Сохранить содержимое открытой заметки; на 412 — поднять конфликт в состояние. */
|
||||
fun saveNote(session: AuthSession, content: String) {
|
||||
val current = _state.value.openNote ?: return
|
||||
_state.update { it.copy(saving = true, error = null) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val saved = repository.saveNote(session, current.copy(content = content))
|
||||
_state.update {
|
||||
it.copy(saving = false, openNote = saved, notes = upsert(it.notes, saved))
|
||||
}
|
||||
} catch (e: NoteConflictException) {
|
||||
_state.update { it.copy(saving = false, conflict = e) }
|
||||
} catch (e: UnauthorizedException) {
|
||||
_state.update { it.copy(saving = false, unauthorized = true, error = e.message) }
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(saving = false, error = e.message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Разрешение конфликта: перезаписать серверную версию текущим содержимым. */
|
||||
fun resolveConflictKeepMine(session: AuthSession) {
|
||||
val conflict = _state.value.conflict ?: return
|
||||
val mine = _state.value.openNote ?: return
|
||||
// берём etag серверной версии, чтобы If-Match прошёл, но содержимое — наше
|
||||
val base = conflict.serverNote?.copy(content = mine.content) ?: mine
|
||||
_state.update { it.copy(conflict = null, saving = true) }
|
||||
launchGuarded {
|
||||
val saved = repository.saveNote(session, base)
|
||||
_state.update { it.copy(saving = false, openNote = saved, notes = upsert(it.notes, saved)) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Разрешение конфликта: принять версию сервера (отбросить локальные правки). */
|
||||
fun resolveConflictUseServer() {
|
||||
val server = _state.value.conflict?.serverNote
|
||||
_state.update { st ->
|
||||
st.copy(
|
||||
conflict = null,
|
||||
openNote = server ?: st.openNote,
|
||||
notes = if (server != null) upsert(st.notes, server) else st.notes,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleFavorite(session: AuthSession, note: Note) {
|
||||
launchGuarded {
|
||||
val updated = repository.setFavorite(session, note.id, !note.favorite)
|
||||
_state.update { it.copy(notes = upsert(it.notes, updated)) }
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteNote(session: AuthSession, note: Note) {
|
||||
launchGuarded {
|
||||
repository.deleteNote(session, note.id)
|
||||
_state.update {
|
||||
it.copy(
|
||||
notes = it.notes.filterNot { n -> n.id == note.id },
|
||||
openNote = if (it.openNote?.id == note.id) null else it.openNote,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearError() = _state.update { it.copy(error = null) }
|
||||
|
||||
private fun launchGuarded(block: suspend () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
block()
|
||||
} catch (e: UnauthorizedException) {
|
||||
_state.update { it.copy(loading = false, saving = false, unauthorized = true, error = e.message) }
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(loading = false, saving = false, error = e.message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun upsert(notes: List<Note>, note: Note): List<Note> {
|
||||
val idx = notes.indexOfFirst { it.id == note.id }
|
||||
return if (idx >= 0) notes.toMutableList().also { it[idx] = note } else notes + note
|
||||
}
|
||||
|
||||
class Factory(private val context: Context) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||
NotesViewModel(NotesRepository(NotesCache(context.applicationContext))) as T
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
<ID>LongMethod:TalkComponents.kt$@Composable fun TalkRoomRow( room: TalkRoom, session: AuthSession, modifier: Modifier = Modifier, onClick: () -> Unit, onToggleFavorite: () -> Unit = {}, onMarkUnread: () -> Unit = {}, )</ID>
|
||||
<ID>LongMethod:TalkComponents.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun TalkCreateRoomSheet( visible: Boolean, session: AuthSession, mode: CreateRoomMode, roomName: String, userSearchQuery: String, userSearchResults: List<TalkUserCandidate>, selectedUsers: List<TalkUserCandidate>, userSearchLoading: Boolean, creating: Boolean, onDismiss: () -> Unit, onModeChange: (CreateRoomMode) -> Unit, onRoomNameChange: (String) -> Unit, onUserSearchChange: (String) -> Unit, onUserToggle: (TalkUserCandidate) -> Unit, onCreate: () -> Unit, )</ID>
|
||||
<ID>LongMethod:TalkScreen.kt$@Composable fun TalkScreen( session: AuthSession, modifier: Modifier = Modifier, openRoomToken: String? = null, scrollToMessageId: Long? = null, chatsListRequest: Int = 0, pushSyncRequest: Int = 0, pushRoomToken: String? = null, onOpenRoomConsumed: () -> Unit = {}, onRoomOpenStateChange: (Boolean) -> Unit = {}, onOpenCalendar: () -> Unit = {}, onUnauthorized: () -> Unit = {}, )</ID>
|
||||
<ID>LongMethod:TalkScreen.kt$@Composable private fun ChatContent( session: AuthSession, state: TalkUiState, draft: String, onDraftChange: (String) -> Unit, onSend: () -> Unit, onAttach: () -> Unit, onVoiceToggle: () -> Unit, voiceRecording: Boolean, onRetry: () -> Unit, onStartCall: () -> Unit, onBackClick: () -> Unit, onOpenRoomInfo: () -> Unit, onScheduleMeeting: () -> Unit, onToggleSystemMessages: () -> Unit, onReply: (TalkMessage) -> Unit, onReplyPrivately: (TalkMessage) -> Unit, onReaction: (TalkMessage, String) -> Unit, onDeleteMessage: (TalkMessage) -> Unit, onEditMessage: (TalkMessage, String) -> Unit, onForwardNoteToSelf: (TalkMessage) -> Unit, onSetReminder: (TalkMessage, ReminderPreset) -> Unit, onMentionQuery: (String?) -> Unit, onMentionSelected: (TalkMention) -> Unit, onClearReply: () -> Unit, onClearHighlight: () -> Unit, )</ID>
|
||||
<ID>LongMethod:TalkScreen.kt$@Composable private fun ChatContent( session: AuthSession, state: TalkUiState, draft: String, onDraftChange: (String) -> Unit, onSend: () -> Unit, onAttach: () -> Unit, onVoiceToggle: () -> Unit, voiceRecording: Boolean, onRetry: () -> Unit, onStartCall: () -> Unit, onBackClick: () -> Unit, onOpenRoomInfo: () -> Unit, onScheduleMeeting: () -> Unit, onToggleSystemMessages: () -> Unit, onReply: (TalkMessage) -> Unit, onReplyPrivately: (TalkMessage) -> Unit, onReaction: (TalkMessage, String) -> Unit, onDeleteMessage: (TalkMessage) -> Unit, onEditMessage: (TalkMessage, String) -> Unit, onForwardNoteToSelf: (TalkMessage) -> Unit, onForwardToRoom: (TalkMessage, String) -> Unit, onSetReminder: (TalkMessage, ReminderPreset) -> Unit, onMentionQuery: (String?) -> Unit, onMentionSelected: (TalkMention) -> Unit, onClearReply: () -> Unit, onClearHighlight: () -> Unit, )</ID>
|
||||
<ID>LoopWithTooManyJumpStatements:TalkRepository.kt$TalkRepository$for</ID>
|
||||
<ID>LoopWithTooManyJumpStatements:TalkViewModel.kt$TalkViewModel$while</ID>
|
||||
<ID>MatchingDeclarationName:TalkFilters.kt$TalkRoomFilter</ID>
|
||||
|
||||
@@ -794,6 +794,39 @@ fun TalkUnreadMarker(modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ForwardTargetSheet(
|
||||
message: TalkMessage?,
|
||||
rooms: List<TalkRoom>,
|
||||
onDismiss: () -> Unit,
|
||||
onPick: (TalkMessage, TalkRoom) -> Unit,
|
||||
) {
|
||||
if (message == null) return
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, containerColor = F7Colors.Surface) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
"Переслать в…",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = F7Colors.TextSecondary,
|
||||
)
|
||||
LazyColumn(modifier = Modifier.heightIn(max = 360.dp)) {
|
||||
items(rooms, key = { it.token }) { room ->
|
||||
F7TextButton(text = room.displayName, onClick = { onPick(message, room) })
|
||||
}
|
||||
}
|
||||
F7TextButton(text = "Отмена", onClick = onDismiss)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TalkMessageActionSheet(
|
||||
@@ -809,6 +842,7 @@ fun TalkMessageActionSheet(
|
||||
onCopy: (TalkMessage) -> Unit,
|
||||
onCopyLink: (TalkMessage) -> Unit,
|
||||
onForwardNoteToSelf: (TalkMessage) -> Unit,
|
||||
onForward: (TalkMessage) -> Unit,
|
||||
onSetReminder: (TalkMessage, ReminderPreset) -> Unit,
|
||||
) {
|
||||
if (message == null) return
|
||||
@@ -847,6 +881,7 @@ fun TalkMessageActionSheet(
|
||||
text = "Переслать в «Личные заметки»",
|
||||
onClick = { onForwardNoteToSelf(message) },
|
||||
)
|
||||
F7TextButton(text = "Переслать…", onClick = { onForward(message) })
|
||||
Text(
|
||||
"Напоминание:",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
|
||||
@@ -297,6 +297,7 @@ fun TalkScreen(
|
||||
onDeleteMessage = { vm.deleteMessage(session, it.id) },
|
||||
onEditMessage = { msg, text -> vm.editMessage(session, msg.id, text) },
|
||||
onForwardNoteToSelf = { vm.forwardToNoteToSelf(session, it) },
|
||||
onForwardToRoom = { msg, token -> vm.forwardToRoom(session, msg, token) },
|
||||
onSetReminder = { msg, preset -> vm.setMessageReminder(session, msg.id, preset) },
|
||||
onMentionQuery = { vm.setMentionQuery(session, it) },
|
||||
onMentionSelected = { mention ->
|
||||
@@ -416,16 +417,42 @@ private fun RoomListContent(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConfirmDeleteMessageDialog(
|
||||
message: TalkMessage?,
|
||||
onConfirm: (TalkMessage) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
if (message == null) return
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Удалить сообщение?") },
|
||||
text = { Text("Сообщение будет удалено для всех участников.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onConfirm(message) }) {
|
||||
Text("Удалить", color = F7Colors.Primary)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Отмена")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageActionOverlays(
|
||||
actionMessage: TalkMessage?,
|
||||
session: AuthSession,
|
||||
roomToken: String?,
|
||||
rooms: List<TalkRoom>,
|
||||
onClearAction: () -> Unit,
|
||||
onReply: (TalkMessage) -> Unit,
|
||||
onReplyPrivately: (TalkMessage) -> Unit,
|
||||
onReaction: (TalkMessage, String) -> Unit,
|
||||
onForwardNoteToSelf: (TalkMessage) -> Unit,
|
||||
onForwardToRoom: (TalkMessage, String) -> Unit,
|
||||
onSetReminder: (TalkMessage, ReminderPreset) -> Unit,
|
||||
onEditMessage: (TalkMessage, String) -> Unit,
|
||||
onDeleteMessage: (TalkMessage) -> Unit,
|
||||
@@ -435,6 +462,7 @@ private fun MessageActionOverlays(
|
||||
var editingMessage by remember { mutableStateOf<TalkMessage?>(null) }
|
||||
var editDraft by remember { mutableStateOf("") }
|
||||
var pendingDelete by remember { mutableStateOf<TalkMessage?>(null) }
|
||||
var forwardMessage by remember { mutableStateOf<TalkMessage?>(null) }
|
||||
|
||||
TalkMessageActionSheet(
|
||||
message = actionMessage,
|
||||
@@ -483,6 +511,10 @@ private fun MessageActionOverlays(
|
||||
Toast.makeText(context, "Переслано в «Личные заметки»", Toast.LENGTH_SHORT).show()
|
||||
onClearAction()
|
||||
},
|
||||
onForward = { msg ->
|
||||
forwardMessage = msg
|
||||
onClearAction()
|
||||
},
|
||||
onSetReminder = { msg, preset ->
|
||||
onSetReminder(msg, preset)
|
||||
Toast.makeText(context, "Напоминание установлено", Toast.LENGTH_SHORT).show()
|
||||
@@ -518,26 +550,25 @@ private fun MessageActionOverlays(
|
||||
)
|
||||
}
|
||||
|
||||
pendingDelete?.let { message ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { pendingDelete = null },
|
||||
title = { Text("Удалить сообщение?") },
|
||||
text = { Text("Сообщение будет удалено для всех участников.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
onDeleteMessage(message)
|
||||
pendingDelete = null
|
||||
}) {
|
||||
Text("Удалить", color = F7Colors.Primary)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { pendingDelete = null }) {
|
||||
Text("Отмена")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
ConfirmDeleteMessageDialog(
|
||||
message = pendingDelete,
|
||||
onConfirm = {
|
||||
onDeleteMessage(it)
|
||||
pendingDelete = null
|
||||
},
|
||||
onDismiss = { pendingDelete = null },
|
||||
)
|
||||
|
||||
ForwardTargetSheet(
|
||||
message = forwardMessage,
|
||||
rooms = rooms.filter { it.token != roomToken },
|
||||
onDismiss = { forwardMessage = null },
|
||||
onPick = { msg, room ->
|
||||
onForwardToRoom(msg, room.token)
|
||||
forwardMessage = null
|
||||
Toast.makeText(context, "Переслано", Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -562,6 +593,7 @@ private fun ChatContent(
|
||||
onDeleteMessage: (TalkMessage) -> Unit,
|
||||
onEditMessage: (TalkMessage, String) -> Unit,
|
||||
onForwardNoteToSelf: (TalkMessage) -> Unit,
|
||||
onForwardToRoom: (TalkMessage, String) -> Unit,
|
||||
onSetReminder: (TalkMessage, ReminderPreset) -> Unit,
|
||||
onMentionQuery: (String?) -> Unit,
|
||||
onMentionSelected: (TalkMention) -> Unit,
|
||||
@@ -618,11 +650,13 @@ private fun ChatContent(
|
||||
actionMessage = actionMessage,
|
||||
session = session,
|
||||
roomToken = state.selectedRoomToken,
|
||||
rooms = state.rooms,
|
||||
onClearAction = { actionMessage = null },
|
||||
onReply = onReply,
|
||||
onReplyPrivately = onReplyPrivately,
|
||||
onReaction = onReaction,
|
||||
onForwardNoteToSelf = onForwardNoteToSelf,
|
||||
onForwardToRoom = onForwardToRoom,
|
||||
onSetReminder = onSetReminder,
|
||||
onEditMessage = onEditMessage,
|
||||
onDeleteMessage = onDeleteMessage,
|
||||
|
||||
@@ -625,6 +625,21 @@ class TalkViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/** Переслать текст сообщения в выбранную комнату. */
|
||||
fun forwardToRoom(session: AuthSession, message: TalkMessage, targetToken: String) {
|
||||
val text = message.displayText.trim()
|
||||
if (text.isEmpty() || targetToken.isBlank()) return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
runCatching { repository.sendMessage(session, targetToken, text) }
|
||||
.onFailure { t ->
|
||||
_state.value = _state.value.copy(
|
||||
error = t.message ?: "Не удалось переслать",
|
||||
unauthorized = t is UnauthorizedException,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Ответить лично: открыть (или создать) 1:1 с автором сообщения. */
|
||||
fun replyPrivately(session: AuthSession, message: TalkMessage) {
|
||||
val userId = message.actorId
|
||||
|
||||
@@ -8,6 +8,8 @@ coreKtx = "1.15.0" # был дрейф 1.13.1 / 1.15.0
|
||||
lifecycle = "2.8.7" # был дрейф 2.8.1 / 2.8.7
|
||||
coil = "2.7.0" # был дрейф 2.6.0 / 2.7.0
|
||||
media3 = "1.4.1" # нативный медиа-слой (видео/аудио) — ExoPlayer
|
||||
roborazzi = "1.42.0" # screenshot-тесты Compose на JVM (Fd-сверка без эмулятора)
|
||||
robolectric = "4.14.1"
|
||||
coroutines = "1.10.1" # был дрейф 1.8.1 / 1.10.1
|
||||
navigationCompose = "2.8.9"
|
||||
room = "2.7.2"
|
||||
@@ -42,6 +44,13 @@ coil-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coil" }
|
||||
coil-gif = { group = "io.coil-kt", name = "coil-gif", version.ref = "coil" }
|
||||
media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
|
||||
media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
|
||||
|
||||
roborazzi = { group = "io.github.takahirom.roborazzi", name = "roborazzi", version.ref = "roborazzi" }
|
||||
roborazzi-compose = { group = "io.github.takahirom.roborazzi", name = "roborazzi-compose", version.ref = "roborazzi" }
|
||||
roborazzi-junit-rule = { group = "io.github.takahirom.roborazzi", name = "roborazzi-junit-rule", version.ref = "roborazzi" }
|
||||
robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }
|
||||
compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||
compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||
coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||
coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "coroutines" }
|
||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||
|
||||
Reference in New Issue
Block a user