diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a5a559..1e60371 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск` +- 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 на каждый ``; общий `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 изолирован в модуле, на устройстве не гонялось diff --git a/core/network/src/main/java/ru/forbion/f7cloud/core/network/CardDavClient.kt b/core/network/src/main/java/ru/forbion/f7cloud/core/network/CardDavClient.kt index 8ac98ab..d0aae7b 100644 --- a/core/network/src/main/java/ru/forbion/f7cloud/core/network/CardDavClient.kt +++ b/core/network/src/main/java/ru/forbion/f7cloud/core/network/CardDavClient.kt @@ -212,6 +212,10 @@ object CardDavClient { ?: error("Не удалось разобрать сохранённый контакт") } + /** Типизированные значения EMAIL/TEL из сырого vCard (значение + токен TYPE). P2. */ + fun parseTypedValues(rawVCard: String, property: String): List = + VCardEditor.parseTypedValues(rawVCard, property) + /** Удаление контакта с If-Match по ETag. 404 трактуем как «уже удалён» — не ошибка. */ fun deleteContact( client: OkHttpClient, @@ -700,6 +704,47 @@ internal object VCardEditor { } } + /** + * Извлекает типизированные значения свойства (EMAIL/TEL) из сырого vCard: значение + сырой + * токен TYPE (WORK/HOME/CELL/…). Порядок — как в vCard, пустые пропускаем. Для карточки и + * префилла формы правки (P2 типы). Нормализацию метки в подпись делает UI-слой. + */ + fun parseTypedValues(rawVCard: String, property: String): List { + 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 = 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 25fbb5e..e36ef72 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 @@ -135,4 +135,50 @@ class VCardEditorTest { assertFalse("нет пустого ADR", out.contains("ADR")) assertFalse("нет пустого ORG", out.contains("ORG")) } + + // --- P2: разбор типов TEL/EMAIL --- + + @Test fun parses_email_and_phone_types() { + val raw = buildString { + append("BEGIN:VCARD\r\nVERSION:3.0\r\nUID:t\r\nFN:X\r\n") + append("EMAIL;TYPE=WORK:work@x.ru\r\n") + append("EMAIL;TYPE=HOME:home@x.ru\r\n") + append("TEL;TYPE=CELL:+7900\r\n") + append("TEL;TYPE=WORK:+7495\r\n") + append("END:VCARD\r\n") + } + val emails = VCardEditor.parseTypedValues(raw, "EMAIL") + assertEquals(listOf("work@x.ru", "home@x.ru"), emails.map { it.value }) + assertEquals(listOf("WORK", "HOME"), emails.map { it.type }) + val phones = VCardEditor.parseTypedValues(raw, "TEL") + assertEquals(listOf("CELL", "WORK"), phones.map { it.type }) + } + + @Test fun ignores_technical_type_tokens() { + // INTERNET/VOICE/PREF — технические, берём содержательный токен + val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:i\r\nFN:X\r\n" + + "EMAIL;TYPE=INTERNET,HOME:h@x.ru\r\n" + + "TEL;TYPE=VOICE,CELL:+7900\r\n" + + "END:VCARD\r\n" + assertEquals("HOME", VCardEditor.parseTypedValues(raw, "EMAIL").single().type) + assertEquals("CELL", VCardEditor.parseTypedValues(raw, "TEL").single().type) + } + + @Test fun typeless_value_yields_blank_type() { + val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:n\r\nFN:X\r\nEMAIL:plain@x.ru\r\nEND:VCARD\r\n" + val e = VCardEditor.parseTypedValues(raw, "EMAIL").single() + assertEquals("plain@x.ru", e.value) + assertEquals("", e.type) + } + + @Test fun round_trip_preserves_parsed_types() { + val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:r\r\nFN:X\r\n" + + "EMAIL;TYPE=WORK:w@x.ru\r\nTEL;TYPE=HOME:+7495\r\nEND:VCARD\r\n" + val emails = VCardEditor.parseTypedValues(raw, "EMAIL") + val phones = VCardEditor.parseTypedValues(raw, "TEL") + val out = VCardEditor.applyEdits(raw, VCardFields(displayName = "X", emails = emails, phones = phones)) + // типы не потеряны при правке (чиним P1-каветат нормализации) + assertTrue(out.contains("EMAIL;TYPE=WORK:w@x.ru")) + assertTrue(out.contains("TEL;TYPE=HOME:+7495")) + } } 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 e126f85..1dc6b44 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 @@ -180,31 +180,27 @@ private fun ContactDetailContent( .padding(horizontal = 16.dp, vertical = 4.dp), verticalArrangement = Arrangement.spacedBy(0.dp), ) { - if (contact.emailLines.isNotEmpty()) { - contact.emailLines.forEachIndexed { index, email -> - ContactDetailField( - label = if (index == 0) "Email" else "Email ${index + 1}", - value = email, - onClick = { - context.startActivity( - Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:$email")), - ) - }, - ) - } + contact.emailEntries.forEachIndexed { index, entry -> + ContactDetailField( + label = ContactUi.emailTypeLabel(entry.type, index), + value = entry.value, + onClick = { + context.startActivity( + Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:${entry.value}")), + ) + }, + ) } - if (contact.phoneLines.isNotEmpty()) { - contact.phoneLines.forEachIndexed { index, phone -> - ContactDetailField( - label = if (index == 0) "Телефон" else "Телефон ${index + 1}", - value = phone, - onClick = { - context.startActivity( - Intent(Intent.ACTION_DIAL, Uri.parse("tel:$phone")), - ) - }, - ) - } + contact.phoneEntries.forEachIndexed { index, entry -> + ContactDetailField( + label = ContactUi.phoneTypeLabel(entry.type, index), + value = entry.value, + onClick = { + context.startActivity( + Intent(Intent.ACTION_DIAL, Uri.parse("tel:${entry.value}")), + ) + }, + ) } if (contact.address.isNotBlank()) { ContactDetailField(label = "Адрес", value = contact.address) @@ -372,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() } + } } 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 ba283bb..810e418 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 @@ -10,6 +10,7 @@ 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, @@ -45,6 +46,21 @@ data class ContactItem( .filter { it.isNotBlank() } .distinct() + // P2: значения с типом (WORK/HOME/CELL) из сырого vCard; фолбэк на значения без типа. + val emailEntries: List + get() = if (raw.isNotBlank()) { + CardDavClient.parseTypedValues(raw, "EMAIL") + } else { + emailLines.map { VCardTypedValue(it) } + } + + val phoneEntries: List + get() = if (raw.isNotBlank()) { + CardDavClient.parseTypedValues(raw, "TEL") + } else { + phoneLines.map { VCardTypedValue(it) } + } + val subtitle: String? get() = when { title.isNotBlank() && organization.isNotBlank() -> "$title · $organization" 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 351d376..4eb18ed 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 @@ -169,12 +169,16 @@ private fun EditContactDialog( 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) }, - phones = lines(phones).map { VCardTypedValue(it) }, + 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(),