feat(contacts): P1 шаг 2/2 — правка/удаление в UI (Room v4→5 href/etag/raw, форма, delete-подтверждение)
This commit is contained in:
@@ -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 на каждый `<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-действие в модуле, на устройстве не гонялось
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseCalendars(xml: String, baseUrl: String): List<DavCalendar></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>PropertyWrapping:LoginFlowClient.kt$LoginFlowClient$private val REQUEST_TOKEN_REGEX = Regex("""name=["']requesttoken["']\s+value=["']([^"']+)["']""", RegexOption.IGNORE_CASE)</ID>
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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>
|
||||
|
||||
+85
@@ -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,
|
||||
|
||||
+67
@@ -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<String>
|
||||
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<ContactItem>,
|
||||
query: String,
|
||||
@@ -224,6 +288,9 @@ class ContactsRepository(context: Context) {
|
||||
birthday = birthday,
|
||||
emails = emails,
|
||||
phones = phones,
|
||||
href = href,
|
||||
etag = etag,
|
||||
raw = raw,
|
||||
)
|
||||
|
||||
companion object {
|
||||
|
||||
+124
-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,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),
|
||||
)
|
||||
|
||||
+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
|
||||
|
||||
Reference in New Issue
Block a user