feat(notes): P1-UI — ViewModel + экран списка + редактор с автосейвом и диалогом конфликта

- 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/).
This commit is contained in:
b-mob-notes
2026-07-13 07:44:52 +00:00
parent 7be35490e4
commit fb120d5795
3 changed files with 383 additions and 0 deletions
@@ -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(),
)
}
}
@@ -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("Заметка была изменена в другой сессии. Выберите, какую версию сохранить.")
}
}
@@ -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<Note> = emptyList(),
val searchQuery: String = "",
val openNote: Note? = null,
val conflict: NoteConflictException? = null,
val error: String? = null,
val unauthorized: Boolean = false,
) {
/** Отфильтрованный по поиску и отсортированный список для отображения. */
val visibleNotes: List<Note>
get() = notes
.filter { searchQuery.isBlank() || it.title.contains(searchQuery, ignoreCase = true) }
.sortedWith(compareByDescending<Note> { 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<NotesUiState> = _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: Note): List<Note> {
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 <T : ViewModel> create(modelClass: Class<T>): T =
NotesViewModel(NotesRepository(NotesCache(context.applicationContext))) as T
}
}