From 2df46667e03604b3f9f993229c6dbba97ff55119 Mon Sep 17 00:00:00 2001 From: b-dev-mobile Date: Fri, 10 Jul 2026 04:07:21 +0000 Subject: [PATCH] =?UTF-8?q?feat(mail):=20=C2=AB=D0=A1=D0=BE=D0=B7=D0=B4?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D1=83=C2=BB?= =?UTF-8?q?=20=D0=B8=20=C2=AB=D0=A1=D0=BE=D0=B7=D0=B4=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D0=B5=C2=BB=20=D0=B8=D0=B7?= =?UTF-8?q?=20=D0=BF=D0=B8=D1=81=D1=8C=D0=BC=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Паритет живого веба: пункты в меню действий письма (иконки темы create-task-black/calendar-black) → модалки в стиле темы (заголовок 18/600, плашки-выбор списка/календаря #F5F5F5 r10, дата через DatePicker, «Весь день», время ЧЧ:ММ). Префилл: название = тема письма, описание = «Из письма от <отправитель>». Сохранение через существующие TasksRepository.createTask / CalendarRepository.saveEvent (CalDAV) — feature:mail теперь зависит от feature:tasks и feature:calendar (цикла нет). Co-Authored-By: Claude Fable 5 --- feature/mail/build.gradle | 3 + .../f7cloud/feature/mail/MailComponents.kt | 18 + .../feature/mail/MailCreateFromMessage.kt | 325 ++++++++++++++++++ .../f7cloud/feature/mail/MailScreen.kt | 30 ++ .../f7cloud/feature/mail/MailViewModel.kt | 104 ++++++ 5 files changed, 480 insertions(+) create mode 100644 feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailCreateFromMessage.kt diff --git a/feature/mail/build.gradle b/feature/mail/build.gradle index 88eb633..b763be8 100644 --- a/feature/mail/build.gradle +++ b/feature/mail/build.gradle @@ -29,6 +29,9 @@ dependencies { implementation project(':core:designsystem') implementation project(':feature:files') implementation project(':feature:contacts') + // создание задач/событий из письма (живой веб-паритет) + implementation project(':feature:tasks') + implementation project(':feature:calendar') implementation libs.coroutines.android implementation libs.json def composeBom = platform(libs.compose.bom) diff --git a/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailComponents.kt b/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailComponents.kt index d78883a..9be580d 100644 --- a/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailComponents.kt +++ b/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailComponents.kt @@ -982,6 +982,8 @@ fun MailMessageSenderCard( onMove: () -> Unit, onSnooze: () -> Unit, modifier: Modifier = Modifier, + onCreateTask: (() -> Unit)? = null, + onCreateEvent: (() -> Unit)? = null, ) { val base = serverUrl.trimEnd('/') var headersExpanded by remember { mutableStateOf(false) } @@ -1126,6 +1128,8 @@ fun MailMessageSenderCard( onEditTags = onEditTags, onMove = onMove, onSnooze = onSnooze, + onCreateTask = onCreateTask, + onCreateEvent = onCreateEvent, ) } } @@ -1185,6 +1189,8 @@ fun MailMessageActionsMenu( onEditTags: () -> Unit, onMove: () -> Unit, onSnooze: () -> Unit, + onCreateTask: (() -> Unit)? = null, + onCreateEvent: (() -> Unit)? = null, ) { DropdownMenu( expanded = expanded, @@ -1238,6 +1244,18 @@ fun MailMessageActionsMenu( onDismiss() onEditTags() } + onCreateTask?.let { create -> + MailMessageIconMenuItem(serverUrl, "create-task-black.svg", "Создать задачу") { + onDismiss() + create() + } + } + onCreateEvent?.let { create -> + MailMessageIconMenuItem(serverUrl, "calendar-black.svg", "Создать событие") { + onDismiss() + create() + } + } MailMessageIconMenuItem(serverUrl, "folder-black.svg", "Переместить сообщение") { onDismiss() onMove() diff --git a/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailCreateFromMessage.kt b/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailCreateFromMessage.kt new file mode 100644 index 0000000..e20a77f --- /dev/null +++ b/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailCreateFromMessage.kt @@ -0,0 +1,325 @@ +package ru.forbion.f7cloud.feature.mail + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import ru.forbion.f7cloud.core.designsystem.F7Colors +import ru.forbion.f7cloud.core.designsystem.F7OutlinedField +import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton +import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +/** Выбор из вариантов «плашками» — как строки в модалках живой темы (#F5F5F5, r10, 36dp). */ +@Composable +private fun PickerRows( + options: List, + selected: T?, + label: (T) -> String, + onSelect: (T) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + options.forEach { option -> + val isSelected = option == selected + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 36.dp) + .clip(RoundedCornerShape(10.dp)) + .background(if (isSelected) F7Colors.PrimaryLight else F7Colors.SurfaceMuted) + .then( + if (isSelected) { + Modifier.border( + 1.dp, + F7Colors.Primary.copy(alpha = 0.25f), + RoundedCornerShape(10.dp), + ) + } else { + Modifier + }, + ) + .clickable { onSelect(option) } + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + label(option), + style = MaterialTheme.typography.bodyMedium, + color = F7Colors.TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun CreateSheetScaffold( + title: String, + onDismiss: () -> Unit, + content: @Composable () -> Unit, +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface( + modifier = Modifier + .fillMaxWidth(0.94f) + .heightIn(max = 620.dp), + shape = RoundedCornerShape(16.dp), + color = Color.White, + ) { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + // Живая тема: заголовки модалок 18/600 + Text( + title, + style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp), + fontWeight = FontWeight.SemiBold, + color = F7Colors.TextPrimary, + ) + content() + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DueDateRow(dueDate: LocalDate?, onChange: (LocalDate?) -> Unit) { + var pickerOpen by remember { mutableStateOf(false) } + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 36.dp) + .clip(RoundedCornerShape(10.dp)) + .background(F7Colors.SurfaceMuted) + .clickable { pickerOpen = true } + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + dueDate?.format(DateTimeFormatter.ofPattern("d MMMM yyyy")) + ?: "Срок не задан (нажмите, чтобы выбрать)", + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyMedium, + color = if (dueDate != null) F7Colors.TextPrimary else F7Colors.TextSecondary, + ) + if (dueDate != null) { + Text( + "Сбросить", + style = MaterialTheme.typography.bodyMedium, + color = F7Colors.Primary, + modifier = Modifier.clickable { onChange(null) }, + ) + } + } + if (pickerOpen) { + val state = rememberDatePickerState( + initialSelectedDateMillis = (dueDate ?: LocalDate.now()) + .atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli(), + ) + DatePickerDialog( + onDismissRequest = { pickerOpen = false }, + confirmButton = { + TextButton(onClick = { + state.selectedDateMillis?.let { millis -> + onChange( + Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).toLocalDate(), + ) + } + pickerOpen = false + }) { Text("ОК", color = F7Colors.Primary) } + }, + dismissButton = { + TextButton(onClick = { pickerOpen = false }) { + Text("Отмена", color = F7Colors.TextSecondary) + } + }, + ) { + DatePicker(state = state) + } + } +} + +/** Модалка «Создать задачу» из письма (паритет живого веба). */ +@Composable +fun MailCreateTaskSheet( + visible: Boolean, + taskLists: List, + defaultSummary: String, + defaultDescription: String, + creating: Boolean, + onDismiss: () -> Unit, + onCreate: (listHref: String, summary: String, description: String, due: LocalDate?) -> Unit, +) { + if (!visible) return + var summary by rememberSaveable(defaultSummary) { mutableStateOf(defaultSummary) } + var description by rememberSaveable(defaultDescription) { mutableStateOf(defaultDescription) } + var dueDate by remember { mutableStateOf(null) } + var selectedList by remember(taskLists) { mutableStateOf(taskLists.firstOrNull()) } + + CreateSheetScaffold(title = "Создать задачу", onDismiss = onDismiss) { + F7OutlinedField(value = summary, onValueChange = { summary = it }, label = "Название") + F7OutlinedField( + value = description, + onValueChange = { description = it }, + label = "Описание", + minLines = 3, + ) + if (taskLists.isNotEmpty()) { + Text("Список задач", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary) + PickerRows( + options = taskLists, + selected = selectedList, + label = { it.name }, + onSelect = { selectedList = it }, + ) + } + Text("Срок", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary) + DueDateRow(dueDate) { dueDate = it } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + F7SecondaryButton("Отмена", onClick = onDismiss, modifier = Modifier.weight(1f)) + F7PrimaryButton( + text = if (creating) "Создание…" else "Создать", + onClick = { + selectedList?.let { onCreate(it.href, summary.trim(), description.trim(), dueDate) } + }, + enabled = !creating && summary.isNotBlank() && selectedList != null, + modifier = Modifier.weight(1f), + ) + } + } +} + +/** Модалка «Создать событие» из письма (паритет живого веба). */ +@Composable +fun MailCreateEventSheet( + visible: Boolean, + calendars: List, + defaultTitle: String, + defaultDescription: String, + creating: Boolean, + onDismiss: () -> Unit, + onCreate: ( + calendarHref: String, + title: String, + description: String, + date: LocalDate, + startTime: String, + endTime: String, + allDay: Boolean, + ) -> Unit, +) { + if (!visible) return + var title by rememberSaveable(defaultTitle) { mutableStateOf(defaultTitle) } + var description by rememberSaveable(defaultDescription) { mutableStateOf(defaultDescription) } + var date by remember { mutableStateOf(LocalDate.now()) } + var startTime by rememberSaveable { mutableStateOf("10:00") } + var endTime by rememberSaveable { mutableStateOf("11:00") } + var allDay by rememberSaveable { mutableStateOf(false) } + var selectedCalendar by remember(calendars) { mutableStateOf(calendars.firstOrNull()) } + + CreateSheetScaffold(title = "Создать событие", onDismiss = onDismiss) { + F7OutlinedField(value = title, onValueChange = { title = it }, label = "Название") + Text("Дата", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary) + DueDateRow(date) { picked -> picked?.let { date = it } } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Checkbox( + checked = allDay, + onCheckedChange = { allDay = it }, + colors = CheckboxDefaults.colors(checkedColor = F7Colors.Primary), + ) + Text("Весь день", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextPrimary) + } + if (!allDay) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box(modifier = Modifier.weight(1f)) { + F7OutlinedField(value = startTime, onValueChange = { startTime = it }, label = "Начало (ЧЧ:ММ)") + } + Box(modifier = Modifier.weight(1f)) { + F7OutlinedField(value = endTime, onValueChange = { endTime = it }, label = "Конец (ЧЧ:ММ)") + } + } + } + if (calendars.isNotEmpty()) { + Text("Календарь", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary) + PickerRows( + options = calendars, + selected = selectedCalendar, + label = { it.name }, + onSelect = { selectedCalendar = it }, + ) + } + F7OutlinedField( + value = description, + onValueChange = { description = it }, + label = "Описание", + minLines = 3, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + F7SecondaryButton("Отмена", onClick = onDismiss, modifier = Modifier.weight(1f)) + F7PrimaryButton( + text = if (creating) "Создание…" else "Создать", + onClick = { + selectedCalendar?.let { + onCreate(it.href, title.trim(), description.trim(), date, startTime, endTime, allDay) + } + }, + enabled = !creating && title.isNotBlank() && selectedCalendar != null, + modifier = Modifier.weight(1f), + ) + } + } +} + +/** Цель создания (список задач или календарь): href + имя. */ +data class MailTargetList(val href: String, val name: String) diff --git a/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailScreen.kt b/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailScreen.kt index 3d689c2..30ed5b4 100644 --- a/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailScreen.kt +++ b/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailScreen.kt @@ -465,6 +465,34 @@ fun MailScreen( openScheduledReplyForMessage(detail, option) }, ) + // «Создать задачу/событие из письма» — паритет живого веба: + // префилл темой письма и отправителем. + val createDefaults = state.messageDetail?.let { detail -> + detail.subject.ifBlank { "(без темы)" } to + "Из письма от ${detail.from.ifBlank { detail.fromEmail }}" + } + MailCreateTaskSheet( + visible = state.createTaskSheetOpen, + taskLists = state.createTargets, + defaultSummary = createDefaults?.first.orEmpty(), + defaultDescription = createDefaults?.second.orEmpty(), + creating = state.creatingFromMessage, + onDismiss = vm::dismissCreateSheets, + onCreate = { listHref, summary, description, due -> + vm.createTaskFromMessage(session, listHref, summary, description, due) + }, + ) + MailCreateEventSheet( + visible = state.createEventSheetOpen, + calendars = state.createTargets, + defaultTitle = createDefaults?.first.orEmpty(), + defaultDescription = createDefaults?.second.orEmpty(), + creating = state.creatingFromMessage, + onDismiss = vm::dismissCreateSheets, + onCreate = { calHref, title, description, date, start, end, allDay -> + vm.createEventFromMessage(session, calHref, title, description, date, start, end, allDay) + }, + ) } } else { MailInboxScreen( @@ -783,6 +811,8 @@ private fun MailMessageDetailHeader( onEditTags = onTagsSheetOpen, onMove = onMoveSheetOpen, onSnooze = onSnoozeSheetOpen, + onCreateTask = { vm.openCreateTaskSheet(session) }, + onCreateEvent = { vm.openCreateEventSheet(session) }, ) } diff --git a/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailViewModel.kt b/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailViewModel.kt index d45e139..e77e64c 100644 --- a/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailViewModel.kt +++ b/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailViewModel.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import ru.forbion.f7cloud.core.auth.AuthSession @@ -44,6 +45,11 @@ data class MailUiState( val appSettingsSaving: Boolean = false, val error: String? = null, val unauthorized: Boolean = false, + // «Создать задачу/событие из письма» (паритет живого веба) + val createTaskSheetOpen: Boolean = false, + val createEventSheetOpen: Boolean = false, + val createTargets: List = emptyList(), + val creatingFromMessage: Boolean = false, ) class MailViewModel( @@ -995,6 +1001,104 @@ class MailViewModel( ) } + // --- «Создать задачу/событие из письма» (паритет живого веба) --- + + fun openCreateTaskSheet(session: AuthSession) { + _state.update { it.copy(createTaskSheetOpen = true, createTargets = emptyList()) } + viewModelScope.launch(Dispatchers.IO) { + runCatching { + ru.forbion.f7cloud.feature.tasks.TasksRepository() + .listTaskLists(session) + .map { MailTargetList(it.href, it.displayName) } + }.onSuccess { lists -> + _state.update { it.copy(createTargets = lists) } + }.onFailure { t -> + _state.update { it.copy(createTaskSheetOpen = false, error = t.message) } + } + } + } + + fun openCreateEventSheet(session: AuthSession) { + _state.update { it.copy(createEventSheetOpen = true, createTargets = emptyList()) } + viewModelScope.launch(Dispatchers.IO) { + runCatching { + ru.forbion.f7cloud.feature.calendar.CalendarRepository() + .listCalendars(session) + .map { MailTargetList(it.href, it.displayName) } + }.onSuccess { calendars -> + _state.update { it.copy(createTargets = calendars) } + }.onFailure { t -> + _state.update { it.copy(createEventSheetOpen = false, error = t.message) } + } + } + } + + fun dismissCreateSheets() { + _state.update { it.copy(createTaskSheetOpen = false, createEventSheetOpen = false) } + } + + fun createTaskFromMessage( + session: AuthSession, + listHref: String, + summary: String, + description: String, + due: java.time.LocalDate?, + ) { + _state.update { it.copy(creatingFromMessage = true) } + viewModelScope.launch(Dispatchers.IO) { + runCatching { + ru.forbion.f7cloud.feature.tasks.TasksRepository().createTask( + session = session, + listHref = listHref, + summary = summary, + dueDate = due, + description = description, + ) + }.onSuccess { + _state.update { + it.copy(creatingFromMessage = false, createTaskSheetOpen = false, error = null) + } + }.onFailure { t -> + _state.update { it.copy(creatingFromMessage = false, error = t.message) } + } + } + } + + fun createEventFromMessage( + session: AuthSession, + calendarHref: String, + title: String, + description: String, + date: java.time.LocalDate, + startTime: String, + endTime: String, + allDay: Boolean, + ) { + _state.update { it.copy(creatingFromMessage = true) } + viewModelScope.launch(Dispatchers.IO) { + runCatching { + ru.forbion.f7cloud.feature.calendar.CalendarRepository().saveEvent( + session, + ru.forbion.f7cloud.feature.calendar.CalendarEventDraft( + title = title, + date = date, + startTime = startTime, + endTime = endTime, + allDay = allDay, + description = description, + calendarHref = calendarHref, + ), + ) + }.onSuccess { + _state.update { + it.copy(creatingFromMessage = false, createEventSheetOpen = false, error = null) + } + }.onFailure { t -> + _state.update { it.copy(creatingFromMessage = false, error = t.message) } + } + } + } + companion object { private const val MARK_READ_DELAY_MS = 2000L private const val FOLDER_CACHE_TTL_MS = 15 * 60 * 1000L