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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user