feat(contacts): P2 шаг1 — типы TEL/EMAIL (парсинг из raw, подписи в карточке, сохранение при правке) +4 теста
This commit is contained in:
@@ -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 на каждый `<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 изолирован в модуле, на устройстве не гонялось
|
||||
|
||||
@@ -212,6 +212,10 @@ object CardDavClient {
|
||||
?: error("Не удалось разобрать сохранённый контакт")
|
||||
}
|
||||
|
||||
/** Типизированные значения EMAIL/TEL из сырого vCard (значение + токен TYPE). P2. */
|
||||
fun parseTypedValues(rawVCard: String, property: String): List<VCardTypedValue> =
|
||||
VCardEditor.parseTypedValues(rawVCard, property)
|
||||
|
||||
/** Удаление контакта с If-Match по ETag. 404 трактуем как «уже удалён» — не ошибка. */
|
||||
fun deleteContact(
|
||||
client: OkHttpClient,
|
||||
@@ -700,6 +704,47 @@ internal object VCardEditor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает типизированные значения свойства (EMAIL/TEL) из сырого vCard: значение + сырой
|
||||
* токен TYPE (WORK/HOME/CELL/…). Порядок — как в vCard, пустые пропускаем. Для карточки и
|
||||
* префилла формы правки (P2 типы). Нормализацию метки в подпись делает UI-слой.
|
||||
*/
|
||||
fun parseTypedValues(rawVCard: String, property: String): List<VCardTypedValue> {
|
||||
val prop = property.uppercase()
|
||||
return groupLogicalLines(rawVCard)
|
||||
.filter { propertyName(it).uppercase() == prop }
|
||||
.mapNotNull { line ->
|
||||
val colon = line.indexOf(':')
|
||||
if (colon < 0) return@mapNotNull null
|
||||
val value = unescapeText(line.substring(colon + 1).trim())
|
||||
if (value.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
VCardTypedValue(value = value, type = extractType(line.substring(0, colon)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val typeParamPattern = Regex("(?i)TYPE=\"?([A-Za-z0-9,\\- ]+)\"?")
|
||||
|
||||
private fun extractType(header: String): String {
|
||||
// vCard 3.0: TYPE=WORK / TYPE="WORK,VOICE"; vCard 2.1: ...;WORK;VOICE
|
||||
val fromParam = typeParamPattern.find(header)?.groupValues?.get(1)
|
||||
val raw = fromParam
|
||||
?: header.substringAfter(';', "").split(';').firstOrNull {
|
||||
it.isNotBlank() && !it.contains('=')
|
||||
}
|
||||
?: ""
|
||||
// берём первый содержательный токен, игнорируя технические (INTERNET/VOICE/PREF)
|
||||
val ignored = setOf("INTERNET", "VOICE", "PREF")
|
||||
return raw.split(',').map { it.trim().uppercase() }
|
||||
.firstOrNull { it.isNotBlank() && it !in ignored }
|
||||
?: raw.split(',').firstOrNull()?.trim()?.uppercase().orEmpty()
|
||||
}
|
||||
|
||||
private fun unescapeText(v: String): String =
|
||||
v.replace("\\n", "\n").replace("\\,", ",").replace("\\;", ";").replace("\\\\", "\\")
|
||||
|
||||
// TEXT-значение (FN/TITLE/URL/NOTE/EMAIL/TEL): по совместимости с существующим buildVCard
|
||||
// экранируем только backslash и перевод строки (запятые в имени оставляем литералом).
|
||||
private fun escapeText(v: String): String =
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
+37
-24
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -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<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"
|
||||
|
||||
+6
-2
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user