Compare commits
46 Commits
f7c0c721a2
..
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 | |||
| e7d0a5a9ee | |||
| 1c1e9c89e2 | |||
| bc1a4970ef |
@@ -2,7 +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,7 +444,41 @@ fun NotificationsSheet(
|
||||
else -> Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.fillMaxHeight(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"Уведомления",
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
fontSize = MaterialTheme.typography.titleMedium.fontSize * 2,
|
||||
lineHeight = MaterialTheme.typography.titleMedium.lineHeight * 2,
|
||||
),
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
|
||||
color = F7Colors.TextPrimary,
|
||||
)
|
||||
TextButton(onClick = { dismissAllNotifications() }) {
|
||||
Text(
|
||||
"Скрыть все",
|
||||
style = MaterialTheme.typography.labelLarge.copy(
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize * 2,
|
||||
lineHeight = MaterialTheme.typography.labelLarge.lineHeight * 2,
|
||||
),
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
|
||||
color = F7Colors.Primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = F7Colors.Border, thickness = 1.dp)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
items.forEachIndexed { index, item ->
|
||||
@@ -412,9 +495,16 @@ fun NotificationsSheet(
|
||||
},
|
||||
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
|
||||
|
||||
+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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
if (!debuggable) {
|
||||
window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
onDispose {
|
||||
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>
|
||||
|
||||
@@ -26,6 +26,35 @@ data class DavContact(
|
||||
val birthday: String = "",
|
||||
val emails: String = "",
|
||||
val phones: String = "",
|
||||
// Ниже — для безопасной правки/удаления (If-Match) и round-trip неизвестных полей vCard.
|
||||
// Заполняются только чтением из книги (listContacts); create возвращает пустыми — ок.
|
||||
val href: String = "",
|
||||
val etag: String = "",
|
||||
val raw: String = "",
|
||||
)
|
||||
|
||||
/** Типизированное значение (EMAIL/TEL) с меткой TYPE (HOME/WORK/CELL/…). */
|
||||
data class VCardTypedValue(
|
||||
val value: String,
|
||||
val type: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* Управляемые поля контакта, которые правит UI. Всё, чего здесь нет
|
||||
* (PHOTO, X-*, UID, кастомные свойства), сохраняется из сырого vCard как есть.
|
||||
*/
|
||||
data class VCardFields(
|
||||
val displayName: String,
|
||||
val firstName: String = "",
|
||||
val lastName: String = "",
|
||||
val emails: List<VCardTypedValue> = emptyList(),
|
||||
val phones: List<VCardTypedValue> = emptyList(),
|
||||
val organization: String = "",
|
||||
val title: String = "",
|
||||
val address: String = "",
|
||||
val website: String = "",
|
||||
val birthday: String = "",
|
||||
val note: String = "",
|
||||
)
|
||||
|
||||
object CardDavClient {
|
||||
@@ -150,6 +179,62 @@ object CardDavClient {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Правка существующего контакта. Берёт СЫРОЙ vCard (round-trip: неизвестные свойства —
|
||||
* PHOTO, X-*, кастомные — сохраняются), заменяет управляемые поля и делает PUT с If-Match
|
||||
* по ETag (защита от гонки). Возвращает обновлённый DavContact с новым ETag.
|
||||
* Сигнатуры list/create не трогаю — это отдельный метод.
|
||||
*/
|
||||
fun updateContact(
|
||||
client: OkHttpClient,
|
||||
href: String,
|
||||
etag: String,
|
||||
rawVCard: String,
|
||||
fields: VCardFields,
|
||||
bookName: String = "",
|
||||
): DavContact {
|
||||
require(href.isNotBlank()) { "Пустой href контакта — правка невозможна" }
|
||||
val newVCard = VCardEditor.applyEdits(rawVCard, fields)
|
||||
val builder = Request.Builder()
|
||||
.url(href)
|
||||
.put(newVCard.toRequestBody("text/vcard; charset=utf-8".toMediaType()))
|
||||
if (etag.isNotBlank()) builder.header("If-Match", etag)
|
||||
val newEtag = client.newCall(builder.build()).execute().use { response ->
|
||||
if (response.code == 412) {
|
||||
error("Контакт изменён на сервере (конфликт ETag) — обновите и повторите")
|
||||
}
|
||||
if (response.code !in 200..299 && response.code != 204) {
|
||||
error("Не удалось сохранить контакт (HTTP ${response.code})")
|
||||
}
|
||||
response.header("ETag").orEmpty()
|
||||
}
|
||||
return davContactFromVCard(newVCard, bookName, href, newEtag.ifBlank { etag })
|
||||
?: 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,
|
||||
href: String,
|
||||
etag: String,
|
||||
) {
|
||||
require(href.isNotBlank()) { "Пустой href контакта — удаление невозможно" }
|
||||
val builder = Request.Builder().url(href).delete()
|
||||
if (etag.isNotBlank()) builder.header("If-Match", etag)
|
||||
client.newCall(builder.build()).execute().use { response ->
|
||||
if (response.code == 412) {
|
||||
error("Контакт изменён на сервере (конфликт ETag) — обновите и повторите")
|
||||
}
|
||||
if (response.code !in 200..299 && response.code != 204 && response.code != 404) {
|
||||
error("Не удалось удалить контакт (HTTP ${response.code})")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildVCard(uid: String, displayName: String, email: String, phone: String): String {
|
||||
val fn = displayName.trim()
|
||||
val parts = fn.split(Regex("\\s+"), limit = 2)
|
||||
@@ -199,7 +284,7 @@ object CardDavClient {
|
||||
</d:propfind>
|
||||
""".trimIndent()
|
||||
val xml = propfind(client, url, depth = 1, body)
|
||||
return parseContacts(xml, bookName).take(limit)
|
||||
return parseContacts(xml, bookName, url).take(limit)
|
||||
}
|
||||
|
||||
private fun propfind(client: OkHttpClient, url: String, depth: Int, body: String): String {
|
||||
@@ -264,31 +349,63 @@ object CardDavClient {
|
||||
return out
|
||||
}
|
||||
|
||||
private fun parseContacts(xml: String, bookName: String): List<DavContact> {
|
||||
private fun parseContacts(xml: String, bookName: String, bookUrl: String): List<DavContact> {
|
||||
val parser = newParser(xml)
|
||||
val out = mutableListOf<DavContact>()
|
||||
var inAddressData = false
|
||||
val data = StringBuilder()
|
||||
var currentHref = ""
|
||||
var currentEtag = ""
|
||||
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
||||
when (parser.eventType) {
|
||||
XmlPullParser.START_TAG -> if (parser.localTag() == "address-data") {
|
||||
XmlPullParser.START_TAG -> when (parser.localTag()) {
|
||||
"response" -> {
|
||||
currentHref = ""
|
||||
currentEtag = ""
|
||||
}
|
||||
// href до address-data — это путь vCard в текущем <response>
|
||||
"href" -> if (!inAddressData && currentHref.isBlank()) {
|
||||
currentHref = parser.readText().trim()
|
||||
}
|
||||
"getetag" -> currentEtag = parser.readText().trim()
|
||||
"address-data" -> {
|
||||
inAddressData = true
|
||||
data.clear()
|
||||
}
|
||||
}
|
||||
XmlPullParser.TEXT -> if (inAddressData) data.append(parser.text)
|
||||
XmlPullParser.END_TAG -> if (parser.localTag() == "address-data" && inAddressData) {
|
||||
val vcard = unfoldVCard(data.toString())
|
||||
val uid = extract(uidPattern, vcard).ifBlank {
|
||||
vcard.hashCode().toString()
|
||||
val fullHref = if (currentHref.isBlank()) "" else resolveHref(bookUrl, currentHref)
|
||||
davContactFromVCard(data.toString(), bookName, fullHref, currentEtag)
|
||||
?.let { out += it }
|
||||
inAddressData = false
|
||||
}
|
||||
}
|
||||
parser.next()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Строит DavContact из сырого vCard. `raw` сохраняем как есть (для round-trip при правке),
|
||||
* разбор — по развёрнутому (unfold) тексту. Общий для чтения книги и для updateContact.
|
||||
*/
|
||||
private fun davContactFromVCard(
|
||||
rawVCard: String,
|
||||
bookName: String,
|
||||
href: String,
|
||||
etag: String,
|
||||
): DavContact? {
|
||||
val vcard = unfoldVCard(rawVCard)
|
||||
val uid = extract(uidPattern, vcard).ifBlank { vcard.hashCode().toString() }
|
||||
val name = resolveContactName(vcard)
|
||||
val allEmails = extractAll(emailPattern, vcard)
|
||||
val allPhones = extractAll(telPattern, vcard)
|
||||
val email = allEmails.firstOrNull().orEmpty()
|
||||
val phone = allPhones.firstOrNull().orEmpty()
|
||||
val photo = parseContactPhoto(vcard)
|
||||
if (name.isNotBlank() || email.isNotBlank()) {
|
||||
out += DavContact(
|
||||
if (name.isBlank() && email.isBlank()) return null
|
||||
return DavContact(
|
||||
uid = uid,
|
||||
displayName = name,
|
||||
email = email,
|
||||
@@ -303,15 +420,11 @@ object CardDavClient {
|
||||
birthday = formatBirthday(extract(bdayPattern, vcard)),
|
||||
emails = allEmails.joinToString("\n"),
|
||||
phones = allPhones.joinToString("\n"),
|
||||
href = href,
|
||||
etag = etag,
|
||||
raw = rawVCard,
|
||||
)
|
||||
}
|
||||
inAddressData = false
|
||||
}
|
||||
}
|
||||
parser.next()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun unfoldVCard(raw: String): String {
|
||||
val normalized = raw.replace("\r\n", "\n").replace('\r', '\n')
|
||||
@@ -484,3 +597,160 @@ object CardDavClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Чистый (без сети) редактор vCard 3.0 для round-trip правки. Управляемые свойства
|
||||
* (FN/N/EMAIL/TEL/ORG/TITLE/ADR/URL/BDAY/NOTE) заменяются значениями из [VCardFields];
|
||||
* все прочие (VERSION, UID, PHOTO, REV, X-* и кастомные) сохраняются из исходного vCard как есть.
|
||||
* Тестируется юнитами: round-trip неизвестных полей, FN с запятыми, PHOTO base64,
|
||||
* многострочный (folded) ADR, CRLF-фолдинг.
|
||||
*/
|
||||
internal object VCardEditor {
|
||||
private const val CRLF = "\r\n"
|
||||
private val MANAGED = setOf(
|
||||
"FN", "N", "EMAIL", "TEL", "ORG", "TITLE", "ADR", "URL", "BDAY", "NOTE",
|
||||
)
|
||||
|
||||
fun applyEdits(rawVCard: String, fields: VCardFields): String {
|
||||
val logical = groupLogicalLines(rawVCard)
|
||||
val preserved = logical.filter { line ->
|
||||
val p = propertyName(line).uppercase()
|
||||
p.isNotEmpty() && p != "BEGIN" && p != "END" && p !in MANAGED
|
||||
}
|
||||
val hasVersion = preserved.any { propertyName(it).equals("VERSION", ignoreCase = true) }
|
||||
val sb = StringBuilder()
|
||||
sb.append("BEGIN:VCARD").append(CRLF)
|
||||
if (!hasVersion) sb.append("VERSION:3.0").append(CRLF)
|
||||
preserved.forEach { appendFolded(sb, it) }
|
||||
emitManaged(sb, fields)
|
||||
sb.append("END:VCARD").append(CRLF)
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun emitManaged(sb: StringBuilder, f: VCardFields) {
|
||||
val fn = f.displayName.trim()
|
||||
if (fn.isNotEmpty()) appendFolded(sb, "FN:${escapeText(fn)}")
|
||||
val last = f.lastName.trim()
|
||||
val first = f.firstName.trim()
|
||||
when {
|
||||
last.isNotEmpty() || first.isNotEmpty() ->
|
||||
appendFolded(sb, "N:${escapeComponent(last)};${escapeComponent(first)};;;")
|
||||
fn.isNotEmpty() -> {
|
||||
// N обязателен в vCard 3.0 — выводим из FN (первое слово — имя, остальное — фамилия)
|
||||
val parts = fn.split(Regex("\\s+"), limit = 2)
|
||||
val given = parts.getOrNull(0).orEmpty()
|
||||
val family = parts.getOrNull(1).orEmpty()
|
||||
appendFolded(sb, "N:${escapeComponent(family)};${escapeComponent(given)};;;")
|
||||
}
|
||||
}
|
||||
f.emails.filter { it.value.isNotBlank() }.forEach {
|
||||
appendFolded(sb, "EMAIL;TYPE=${it.type.ifBlank { "INTERNET" }}:${escapeText(it.value.trim())}")
|
||||
}
|
||||
f.phones.filter { it.value.isNotBlank() }.forEach {
|
||||
appendFolded(sb, "TEL;TYPE=${it.type.ifBlank { "CELL" }}:${escapeText(it.value.trim())}")
|
||||
}
|
||||
if (f.organization.isNotBlank()) appendFolded(sb, "ORG:${escapeComponent(f.organization.trim())}")
|
||||
if (f.title.isNotBlank()) appendFolded(sb, "TITLE:${escapeText(f.title.trim())}")
|
||||
if (f.address.isNotBlank()) {
|
||||
// плоский адрес → поле street структуры ADR: pobox;ext;street;city;region;zip;country
|
||||
appendFolded(sb, "ADR;TYPE=HOME:;;${escapeComponent(f.address.trim())};;;;")
|
||||
}
|
||||
if (f.website.isNotBlank()) appendFolded(sb, "URL:${escapeText(f.website.trim())}")
|
||||
if (f.birthday.isNotBlank()) appendFolded(sb, "BDAY:${f.birthday.trim()}")
|
||||
if (f.note.isNotBlank()) appendFolded(sb, "NOTE:${escapeText(f.note.trim())}")
|
||||
}
|
||||
|
||||
/** Группирует физические строки в логические: продолжение начинается с ' ' или '\t'. */
|
||||
private fun groupLogicalLines(raw: String): List<String> {
|
||||
val normalized = raw.replace("\r\n", "\n").replace('\r', '\n')
|
||||
val out = mutableListOf<String>()
|
||||
val cur = StringBuilder()
|
||||
for (line in normalized.split('\n')) {
|
||||
if (line.isEmpty()) continue
|
||||
if ((line[0] == ' ' || line[0] == '\t') && cur.isNotEmpty()) {
|
||||
cur.append(line.substring(1))
|
||||
} else {
|
||||
if (cur.isNotEmpty()) out += cur.toString()
|
||||
cur.setLength(0)
|
||||
cur.append(line)
|
||||
}
|
||||
}
|
||||
if (cur.isNotEmpty()) out += cur.toString()
|
||||
return out
|
||||
}
|
||||
|
||||
private fun propertyName(logicalLine: String): String {
|
||||
val end = logicalLine.indexOfFirst { it == ':' || it == ';' }
|
||||
val head = if (end < 0) logicalLine else logicalLine.substring(0, end)
|
||||
// свойство может иметь group-префикс "item1.EMAIL" — берём часть после точки
|
||||
return head.substringAfterLast('.').trim()
|
||||
}
|
||||
|
||||
/** Фолдинг длинных строк по 75 символов с CRLF+пробел (RFC 2426 §2.6). */
|
||||
private fun appendFolded(sb: StringBuilder, line: String) {
|
||||
if (line.length <= 75) {
|
||||
sb.append(line).append(CRLF)
|
||||
return
|
||||
}
|
||||
var i = 0
|
||||
var first = true
|
||||
while (i < line.length) {
|
||||
val take = if (first) 75 else 74
|
||||
val end = minOf(i + take, line.length)
|
||||
if (!first) sb.append(' ')
|
||||
sb.append(line, i, end).append(CRLF)
|
||||
i = end
|
||||
first = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает типизированные значения свойства (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 =
|
||||
v.replace("\\", "\\\\").replace("\n", "\\n").replace("\r", "")
|
||||
|
||||
// компонент структурного значения (N/ADR/ORG): дополнительно экранируем разделители ; и ,
|
||||
private fun escapeComponent(v: String): String =
|
||||
escapeText(v).replace(";", "\\;").replace(",", "\\,")
|
||||
}
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
package ru.forbion.f7cloud.core.network
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Тесты round-trip редактора vCard (P1 правка контакта, mail/072).
|
||||
* Ключевое: неизвестные свойства (PHOTO, X-*, UID) сохраняются, управляемые — заменяются.
|
||||
* Краевые из письма лида: FN с запятыми, PHOTO base64, многострочный ADR, CRLF-фолдинг.
|
||||
*/
|
||||
class VCardEditorTest {
|
||||
|
||||
private fun edit(raw: String, fields: VCardFields) = VCardEditor.applyEdits(raw, fields)
|
||||
|
||||
private val base = VCardFields(displayName = "Иван Петров")
|
||||
|
||||
@Test fun preserves_unknown_properties_and_uid_and_photo() {
|
||||
val raw = buildString {
|
||||
append("BEGIN:VCARD\r\n")
|
||||
append("VERSION:3.0\r\n")
|
||||
append("UID:abc-123\r\n")
|
||||
append("FN:Старое Имя\r\n")
|
||||
append("N:Имя;Старое;;;\r\n")
|
||||
append("PHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQSkZJRgABAQAAAQABAAD\r\n")
|
||||
append("X-CUSTOM-FIELD:держим-как-есть\r\n")
|
||||
append("TEL;TYPE=CELL:+70000000000\r\n")
|
||||
append("END:VCARD\r\n")
|
||||
}
|
||||
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"))
|
||||
assertTrue("X-CUSTOM сохранён", out.contains("X-CUSTOM-FIELD:держим-как-есть"))
|
||||
// управляемые поля заменены на новые
|
||||
assertTrue("новый FN", out.contains("FN:Иван Петров"))
|
||||
assertFalse("старый FN убран", out.contains("Старое Имя"))
|
||||
assertTrue("новый телефон", out.contains("TEL;TYPE=WORK:+79990001122"))
|
||||
assertFalse("старый телефон убран", out.contains("+70000000000"))
|
||||
// валидный каркас
|
||||
assertTrue(out.startsWith("BEGIN:VCARD\r\n"))
|
||||
assertTrue(out.trimEnd().endsWith("END:VCARD"))
|
||||
assertEquals("одна VERSION", 1, Regex("(?m)^VERSION:").findAll(out).count())
|
||||
}
|
||||
|
||||
@Test fun fn_with_commas_is_kept_literally() {
|
||||
val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:x\r\nFN:X\r\nEND:VCARD\r\n"
|
||||
val out = edit(raw, VCardFields(displayName = "Петров, Иван, Иванович"))
|
||||
assertTrue("запятые в FN сохранены литералом", out.contains("FN:Петров, Иван, Иванович"))
|
||||
}
|
||||
|
||||
@Test fun folded_multiline_adr_is_regrouped_and_replaced() {
|
||||
// ADR разбит фолдингом на 2 физические строки (продолжение с пробела)
|
||||
val raw = buildString {
|
||||
append("BEGIN:VCARD\r\n")
|
||||
append("VERSION:3.0\r\n")
|
||||
append("UID:y\r\n")
|
||||
append("FN:Кто-то\r\n")
|
||||
append("ADR;TYPE=HOME:;;ул. Очень Длинная Дом 1 Корпус 2 Строение 3\r\n")
|
||||
append(" Квартира 456 Подъезд 7;Город;;;\r\n")
|
||||
append("END:VCARD\r\n")
|
||||
}
|
||||
val out = edit(raw, VCardFields(displayName = "Кто-то", address = "Новый Город, ул. Новая 5"))
|
||||
assertTrue("новый адрес в ADR", out.contains("Новый Город"))
|
||||
assertFalse("старый адрес убран", out.contains("Очень Длинная"))
|
||||
// не осталось «висящей» строки-продолжения старого ADR
|
||||
assertFalse(out.contains("Квартира 456"))
|
||||
}
|
||||
|
||||
@Test fun long_line_is_folded_at_75_with_crlf_space() {
|
||||
val longNote = "Z".repeat(200)
|
||||
val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:z\r\nFN:N\r\nEND:VCARD\r\n"
|
||||
val out = edit(raw, base.copy(note = longNote))
|
||||
// каждая физическая строка ≤ 76 символов (75 + возможный ведущий пробел)
|
||||
val physical = out.split("\r\n").filter { it.isNotEmpty() }
|
||||
assertTrue("все строки свёрнуты ≤76", physical.all { it.length <= 76 })
|
||||
// продолжения начинаются с пробела
|
||||
assertTrue("есть свёрнутое продолжение", physical.any { it.startsWith(" Z") })
|
||||
// при развороте NOTE восстанавливается целиком
|
||||
val unfolded = out.replace("\r\n ", "")
|
||||
assertTrue(unfolded.contains("NOTE:$longNote"))
|
||||
}
|
||||
|
||||
@Test fun crlf_and_lf_inputs_both_parse() {
|
||||
val lf = "BEGIN:VCARD\nVERSION:3.0\nUID:m\nFN:Старое\nX-KEEP:1\nEND:VCARD\n"
|
||||
val out = edit(lf, base)
|
||||
assertTrue(out.contains("X-KEEP:1"))
|
||||
assertTrue(out.contains("FN:Иван Петров"))
|
||||
// выход всегда CRLF
|
||||
assertTrue(out.contains("\r\n"))
|
||||
assertFalse("нет одиночных LF", out.replace("\r\n", "").contains("\n"))
|
||||
}
|
||||
|
||||
@Test fun n_derived_from_fn_when_name_parts_absent() {
|
||||
val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:n\r\nEND:VCARD\r\n"
|
||||
val out = edit(raw, VCardFields(displayName = "Иван Петров"))
|
||||
// N: фамилия;имя;;; — из FN: given=Иван, family=Петров
|
||||
assertTrue(out.contains("N:Петров;Иван;;;"))
|
||||
}
|
||||
|
||||
@Test fun explicit_name_parts_win_over_fn() {
|
||||
val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:e\r\nEND:VCARD\r\n"
|
||||
val out = edit(raw, VCardFields(displayName = "Ivan P", firstName = "Ivan", lastName = "Petrov"))
|
||||
assertTrue(out.contains("N:Petrov;Ivan;;;"))
|
||||
}
|
||||
|
||||
@Test fun multiple_emails_and_types_emitted() {
|
||||
val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:me\r\nFN:X\r\nEMAIL:old@x.ru\r\nEND:VCARD\r\n"
|
||||
val out = edit(
|
||||
raw,
|
||||
base.copy(
|
||||
emails = listOf(
|
||||
VCardTypedValue("home@x.ru", "HOME"),
|
||||
VCardTypedValue("work@x.ru", "WORK"),
|
||||
),
|
||||
),
|
||||
)
|
||||
assertFalse(out.contains("old@x.ru"))
|
||||
assertTrue(out.contains("EMAIL;TYPE=HOME:home@x.ru"))
|
||||
assertTrue(out.contains("EMAIL;TYPE=WORK:work@x.ru"))
|
||||
}
|
||||
|
||||
@Test fun blank_managed_fields_are_omitted() {
|
||||
val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:b\r\nFN:X\r\nEND:VCARD\r\n"
|
||||
val out = edit(raw, VCardFields(displayName = "Только Имя"))
|
||||
assertFalse("нет пустого EMAIL", out.contains("EMAIL"))
|
||||
assertFalse("нет пустого TEL", out.contains("TEL"))
|
||||
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()
|
||||
return try {
|
||||
|
||||
var lastCode = 0
|
||||
for (attempt in 0 until MAX_ATTEMPTS) {
|
||||
if (attempt > 0) {
|
||||
val wait = BACKOFF_MS[(attempt - 1).coerceAtMost(BACKOFF_MS.size - 1)]
|
||||
try {
|
||||
Thread.sleep(wait)
|
||||
} catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
return lastCode
|
||||
}
|
||||
}
|
||||
lastCode = try {
|
||||
postJson(endpoint, session, body)
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "register failed", t)
|
||||
Log.w(TAG, "register attempt ${attempt + 1} failed", t)
|
||||
0
|
||||
}
|
||||
if (!isRetryable(lastCode)) {
|
||||
return lastCode
|
||||
}
|
||||
Log.w(
|
||||
TAG,
|
||||
"register attempt ${attempt + 1} got $lastCode, " +
|
||||
if (attempt + 1 < MAX_ATTEMPTS) "retrying" else "giving up"
|
||||
)
|
||||
}
|
||||
return lastCode
|
||||
}
|
||||
|
||||
// Транзиентные сбои: сетевое исключение (0), ошибка сервера (5xx), 408/429.
|
||||
// 2xx — успех, 4xx (кроме 408/429) — постоянная ошибка (кривой app-password/запрос), повтор не поможет.
|
||||
private fun isRetryable(code: Int): Boolean {
|
||||
return code == 0 || code >= 500 || code == 408 || code == 429
|
||||
}
|
||||
|
||||
/**
|
||||
* Снимает устройство с регистрации в f7push (при logout) ДО очистки локальной сессии.
|
||||
* Контракт сервера (f7push v0.2.4, подтверждён integrator-b, mail/110):
|
||||
* `DELETE /ocs/v2.php/apps/f7push/api/v1/devices/{deviceId}`, та же Basic-авторизация +
|
||||
* `OCS-APIRequest`; идемпотентен (200 `success:true` даже если строки нет). Best-effort:
|
||||
* ошибку глотаем (сеть могла отвалиться при выходе), возвращаем HTTP-код или 0.
|
||||
*/
|
||||
fun unregisterBlocking(
|
||||
context: Context,
|
||||
session: AuthSession,
|
||||
): Int {
|
||||
val deviceId = getDeviceId(context)
|
||||
val endpoint = session.serverUrl.trimEnd('/') +
|
||||
"/ocs/v2.php/apps/f7push/api/v1/devices/$deviceId"
|
||||
return try {
|
||||
deleteRequest(endpoint, session)
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "unregister failed", t)
|
||||
0
|
||||
}
|
||||
}
|
||||
@@ -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 | 51.6 | 67.0 | ~38 | ~7 | нет | diff итер.1 (`calendar.diff.md`); P1 разворот повторений; 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,13 @@
|
||||
# Diff: Календарь (Calendar) — итерация 1 (2026-07-13)
|
||||
|
||||
Ff_app: **51.6%** · Fd_app: **67.0%** (среднее по 6 экранам, невзвешенно) · n-a: 0
|
||||
_(без экрана Appointments, если владелец сочтёт его мобильно-неприменимым: Ff_app **62.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>`,
|
||||
> mail/072) + `eventOccursOnDay` теперь RRULE-осознан; +11 юнит-тестов
|
||||
> (`CalendarRecurrenceTest`, зелёные), `:feature:calendar:compileReleaseKotlin`+`detekt` green.
|
||||
> Ждёт девайс-сверки (подтверждающий второй прогон, mail/084).
|
||||
|
||||
Сверка веб (`nextcloud/calendar v6.4.0` + тема `_mobile-calendar.css`, карта `calendar.md`) ↔
|
||||
натив (`feature/calendar`: `CalendarScreen`, `CalendarComponents`, `CalendarExtendedUi`,
|
||||
@@ -21,17 +26,17 @@ mail/085) добавлю следующим проходом — этот про
|
||||
|
||||
---
|
||||
|
||||
## Экран 1. Календарь (сетка + шапка + виды) — Ff 90.0% · Fd 75.0%
|
||||
Ф (10, open 1):
|
||||
## Экран 1. Календарь (сетка + шапка + виды) — Ff 100% · Fd 75.0%
|
||||
Ф (10, open 0):
|
||||
- [Ф][done] предыдущий/следующий период · [Ф][done] «Сегодня»
|
||||
- [Ф][done] переключатель вида: День/Неделя/Месяц/**Год/Список** (все 5, `CalendarViewMode`)
|
||||
- [Ф][done] «Создать событие» · [Ф][done] открыть навигацию (бургер)
|
||||
- [Ф][done] тап по дню → выбор дня · [Ф][done] тап по событию → открыть
|
||||
- [Ф][done] «Показать незапланированные задачи» (`loadUnscheduledTasks`)
|
||||
- [Ф][open] **разворот повторяющихся событий в сетке** — `rememberEventsByDay` →
|
||||
`eventOccursOnDay` кладёт событие только на DTSTART (для timed) / DTSTART..DTEND (all-day),
|
||||
**RRULE НЕ итерируется**: 2-я и дальше оккуренции в сетке/списке не видны (долг №2 mail/072,
|
||||
подтверждён лидом). Это ядро P1.
|
||||
- [Ф][done] **разворот повторяющихся событий в сетке** — реализован: `CalendarRecurrence`
|
||||
генерирует оккуренции RRULE (DAILY/WEEKLY+BYDAY/MONTHLY/YEARLY, INTERVAL, UNTIL/COUNT) на
|
||||
лету, `eventOccursOnDay` теперь RRULE-осознан → 2-я и дальше оккуренции видны в сетке/списке/
|
||||
дне (долг №2 закрыт в коде; +11 юнит-тестов). Ждёт девайс-сверки.
|
||||
Д (8, open 2): [Д][done] безрамочная месячная сетка + гридлайны (v0.5.114) · [Д][done] «сегодня»
|
||||
в зелёном круге · [Д][done] пилюли событий (до 2 + «+N») · [Д][done] пилюли дней недели ·
|
||||
[Д][done] таймлайн День/Неделя · [Д][done] русская локализация шапки · [Д][open] пиксель-вид
|
||||
@@ -85,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] «Календари для проверки конфликтов»
|
||||
@@ -94,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):
|
||||
@@ -140,10 +147,11 @@ pill-поля 40dp, шапка с крестиком/галочкой, высо
|
||||
Натив: Изменить `done` (открывает диалог с удалением) · Экспорт/Копировать-ссылку `open`.
|
||||
|
||||
## Предлагаемый порядок правок (жду приоритизацию лида)
|
||||
- **P1 — Разворот повторений в сетке/списке** (Экран 1, долг №2): в `CalendarRepository`
|
||||
реализовать генерацию оккуренций по RRULE в видимом диапазоне поверх CTag-кэша (по граблям из
|
||||
ответа лида mail/072: развёртка ПОСЛЕ парсинга, экземпляры не кэшировать, ключ уникальности
|
||||
`uid+recurrenceStart`). Заодно вскроется `RecurrenceRule.toRrule` для `BYDAY=1MO`.
|
||||
- **P1 — Разворот повторений в сетке/списке** (Экран 1, долг №2): ✅ **СДЕЛАНО в коде**
|
||||
(`CalendarRecurrence` + `eventOccursOnDay`, mail/072-совместимо: развёртка на лету, без кэша
|
||||
и `<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.0%** · Fd_app: **предварительно** (только сайдбар сверен live; дизайн-проход на устройстве — след. итерация) · n-a: 4
|
||||
Ff_app: **41.3%** · Fd_app: **предварительно** (только сайдбар сверен live; дизайн-проход на устройстве — след. итерация) · n-a: 4
|
||||
|
||||
> Прогресс P1 (коммиты 59543a7, bbaaf7a): реализованы меню сообщения «Копировать», «Копировать
|
||||
> ссылку», «Переслать в «Личные заметки»», «Напоминание» (пресеты) — `[done]` ниже
|
||||
> (Ff экрана «Беседа» 38.1%→54.5%).
|
||||
> Прогресс 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 54.5% (10 open Ф / 22)
|
||||
## Экран 3: Беседа + композер + меню сообщения — Ff 63.6% (8 open Ф / 22)
|
||||
Композер:
|
||||
- [Ф][done] Отправить сообщение · «Отправить».
|
||||
- [Ф][done] Эмодзи · «Смайлики».
|
||||
@@ -57,10 +59,10 @@ Ff/Fd на экран. Дизайн (Fd) сверяю на устройстве
|
||||
- [Ф][done] Изменить · «Изменить».
|
||||
- [Ф][done] Удалить · «Удалить».
|
||||
- [Ф][done] Реакция · набор эмодзи (реакции есть).
|
||||
- [Ф][open] **Ответить лично** (Reply privately) — нет.
|
||||
- [Ф][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);
|
||||
|
||||
@@ -12,88 +12,124 @@
|
||||
<ID>ArgumentListWrapping:CalendarComponents.kt$(12.dp)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarComponents.kt$(cal.accountName, style = MaterialTheme.typography.labelSmall, color = F7Colors.TextMuted)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarComponents.kt$(event.location, style = MaterialTheme.typography.labelSmall, color = F7Colors.TextMuted, maxLines = 1)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$( "#0082C9", "#7CBC3D", "#E9322D", "#F1DB50", "#795AAB", "#E97D30", "#00C7B4", )</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$( attendee.displayName.ifBlank { attendee.email }, color = F7Colors.TextPrimary, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, )</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$( context, { _, hh, mm -> onPick("%02d:%02d".format(hh, mm)) }, h, mi, true, )</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$( context, { _, y, m, d -> onPick(LocalDate.of(y, m + 1, d)) }, current.year, current.monthValue - 1, current.dayOfMonth, )</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$( context, { _, y, m, d -> rule = rule.copy(untilDate = LocalDate.of(y, m + 1, d)) }, until.year, until.monthValue - 1, until.dayOfMonth, )</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$( draft.date.format(DateTimeFormatter.ofPattern("d MMMM yyyy", Locale.forLanguageTag("ru"))), color = F7Colors.TextSecondary, fontSize = 13.sp, )</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$( if (draft.attachments.isEmpty()) "Нет ни одного вложения" else "Вложений: ${draft.attachments.size}", color = EVENT_TEXT, fontSize = 14.sp, modifier = Modifier.weight(1f), )</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$( sharee.displayName + if (sharee.writable) " (редактирование)" else " (чтение)", color = F7Colors.TextPrimary, fontSize = 14.sp, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis, )</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$( when (rule.endType) { RecurrenceRule.EndType.NEVER -> "Никогда" RecurrenceRule.EndType.UNTIL -> "До даты" RecurrenceRule.EndType.COUNT -> "После N повторений" }, color = EVENT_TEXT, fontSize = 14.sp, modifier = Modifier.weight(1f), )</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("$count событ.", style = MaterialTheme.typography.labelSmall, color = F7Colors.TextSecondary)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("${s.name} · ${s.email}", modifier = Modifier.clickable { onAddAttendee(s) }.padding(start = 8.dp), color = F7Colors.Primary)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("/${path}", color = F7Colors.TextSecondary, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("ru")</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Весь день")</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Комната Talk")</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Выберите беседу", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = F7Colors.TextPrimary)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Выберите файл", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = F7Colors.TextPrimary)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("До даты")</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Загрузка…", color = F7Colors.TextSecondary, fontSize = 14.sp, modifier = Modifier.padding(vertical = 12.dp))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Занятость участников", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = F7Colors.TextPrimary)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Или создать новую беседу", color = F7Colors.TextPrimary, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Корзина пуста", color = F7Colors.TextSecondary, modifier = Modifier.padding(top = 12.dp))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Настройки календаря", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = F7Colors.TextPrimary)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Нет доступных бесед", color = F7Colors.TextSecondary, fontSize = 14.sp, modifier = Modifier.padding(vertical = 12.dp))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Нет задач без срока", color = F7Colors.TextSecondary, modifier = Modifier.padding(top = 12.dp))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Никогда")</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Отмена", color = F7Colors.TextSecondary)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Папка пуста", color = F7Colors.TextSecondary, fontSize = 14.sp, modifier = Modifier.padding(vertical = 12.dp))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Повторять событие", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = F7Colors.TextPrimary)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("После N повторений")</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$("Удалить календарь и все его события?", color = F7Colors.TextPrimary, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$('/')</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$('/', "")</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(10.dp)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(12.dp)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(16.dp)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(8.dp)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.clickable { onDraftChange(draft.copy(calendarHref = cal.href)) }, verticalAlignment = Alignment.CenterVertically)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.clickable { onDraftChange(draft.copy(classification = cl)) }, verticalAlignment = Alignment.CenterVertically)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.clickable { onDraftChange(draft.copy(recurrence = preset, customRrule = "")) }, verticalAlignment = Alignment.CenterVertically)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.clickable { onDraftChange(draft.copy(status = st)) }, verticalAlignment = Alignment.CenterVertically)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(18.dp)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(1f)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(2.dp)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(24.dp)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(28.dp)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(3.dp, F7Colors.TextPrimary, CircleShape)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(32.dp)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(CircleShape)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(F7Colors.Primary)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(F7Colors.PrimaryLight)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Icons.Default.Add, contentDescription = "Добавить вложение", tint = EVENT_PLACEHOLDER, modifier = Modifier.size(18.dp))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Icons.Default.Close, contentDescription = "Убрать доступ", tint = F7Colors.TextSecondary, modifier = Modifier.size(16.dp))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Icons.Default.Close, contentDescription = "Удалить напоминание", tint = F7Colors.TextSecondary, modifier = Modifier.size(16.dp))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Icons.Default.Close, contentDescription = "Удалить", tint = F7Colors.TextSecondary, modifier = Modifier.size(16.dp))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Icons.Default.InsertDriveFile, contentDescription = null, tint = EVENT_PLACEHOLDER, modifier = Modifier.size(16.dp))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Icons.Default.KeyboardArrowDown, contentDescription = null, tint = EVENT_PLACEHOLDER, modifier = Modifier.size(16.dp))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Icons.Default.Link, contentDescription = null, tint = F7Colors.Primary, modifier = Modifier.size(18.dp))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Icons.Default.Notifications, contentDescription = null, tint = EVENT_PLACEHOLDER, modifier = Modifier.size(16.dp))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.fillMaxWidth().clickable { onDraftChange(draft.copy(mode = mode)) }, verticalAlignment = Alignment.CenterVertically)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.fillMaxWidth().heightIn(max = 520.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.padding(16.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(addTalkRoom = it)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(allDay = it)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(calendarHref = cal.href)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(categories = it)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(checked = draft.addTalkRoom, onCheckedChange = { onDraftChange(draft.copy(addTalkRoom = it)) })</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(checked = draft.allDay, onCheckedChange = { onDraftChange(draft.copy(allDay = it)) })</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(classification = cl)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(description = it)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(addTalkRoom = it))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(allDay = it))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(calendarHref = cal.href))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(categories = it))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(classification = cl))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(description = it))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.padding(horizontal = 16.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(12.dp))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.size(24.dp).clip(CircleShape).background(F7Colors.Primary), contentAlignment = Alignment.Center)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.size(32.dp).clip(CircleShape).background(F7Colors.PrimaryLight), contentAlignment = Alignment.Center)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(Modifier.weight(1f).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(2.dp))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(allDay = !draft.allDay)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(att.fileName.ifBlank { att.url.substringAfterLast('/') }, color = F7Colors.TextPrimary, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(calendarName, color = EVENT_TEXT, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(allDay = !draft.allDay))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(endDate = picked.coerceAtLeast(draft.date).takeIf { it != draft.date }))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(endTime = it))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(mode = mode))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(name = it))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(recurrence = preset))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(recurrence = preset, customRrule = ""))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(reminderMinutes = it))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(recurrence = RecurrencePreset.NONE, customRrule = rule.toRrule()))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(reminders = draft.reminders - min))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(startTime = it))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(status = st))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(subscriptionUrl = it))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.copy(title = it))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.date)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.endTime)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(draft.startTime)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(endDate = picked.coerceAtLeast(draft.date).takeIf { it != draft.date })</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(endTime = it)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(entry.name, color = F7Colors.TextPrimary, fontSize = 15.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(freqLabel(rule.freq, rule.interval), color = EVENT_TEXT, fontSize = 14.sp, modifier = Modifier.weight(1f))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(horizontal = 16.dp, vertical = 14.dp)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(icon = Icons.Default.Schedule, modifier = Modifier.weight(1f), onClick = { pickTime(draft.endTime) { onDraftChange(draft.copy(endTime = it)) } })</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(icon = Icons.Default.Schedule, modifier = Modifier.weight(1f), onClick = { pickTime(draft.startTime) { onDraftChange(draft.copy(startTime = it)) } })</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(if (month == YearMonth.from(selectedDay)) F7Colors.PrimaryLight else F7Colors.Surface)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(if (state.deleting) "Удаление…" else "Удалить", color = F7Colors.Error)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(if (selected) Modifier.border(3.dp, F7Colors.TextPrimary, CircleShape) else Modifier)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(java.time.format.TextStyle.FULL_STANDALONE, java.util.Locale.forLanguageTag("ru"))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(max = 520.dp)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(label, color = EVENT_TEXT, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(min)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(mode = mode)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(month.month.getDisplayName(java.time.format.TextStyle.FULL_STANDALONE, java.util.Locale.forLanguageTag("ru")), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(name = it)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(onClick = onDelete, enabled = !state.deleting)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(recurrence = preset)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(recurrence = preset, customRrule = "")</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(onClick = { confirmDelete = false })</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(onClick = { onDraftChange(draft.copy(reminders = draft.reminders - min)) }, modifier = Modifier.size(24.dp))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(onClick = { onNavigate(path.substringBeforeLast('/', "")) }, modifier = Modifier.size(28.dp))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(path.substringBeforeLast('/', ""))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(recurrence = RecurrencePreset.NONE, customRrule = rule.toRrule())</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(rememberScrollState())</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(reminderMinutes = it)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(s)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(s.address.ifBlank { s.name }, modifier = Modifier.clickable { onApplyLocation(s) }.padding(start = 8.dp), color = F7Colors.Primary)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(selected = draft.calendarHref == cal.href, onClick = { onDraftChange(draft.copy(calendarHref = cal.href)) })</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(selected = draft.classification == cl, onClick = { onDraftChange(draft.copy(classification = cl)) })</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(selected = draft.recurrence == preset, onClick = { onDraftChange(draft.copy(recurrence = preset)) })</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(start = 8.dp)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(reminderLabelFor(min), color = F7Colors.TextPrimary, fontSize = 13.sp, modifier = Modifier.weight(1f))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(reminders = draft.reminders - min)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(room.displayName, color = F7Colors.TextPrimary, fontSize = 15.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(rule.freq, rule.interval)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(startTime = it)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(status = st)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(subscriptionUrl = it)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(text = if (saving) "Сохранение…" else "Создать", onClick = onSave, enabled = !saving)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(text = if (state.saving) "Сохранение…" else "Сохранить", onClick = onSave, enabled = !state.saving)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(title = it)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(text = { Text("До даты") }, onClick = { rule = rule.copy(endType = RecurrenceRule.EndType.UNTIL, untilDate = rule.untilDate ?: LocalDate.now().plusMonths(1)); endMenu = false })</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(text = { Text("Никогда") }, onClick = { rule = rule.copy(endType = RecurrenceRule.EndType.NEVER); endMenu = false })</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(text = { Text("После N повторений") }, onClick = { rule = rule.copy(endType = RecurrenceRule.EndType.COUNT); endMenu = false })</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(top = 12.dp)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(value = draft.attendeeQuery, onValueChange = onSearchAttendees, label = "Поиск участника")</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(value = draft.categories, onValueChange = { onDraftChange(draft.copy(categories = it)) }, label = "Категории")</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(value = draft.description, onValueChange = { onDraftChange(draft.copy(description = it)) }, label = "Описание")</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(value = draft.endTime, onValueChange = { onDraftChange(draft.copy(endTime = it)) }, label = "Окончание")</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(value = draft.name, onValueChange = { onDraftChange(draft.copy(name = it)) }, label = "Название")</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(value = draft.reminderMinutes.toString(), onValueChange = { v -> v.toIntOrNull()?.let { onDraftChange(draft.copy(reminderMinutes = it)) } }, label = "Напоминание (мин)")</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(value = draft.startTime, onValueChange = { onDraftChange(draft.copy(startTime = it)) }, label = "Начало")</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(value = draft.subscriptionUrl, onValueChange = { onDraftChange(draft.copy(subscriptionUrl = it)) }, label = "URL подписки (WebCal)")</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(value = draft.title, onValueChange = { onDraftChange(draft.copy(title = it)) }, label = "Название")</ID>
|
||||
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$("MMM d", Locale.ENGLISH)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$("MMM d, yyyy", Locale.ENGLISH)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(value = name, onValueChange = { name = it }, placeholder = "Название календаря", modifier = Modifier.weight(1f))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(vertical = 12.dp)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarExtendedUi.kt$(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { onDraftChange(draft.copy(allDay = !draft.allDay)) })</ID>
|
||||
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$("d MMM yyyy", RU)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$("d MMM", RU)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$("dd.MM.yyyy HH:mm")</ID>
|
||||
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(DateTimeFormatter.ofPattern("MMM d", Locale.ENGLISH))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.ENGLISH))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(DateTimeFormatter.ofPattern("d MMM yyyy", RU))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(DateTimeFormatter.ofPattern("d MMM", RU))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"))</ID>
|
||||
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(ZoneId.systemDefault())</ID>
|
||||
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(epoch)</ID>
|
||||
@@ -102,35 +138,62 @@
|
||||
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(st.visibleYear, 1, 1)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(st.visibleYear, 12, 31)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(start)</ID>
|
||||
<ID>ArgumentListWrapping:CalendarViewModel.kt$CalendarViewModel$(talkRoomUrl = room.callUrl, addTalkRoom = false, location = it.location.ifBlank { room.callUrl })</ID>
|
||||
<ID>ArgumentListWrapping:RecurrenceRule.kt$RecurrenceRule.Companion$( "MO" to "Пн", "TU" to "Вт", "WE" to "Ср", "TH" to "Чт", "FR" to "Пт", "SA" to "Сб", "SU" to "Вс", )</ID>
|
||||
<ID>ArgumentListWrapping:RecurrenceRule.kt$RecurrenceRule.Companion$("dd.MM.yyyy")</ID>
|
||||
<ID>ArgumentListWrapping:RecurrenceRule.kt$RecurrenceRule.Companion$(DateTimeFormatter.ofPattern("dd.MM.yyyy"))</ID>
|
||||
<ID>CyclomaticComplexMethod:CalendarExtendedUi.kt$@Composable fun CalendarFullEventEditorDialog( state: CalendarUiState, onDismiss: () -> Unit, onDraftChange: (CalendarEventDraft) -> Unit, onSave: () -> Unit, onDelete: () -> Unit, onSearchAttendees: (String) -> Unit, onAddAttendee: (CalendarAttendeeSuggestion) -> Unit, onRemoveAttendee: (String) -> Unit, onSearchLocations: (String) -> Unit, onApplyLocation: (CalendarLocationSuggestion) -> Unit, onOpenTalkPicker: () -> Unit = {}, onPickDeviceAttachment: () -> Unit = {}, onPickFilesAttachment: () -> Unit = {}, onRemoveAttachment: (String) -> Unit = {}, onOpenFreeBusy: () -> Unit = {}, )</ID>
|
||||
<ID>CyclomaticComplexMethod:CalendarScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun CalendarScreen( session: AuthSession, modifier: Modifier = Modifier, focusEventUid: String? = null, settingsRequest: Int = 0, sidebarOpen: Boolean = false, onSidebarOpenChange: (Boolean) -> Unit = {}, onFocusEventConsumed: () -> Unit = {}, onUnauthorized: () -> Unit = {}, )</ID>
|
||||
<ID>CyclomaticComplexMethod:CalendarSyncEngine.kt$CalendarSyncEngine$fun run( session: AuthSession, deviceCalendarId: Long, ): CalendarSyncResult</ID>
|
||||
<ID>ForEachOnRange:CalendarComponents.kt$0..23</ID>
|
||||
<ID>ForEachOnRange:CalendarComponents.kt$0..24</ID>
|
||||
<ID>ImportOrdering:CalendarComponents.kt$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.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio 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.width 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.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.RadioButton import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberDatePickerState import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.offset import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem 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.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.sp import java.time.LocalTime 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.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import coil.request.ImageRequest import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.designsystem.F7Colors import ru.forbion.f7cloud.core.designsystem.F7ListCard import ru.forbion.f7cloud.core.designsystem.F7OutlinedField import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton import ru.forbion.f7cloud.core.designsystem.F7TextButton import java.time.Instant import java.time.LocalDate import java.time.YearMonth import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Locale</ID>
|
||||
<ID>ImportOrdering:CalendarRepository.kt$import okhttp3.OkHttpClient import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.auth.OcsUserResolver import ru.forbion.f7cloud.core.network.CalendarAlarmData import ru.forbion.f7cloud.core.network.CalendarAttendeeData import ru.forbion.f7cloud.core.network.CalendarEventData import ru.forbion.f7cloud.core.network.CalendarIcs import ru.forbion.f7cloud.core.network.CalDavClient import ru.forbion.f7cloud.core.network.DavCalendar import ru.forbion.f7cloud.core.network.DavEvent import ru.forbion.f7cloud.core.network.DavTask import ru.forbion.f7cloud.core.network.DavTrashEvent import ru.forbion.f7cloud.core.network.NetworkFactory import java.time.Instant import java.time.LocalDate import java.time.LocalDateTime import java.time.YearMonth import java.time.ZoneId import java.time.ZoneOffset</ID>
|
||||
<ID>ImportOrdering:CalendarComponents.kt$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.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row 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.width 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.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.RadioButton import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.offset import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.sp import java.time.LocalTime 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.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import coil.request.ImageRequest import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.designsystem.F7Colors import ru.forbion.f7cloud.core.designsystem.F7ListCard import ru.forbion.f7cloud.core.designsystem.F7OutlinedField import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton import ru.forbion.f7cloud.core.designsystem.F7TextButton import java.time.Instant import java.time.LocalDate import java.time.YearMonth import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Locale</ID>
|
||||
<ID>ImportOrdering:CalendarExtendedUi.kt$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.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight 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.width 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.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.AttachFile import androidx.compose.material.icons.filled.Autorenew import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.DateRange import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.InsertDriveFile import androidx.compose.material.icons.filled.Upload import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.LocalOffer import androidx.compose.material.icons.filled.Notes import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Place import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Visibility import androidx.compose.material3.AlertDialog import androidx.compose.material3.Checkbox import androidx.compose.material3.CheckboxDefaults import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.RadioButton import androidx.compose.material3.Surface import androidx.compose.material3.Switch 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 import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties 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 ru.forbion.f7cloud.core.designsystem.F7TextButton import java.time.LocalDate import java.time.YearMonth import java.time.format.DateTimeFormatter import java.util.Locale</ID>
|
||||
<ID>ImportOrdering:CalendarRepository.kt$import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.RequestBody.Companion.toRequestBody import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.auth.OcsUserResolver import ru.forbion.f7cloud.core.network.CalendarAlarmData import ru.forbion.f7cloud.core.network.CalendarAttachmentData import ru.forbion.f7cloud.core.network.CalendarAttendeeData import ru.forbion.f7cloud.core.network.CalendarEventData import ru.forbion.f7cloud.core.network.CalendarIcs import ru.forbion.f7cloud.core.network.CalDavClient import ru.forbion.f7cloud.core.network.DavCalendar import ru.forbion.f7cloud.core.network.DavClient import ru.forbion.f7cloud.core.network.DavEvent import ru.forbion.f7cloud.core.network.DavTask import ru.forbion.f7cloud.core.network.DavTrashEvent import ru.forbion.f7cloud.core.network.NetworkFactory import ru.forbion.f7cloud.core.network.davFileUrl import ru.forbion.f7cloud.core.network.davFolderUrl import java.time.Instant import java.time.LocalDate import java.time.LocalDateTime import java.time.YearMonth import java.time.ZoneId import java.time.ZoneOffset</ID>
|
||||
<ID>ImportOrdering:CalendarScreen.kt$import android.Manifest import android.content.pm.PackageManager import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.snapshotFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme 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.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.lifecycle.viewmodel.compose.viewModel import kotlinx.coroutines.delay import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.designsystem.F7Colors import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler import java.time.format.DateTimeFormatter import java.util.Locale</ID>
|
||||
<ID>Indentation:CalendarScreen.kt$ </ID>
|
||||
<ID>LargeClass:CalendarViewModel.kt$CalendarViewModel : ViewModel</ID>
|
||||
<ID>LongMethod:CalendarComponents.kt$@Composable fun CalendarDayTimeline( session: AuthSession, day: LocalDate, events: List<CalendarEventItem>, viewMode: CalendarViewMode, onViewMode: (CalendarViewMode) -> Unit, onEventClick: (CalendarEventItem) -> Unit, modifier: Modifier = Modifier, )</ID>
|
||||
<ID>LongMethod:CalendarExtendedUi.kt$@Composable fun CalendarFullEventEditorDialog( state: CalendarUiState, onDismiss: () -> Unit, onDraftChange: (CalendarEventDraft) -> Unit, onSave: () -> Unit, onDelete: () -> Unit, onSearchAttendees: (String) -> Unit, onAddAttendee: (CalendarAttendeeSuggestion) -> Unit, onRemoveAttendee: (String) -> Unit, onSearchLocations: (String) -> Unit, onApplyLocation: (CalendarLocationSuggestion) -> Unit, onOpenTalkPicker: () -> Unit = {}, onPickDeviceAttachment: () -> Unit = {}, onPickFilesAttachment: () -> Unit = {}, onRemoveAttachment: (String) -> Unit = {}, onOpenFreeBusy: () -> Unit = {}, )</ID>
|
||||
<ID>LongMethod:CalendarExtendedUi.kt$@Composable fun CalendarRepeatModal( initial: RecurrenceRule, onDismiss: () -> Unit, onApply: (RecurrenceRule) -> Unit, onClear: () -> Unit, )</ID>
|
||||
<ID>LongMethod:CalendarScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun CalendarScreen( session: AuthSession, modifier: Modifier = Modifier, focusEventUid: String? = null, settingsRequest: Int = 0, sidebarOpen: Boolean = false, onSidebarOpenChange: (Boolean) -> Unit = {}, onFocusEventConsumed: () -> Unit = {}, onUnauthorized: () -> Unit = {}, )</ID>
|
||||
<ID>LongMethod:CalendarSyncEngine.kt$CalendarSyncEngine$fun run( session: AuthSession, deviceCalendarId: Long, ): CalendarSyncResult</ID>
|
||||
<ID>LoopWithTooManyJumpStatements:CalendarApiClient.kt$CalendarApiClient$for</ID>
|
||||
<ID>LoopWithTooManyJumpStatements:CalendarSyncEngine.kt$CalendarSyncEngine$for</ID>
|
||||
<ID>MaxLineLength:CalendarApiClient.kt$CalendarApiClient$val url = "${session.serverUrl.trimEnd('/')}/index.php/apps/calendar/v1/autocompletion/location?search=${java.net.URLEncoder.encode(query.trim(), Charsets.UTF_8.name())}"</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$Column</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$F7OutlinedField(value = draft.categories, onValueChange = { onDraftChange(draft.copy(categories = it)) }, label = "Категории")</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$F7OutlinedField(value = draft.description, onValueChange = { onDraftChange(draft.copy(description = it)) }, label = "Описание")</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$F7OutlinedField(value = draft.reminderMinutes.toString(), onValueChange = { v -> v.toIntOrNull()?.let { onDraftChange(draft.copy(reminderMinutes = it)) } }, label = "Напоминание (мин)")</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$F7OutlinedField(value = draft.startTime, onValueChange = { onDraftChange(draft.copy(startTime = it)) }, label = "Начало")</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$Box</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$DropdownMenuItem(text = { Text("До даты") }, onClick = { rule = rule.copy(endType = RecurrenceRule.EndType.UNTIL, untilDate = rule.untilDate ?: LocalDate.now().plusMonths(1)); endMenu = false })</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$DropdownMenuItem(text = { Text("Никогда") }, onClick = { rule = rule.copy(endType = RecurrenceRule.EndType.NEVER); endMenu = false })</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$DropdownMenuItem(text = { Text("После N повторений") }, onClick = { rule = rule.copy(endType = RecurrenceRule.EndType.COUNT); endMenu = false })</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$EventInput(value = name, onValueChange = { name = it }, placeholder = "Название календаря", modifier = Modifier.weight(1f))</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$EventPill</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$F7OutlinedField(value = draft.subscriptionUrl, onValueChange = { onDraftChange(draft.copy(subscriptionUrl = it)) }, label = "URL подписки (WebCal)")</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$RadioButton(selected = draft.calendarHref == cal.href, onClick = { onDraftChange(draft.copy(calendarHref = cal.href)) })</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$Icon(Icons.Default.Add, contentDescription = "Добавить вложение", tint = EVENT_PLACEHOLDER, modifier = Modifier.size(18.dp))</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$Icon(Icons.Default.Close, contentDescription = "Убрать доступ", tint = F7Colors.TextSecondary, modifier = Modifier.size(16.dp))</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$Icon(Icons.Default.Close, contentDescription = "Удалить напоминание", tint = F7Colors.TextSecondary, modifier = Modifier.size(16.dp))</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$Icon(Icons.Default.Close, contentDescription = "Удалить", tint = F7Colors.TextSecondary, modifier = Modifier.size(16.dp))</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$Icon(Icons.Default.InsertDriveFile, contentDescription = null, tint = EVENT_PLACEHOLDER, modifier = Modifier.size(16.dp))</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$Icon(Icons.Default.KeyboardArrowDown, contentDescription = null, tint = EVENT_PLACEHOLDER, modifier = Modifier.size(16.dp))</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$Icon(Icons.Default.Link, contentDescription = null, tint = F7Colors.Primary, modifier = Modifier.size(18.dp))</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$Icon(Icons.Default.Notifications, contentDescription = null, tint = EVENT_PLACEHOLDER, modifier = Modifier.size(16.dp))</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$IconButton</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$Row</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$Text("${s.name} · ${s.email}", modifier = Modifier.clickable { onAddAttendee(s) }.padding(start = 8.dp), color = F7Colors.Primary)</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$Text("Комната Talk"); Switch(checked = draft.addTalkRoom, onCheckedChange = { onDraftChange(draft.copy(addTalkRoom = it)) })</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$Text("Выберите беседу", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = F7Colors.TextPrimary)</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$Text("Выберите файл", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = F7Colors.TextPrimary)</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$Text("Занятость участников", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = F7Colors.TextPrimary)</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$Text("Настройки календаря", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = F7Colors.TextPrimary)</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$Text("Повторять событие", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = F7Colors.TextPrimary)</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$Text("Удалить календарь и все его события?", color = F7Colors.TextPrimary, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$Text(att.fileName.ifBlank { att.url.substringAfterLast('/') }, color = F7Colors.TextPrimary, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$Text(calendarName, color = EVENT_TEXT, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$Text(entry.name, color = F7Colors.TextPrimary, fontSize = 15.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$Text(label, color = EVENT_TEXT, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$Text(month.month.getDisplayName(java.time.format.TextStyle.FULL_STANDALONE, java.util.Locale.forLanguageTag("ru")), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold)</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$Text(s.address.ifBlank { s.name }, modifier = Modifier.clickable { onApplyLocation(s) }.padding(start = 8.dp), color = F7Colors.Primary)</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$confirmButton = { F7PrimaryButton(text = if (state.saving) "Сохранение…" else "Сохранить", onClick = onSave, enabled = !state.saving) }</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$if (isEdit) TextButton(onClick = onDelete, enabled = !state.deleting) { Text(if (state.deleting) "Удаление…" else "Удалить", color = F7Colors.Error) }</ID>
|
||||
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$"${start.format(DateTimeFormatter.ofPattern("MMM d", Locale.ENGLISH))} – ${end.format(DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.ENGLISH))}"</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$Text(room.displayName, color = F7Colors.TextPrimary, fontSize = 15.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$state.filesPickerEntries.isEmpty() -> Text("Папка пуста", color = F7Colors.TextSecondary, fontSize = 14.sp, modifier = Modifier.padding(vertical = 12.dp))</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$state.filesPickerLoading -> Text("Загрузка…", color = F7Colors.TextSecondary, fontSize = 14.sp, modifier = Modifier.padding(vertical = 12.dp))</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$state.talkPickerLoading -> Text("Загрузка…", color = F7Colors.TextSecondary, fontSize = 14.sp, modifier = Modifier.padding(vertical = 12.dp))</ID>
|
||||
<ID>MaxLineLength:CalendarExtendedUi.kt$state.talkRooms.isEmpty() -> Text("Нет доступных бесед", color = F7Colors.TextSecondary, fontSize = 14.sp, modifier = Modifier.padding(vertical = 12.dp))</ID>
|
||||
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$.</ID>
|
||||
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$CalendarViewMode.DAY -> st.copy(selectedDay = st.selectedDay.minusDays(1), visibleMonth = YearMonth.from(st.selectedDay.minusDays(1)))</ID>
|
||||
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$CalendarViewMode.DAY -> st.copy(selectedDay = st.selectedDay.plusDays(1), visibleMonth = YearMonth.from(st.selectedDay.plusDays(1)))</ID>
|
||||
@@ -142,17 +205,27 @@
|
||||
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$CalendarViewMode.WEEK -> st.copy(selectedDay = st.selectedDay.plusWeeks(1), visibleMonth = YearMonth.from(st.selectedDay.plusWeeks(1)))</ID>
|
||||
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$CalendarViewMode.YEAR -> repository.loadRange(session, LocalDate.of(st.visibleYear, 1, 1), LocalDate.of(st.visibleYear, 12, 31), visibleCalendarHrefs)</ID>
|
||||
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$_state.value = _state.value.copy(deleting = false, createDialogOpen = false, eventDetail = null, focusedEventUid = null, snackMessage = "Удалено")</ID>
|
||||
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$_state.value = _state.value.copy(editCalendarBusy = false, editCalendarOpen = false, editCalendarTarget = null, snackMessage = "Календарь обновлён")</ID>
|
||||
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$_state.value = _state.value.copy(editCalendarBusy = false, editCalendarOpen = false, editCalendarTarget = null, snackMessage = "Календарь удалён")</ID>
|
||||
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$_state.value = _state.value.copy(selectedDay = day, visibleMonth = YearMonth.from(day), eventDetail = event, focusedEventUid = event.uid)</ID>
|
||||
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$_state.value = _state.value.copy(selectedDay = day, visibleMonth = YearMonth.from(day), visibleYear = day.year, datePickerOpen = false)</ID>
|
||||
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$classification = EventClassification.entries.firstOrNull { it.icsValue == event.classification } ?: EventClassification.PUBLIC</ID>
|
||||
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$private fun formatLastSync(epoch: Long)</ID>
|
||||
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$return if (visible.isEmpty()) _state.value.events else _state.value.events.filter { it.calendarHref in visible || it.calendarHref.isBlank() }</ID>
|
||||
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$snackMessage = if (showSnack) "Синхронизация завершена (${result.pushedToDevice + result.pushedToServer + result.updated + result.linked})" else _state.value.snackMessage</ID>
|
||||
<ID>MaxLineLength:CalendarViewModel.kt$CalendarViewModel$updateEventDraft { it.copy(talkRoomUrl = room.callUrl, addTalkRoom = false, location = it.location.ifBlank { room.callUrl }) }</ID>
|
||||
<ID>MaximumLineLength:CalendarApiClient.kt$CalendarApiClient$ </ID>
|
||||
<ID>MaximumLineLength:CalendarComponents.kt$ </ID>
|
||||
<ID>MaximumLineLength:CalendarExtendedUi.kt$ </ID>
|
||||
<ID>MaximumLineLength:CalendarRepository.kt$CalendarRepository$ </ID>
|
||||
<ID>MaximumLineLength:CalendarViewModel.kt$CalendarViewModel$ </ID>
|
||||
<ID>MaximumLineLength:RecurrenceRule.kt$RecurrenceRule.Companion$ </ID>
|
||||
<ID>MultiLineIfElse:CalendarExtendedUi.kt$draft.date.atStartOfDay(zone).toInstant().toEpochMilli()</ID>
|
||||
<ID>MultiLineIfElse:CalendarExtendedUi.kt$draft.date.atTime( draft.startTime.substringBefore(':').toIntOrNull() ?: 10, draft.startTime.substringAfter(':').toIntOrNull() ?: 0, ).atZone(zone).toInstant().toEpochMilli()</ID>
|
||||
<ID>MultiLineIfElse:CalendarExtendedUi.kt$endDay.atTime( draft.endTime.substringBefore(':').toIntOrNull() ?: 11, draft.endTime.substringAfter(':').toIntOrNull() ?: 0, ).atZone(zone).toInstant().toEpochMilli()</ID>
|
||||
<ID>MultiLineIfElse:CalendarExtendedUi.kt$endDay.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()</ID>
|
||||
<ID>MultiLineIfElse:RecurrenceRule.kt$RecurrenceRule.Companion$""</ID>
|
||||
<ID>NestedBlockDepth:CalendarApiClient.kt$CalendarApiClient$fun searchSharees(session: AuthSession, query: String): List<CalendarShareeSuggestion></ID>
|
||||
<ID>NoMultipleSpaces:CalendarSyncEngine.kt$CalendarSyncEngine.Companion$ </ID>
|
||||
<ID>NoUnusedImports:CalendarApiClient.kt$ru.forbion.f7cloud.feature.calendar.CalendarApiClient.kt</ID>
|
||||
<ID>NoUnusedImports:CalendarComponents.kt$ru.forbion.f7cloud.feature.calendar.CalendarComponents.kt</ID>
|
||||
@@ -162,34 +235,41 @@
|
||||
<ID>NoUnusedImports:CalendarSyncEngine.kt$ru.forbion.f7cloud.feature.calendar.CalendarSyncEngine.kt</ID>
|
||||
<ID>NoUnusedImports:DeviceCalendarClient.kt$ru.forbion.f7cloud.feature.calendar.DeviceCalendarClient.kt</ID>
|
||||
<ID>ParameterListWrapping:CalendarViewModel.kt$CalendarViewModel$(draft: CalendarCreateBookDraft)</ID>
|
||||
<ID>StringTemplate:CalendarExtendedUi.kt$${path}</ID>
|
||||
<ID>UnusedPrivateMember:CalendarComponents.kt$@Composable private fun CalendarIconButton( iconUrl: String, contentDescription: String?, onClick: () -> Unit, )</ID>
|
||||
<ID>UnusedPrivateMember:CalendarComponents.kt$@Composable private fun CalendarNavButton(text: String, onClick: () -> Unit)</ID>
|
||||
<ID>UnusedPrivateProperty:CalendarComponents.kt$private val toolbarDateFormatter = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.ENGLISH)</ID>
|
||||
<ID>UnusedPrivateProperty:CalendarSyncEngine.kt$CalendarSyncEngine$val mappedDevIds2 = mappings.map { it.deviceEventId }.toSet()</ID>
|
||||
<ID>UseCheckOrError:CalendarRepository.kt$CalendarRepository$throw IllegalStateException("Календари не найдены")</ID>
|
||||
<ID>UseCheckOrError:CalendarRepository.kt$CalendarRepository$throw IllegalStateException("Не удалось загрузить события календаря: $lastError")</ID>
|
||||
<ID>Wrapping:CalendarComponents.kt$;</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$;</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$F7PrimaryButton(text = if (saving) "Сохранение…" else "Создать", onClick = onSave, enabled = !saving)</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$F7PrimaryButton(text = if (state.saving) "Сохранение…" else "Сохранить", onClick = onSave, enabled = !state.saving)</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$Text(if (state.deleting) "Удаление…" else "Удалить", color = F7Colors.Error)</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$onAddAttendee(s)</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$onApplyLocation(s)</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(addTalkRoom = it))</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(calendarHref = cal.href))</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(categories = it))</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(classification = cl))</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(description = it))</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$Text("До даты")</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$Text("Никогда")</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$Text("После N повторений")</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$att.url.substringAfterLast('/')</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$name = it</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(allDay = !draft.allDay))</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(endTime = it))</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(mode = mode))</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(recurrence = preset, customRrule = ""))</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(reminderMinutes = it))</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(reminders = draft.reminders - min))</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(startTime = it))</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$onDraftChange(draft.copy(subscriptionUrl = it))</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$s.name</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$pickTime(draft.endTime) { onDraftChange(draft.copy(endTime = it)) }</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$pickTime(draft.startTime) { onDraftChange(draft.copy(startTime = it)) }</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$rule = rule.copy(endType = RecurrenceRule.EndType.COUNT); endMenu = false</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$rule = rule.copy(endType = RecurrenceRule.EndType.NEVER); endMenu = false</ID>
|
||||
<ID>Wrapping:CalendarExtendedUi.kt$rule = rule.copy(endType = RecurrenceRule.EndType.UNTIL, untilDate = rule.untilDate ?: LocalDate.now().plusMonths(1)); endMenu = false</ID>
|
||||
<ID>Wrapping:CalendarRepository.kt$CalendarRepository$;</ID>
|
||||
<ID>Wrapping:CalendarViewModel.kt$CalendarViewModel$;</ID>
|
||||
<ID>Wrapping:CalendarViewModel.kt$CalendarViewModel$_state.value = _state.value.copy(attachmentUploading = false, snackMessage = "Ошибка загрузки: ${it.message}")</ID>
|
||||
<ID>Wrapping:CalendarViewModel.kt$CalendarViewModel$_state.value = _state.value.copy(editCalendarBusy = false, snackMessage = "Не удалось обновить: ${it.message}")</ID>
|
||||
<ID>Wrapping:CalendarViewModel.kt$CalendarViewModel$_state.value = _state.value.copy(editCalendarBusy = false, snackMessage = "Не удалось удалить: ${it.message}")</ID>
|
||||
<ID>Wrapping:CalendarViewModel.kt$CalendarViewModel$_state.value = _state.value.copy(loading = false, error = it.message, unauthorized = it is UnauthorizedException)</ID>
|
||||
<ID>Wrapping:CalendarViewModel.kt$CalendarViewModel$it.calendarHref in visible || it.calendarHref.isBlank()</ID>
|
||||
<ID>Wrapping:CalendarViewModel.kt$CalendarViewModel$it.copy(talkRoomUrl = room.callUrl, addTalkRoom = false, location = it.location.ifBlank { room.callUrl })</ID>
|
||||
<ID>Wrapping:CalendarViewModel.kt$CalendarViewModel$it.icsValue == event.classification</ID>
|
||||
<ID>Wrapping:CalendarViewModel.kt$CalendarViewModel$room.callUrl</ID>
|
||||
<ID>Wrapping:CalendarViewModel.kt$CalendarViewModel${ _state.value = _state.value.copy(createBookOpen = false, createBookDraft = CalendarCreateBookDraft()) }</ID>
|
||||
</CurrentIssues>
|
||||
</SmellBaseline>
|
||||
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
package ru.forbion.f7cloud.feature.calendar
|
||||
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDate
|
||||
import java.time.temporal.ChronoField
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
/**
|
||||
* Локальный разворот повторяющихся событий (P1, mail/072/093).
|
||||
*
|
||||
* Генерирует даты-старты оккуренций правила RRULE в заданном диапазоне БЕЗ обращения к серверу
|
||||
* (`<c:expand>` не используем — это правка core, mail/072). Поддерживается то же подмножество,
|
||||
* что умеет создавать нативный редактор ([RecurrenceRule]): FREQ=DAILY|WEEKLY|MONTHLY|YEARLY,
|
||||
* INTERVAL, BYDAY (для WEEKLY), окончание UNTIL/COUNT/никогда.
|
||||
*
|
||||
* Развёртка делается на КАЖДЫЙ показ (не кэшируется — иначе окно диапазона «вмораживается»,
|
||||
* mail/072). Экземпляры различимы парой (uid, дата-старт оккуренции).
|
||||
*/
|
||||
object CalendarRecurrence {
|
||||
|
||||
/** Жёсткий предел числа шагов генератора — страховка от бесконечных/патологических правил. */
|
||||
private const val MAX_STEPS = 3660
|
||||
|
||||
/**
|
||||
* Покрывает ли событие [day] с учётом повторения.
|
||||
*
|
||||
* @param start дата-старт мастер-события (в нужной зоне).
|
||||
* @param spanDays длительность события в днях для all-day (>=1); для timed игнорируется.
|
||||
* @param allDay all-day событие занимает [occStart, occStart+spanDays); timed — только occStart.
|
||||
*/
|
||||
fun occursOnDay(
|
||||
rule: RecurrenceRule,
|
||||
start: LocalDate,
|
||||
day: LocalDate,
|
||||
spanDays: Long,
|
||||
allDay: Boolean,
|
||||
): Boolean {
|
||||
if (day.isBefore(start)) return false
|
||||
val span = if (allDay) spanDays.coerceAtLeast(1) else 1L
|
||||
// Оккуренция покрывает day, если её старт в диапазоне (day - (span-1) .. day).
|
||||
val earliestStart = day.minusDays(span - 1)
|
||||
var found = false
|
||||
forEachOccurrenceStart(rule, start, upTo = day) { occStart ->
|
||||
if (!occStart.isBefore(earliestStart) && !occStart.isAfter(day)) {
|
||||
found = true
|
||||
false // стоп
|
||||
} else {
|
||||
true // продолжаем
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/** Список дат-стартов оккуренций в [rangeStart, rangeEnd] включительно (для списковых видов). */
|
||||
fun occurrenceStartsInRange(
|
||||
rule: RecurrenceRule,
|
||||
start: LocalDate,
|
||||
rangeStart: LocalDate,
|
||||
rangeEnd: LocalDate,
|
||||
): List<LocalDate> {
|
||||
val out = mutableListOf<LocalDate>()
|
||||
forEachOccurrenceStart(rule, start, upTo = rangeEnd) { occStart ->
|
||||
if (!occStart.isBefore(rangeStart)) out.add(occStart)
|
||||
true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Границы окончания правила: (UNTIL-дата или null, максимум оккуренций по COUNT). */
|
||||
private fun bounds(rule: RecurrenceRule): Pair<LocalDate?, Int> {
|
||||
val until = if (rule.endType == RecurrenceRule.EndType.UNTIL) rule.untilDate else null
|
||||
val maxCount = if (rule.endType == RecurrenceRule.EndType.COUNT) rule.count.coerceAtLeast(1) else Int.MAX_VALUE
|
||||
return until to maxCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Перебирает даты-старты оккуренций от [start], пока не превышена [upTo], не исчерпан
|
||||
* UNTIL/COUNT или лимит шагов. [onOccurrence] возвращает false, чтобы прервать перебор.
|
||||
*/
|
||||
private inline fun forEachOccurrenceStart(
|
||||
rule: RecurrenceRule,
|
||||
start: LocalDate,
|
||||
upTo: LocalDate,
|
||||
onOccurrence: (LocalDate) -> Boolean,
|
||||
) {
|
||||
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,
|
||||
start: LocalDate,
|
||||
upTo: LocalDate,
|
||||
onOccurrence: (LocalDate) -> Boolean,
|
||||
) {
|
||||
val interval = rule.interval.coerceAtLeast(1)
|
||||
val (until, maxCount) = bounds(rule)
|
||||
val weeklyDows = RecurrenceRule.ICS_DAYS.filter { it in rule.byDays }.map { icsDayToDow(it).value.toLong() }
|
||||
val weekBase = mondayOf(start)
|
||||
var emitted = 0
|
||||
var block = 0L
|
||||
while (block < MAX_STEPS) {
|
||||
val weekStart = weekBase.plusWeeks(block * interval)
|
||||
if (weekStart.isAfter(upTo)) return // все дни этой и следующих недель уже за upTo
|
||||
for (dowValue in weeklyDows) {
|
||||
val occ = weekStart.with(ChronoField.DAY_OF_WEEK, dowValue)
|
||||
if (occ.isBefore(start) || occ.isAfter(upTo)) continue
|
||||
if (until != null && occ.isAfter(until)) return
|
||||
if (emitted >= maxCount) return
|
||||
emitted++
|
||||
if (!onOccurrence(occ)) return
|
||||
}
|
||||
block++
|
||||
}
|
||||
}
|
||||
|
||||
/** DAILY / WEEKLY(без BYDAY) / MONTHLY / YEARLY: простой шаг по мастер-старту. */
|
||||
private inline fun forEachSimpleStep(
|
||||
rule: RecurrenceRule,
|
||||
start: LocalDate,
|
||||
upTo: LocalDate,
|
||||
onOccurrence: (LocalDate) -> Boolean,
|
||||
) {
|
||||
val interval = rule.interval.coerceAtLeast(1)
|
||||
val (until, maxCount) = bounds(rule)
|
||||
var emitted = 0
|
||||
var n = 0L
|
||||
while (n < MAX_STEPS) {
|
||||
val occ = when (rule.freq) {
|
||||
"DAILY" -> start.plusDays(n * interval)
|
||||
"WEEKLY" -> start.plusWeeks(n * interval)
|
||||
"MONTHLY" -> start.plusMonths(n * interval)
|
||||
"YEARLY" -> start.plusYears(n * interval)
|
||||
else -> return
|
||||
}
|
||||
val pastUntil = until != null && occ.isAfter(until)
|
||||
if (occ.isAfter(upTo) || pastUntil || emitted >= maxCount) return
|
||||
emitted++
|
||||
if (!onOccurrence(occ)) return
|
||||
n++
|
||||
}
|
||||
}
|
||||
|
||||
private fun mondayOf(date: LocalDate): LocalDate = date.with(ChronoField.DAY_OF_WEEK, 1L)
|
||||
|
||||
private fun icsDayToDow(ics: String): DayOfWeek = when (ics) {
|
||||
"MO" -> DayOfWeek.MONDAY
|
||||
"TU" -> DayOfWeek.TUESDAY
|
||||
"WE" -> DayOfWeek.WEDNESDAY
|
||||
"TH" -> DayOfWeek.THURSDAY
|
||||
"FR" -> DayOfWeek.FRIDAY
|
||||
"SA" -> DayOfWeek.SATURDAY
|
||||
else -> DayOfWeek.SUNDAY
|
||||
}
|
||||
|
||||
/** Число дней-длительности для all-day по границам [start,end) (в датах). */
|
||||
fun allDaySpanDays(start: LocalDate, end: LocalDate): Long =
|
||||
ChronoUnit.DAYS.between(start, end).coerceAtLeast(1)
|
||||
}
|
||||
+7
@@ -430,6 +430,13 @@ class CalendarRepository(
|
||||
fun eventOccursOnDay(event: CalendarEventItem, day: LocalDate, zone: ZoneId = ZoneId.systemDefault()): Boolean {
|
||||
val start = Instant.ofEpochMilli(event.startEpochMilli).atZone(zone).toLocalDate()
|
||||
val end = Instant.ofEpochMilli(event.endEpochMilli).atZone(zone).toLocalDate()
|
||||
// Повторяющееся событие: разворачиваем оккуренции на лету (P1, mail/072/093).
|
||||
val rule = RecurrenceRule.parse(event.rrule)
|
||||
if (rule != null) {
|
||||
val spanDays = if (event.allDay) CalendarRecurrence.allDaySpanDays(start, end) else 1L
|
||||
return CalendarRecurrence.occursOnDay(rule, start, day, spanDays, event.allDay)
|
||||
}
|
||||
// Одиночное событие: прежнее поведение (all-day занимает [start, end)).
|
||||
return if (event.allDay) !day.isBefore(start) && day.isBefore(end) else day == start
|
||||
}
|
||||
|
||||
|
||||
+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
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
package ru.forbion.f7cloud.feature.calendar
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* Разворот повторений (P1): [CalendarRecurrence.occursOnDay] / [occurrenceStartsInRange].
|
||||
*/
|
||||
class CalendarRecurrenceTest {
|
||||
|
||||
private fun rule(s: String) = RecurrenceRule.parse(s)!!
|
||||
private fun d(s: String) = LocalDate.parse(s)
|
||||
|
||||
private fun occurs(rrule: String, start: String, day: String, span: Long = 1, allDay: Boolean = false) =
|
||||
CalendarRecurrence.occursOnDay(rule(rrule), d(start), d(day), span, allDay)
|
||||
|
||||
@Test fun daily_hits_every_day() {
|
||||
assertTrue(occurs("FREQ=DAILY", "2026-07-01", "2026-07-01"))
|
||||
assertTrue(occurs("FREQ=DAILY", "2026-07-01", "2026-07-15"))
|
||||
assertFalse("до старта — нет", occurs("FREQ=DAILY", "2026-07-10", "2026-07-05"))
|
||||
}
|
||||
|
||||
@Test fun daily_interval_2_skips_odd_offsets() {
|
||||
assertTrue(occurs("FREQ=DAILY;INTERVAL=2", "2026-07-01", "2026-07-03"))
|
||||
assertFalse(occurs("FREQ=DAILY;INTERVAL=2", "2026-07-01", "2026-07-02"))
|
||||
}
|
||||
|
||||
@Test fun weekly_same_weekday() {
|
||||
// 2026-07-01 — среда; +неделя = 08, 15…
|
||||
assertTrue(occurs("FREQ=WEEKLY", "2026-07-01", "2026-07-08"))
|
||||
assertTrue(occurs("FREQ=WEEKLY", "2026-07-01", "2026-07-29"))
|
||||
assertFalse(occurs("FREQ=WEEKLY", "2026-07-01", "2026-07-09"))
|
||||
}
|
||||
|
||||
@Test fun weekly_byday_mon_wed_fri() {
|
||||
// старт среда 2026-07-01; BYDAY=MO,WE,FR
|
||||
val r = "FREQ=WEEKLY;BYDAY=MO,WE,FR"
|
||||
assertTrue("среда старта", occurs(r, "2026-07-01", "2026-07-01"))
|
||||
assertTrue("пятница той же недели", occurs(r, "2026-07-01", "2026-07-03"))
|
||||
assertTrue("понедельник след. недели", occurs(r, "2026-07-01", "2026-07-06"))
|
||||
assertFalse("вторник — нет", occurs(r, "2026-07-01", "2026-07-07"))
|
||||
assertFalse("понедельник ДО старта той же недели — нет", occurs(r, "2026-07-01", "2026-06-29"))
|
||||
}
|
||||
|
||||
@Test fun weekly_byday_interval_2_skips_middle_week() {
|
||||
val r = "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO"
|
||||
// старт среда 01.07; понедельники: блок0 нед. 29.06 (до старта, нет), блок2 нед. 13.07 → 13
|
||||
assertTrue(occurs(r, "2026-07-01", "2026-07-13"))
|
||||
assertFalse("промежуточная неделя 06.07 пропущена", occurs(r, "2026-07-01", "2026-07-06"))
|
||||
}
|
||||
|
||||
@Test fun monthly_same_day_of_month() {
|
||||
assertTrue(occurs("FREQ=MONTHLY", "2026-01-15", "2026-04-15"))
|
||||
assertFalse(occurs("FREQ=MONTHLY", "2026-01-15", "2026-04-16"))
|
||||
}
|
||||
|
||||
@Test fun yearly() {
|
||||
assertTrue(occurs("FREQ=YEARLY", "2024-02-29", "2028-02-29"))
|
||||
assertTrue(occurs("FREQ=YEARLY", "2026-03-10", "2030-03-10"))
|
||||
assertFalse(occurs("FREQ=YEARLY", "2026-03-10", "2027-03-11"))
|
||||
}
|
||||
|
||||
@Test fun count_limits_occurrences() {
|
||||
// COUNT=3 → 01, 02, 03; 04 уже нет
|
||||
assertTrue(occurs("FREQ=DAILY;COUNT=3", "2026-07-01", "2026-07-03"))
|
||||
assertFalse(occurs("FREQ=DAILY;COUNT=3", "2026-07-01", "2026-07-04"))
|
||||
}
|
||||
|
||||
@Test fun until_bounds_occurrences() {
|
||||
assertTrue(occurs("FREQ=DAILY;UNTIL=20260705T235959Z", "2026-07-01", "2026-07-05"))
|
||||
assertFalse(occurs("FREQ=DAILY;UNTIL=20260705T235959Z", "2026-07-01", "2026-07-06"))
|
||||
}
|
||||
|
||||
@Test fun allday_multiday_occurrence_covers_span() {
|
||||
// 2-дневное all-day (span=2), еженедельно: оккуренция 08.07 покрывает 08 и 09
|
||||
assertTrue(occurs("FREQ=WEEKLY", "2026-07-01", "2026-07-09", span = 2, allDay = true))
|
||||
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"),
|
||||
start = d("2026-07-01"),
|
||||
rangeStart = d("2026-07-05"),
|
||||
rangeEnd = d("2026-07-25"),
|
||||
)
|
||||
assertEquals(listOf(d("2026-07-08"), d("2026-07-15"), d("2026-07-22")), starts)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+110
-12
@@ -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,32 +180,28 @@ private fun ContactDetailContent(
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
) {
|
||||
if (contact.emailLines.isNotEmpty()) {
|
||||
contact.emailLines.forEachIndexed { index, email ->
|
||||
contact.emailEntries.forEachIndexed { index, entry ->
|
||||
ContactDetailField(
|
||||
label = if (index == 0) "Email" else "Email ${index + 1}",
|
||||
value = email,
|
||||
label = ContactUi.emailTypeLabel(entry.type, index),
|
||||
value = entry.value,
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:$email")),
|
||||
Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:${entry.value}")),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
if (contact.phoneLines.isNotEmpty()) {
|
||||
contact.phoneLines.forEachIndexed { index, phone ->
|
||||
contact.phoneEntries.forEachIndexed { index, entry ->
|
||||
ContactDetailField(
|
||||
label = if (index == 0) "Телефон" else "Телефон ${index + 1}",
|
||||
value = phone,
|
||||
label = ContactUi.phoneTypeLabel(entry.type, index),
|
||||
value = entry.value,
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_DIAL, Uri.parse("tel:$phone")),
|
||||
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)
|
||||
ConfirmDeleteMessageDialog(
|
||||
message = pendingDelete,
|
||||
onConfirm = {
|
||||
onDeleteMessage(it)
|
||||
pendingDelete = null
|
||||
}) {
|
||||
Text("Удалить", color = F7Colors.Primary)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { pendingDelete = null }) {
|
||||
Text("Отмена")
|
||||
}
|
||||
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