From 6b98c5a0badadb6081f5a1691c847af8b17d9ba1 Mon Sep 17 00:00:00 2001 From: b-mob-contacts Date: Mon, 13 Jul 2026 07:59:00 +0000 Subject: [PATCH] =?UTF-8?q?feat(contacts):=20P1=20=D1=88=D0=B0=D0=B3=202/2?= =?UTF-8?q?=20=E2=80=94=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B0/=D1=83?= =?UTF-8?q?=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B2=20UI=20(Roo?= =?UTF-8?q?m=20v4=E2=86=925=20href/etag/raw,=20=D1=84=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=D0=B0,=20delete-=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + .../f7cloud/core/database/ContactEntity.kt | 4 + .../f7cloud/core/database/ContactsDao.kt | 3 + .../f7cloud/core/database/F7Database.kt | 13 +- core/network/detekt-baseline.xml | 2 +- .../f7cloud/core/network/VCardEditorTest.kt | 8 +- feature/contacts/detekt-baseline.xml | 2 +- .../feature/contacts/ContactDetailSheet.kt | 85 ++++++++++++ .../feature/contacts/ContactsRepository.kt | 67 +++++++++ .../feature/contacts/ContactsScreen.kt | 127 +++++++++++++++++- .../feature/contacts/ContactsViewModel.kt | 69 ++++++++++ 11 files changed, 373 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf26055..7a5a559 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск` +- 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 на каждый ``; общий `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-действие в модуле, на устройстве не гонялось diff --git a/core/database/src/main/java/ru/forbion/f7cloud/core/database/ContactEntity.kt b/core/database/src/main/java/ru/forbion/f7cloud/core/database/ContactEntity.kt index 8972750..d1ffff0 100644 --- a/core/database/src/main/java/ru/forbion/f7cloud/core/database/ContactEntity.kt +++ b/core/database/src/main/java/ru/forbion/f7cloud/core/database/ContactEntity.kt @@ -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 = "", ) diff --git a/core/database/src/main/java/ru/forbion/f7cloud/core/database/ContactsDao.kt b/core/database/src/main/java/ru/forbion/f7cloud/core/database/ContactsDao.kt index 2f4d44c..8bb1bd3 100644 --- a/core/database/src/main/java/ru/forbion/f7cloud/core/database/ContactsDao.kt +++ b/core/database/src/main/java/ru/forbion/f7cloud/core/database/ContactsDao.kt @@ -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) { deleteAll(accountKey) diff --git a/core/database/src/main/java/ru/forbion/f7cloud/core/database/F7Database.kt b/core/database/src/main/java/ru/forbion/f7cloud/core/database/F7Database.kt index 11f21fe..54df187 100644 --- a/core/database/src/main/java/ru/forbion/f7cloud/core/database/F7Database.kt +++ b/core/database/src/main/java/ru/forbion/f7cloud/core/database/F7Database.kt @@ -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 } } diff --git a/core/network/detekt-baseline.xml b/core/network/detekt-baseline.xml index 6b40670..f014eac 100644 --- a/core/network/detekt-baseline.xml +++ b/core/network/detekt-baseline.xml @@ -18,7 +18,7 @@ NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseCalendars(xml: String, baseUrl: String): List<DavCalendar> NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseTrashResponses(xml: String, trashUrl: String): List<DavTrashEvent> NestedBlockDepth:CardDavClient.kt$CardDavClient$private fun parseAddressBooks(xml: String, baseUrl: String): List<Pair<String, String>> - NestedBlockDepth:CardDavClient.kt$CardDavClient$private fun parseContacts(xml: String, bookName: String): List<DavContact> + NestedBlockDepth:CardDavClient.kt$CardDavClient$private fun parseContacts(xml: String, bookName: String, bookUrl: String): List<DavContact> NestedBlockDepth:DavClient.kt$DavClient$private fun parseMultiStatus(xml: String, folderUrl: String): List<DavEntry> NoMultipleSpaces:CalendarIcs.kt$CalendarIcs$ PropertyWrapping:LoginFlowClient.kt$LoginFlowClient$private val REQUEST_TOKEN_REGEX = Regex("""name=["']requesttoken["']\s+value=["']([^"']+)["']""", RegexOption.IGNORE_CASE) diff --git a/core/network/src/test/java/ru/forbion/f7cloud/core/network/VCardEditorTest.kt b/core/network/src/test/java/ru/forbion/f7cloud/core/network/VCardEditorTest.kt index b78d482..25fbb5e 100644 --- a/core/network/src/test/java/ru/forbion/f7cloud/core/network/VCardEditorTest.kt +++ b/core/network/src/test/java/ru/forbion/f7cloud/core/network/VCardEditorTest.kt @@ -28,7 +28,13 @@ class VCardEditorTest { append("TEL;TYPE=CELL:+70000000000\r\n") append("END:VCARD\r\n") } - val out = edit(raw, VCardFields(displayName = "Иван Петров", phones = listOf(VCardTypedValue("+79990001122", "WORK")))) + val out = edit( + raw, + VCardFields( + displayName = "Иван Петров", + phones = listOf(VCardTypedValue("+79990001122", "WORK")), + ), + ) // UID и неизвестные свойства сохранены assertTrue("UID сохранён", out.contains("UID:abc-123")) assertTrue("PHOTO сохранён", out.contains("/9j/4AAQSkZJRgABAQAAAQABAAD")) diff --git a/feature/contacts/detekt-baseline.xml b/feature/contacts/detekt-baseline.xml index 9a4f5e9..19c0616 100644 --- a/feature/contacts/detekt-baseline.xml +++ b/feature/contacts/detekt-baseline.xml @@ -3,7 +3,7 @@ ComplexCondition:ContactsRepository.kt$ContactsRepository$!force && !needsPhotoBackfill && !needsDetailsBackfill && System.currentTimeMillis() - lastSync < SYNC_INTERVAL_MS - LongMethod:ContactDetailSheet.kt$@Composable private fun ContactDetailContent( contact: ContactItem, onDismiss: () -> Unit, ) + LongMethod:ContactDetailSheet.kt$@Composable private fun ContactDetailContent( contact: ContactItem, onDismiss: () -> Unit, onEdit: () -> Unit, onDelete: () -> Unit, deleting: Boolean, ) MultiLineIfElse:ContactDetailSheet.kt$"https://$it" MultiLineIfElse:ContactDetailSheet.kt$it diff --git a/feature/contacts/src/main/java/ru/forbion/f7cloud/feature/contacts/ContactDetailSheet.kt b/feature/contacts/src/main/java/ru/forbion/f7cloud/feature/contacts/ContactDetailSheet.kt index 10fa4cb..e126f85 100644 --- a/feature/contacts/src/main/java/ru/forbion/f7cloud/feature/contacts/ContactDetailSheet.kt +++ b/feature/contacts/src/main/java/ru/forbion/f7cloud/feature/contacts/ContactDetailSheet.kt @@ -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) @@ -213,6 +230,15 @@ private fun ContactDetailContent( } } + if (editable) { + ContactDetailActions( + contact = contact, + deleting = deleting, + onEdit = onEdit, + onDelete = onDelete, + ) + } + Spacer(Modifier.height(4.dp)) Text( "Закрыть", @@ -225,6 +251,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, diff --git a/feature/contacts/src/main/java/ru/forbion/f7cloud/feature/contacts/ContactsRepository.kt b/feature/contacts/src/main/java/ru/forbion/f7cloud/feature/contacts/ContactsRepository.kt index ba82d89..ba283bb 100644 --- a/feature/contacts/src/main/java/ru/forbion/f7cloud/feature/contacts/ContactsRepository.kt +++ b/feature/contacts/src/main/java/ru/forbion/f7cloud/feature/contacts/ContactsRepository.kt @@ -9,6 +9,7 @@ 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 data class ContactItem( val uid: String, @@ -25,6 +26,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 get() = (if (emails.isNotBlank()) emails else email) @@ -104,6 +109,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 +155,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, query: String, @@ -224,6 +288,9 @@ class ContactsRepository(context: Context) { birthday = birthday, emails = emails, phones = phones, + href = href, + etag = etag, + raw = raw, ) companion object { diff --git a/feature/contacts/src/main/java/ru/forbion/f7cloud/feature/contacts/ContactsScreen.kt b/feature/contacts/src/main/java/ru/forbion/f7cloud/feature/contacts/ContactsScreen.kt index 10837c3..351d376 100644 --- a/feature/contacts/src/main/java/ru/forbion/f7cloud/feature/contacts/ContactsScreen.kt +++ b/feature/contacts/src/main/java/ru/forbion/f7cloud/feature/contacts/ContactsScreen.kt @@ -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,110 @@ 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) } + + 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) }, + phones = lines(phones).map { VCardTypedValue(it) }, + 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 +329,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 +341,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), ) diff --git a/feature/contacts/src/main/java/ru/forbion/f7cloud/feature/contacts/ContactsViewModel.kt b/feature/contacts/src/main/java/ru/forbion/f7cloud/feature/contacts/ContactsViewModel.kt index 6c99c38..dcdc0cb 100644 --- a/feature/contacts/src/main/java/ru/forbion/f7cloud/feature/contacts/ContactsViewModel.kt +++ b/feature/contacts/src/main/java/ru/forbion/f7cloud/feature/contacts/ContactsViewModel.kt @@ -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 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