2 Commits

Author SHA1 Message Date
b-mob-contacts 6b98c5a0ba feat(contacts): P1 шаг 2/2 — правка/удаление в UI (Room v4→5 href/etag/raw, форма, delete-подтверждение) 2026-07-13 07:59:00 +00:00
b-mob-deck 73762d80c5 feat(deck): меню колонки — Переименовать / Удалить список (P1 diff)
DeckRepository.renameStack/deleteStack (PUT/DELETE /stacks/{id}); VM renameStack/deleteStack;
overflow-меню (⋯) в шапке колонки → DropdownMenu; диалог переименования (префилл) +
DeckConfirmDialog для удаления. Веб-паритет меню Stack.
2026-07-13 07:53:54 +00:00
15 changed files with 468 additions and 9 deletions
+1
View File
@@ -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 }
}
+1 -1
View File
@@ -18,7 +18,7 @@
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseCalendars(xml: String, baseUrl: String): List&lt;DavCalendar&gt;</ID>
<ID>NestedBlockDepth:CalDavClient.kt$CalDavClient$private fun parseTrashResponses(xml: String, trashUrl: String): List&lt;DavTrashEvent&gt;</ID>
<ID>NestedBlockDepth:CardDavClient.kt$CardDavClient$private fun parseAddressBooks(xml: String, baseUrl: String): List&lt;Pair&lt;String, String&gt;&gt;</ID>
<ID>NestedBlockDepth:CardDavClient.kt$CardDavClient$private fun parseContacts(xml: String, bookName: String): List&lt;DavContact&gt;</ID>
<ID>NestedBlockDepth:CardDavClient.kt$CardDavClient$private fun parseContacts(xml: String, bookName: String, bookUrl: String): List&lt;DavContact&gt;</ID>
<ID>NestedBlockDepth:DavClient.kt$DavClient$private fun parseMultiStatus(xml: String, folderUrl: String): List&lt;DavEntry&gt;</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"))
+1 -1
View File
@@ -3,7 +3,7 @@
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>ComplexCondition:ContactsRepository.kt$ContactsRepository$!force &amp;&amp; !needsPhotoBackfill &amp;&amp; !needsDetailsBackfill &amp;&amp; System.currentTimeMillis() - lastSync &lt; SYNC_INTERVAL_MS</ID>
<ID>LongMethod:ContactDetailSheet.kt$@Composable private fun ContactDetailContent( contact: ContactItem, onDismiss: () -&gt; Unit, )</ID>
<ID>LongMethod:ContactDetailSheet.kt$@Composable private fun ContactDetailContent( contact: ContactItem, onDismiss: () -&gt; Unit, onEdit: () -&gt; Unit, onDelete: () -&gt; Unit, deleting: Boolean, )</ID>
<ID>MultiLineIfElse:ContactDetailSheet.kt$"https://$it"</ID>
<ID>MultiLineIfElse:ContactDetailSheet.kt$it</ID>
</CurrentIssues>
@@ -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,
@@ -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 {
@@ -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),
)
@@ -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
@@ -236,8 +236,9 @@ fun DeckTextPromptDialog(
confirmText: String,
onConfirm: (String) -> Unit,
onDismiss: () -> Unit,
initial: String = "",
) {
var text by remember { mutableStateOf("") }
var text by remember { mutableStateOf(initial) }
Dialog(onDismissRequest = onDismiss) {
Surface(shape = RoundedCornerShape(16.dp), color = Color.White) {
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 в миллисекунды (для инициализации пикера). */
internal fun parseDeckDueMillis(iso: String): Long? = runCatching {
Instant.parse(iso).toEpochMilli()
@@ -215,6 +215,15 @@ class DeckRepository {
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 ---
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.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -62,6 +65,8 @@ fun DeckScreen(
val state by vm.state.collectAsState()
var addStackOpen by remember { mutableStateOf(false) }
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(openCardId) {
@@ -110,6 +115,8 @@ fun DeckScreen(
onCardClick = { vm.openCard(it) },
onToggleDone = { vm.toggleDone(session, it) },
onAddCard = { addCardStackId = stack.id },
onRename = { renameStackTarget = stack },
onDelete = { deleteStackTarget = stack },
)
}
}
@@ -165,6 +172,25 @@ fun DeckScreen(
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
@@ -206,7 +232,10 @@ private fun DeckStackSection(
onCardClick: (DeckCard) -> Unit,
onToggleDone: (DeckCard) -> Unit,
onAddCard: () -> Unit,
onRename: () -> Unit,
onDelete: () -> Unit,
) {
var menuOpen by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxWidth()
@@ -240,6 +269,28 @@ private fun DeckStackSection(
) {
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 ->
@@ -124,6 +124,12 @@ class DeckViewModel(
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(
session: AuthSession,
closeCardAfter: Boolean = false,