feat(mail): «Создать задачу» и «Создать событие» из письма

Паритет живого веба: пункты в меню действий письма (иконки темы
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 <noreply@anthropic.com>
This commit is contained in:
b-dev-mobile
2026-07-10 04:07:21 +00:00
parent 9ec3114506
commit 2df46667e0
5 changed files with 480 additions and 0 deletions
+3
View File
@@ -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)
@@ -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()
@@ -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 <T> PickerRows(
options: List<T>,
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<MailTargetList>,
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<LocalDate?>(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<MailTargetList>,
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)
@@ -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) },
)
}
@@ -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<MailTargetList> = 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