Compare commits
2 Commits
e3725b7339
...
6b98c5a0ba
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b98c5a0ba | |||
| 73762d80c5 |
@@ -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 | (без бампа — версия/релиз за лидом) | **Контакты — ядро правки/удаления (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 | (без бампа — версия/релиз за лидом) | **Задачи — умные коллекции в 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).** Пункт «Переслать…» открывает лист выбора комнаты (`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 birthday: String = "",
|
||||||
val emails: String = "",
|
val emails: String = "",
|
||||||
val phones: 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")
|
@Query("DELETE FROM contacts WHERE accountKey = :accountKey")
|
||||||
suspend fun deleteAll(accountKey: String)
|
suspend fun deleteAll(accountKey: String)
|
||||||
|
|
||||||
|
@Query("DELETE FROM contacts WHERE accountKey = :accountKey AND uid = :uid")
|
||||||
|
suspend fun deleteByUid(accountKey: String, uid: String)
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
suspend fun replaceAll(accountKey: String, contacts: List<ContactEntity>) {
|
suspend fun replaceAll(accountKey: String, contacts: List<ContactEntity>) {
|
||||||
deleteAll(accountKey)
|
deleteAll(accountKey)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [FileEntity::class, ContactEntity::class],
|
entities = [FileEntity::class, ContactEntity::class],
|
||||||
version = 4,
|
version = 5,
|
||||||
exportSchema = false,
|
exportSchema = false,
|
||||||
)
|
)
|
||||||
abstract class F7Database : RoomDatabase() {
|
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 {
|
fun get(context: Context): F7Database {
|
||||||
return INSTANCE ?: synchronized(this) {
|
return INSTANCE ?: synchronized(this) {
|
||||||
INSTANCE ?: Room.databaseBuilder(
|
INSTANCE ?: Room.databaseBuilder(
|
||||||
@@ -71,7 +80,7 @@ abstract class F7Database : RoomDatabase() {
|
|||||||
F7Database::class.java,
|
F7Database::class.java,
|
||||||
"f7cloud-mobile.db",
|
"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()
|
.build()
|
||||||
.also { INSTANCE = it }
|
.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 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: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 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>NestedBlockDepth:DavClient.kt$DavClient$private fun parseMultiStatus(xml: String, folderUrl: String): List<DavEntry></ID>
|
||||||
<ID>NoMultipleSpaces:CalendarIcs.kt$CalendarIcs$ </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>
|
<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("TEL;TYPE=CELL:+70000000000\r\n")
|
||||||
append("END:VCARD\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 и неизвестные свойства сохранены
|
// UID и неизвестные свойства сохранены
|
||||||
assertTrue("UID сохранён", out.contains("UID:abc-123"))
|
assertTrue("UID сохранён", out.contains("UID:abc-123"))
|
||||||
assertTrue("PHOTO сохранён", out.contains("/9j/4AAQSkZJRgABAQAAAQABAAD"))
|
assertTrue("PHOTO сохранён", out.contains("/9j/4AAQSkZJRgABAQAAAQABAAD"))
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<ManuallySuppressedIssues/>
|
<ManuallySuppressedIssues/>
|
||||||
<CurrentIssues>
|
<CurrentIssues>
|
||||||
<ID>ComplexCondition:ContactsRepository.kt$ContactsRepository$!force && !needsPhotoBackfill && !needsDetailsBackfill && System.currentTimeMillis() - lastSync < SYNC_INTERVAL_MS</ID>
|
<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$"https://$it"</ID>
|
||||||
<ID>MultiLineIfElse:ContactDetailSheet.kt$it</ID>
|
<ID>MultiLineIfElse:ContactDetailSheet.kt$it</ID>
|
||||||
</CurrentIssues>
|
</CurrentIssues>
|
||||||
|
|||||||
+85
@@ -17,14 +17,20 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -42,6 +48,9 @@ import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton
|
|||||||
fun ContactDetailSheet(
|
fun ContactDetailSheet(
|
||||||
contact: ContactItem,
|
contact: ContactItem,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
|
onEdit: () -> Unit = {},
|
||||||
|
onDelete: () -> Unit = {},
|
||||||
|
deleting: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
@@ -52,6 +61,9 @@ fun ContactDetailSheet(
|
|||||||
ContactDetailContent(
|
ContactDetailContent(
|
||||||
contact = contact,
|
contact = contact,
|
||||||
onDismiss = onDismiss,
|
onDismiss = onDismiss,
|
||||||
|
onEdit = onEdit,
|
||||||
|
onDelete = onDelete,
|
||||||
|
deleting = deleting,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,8 +72,13 @@ fun ContactDetailSheet(
|
|||||||
private fun ContactDetailContent(
|
private fun ContactDetailContent(
|
||||||
contact: ContactItem,
|
contact: ContactItem,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
|
onEdit: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
deleting: Boolean,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
// Правка/удаление доступны только для синхронизированных контактов (есть href/raw).
|
||||||
|
val editable = contact.href.isNotBlank() && contact.raw.isNotBlank()
|
||||||
val label = contact.displayName.ifBlank { contact.email }
|
val label = contact.displayName.ifBlank { contact.email }
|
||||||
val photoBytes = remember(contact.uid, contact.photoBase64) {
|
val photoBytes = remember(contact.uid, contact.photoBase64) {
|
||||||
ContactUi.decodeContactPhoto(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))
|
Spacer(Modifier.height(4.dp))
|
||||||
Text(
|
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
|
@Composable
|
||||||
private fun ContactDetailField(
|
private fun ContactDetailField(
|
||||||
label: String,
|
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.database.F7Database
|
||||||
import ru.forbion.f7cloud.core.network.CardDavClient
|
import ru.forbion.f7cloud.core.network.CardDavClient
|
||||||
import ru.forbion.f7cloud.core.network.NetworkFactory
|
import ru.forbion.f7cloud.core.network.NetworkFactory
|
||||||
|
import ru.forbion.f7cloud.core.network.VCardFields
|
||||||
|
|
||||||
data class ContactItem(
|
data class ContactItem(
|
||||||
val uid: String,
|
val uid: String,
|
||||||
@@ -25,6 +26,10 @@ data class ContactItem(
|
|||||||
val birthday: String = "",
|
val birthday: String = "",
|
||||||
val emails: String = "",
|
val emails: String = "",
|
||||||
val phones: String = "",
|
val phones: String = "",
|
||||||
|
// Для правки/удаления (If-Match) и round-trip неизвестных полей vCard (P1).
|
||||||
|
val href: String = "",
|
||||||
|
val etag: String = "",
|
||||||
|
val raw: String = "",
|
||||||
) {
|
) {
|
||||||
val emailLines: List<String>
|
val emailLines: List<String>
|
||||||
get() = (if (emails.isNotBlank()) emails else email)
|
get() = (if (emails.isNotBlank()) emails else email)
|
||||||
@@ -104,6 +109,9 @@ class ContactsRepository(context: Context) {
|
|||||||
birthday = contact.birthday,
|
birthday = contact.birthday,
|
||||||
emails = contact.emails,
|
emails = contact.emails,
|
||||||
phones = contact.phones,
|
phones = contact.phones,
|
||||||
|
href = contact.href,
|
||||||
|
etag = contact.etag,
|
||||||
|
raw = contact.raw,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
dao.replaceAll(key, entities)
|
dao.replaceAll(key, entities)
|
||||||
@@ -147,6 +155,62 @@ class ContactsRepository(context: Context) {
|
|||||||
return entity.toItem()
|
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(
|
fun filterSuggestions(
|
||||||
contacts: List<ContactItem>,
|
contacts: List<ContactItem>,
|
||||||
query: String,
|
query: String,
|
||||||
@@ -224,6 +288,9 @@ class ContactsRepository(context: Context) {
|
|||||||
birthday = birthday,
|
birthday = birthday,
|
||||||
emails = emails,
|
emails = emails,
|
||||||
phones = phones,
|
phones = phones,
|
||||||
|
href = href,
|
||||||
|
etag = etag,
|
||||||
|
raw = raw,
|
||||||
)
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
+124
-3
@@ -15,15 +15,17 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
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.Text
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
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.F7Colors
|
||||||
import ru.forbion.f7cloud.core.designsystem.F7CreateButton
|
import ru.forbion.f7cloud.core.designsystem.F7CreateButton
|
||||||
import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
|
import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
|
||||||
|
import ru.forbion.f7cloud.core.network.VCardFields
|
||||||
|
import ru.forbion.f7cloud.core.network.VCardTypedValue
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -121,7 +125,19 @@ fun ContactsScreen(
|
|||||||
ContactDetailSheet(
|
ContactDetailSheet(
|
||||||
contact = contact,
|
contact = contact,
|
||||||
onDismiss = vm::closeContactDetail,
|
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) {
|
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
|
@Composable
|
||||||
private fun AddContactDialog(
|
private fun AddContactDialog(
|
||||||
saving: Boolean,
|
saving: Boolean,
|
||||||
@@ -209,6 +329,7 @@ private fun AddContactField(
|
|||||||
label: String,
|
label: String,
|
||||||
value: String,
|
value: String,
|
||||||
onValueChange: (String) -> Unit,
|
onValueChange: (String) -> Unit,
|
||||||
|
singleLine: Boolean = true,
|
||||||
) {
|
) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
Text(label, fontSize = 13.sp, color = F7Colors.TextSecondary)
|
Text(label, fontSize = 13.sp, color = F7Colors.TextSecondary)
|
||||||
@@ -220,7 +341,7 @@ private fun AddContactField(
|
|||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.border(1.dp, F7Colors.Border, RoundedCornerShape(8.dp))
|
.border(1.dp, F7Colors.Border, RoundedCornerShape(8.dp))
|
||||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||||
singleLine = true,
|
singleLine = singleLine,
|
||||||
textStyle = TextStyle(fontSize = 15.sp, color = F7Colors.TextPrimary),
|
textStyle = TextStyle(fontSize = 15.sp, color = F7Colors.TextPrimary),
|
||||||
cursorBrush = SolidColor(F7Colors.Primary),
|
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.auth.AuthSession
|
||||||
import ru.forbion.f7cloud.core.designsystem.AppForegroundTracker
|
import ru.forbion.f7cloud.core.designsystem.AppForegroundTracker
|
||||||
import ru.forbion.f7cloud.core.network.UnauthorizedException
|
import ru.forbion.f7cloud.core.network.UnauthorizedException
|
||||||
|
import ru.forbion.f7cloud.core.network.VCardFields
|
||||||
|
|
||||||
data class ContactsUiState(
|
data class ContactsUiState(
|
||||||
val loading: Boolean = false,
|
val loading: Boolean = false,
|
||||||
@@ -28,6 +29,10 @@ data class ContactsUiState(
|
|||||||
val savingContact: Boolean = false,
|
val savingContact: Boolean = false,
|
||||||
val addError: String? = null,
|
val addError: String? = null,
|
||||||
val selectedContact: ContactItem? = 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>
|
val filteredContacts: List<ContactItem>
|
||||||
get() {
|
get() {
|
||||||
@@ -70,6 +75,70 @@ class ContactsViewModel(
|
|||||||
_state.update { it.copy(selectedContact = null) }
|
_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) {
|
fun load(session: AuthSession) {
|
||||||
if (activeSession?.serverUrl == session.serverUrl &&
|
if (activeSession?.serverUrl == session.serverUrl &&
|
||||||
activeSession?.username == session.username
|
activeSession?.username == session.username
|
||||||
|
|||||||
@@ -236,8 +236,9 @@ fun DeckTextPromptDialog(
|
|||||||
confirmText: String,
|
confirmText: String,
|
||||||
onConfirm: (String) -> Unit,
|
onConfirm: (String) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
|
initial: String = "",
|
||||||
) {
|
) {
|
||||||
var text by remember { mutableStateOf("") }
|
var text by remember { mutableStateOf(initial) }
|
||||||
Dialog(onDismissRequest = onDismiss) {
|
Dialog(onDismissRequest = onDismiss) {
|
||||||
Surface(shape = RoundedCornerShape(16.dp), color = Color.White) {
|
Surface(shape = RoundedCornerShape(16.dp), color = Color.White) {
|
||||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
@@ -263,6 +264,33 @@ 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Парсит ISO-строку срока Deck в миллисекунды (для инициализации пикера). */
|
/** Парсит ISO-строку срока Deck в миллисекунды (для инициализации пикера). */
|
||||||
internal fun parseDeckDueMillis(iso: String): Long? = runCatching {
|
internal fun parseDeckDueMillis(iso: String): Long? = runCatching {
|
||||||
Instant.parse(iso).toEpochMilli()
|
Instant.parse(iso).toEpochMilli()
|
||||||
|
|||||||
@@ -215,6 +215,15 @@ class DeckRepository {
|
|||||||
return DeckStack(id = id, boardId = boardId, title = title, order = 999, cards = emptyList())
|
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 ---
|
// --- HTTP ---
|
||||||
|
|
||||||
private fun getJson(client: OkHttpClient, url: String): Any {
|
private fun getJson(client: OkHttpClient, url: String): Any {
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ import androidx.compose.foundation.shape.CircleShape
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -62,6 +65,8 @@ fun DeckScreen(
|
|||||||
val state by vm.state.collectAsState()
|
val state by vm.state.collectAsState()
|
||||||
var addStackOpen by remember { mutableStateOf(false) }
|
var addStackOpen by remember { mutableStateOf(false) }
|
||||||
var addCardStackId by remember { mutableStateOf<Int?>(null) }
|
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(session.serverUrl, session.username) { vm.load(session) }
|
||||||
LaunchedEffect(openCardId) {
|
LaunchedEffect(openCardId) {
|
||||||
@@ -110,6 +115,8 @@ fun DeckScreen(
|
|||||||
onCardClick = { vm.openCard(it) },
|
onCardClick = { vm.openCard(it) },
|
||||||
onToggleDone = { vm.toggleDone(session, it) },
|
onToggleDone = { vm.toggleDone(session, it) },
|
||||||
onAddCard = { addCardStackId = stack.id },
|
onAddCard = { addCardStackId = stack.id },
|
||||||
|
onRename = { renameStackTarget = stack },
|
||||||
|
onDelete = { deleteStackTarget = stack },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,6 +172,25 @@ fun DeckScreen(
|
|||||||
onDismiss = { addCardStackId = null },
|
onDismiss = { addCardStackId = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
renameStackTarget?.let { stack ->
|
||||||
|
DeckTextPromptDialog(
|
||||||
|
title = "Переименовать колонку",
|
||||||
|
label = "Название колонки",
|
||||||
|
confirmText = "Сохранить",
|
||||||
|
initial = stack.title,
|
||||||
|
onConfirm = { vm.renameStack(session, stack, it); renameStackTarget = null },
|
||||||
|
onDismiss = { renameStackTarget = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
deleteStackTarget?.let { stack ->
|
||||||
|
DeckConfirmDialog(
|
||||||
|
title = "Удалить колонку?",
|
||||||
|
message = "Колонка «${stack.title}» и её карточки будут удалены.",
|
||||||
|
confirmText = "Удалить",
|
||||||
|
onConfirm = { vm.deleteStack(session, stack); deleteStackTarget = null },
|
||||||
|
onDismiss = { deleteStackTarget = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -206,7 +232,10 @@ private fun DeckStackSection(
|
|||||||
onCardClick: (DeckCard) -> Unit,
|
onCardClick: (DeckCard) -> Unit,
|
||||||
onToggleDone: (DeckCard) -> Unit,
|
onToggleDone: (DeckCard) -> Unit,
|
||||||
onAddCard: () -> Unit,
|
onAddCard: () -> Unit,
|
||||||
|
onRename: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
var menuOpen by remember { mutableStateOf(false) }
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -240,6 +269,28 @@ private fun DeckStackSection(
|
|||||||
) {
|
) {
|
||||||
Icon(Icons.Filled.Add, contentDescription = "Добавить карточку", tint = F7Colors.Primary, modifier = Modifier.size(18.dp))
|
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 ->
|
stack.cards.forEach { card ->
|
||||||
|
|||||||
@@ -124,6 +124,12 @@ class DeckViewModel(
|
|||||||
mutate(session) { repository.createStack(session, boardId, title.trim()) }
|
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(
|
private fun mutate(
|
||||||
session: AuthSession,
|
session: AuthSession,
|
||||||
closeCardAfter: Boolean = false,
|
closeCardAfter: Boolean = false,
|
||||||
|
|||||||
Reference in New Issue
Block a user