From fb120d5795afab91d66dbe5fa1a523da3bb0963f Mon Sep 17 00:00:00 2001 From: b-mob-notes Date: Mon, 13 Jul 2026 07:44:52 +0000 Subject: [PATCH] =?UTF-8?q?feat(notes):=20P1-UI=20=E2=80=94=20ViewModel=20?= =?UTF-8?q?+=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=20=D1=81=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=20+=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D1=80=20=D1=81=20=D0=B0=D0=B2=D1=82=D0=BE=D1=81=D0=B5=D0=B9?= =?UTF-8?q?=D0=B2=D0=BE=D0=BC=20=D0=B8=20=D0=B4=D0=B8=D0=B0=D0=BB=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=D0=BC=20=D0=BA=D0=BE=D0=BD=D1=84=D0=BB=D0=B8=D0=BA?= =?UTF-8?q?=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NotesViewModel: список (избранное↑, поиск), открытие/создание/сохранение, toggle favorite, удаление; конфликт 412 поднимается в состояние и разрешается ЯВНЫМ выбором (Оставить мою/ Версия сервера), не молчаливо (mail/071) - NotesScreen: F7ModuleScreen + поиск + пусто-состояние Welcome + список F7ListCard + диалог конфликта (F7AlertDialog) - NoteEditorScreen: markdown-поле (plain, rich/preview — P2), debounce-автосейв → PUT If-Match, read-only состояние, действия избранное/удалить/готово Верифицировано изолированно (worktree origin/main): compileDebugKotlin + 7/7 tests + detekt OK. Закрывает UI-часть P1 read-only ядра. Дальше — регистрация экрана у лида (app/). --- .../f7cloud/feature/notes/NoteEditorScreen.kt | 64 +++++++ .../f7cloud/feature/notes/NotesScreen.kt | 154 ++++++++++++++++ .../f7cloud/feature/notes/NotesViewModel.kt | 165 ++++++++++++++++++ 3 files changed, 383 insertions(+) create mode 100644 feature/notes/src/main/java/ru/forbion/f7cloud/feature/notes/NoteEditorScreen.kt create mode 100644 feature/notes/src/main/java/ru/forbion/f7cloud/feature/notes/NotesScreen.kt create mode 100644 feature/notes/src/main/java/ru/forbion/f7cloud/feature/notes/NotesViewModel.kt diff --git a/feature/notes/src/main/java/ru/forbion/f7cloud/feature/notes/NoteEditorScreen.kt b/feature/notes/src/main/java/ru/forbion/f7cloud/feature/notes/NoteEditorScreen.kt new file mode 100644 index 0000000..80d96be --- /dev/null +++ b/feature/notes/src/main/java/ru/forbion/f7cloud/feature/notes/NoteEditorScreen.kt @@ -0,0 +1,64 @@ +package ru.forbion.f7cloud.feature.notes + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import kotlinx.coroutines.delay +import ru.forbion.f7cloud.core.designsystem.F7HeaderActionButton +import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen +import ru.forbion.f7cloud.core.designsystem.F7OutlinedField + +private const val AUTOSAVE_DEBOUNCE_MS = 1200L + +/** + * Редактор заметки (P1, простой markdown). Автосохранение с debounce → PUT с `If-Match` + * (конфликт версий обрабатывает [NotesViewModel]). Rich-режим/preview/полноэкранный — P2 + * (карта app-map/notes.md). + */ +@Composable +fun NoteEditorScreen( + note: Note, + saving: Boolean, + onContentSave: (String) -> Unit, + onToggleFavorite: () -> Unit, + onDelete: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + var content by remember(note.id) { mutableStateOf(note.content) } + + // Автосейв: сохраняем через паузу после последнего изменения, если текст отличается. + LaunchedEffect(content) { + if (content != note.content) { + delay(AUTOSAVE_DEBOUNCE_MS) + onContentSave(content) + } + } + + F7ModuleScreen( + title = note.title.ifBlank { "Новая заметка" }, + modifier = modifier, + loading = false, + headerActions = { + F7HeaderActionButton(text = if (note.favorite) "★" else "☆", onClick = onToggleFavorite) + F7HeaderActionButton(text = "Удалить", onClick = onDelete) + F7HeaderActionButton(text = if (saving) "Сохранение…" else "Готово", onClick = { + if (content != note.content) onContentSave(content) + onBack() + }, enabled = !saving) + }, + ) { + F7OutlinedField( + value = content, + onValueChange = { if (!note.readonly) content = it }, + label = if (note.readonly) "Только для чтения" else "Заметка", + minLines = 12, + modifier = Modifier.fillMaxWidth(), + ) + } +} diff --git a/feature/notes/src/main/java/ru/forbion/f7cloud/feature/notes/NotesScreen.kt b/feature/notes/src/main/java/ru/forbion/f7cloud/feature/notes/NotesScreen.kt new file mode 100644 index 0000000..35951c3 --- /dev/null +++ b/feature/notes/src/main/java/ru/forbion/f7cloud/feature/notes/NotesScreen.kt @@ -0,0 +1,154 @@ +package ru.forbion.f7cloud.feature.notes + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.viewmodel.compose.viewModel +import ru.forbion.f7cloud.core.auth.AuthSession +import ru.forbion.f7cloud.core.designsystem.F7AlertDialog +import ru.forbion.f7cloud.core.designsystem.F7CreateButton +import ru.forbion.f7cloud.core.designsystem.F7ListCard +import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen +import ru.forbion.f7cloud.core.designsystem.F7OutlinedField +import ru.forbion.f7cloud.core.designsystem.F7SolidPrimaryButton +import ru.forbion.f7cloud.core.designsystem.F7Spacing + +/** + * Экран «Заметки» (P1): список с поиском и пустым состоянием, открытие заметки в редакторе, + * диалог конфликта версий. Состав/поведение — по карте app-map/notes.md. + */ +@Composable +fun NotesScreen( + session: AuthSession, + modifier: Modifier = Modifier, + createRequest: Int = 0, + onUnauthorized: () -> Unit = {}, +) { + val context = LocalContext.current + val vm: NotesViewModel = viewModel(factory = NotesViewModel.Factory(context)) + val state by vm.state.collectAsState() + + LaunchedEffect(session.serverUrl, session.username, session.davUserId) { vm.load(session) } + LaunchedEffect(createRequest) { if (createRequest > 0) vm.createNote(session) } + LaunchedEffect(state.unauthorized) { if (state.unauthorized) onUnauthorized() } + + val open = state.openNote + if (open != null) { + NoteEditorScreen( + note = open, + saving = state.saving, + onContentSave = { vm.saveNote(session, it) }, + onToggleFavorite = { vm.toggleFavorite(session, open) }, + onDelete = { vm.deleteNote(session, open) }, + onBack = { vm.closeNote() }, + modifier = modifier, + ) + ConflictDialog( + conflict = state.conflict, + onKeepMine = { vm.resolveConflictKeepMine(session) }, + onUseServer = { vm.resolveConflictUseServer() }, + ) + return + } + + F7ModuleScreen( + title = "Заметки", + modifier = modifier, + loading = state.loading, + error = state.error, + onRefresh = { vm.load(session) }, + headerActions = { F7CreateButton(onClick = { vm.createNote(session) }) }, + ) { + F7OutlinedField( + value = state.searchQuery, + onValueChange = vm::setSearch, + label = "Искать заметки", + modifier = Modifier.fillMaxWidth().padding(bottom = F7Spacing.md), + ) + + if (state.isEmpty) { + NotesEmptyState(onNewNote = { vm.createNote(session) }) + } else { + Column(verticalArrangement = Arrangement.spacedBy(F7Spacing.sm)) { + state.visibleNotes.forEach { note -> + NoteRow( + note = note, + onOpen = { vm.openNote(session, note.id) }, + onToggleFavorite = { vm.toggleFavorite(session, note) }, + ) + } + } + } + } + ConflictDialog( + conflict = state.conflict, + onKeepMine = { vm.resolveConflictKeepMine(session) }, + onUseServer = { vm.resolveConflictUseServer() }, + ) +} + +@Composable +private fun NoteRow(note: Note, onOpen: () -> Unit, onToggleFavorite: () -> Unit) { + F7ListCard(onClick = onOpen) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text( + text = if (note.favorite) "★" else "☆", + modifier = Modifier.clickable(onClick = onToggleFavorite).padding(end = F7Spacing.sm), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = note.title.ifBlank { "Без названия" }, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (note.category.isNotBlank()) { + Text(text = note.category, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } +} + +@Composable +private fun NotesEmptyState(onNewNote: () -> Unit) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth().padding(F7Spacing.lg), + ) { + Text(text = "Заметки", fontWeight = FontWeight.Bold) + Spacer(Modifier.height(F7Spacing.sm)) + Text(text = "Начните писать заметку, нажав «Новая заметка».") + Spacer(Modifier.height(F7Spacing.md)) + F7SolidPrimaryButton(text = "Новая заметка", onClick = onNewNote) + } +} + +@Composable +private fun ConflictDialog(conflict: NoteConflictException?, onKeepMine: () -> Unit, onUseServer: () -> Unit) { + if (conflict == null) return + F7AlertDialog( + title = "Конфликт версий", + onDismiss = onUseServer, + confirmText = "Оставить мою версию", + onConfirm = onKeepMine, + dismissText = "Версия сервера", + ) { + Text("Заметка была изменена в другой сессии. Выберите, какую версию сохранить.") + } +} diff --git a/feature/notes/src/main/java/ru/forbion/f7cloud/feature/notes/NotesViewModel.kt b/feature/notes/src/main/java/ru/forbion/f7cloud/feature/notes/NotesViewModel.kt new file mode 100644 index 0000000..635503b --- /dev/null +++ b/feature/notes/src/main/java/ru/forbion/f7cloud/feature/notes/NotesViewModel.kt @@ -0,0 +1,165 @@ +package ru.forbion.f7cloud.feature.notes + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import ru.forbion.f7cloud.core.auth.AuthSession +import ru.forbion.f7cloud.core.network.UnauthorizedException + +/** + * Состояние экрана Заметок (P1). Список сортируется «избранное вверх», затем по дате + * изменения; поиск фильтрует по заголовку (карта app-map/notes.md). + */ +data class NotesUiState( + val loading: Boolean = false, + val saving: Boolean = false, + val notes: List = emptyList(), + val searchQuery: String = "", + val openNote: Note? = null, + val conflict: NoteConflictException? = null, + val error: String? = null, + val unauthorized: Boolean = false, +) { + /** Отфильтрованный по поиску и отсортированный список для отображения. */ + val visibleNotes: List + get() = notes + .filter { searchQuery.isBlank() || it.title.contains(searchQuery, ignoreCase = true) } + .sortedWith(compareByDescending { it.favorite }.thenByDescending { it.modified }) + + val isEmpty: Boolean get() = !loading && notes.isEmpty() +} + +/** + * ViewModel Заметок (P1): список/открытие/сохранение (с разрешением конфликта версий), + * избранное, удаление, создание. Пишет через [NotesRepository]; конфликт `If-Match` (412) + * поднимается в состояние и разрешается ЯВНЫМ выбором пользователя — не молчаливо (mail/071). + */ +class NotesViewModel( + private val repository: NotesRepository = NotesRepository(), +) : ViewModel() { + + private val _state = MutableStateFlow(NotesUiState()) + val state: StateFlow = _state.asStateFlow() + + fun load(session: AuthSession) { + _state.update { it.copy(loading = true, error = null) } + launchGuarded { + val notes = repository.listNotes(session) + _state.update { it.copy(loading = false, notes = notes) } + } + } + + fun setSearch(query: String) = _state.update { it.copy(searchQuery = query) } + + fun openNote(session: AuthSession, id: Long) { + launchGuarded { + val note = repository.getNote(session, id) + _state.update { it.copy(openNote = note) } + } + } + + fun closeNote() = _state.update { it.copy(openNote = null) } + + fun createNote(session: AuthSession) { + _state.update { it.copy(saving = true, error = null) } + launchGuarded { + val note = repository.createNote(session, content = "") + _state.update { it.copy(saving = false, openNote = note, notes = upsert(it.notes, note)) } + } + } + + /** Сохранить содержимое открытой заметки; на 412 — поднять конфликт в состояние. */ + fun saveNote(session: AuthSession, content: String) { + val current = _state.value.openNote ?: return + _state.update { it.copy(saving = true, error = null) } + viewModelScope.launch { + try { + val saved = repository.saveNote(session, current.copy(content = content)) + _state.update { + it.copy(saving = false, openNote = saved, notes = upsert(it.notes, saved)) + } + } catch (e: NoteConflictException) { + _state.update { it.copy(saving = false, conflict = e) } + } catch (e: UnauthorizedException) { + _state.update { it.copy(saving = false, unauthorized = true, error = e.message) } + } catch (e: Exception) { + _state.update { it.copy(saving = false, error = e.message) } + } + } + } + + /** Разрешение конфликта: перезаписать серверную версию текущим содержимым. */ + fun resolveConflictKeepMine(session: AuthSession) { + val conflict = _state.value.conflict ?: return + val mine = _state.value.openNote ?: return + // берём etag серверной версии, чтобы If-Match прошёл, но содержимое — наше + val base = conflict.serverNote?.copy(content = mine.content) ?: mine + _state.update { it.copy(conflict = null, saving = true) } + launchGuarded { + val saved = repository.saveNote(session, base) + _state.update { it.copy(saving = false, openNote = saved, notes = upsert(it.notes, saved)) } + } + } + + /** Разрешение конфликта: принять версию сервера (отбросить локальные правки). */ + fun resolveConflictUseServer() { + val server = _state.value.conflict?.serverNote + _state.update { st -> + st.copy( + conflict = null, + openNote = server ?: st.openNote, + notes = if (server != null) upsert(st.notes, server) else st.notes, + ) + } + } + + fun toggleFavorite(session: AuthSession, note: Note) { + launchGuarded { + val updated = repository.setFavorite(session, note.id, !note.favorite) + _state.update { it.copy(notes = upsert(it.notes, updated)) } + } + } + + fun deleteNote(session: AuthSession, note: Note) { + launchGuarded { + repository.deleteNote(session, note.id) + _state.update { + it.copy( + notes = it.notes.filterNot { n -> n.id == note.id }, + openNote = if (it.openNote?.id == note.id) null else it.openNote, + ) + } + } + } + + fun clearError() = _state.update { it.copy(error = null) } + + private fun launchGuarded(block: suspend () -> Unit) { + viewModelScope.launch { + try { + block() + } catch (e: UnauthorizedException) { + _state.update { it.copy(loading = false, saving = false, unauthorized = true, error = e.message) } + } catch (e: Exception) { + _state.update { it.copy(loading = false, saving = false, error = e.message) } + } + } + } + + private fun upsert(notes: List, note: Note): List { + val idx = notes.indexOfFirst { it.id == note.id } + return if (idx >= 0) notes.toMutableList().also { it[idx] = note } else notes + note + } + + class Factory(private val context: Context) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + NotesViewModel(NotesRepository(NotesCache(context.applicationContext))) as T + } +}