feat(v1.5.0): Complete NoteEditor Redesign with Jetpack Compose

Features:
- Migrate NoteEditorActivity from XML to Jetpack Compose
- Support both TEXT and CHECKLIST note types
- FOSS native drag & drop using Compose Foundation APIs (no external dependencies)
- Auto-keyboard focus with explicit keyboard controller show() calls
- Consistent placeholder text for empty checklist items
- Unified delete dialog with Server/Local deletion options

Components:
- ComposeNoteEditorActivity: Activity wrapper with ViewModelFactory for SavedStateHandle
- NoteEditorScreen: Main editor screen supporting TEXT and CHECKLIST modes
- NoteEditorViewModel: State management with WebDav server deletion support
- ChecklistItemRow: Individual checklist item with drag handle, checkbox, text input
- DragDropState: FOSS drag & drop implementation using LazyListState

Improvements:
- Auto-keyboard shows when creating new notes (focuses title/content)
- Keyboard consistently shows when adding new checklist items
- Placeholder text 'Neues Element…' for empty list items
- Delete dialog unified with MainScreen (Server/Local options)
- Server deletion via WebDavSyncService.deleteNoteFromServer()

Debug Enhancements:
- Debug builds have orange icon background (#FFB74D) with red badge
- Debug builds show 'Simple Notes (Debug)' app name
- Easy differentiation between debug and release APKs in launcher

Build Status:
- compileStandardDebug: SUCCESS
- No breaking changes to existing XML-based screens
- Material 3 theming with Dynamic Colors (Material You) applied

Migration Notes:
- Old NoteEditorActivity (XML-based) remains for reference/backwards compatibility
- All editor UI is now Compose-based
- ComposeMainActivity updated to use new ComposeNoteEditorActivity
- Plan document (v1.5.0_EXTENDED_FEATURES_PLAN.md) updated with implementation details
This commit is contained in:
inventory69
2026-01-15 17:19:56 +01:00
parent c33448f841
commit 20ec5ba9f9
12 changed files with 1209 additions and 6 deletions

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Debug overlay for launcher icon - original icon + red "DEBUG" badge -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Original foreground icon from mipmap -->
<item android:drawable="@mipmap/ic_launcher_foreground" />
<!-- Debug Badge: Red circle with "D" in top-right corner -->
<item>
<inset
android:insetLeft="60dp"
android:insetTop="10dp"
android:insetRight="10dp"
android:insetBottom="60dp">
<layer-list>
<!-- Red circle background -->
<item>
<shape android:shape="oval">
<solid android:color="#E53935" />
<size android:width="28dp" android:height="28dp" />
</shape>
</item>
</layer-list>
</inset>
</item>
</layer-list>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Debug version of adaptive icon with badge -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Different background color for debug (darker/orange tint) -->
<background android:drawable="@color/ic_launcher_background_debug"/>
<foreground android:drawable="@drawable/ic_launcher_foreground_debug"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Debug version of adaptive icon with badge (round) -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Different background color for debug (darker/orange tint) -->
<background android:drawable="@color/ic_launcher_background_debug"/>
<foreground android:drawable="@drawable/ic_launcher_foreground_debug"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Debug version: Orange-tinted background to distinguish from release -->
<color name="ic_launcher_background_debug">#FFB74D</color>
</resources>

View File

@@ -44,12 +44,19 @@
android:exported="false"
android:theme="@style/Theme.SimpleNotes" />
<!-- Editor Activity -->
<!-- Editor Activity (Legacy - XML-based) -->
<activity
android:name=".NoteEditorActivity"
android:windowSoftInputMode="adjustResize"
android:parentActivityName=".ui.main.ComposeMainActivity" />
<!-- Editor Activity v1.5.0 (Jetpack Compose) -->
<activity
android:name=".ui.editor.ComposeNoteEditorActivity"
android:windowSoftInputMode="adjustResize"
android:parentActivityName=".ui.main.ComposeMainActivity"
android:theme="@style/Theme.SimpleNotes" />
<!-- Settings Activity (Legacy - XML-based) -->
<activity
android:name=".SettingsActivity"

View File

@@ -0,0 +1,84 @@
package dev.dettmer.simplenotes.ui.editor
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.lifecycle.AbstractSavedStateViewModelFactory
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.savedstate.SavedStateRegistryOwner
import com.google.android.material.color.DynamicColors
import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.ui.theme.SimpleNotesTheme
/**
* Compose-based Note Editor Activity
*
* v1.5.0: Jetpack Compose NoteEditor Redesign
* Replaces the old NoteEditorActivity with a modern Compose implementation.
*
* Supports:
* - TEXT notes with title and content
* - CHECKLIST notes with drag & drop reordering
* - Auto-keyboard focus for new checklist items
*/
class ComposeNoteEditorActivity : ComponentActivity() {
companion object {
const val EXTRA_NOTE_ID = "extra_note_id"
const val EXTRA_NOTE_TYPE = "extra_note_type"
}
private val viewModel: NoteEditorViewModel by viewModels {
NoteEditorViewModelFactory(
application = application,
owner = this,
noteId = intent.getStringExtra(EXTRA_NOTE_ID),
noteType = intent.getStringExtra(EXTRA_NOTE_TYPE) ?: NoteType.TEXT.name
)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Apply Dynamic Colors for Android 12+ (Material You)
DynamicColors.applyToActivityIfAvailable(this)
enableEdgeToEdge()
setContent {
SimpleNotesTheme {
NoteEditorScreen(
viewModel = viewModel,
onNavigateBack = { finish() }
)
}
}
}
}
/**
* Custom ViewModelFactory to pass SavedStateHandle with intent extras
*/
class NoteEditorViewModelFactory(
private val application: android.app.Application,
owner: SavedStateRegistryOwner,
private val noteId: String?,
private val noteType: String
) : AbstractSavedStateViewModelFactory(owner, null) {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(
key: String,
modelClass: Class<T>,
handle: SavedStateHandle
): T {
// Populate SavedStateHandle with intent extras
handle[NoteEditorViewModel.ARG_NOTE_ID] = noteId
handle[NoteEditorViewModel.ARG_NOTE_TYPE] = noteType
return NoteEditorViewModel(application, handle) as T
}
}

View File

@@ -0,0 +1,154 @@
package dev.dettmer.simplenotes.ui.editor
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.pointerInput
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
/**
* FOSS Drag & Drop State für LazyList
*
* Native Compose-Implementierung ohne externe Dependencies
* v1.5.0: NoteEditor Redesign
*/
class DragDropListState(
private val state: LazyListState,
private val scope: CoroutineScope,
private val onMove: (Int, Int) -> Unit
) {
var draggingItemIndex by mutableStateOf<Int?>(null)
private set
private var draggingItemDraggedDelta by mutableFloatStateOf(0f)
private var draggingItemInitialOffset by mutableFloatStateOf(0f)
private var overscrollJob by mutableStateOf<Job?>(null)
val draggingItemOffset: Float
get() = draggingItemLayoutInfo?.let { item ->
draggingItemInitialOffset + draggingItemDraggedDelta - item.offset
} ?: 0f
private val draggingItemLayoutInfo: LazyListItemInfo?
get() = state.layoutInfo.visibleItemsInfo
.firstOrNull { it.index == draggingItemIndex }
fun onDragStart(offset: Offset, itemIndex: Int) {
draggingItemIndex = itemIndex
draggingItemInitialOffset = draggingItemLayoutInfo?.offset?.toFloat() ?: 0f
draggingItemDraggedDelta = 0f
}
fun onDragInterrupted() {
draggingItemDraggedDelta = 0f
draggingItemIndex = null
draggingItemInitialOffset = 0f
overscrollJob?.cancel()
}
fun onDrag(offset: Offset) {
draggingItemDraggedDelta += offset.y
val draggingItem = draggingItemLayoutInfo ?: return
val startOffset = draggingItem.offset + draggingItemOffset
val endOffset = startOffset + draggingItem.size
val middleOffset = startOffset + (endOffset - startOffset) / 2f
val targetItem = state.layoutInfo.visibleItemsInfo.find { item ->
middleOffset.toInt() in item.offset..item.offsetEnd &&
draggingItem.index != item.index
}
if (targetItem != null) {
val scrollToIndex = if (targetItem.index == state.firstVisibleItemIndex) {
draggingItem.index
} else if (draggingItem.index == state.firstVisibleItemIndex) {
targetItem.index
} else {
null
}
if (scrollToIndex != null) {
scope.launch {
state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset)
onMove(draggingItem.index, targetItem.index)
}
} else {
onMove(draggingItem.index, targetItem.index)
}
draggingItemIndex = targetItem.index
} else {
val overscroll = when {
draggingItemDraggedDelta > 0 ->
(endOffset - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f)
draggingItemDraggedDelta < 0 ->
(startOffset - state.layoutInfo.viewportStartOffset).coerceAtMost(0f)
else -> 0f
}
if (overscroll != 0f) {
if (overscrollJob?.isActive != true) {
overscrollJob = scope.launch {
state.scrollBy(overscroll)
}
}
} else {
overscrollJob?.cancel()
}
}
}
private val LazyListItemInfo.offsetEnd: Int
get() = this.offset + this.size
}
@Composable
fun rememberDragDropListState(
lazyListState: LazyListState,
scope: CoroutineScope,
onMove: (Int, Int) -> Unit
): DragDropListState {
return remember(lazyListState, scope) {
DragDropListState(
state = lazyListState,
scope = scope,
onMove = onMove
)
}
}
fun Modifier.dragContainer(
dragDropState: DragDropListState,
itemIndex: Int
): Modifier {
return this.pointerInput(dragDropState) {
detectDragGesturesAfterLongPress(
onDragStart = { offset ->
dragDropState.onDragStart(offset, itemIndex)
},
onDragEnd = {
dragDropState.onDragInterrupted()
},
onDragCancel = {
dragDropState.onDragInterrupted()
},
onDrag = { change, offset ->
change.consume()
dragDropState.onDrag(offset)
}
)
}
}

View File

@@ -0,0 +1,350 @@
package dev.dettmer.simplenotes.ui.editor
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.ui.editor.components.ChecklistItemRow
import dev.dettmer.simplenotes.ui.main.components.DeleteConfirmationDialog
import kotlinx.coroutines.delay
import dev.dettmer.simplenotes.utils.showToast
import kotlin.math.roundToInt
/**
* Main Composable for the Note Editor screen.
*
* v1.5.0: Jetpack Compose NoteEditor Redesign
* - Supports both TEXT and CHECKLIST notes
* - Drag & Drop reordering for checklist items
* - Auto-keyboard focus for new items
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NoteEditorScreen(
viewModel: NoteEditorViewModel,
onNavigateBack: () -> Unit
) {
val context = LocalContext.current
val uiState by viewModel.uiState.collectAsState()
val checklistItems by viewModel.checklistItems.collectAsState()
var showDeleteDialog by remember { mutableStateOf(false) }
var focusNewItemId by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
// v1.5.0: Auto-keyboard support
val keyboardController = LocalSoftwareKeyboardController.current
val titleFocusRequester = remember { FocusRequester() }
val contentFocusRequester = remember { FocusRequester() }
// v1.5.0: Auto-focus and show keyboard
LaunchedEffect(uiState.isNewNote, uiState.noteType) {
delay(100) // Wait for layout
when {
uiState.isNewNote -> {
// New note: focus title
titleFocusRequester.requestFocus()
keyboardController?.show()
}
!uiState.isNewNote && uiState.noteType == NoteType.TEXT -> {
// Editing text note: focus content
contentFocusRequester.requestFocus()
keyboardController?.show()
}
}
}
// Handle events
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
is NoteEditorEvent.ShowToast -> {
val message = when (event.message) {
ToastMessage.NOTE_IS_EMPTY -> context.getString(R.string.note_is_empty)
ToastMessage.NOTE_SAVED -> context.getString(R.string.note_saved)
ToastMessage.NOTE_DELETED -> context.getString(R.string.note_deleted)
}
context.showToast(message)
}
is NoteEditorEvent.NavigateBack -> onNavigateBack()
is NoteEditorEvent.ShowDeleteConfirmation -> showDeleteDialog = true
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = when (uiState.toolbarTitle) {
ToolbarTitle.NEW_NOTE -> stringResource(R.string.new_note)
ToolbarTitle.EDIT_NOTE -> stringResource(R.string.edit_note)
ToolbarTitle.NEW_CHECKLIST -> stringResource(R.string.new_checklist)
ToolbarTitle.EDIT_CHECKLIST -> stringResource(R.string.edit_checklist)
}
)
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back)
)
}
},
actions = {
// Delete button (only for existing notes)
if (viewModel.canDelete()) {
IconButton(onClick = { showDeleteDialog = true }) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringResource(R.string.delete)
)
}
}
// Save button
IconButton(onClick = { viewModel.saveNote() }) {
Icon(
imageVector = Icons.Default.Save,
contentDescription = stringResource(R.string.save)
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
},
modifier = Modifier.imePadding()
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp)
) {
// Title Input (for both types)
OutlinedTextField(
value = uiState.title,
onValueChange = { viewModel.updateTitle(it) },
modifier = Modifier
.fillMaxWidth()
.focusRequester(titleFocusRequester),
label = { Text(stringResource(R.string.title)) },
singleLine = false,
maxLines = 2,
shape = RoundedCornerShape(16.dp)
)
Spacer(modifier = Modifier.height(16.dp))
when (uiState.noteType) {
NoteType.TEXT -> {
// Content Input for TEXT notes
TextNoteContent(
content = uiState.content,
onContentChange = { viewModel.updateContent(it) },
focusRequester = contentFocusRequester,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
)
}
NoteType.CHECKLIST -> {
// Checklist Editor
ChecklistEditor(
items = checklistItems,
scope = scope,
focusNewItemId = focusNewItemId,
onTextChange = { id, text -> viewModel.updateChecklistItemText(id, text) },
onCheckedChange = { id, checked -> viewModel.updateChecklistItemChecked(id, checked) },
onDelete = { id -> viewModel.deleteChecklistItem(id) },
onAddNewItemAfter = { id ->
val newId = viewModel.addChecklistItemAfter(id)
focusNewItemId = newId
},
onAddItemAtEnd = {
val newId = viewModel.addChecklistItemAtEnd()
focusNewItemId = newId
},
onMove = { from, to -> viewModel.moveChecklistItem(from, to) },
onFocusHandled = { focusNewItemId = null },
modifier = Modifier
.fillMaxWidth()
.weight(1f)
)
}
}
}
}
// Delete Confirmation Dialog - v1.5.0: Use shared component with server/local options
if (showDeleteDialog) {
DeleteConfirmationDialog(
noteCount = 1,
onDismiss = { showDeleteDialog = false },
onDeleteLocal = {
showDeleteDialog = false
viewModel.deleteNote(deleteOnServer = false)
},
onDeleteEverywhere = {
showDeleteDialog = false
viewModel.deleteNote(deleteOnServer = true)
}
)
}
}
@Composable
private fun TextNoteContent(
content: String,
onContentChange: (String) -> Unit,
focusRequester: FocusRequester,
modifier: Modifier = Modifier
) {
OutlinedTextField(
value = content,
onValueChange = onContentChange,
modifier = modifier.focusRequester(focusRequester),
label = { Text(stringResource(R.string.content)) },
shape = RoundedCornerShape(16.dp)
)
}
@Composable
private fun ChecklistEditor(
items: List<ChecklistItemState>,
scope: kotlinx.coroutines.CoroutineScope,
focusNewItemId: String?,
onTextChange: (String, String) -> Unit,
onCheckedChange: (String, Boolean) -> Unit,
onDelete: (String) -> Unit,
onAddNewItemAfter: (String) -> Unit,
onAddItemAtEnd: () -> Unit,
onMove: (Int, Int) -> Unit,
onFocusHandled: () -> Unit,
modifier: Modifier = Modifier
) {
val listState = rememberLazyListState()
val dragDropState = rememberDragDropListState(
lazyListState = listState,
scope = scope,
onMove = onMove
)
Column(modifier = modifier) {
LazyColumn(
state = listState,
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
itemsIndexed(
items = items,
key = { _, item -> item.id }
) { index, item ->
val isDragging = dragDropState.draggingItemIndex == index
val elevation by animateDpAsState(
targetValue = if (isDragging) 8.dp else 0.dp,
label = "elevation"
)
val shouldFocus = item.id == focusNewItemId
// v1.5.0: Clear focus request after handling
LaunchedEffect(shouldFocus) {
if (shouldFocus) {
onFocusHandled()
}
}
ChecklistItemRow(
item = item,
onTextChange = { onTextChange(item.id, it) },
onCheckedChange = { onCheckedChange(item.id, it) },
onDelete = { onDelete(item.id) },
onAddNewItem = { onAddNewItemAfter(item.id) },
requestFocus = shouldFocus,
modifier = Modifier
.dragContainer(dragDropState, index)
.offset {
IntOffset(
0,
if (isDragging) dragDropState.draggingItemOffset.roundToInt() else 0
)
}
.shadow(elevation, shape = RoundedCornerShape(8.dp))
.background(
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(8.dp)
)
)
}
}
// Add Item Button
TextButton(
onClick = onAddItemAtEnd,
modifier = Modifier.padding(start = 8.dp)
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp)
)
Text(stringResource(R.string.add_item))
}
}
}
// v1.5.0: Local DeleteConfirmationDialog removed - now using shared component from ui/main/components/

View File

@@ -0,0 +1,383 @@
package dev.dettmer.simplenotes.ui.editor
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import dev.dettmer.simplenotes.models.ChecklistItem
import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.sync.WebDavSyncService
import dev.dettmer.simplenotes.utils.DeviceIdGenerator
import dev.dettmer.simplenotes.utils.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.UUID
/**
* ViewModel for NoteEditor Compose Screen
* v1.5.0: Jetpack Compose NoteEditor Redesign
*
* Manages note editing state including title, content, and checklist items.
*/
class NoteEditorViewModel(
application: Application,
private val savedStateHandle: SavedStateHandle
) : AndroidViewModel(application) {
companion object {
private const val TAG = "NoteEditorViewModel"
const val ARG_NOTE_ID = "noteId"
const val ARG_NOTE_TYPE = "noteType"
}
private val storage = NotesStorage(application)
// ═══════════════════════════════════════════════════════════════════════
// State
// ═══════════════════════════════════════════════════════════════════════
private val _uiState = MutableStateFlow(NoteEditorUiState())
val uiState: StateFlow<NoteEditorUiState> = _uiState.asStateFlow()
private val _checklistItems = MutableStateFlow<List<ChecklistItemState>>(emptyList())
val checklistItems: StateFlow<List<ChecklistItemState>> = _checklistItems.asStateFlow()
// ═══════════════════════════════════════════════════════════════════════
// Events
// ═══════════════════════════════════════════════════════════════════════
private val _events = MutableSharedFlow<NoteEditorEvent>()
val events: SharedFlow<NoteEditorEvent> = _events.asSharedFlow()
// Internal state
private var existingNote: Note? = null
private var currentNoteType: NoteType = NoteType.TEXT
init {
loadNote()
}
private fun loadNote() {
val noteId = savedStateHandle.get<String>(ARG_NOTE_ID)
val noteTypeString = savedStateHandle.get<String>(ARG_NOTE_TYPE) ?: NoteType.TEXT.name
if (noteId != null) {
// Load existing note
existingNote = storage.loadNote(noteId)
existingNote?.let { note ->
currentNoteType = note.noteType
_uiState.update { state ->
state.copy(
title = note.title,
content = note.content,
noteType = note.noteType,
isNewNote = false,
toolbarTitle = if (note.noteType == NoteType.CHECKLIST) {
ToolbarTitle.EDIT_CHECKLIST
} else {
ToolbarTitle.EDIT_NOTE
}
)
}
if (note.noteType == NoteType.CHECKLIST) {
val items = note.checklistItems?.sortedBy { it.order }?.map {
ChecklistItemState(
id = it.id,
text = it.text,
isChecked = it.isChecked,
order = it.order
)
} ?: emptyList()
_checklistItems.value = items
}
}
} else {
// New note
currentNoteType = try {
NoteType.valueOf(noteTypeString)
} catch (e: IllegalArgumentException) {
Logger.w(TAG, "Invalid note type '$noteTypeString', defaulting to TEXT")
NoteType.TEXT
}
_uiState.update { state ->
state.copy(
noteType = currentNoteType,
isNewNote = true,
toolbarTitle = if (currentNoteType == NoteType.CHECKLIST) {
ToolbarTitle.NEW_CHECKLIST
} else {
ToolbarTitle.NEW_NOTE
}
)
}
// Add first empty item for new checklists
if (currentNoteType == NoteType.CHECKLIST) {
_checklistItems.value = listOf(ChecklistItemState.createEmpty(0))
}
}
}
// ═══════════════════════════════════════════════════════════════════════
// Actions
// ═══════════════════════════════════════════════════════════════════════
fun updateTitle(title: String) {
_uiState.update { it.copy(title = title) }
}
fun updateContent(content: String) {
_uiState.update { it.copy(content = content) }
}
fun updateChecklistItemText(itemId: String, newText: String) {
_checklistItems.update { items ->
items.map { item ->
if (item.id == itemId) item.copy(text = newText) else item
}
}
}
fun updateChecklistItemChecked(itemId: String, isChecked: Boolean) {
_checklistItems.update { items ->
items.map { item ->
if (item.id == itemId) item.copy(isChecked = isChecked) else item
}
}
}
fun addChecklistItemAfter(afterItemId: String): String {
val newItem = ChecklistItemState.createEmpty(0)
_checklistItems.update { items ->
val index = items.indexOfFirst { it.id == afterItemId }
if (index >= 0) {
val newList = items.toMutableList()
newList.add(index + 1, newItem)
// Update order values
newList.mapIndexed { i, item -> item.copy(order = i) }
} else {
items + newItem.copy(order = items.size)
}
}
return newItem.id
}
fun addChecklistItemAtEnd(): String {
val newItem = ChecklistItemState.createEmpty(_checklistItems.value.size)
_checklistItems.update { items -> items + newItem }
return newItem.id
}
fun deleteChecklistItem(itemId: String) {
_checklistItems.update { items ->
val filtered = items.filter { it.id != itemId }
// Ensure at least one item exists
if (filtered.isEmpty()) {
listOf(ChecklistItemState.createEmpty(0))
} else {
// Update order values
filtered.mapIndexed { index, item -> item.copy(order = index) }
}
}
}
fun moveChecklistItem(fromIndex: Int, toIndex: Int) {
_checklistItems.update { items ->
val mutableList = items.toMutableList()
val item = mutableList.removeAt(fromIndex)
mutableList.add(toIndex, item)
// Update order values
mutableList.mapIndexed { index, i -> i.copy(order = index) }
}
}
fun saveNote() {
viewModelScope.launch {
val state = _uiState.value
val title = state.title.trim()
when (currentNoteType) {
NoteType.TEXT -> {
val content = state.content.trim()
if (title.isEmpty() && content.isEmpty()) {
_events.emit(NoteEditorEvent.ShowToast(ToastMessage.NOTE_IS_EMPTY))
return@launch
}
val note = if (existingNote != null) {
existingNote!!.copy(
title = title,
content = content,
noteType = NoteType.TEXT,
checklistItems = null,
updatedAt = System.currentTimeMillis(),
syncStatus = SyncStatus.PENDING
)
} else {
Note(
title = title,
content = content,
noteType = NoteType.TEXT,
checklistItems = null,
deviceId = DeviceIdGenerator.getDeviceId(getApplication()),
syncStatus = SyncStatus.LOCAL_ONLY
)
}
storage.saveNote(note)
}
NoteType.CHECKLIST -> {
// Filter empty items
val validItems = _checklistItems.value
.filter { it.text.isNotBlank() }
.mapIndexed { index, item ->
ChecklistItem(
id = item.id,
text = item.text,
isChecked = item.isChecked,
order = index
)
}
if (title.isEmpty() && validItems.isEmpty()) {
_events.emit(NoteEditorEvent.ShowToast(ToastMessage.NOTE_IS_EMPTY))
return@launch
}
val note = if (existingNote != null) {
existingNote!!.copy(
title = title,
content = "", // Empty for checklists
noteType = NoteType.CHECKLIST,
checklistItems = validItems,
updatedAt = System.currentTimeMillis(),
syncStatus = SyncStatus.PENDING
)
} else {
Note(
title = title,
content = "",
noteType = NoteType.CHECKLIST,
checklistItems = validItems,
deviceId = DeviceIdGenerator.getDeviceId(getApplication()),
syncStatus = SyncStatus.LOCAL_ONLY
)
}
storage.saveNote(note)
}
}
_events.emit(NoteEditorEvent.ShowToast(ToastMessage.NOTE_SAVED))
_events.emit(NoteEditorEvent.NavigateBack)
}
}
/**
* Delete the current note
* @param deleteOnServer if true, also triggers server deletion; if false, only deletes locally
* v1.5.0: Added deleteOnServer parameter for unified delete dialog
*/
fun deleteNote(deleteOnServer: Boolean = true) {
viewModelScope.launch {
existingNote?.let { note ->
val noteId = note.id
// Delete locally first
storage.deleteNote(noteId)
// Delete from server if requested
if (deleteOnServer) {
try {
val webdavService = WebDavSyncService(getApplication())
val success = withContext(Dispatchers.IO) {
webdavService.deleteNoteFromServer(noteId)
}
if (success) {
Logger.d(TAG, "Note $noteId deleted from server")
} else {
Logger.w(TAG, "Failed to delete note $noteId from server")
}
} catch (e: Exception) {
Logger.e(TAG, "Error deleting note from server: ${e.message}")
}
}
_events.emit(NoteEditorEvent.ShowToast(ToastMessage.NOTE_DELETED))
_events.emit(NoteEditorEvent.NavigateBack)
}
}
}
fun showDeleteConfirmation() {
viewModelScope.launch {
_events.emit(NoteEditorEvent.ShowDeleteConfirmation)
}
}
fun canDelete(): Boolean = existingNote != null
}
// ═══════════════════════════════════════════════════════════════════════════
// State Classes
// ═══════════════════════════════════════════════════════════════════════════
data class NoteEditorUiState(
val title: String = "",
val content: String = "",
val noteType: NoteType = NoteType.TEXT,
val isNewNote: Boolean = true,
val toolbarTitle: ToolbarTitle = ToolbarTitle.NEW_NOTE
)
data class ChecklistItemState(
val id: String = UUID.randomUUID().toString(),
val text: String = "",
val isChecked: Boolean = false,
val order: Int = 0
) {
companion object {
fun createEmpty(order: Int): ChecklistItemState {
return ChecklistItemState(
id = UUID.randomUUID().toString(),
text = "",
isChecked = false,
order = order
)
}
}
}
enum class ToolbarTitle {
NEW_NOTE,
EDIT_NOTE,
NEW_CHECKLIST,
EDIT_CHECKLIST
}
enum class ToastMessage {
NOTE_IS_EMPTY,
NOTE_SAVED,
NOTE_DELETED
}
sealed interface NoteEditorEvent {
data class ShowToast(val message: ToastMessage) : NoteEditorEvent
data object NavigateBack : NoteEditorEvent
data object ShowDeleteConfirmation : NoteEditorEvent
}

View File

@@ -0,0 +1,177 @@
package dev.dettmer.simplenotes.ui.editor.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.ui.editor.ChecklistItemState
/**
* A single row in the checklist editor with drag handle, checkbox, text input, and delete button.
*
* v1.5.0: Jetpack Compose NoteEditor Redesign
*/
@Composable
fun ChecklistItemRow(
item: ChecklistItemState,
onTextChange: (String) -> Unit,
onCheckedChange: (Boolean) -> Unit,
onDelete: () -> Unit,
onAddNewItem: () -> Unit,
requestFocus: Boolean = false,
modifier: Modifier = Modifier
) {
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
var textFieldValue by remember(item.id) {
mutableStateOf(TextFieldValue(text = item.text, selection = TextRange(item.text.length)))
}
// v1.5.0: Auto-focus AND show keyboard when requestFocus is true (new items)
LaunchedEffect(requestFocus) {
if (requestFocus) {
focusRequester.requestFocus()
keyboardController?.show()
}
}
// Update text field when external state changes
LaunchedEffect(item.text) {
if (textFieldValue.text != item.text) {
textFieldValue = TextFieldValue(
text = item.text,
selection = TextRange(item.text.length)
)
}
}
val alpha = if (item.isChecked) 0.6f else 1.0f
val textDecoration = if (item.isChecked) TextDecoration.LineThrough else TextDecoration.None
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Drag Handle
Icon(
imageVector = Icons.Default.DragHandle,
contentDescription = stringResource(R.string.drag_to_reorder),
modifier = Modifier
.size(24.dp)
.alpha(0.5f),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(4.dp))
// Checkbox
Checkbox(
checked = item.isChecked,
onCheckedChange = onCheckedChange,
modifier = Modifier.alpha(alpha)
)
Spacer(modifier = Modifier.width(4.dp))
// Text Input with placeholder
BasicTextField(
value = textFieldValue,
onValueChange = { newValue ->
// Check for newline (Enter key)
if (newValue.text.contains("\n")) {
val cleanText = newValue.text.replace("\n", "")
textFieldValue = TextFieldValue(
text = cleanText,
selection = TextRange(cleanText.length)
)
onTextChange(cleanText)
onAddNewItem()
} else {
textFieldValue = newValue
onTextChange(newValue.text)
}
},
modifier = Modifier
.weight(1f)
.focusRequester(focusRequester)
.alpha(alpha),
textStyle = LocalTextStyle.current.copy(
color = MaterialTheme.colorScheme.onSurface,
textDecoration = textDecoration
),
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { onAddNewItem() }
),
singleLine = true,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
decorationBox = { innerTextField ->
Box {
if (textFieldValue.text.isEmpty()) {
Text(
text = stringResource(R.string.item_placeholder),
style = LocalTextStyle.current.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
)
}
innerTextField()
}
}
)
Spacer(modifier = Modifier.width(4.dp))
// Delete Button
IconButton(
onClick = onDelete,
modifier = Modifier.size(36.dp)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.delete_item),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
}
}
}

View File

@@ -28,7 +28,7 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.lifecycleScope
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.google.android.material.color.DynamicColors
import dev.dettmer.simplenotes.NoteEditorActivity
import dev.dettmer.simplenotes.ui.editor.ComposeNoteEditorActivity
import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.storage.NotesStorage
@@ -230,17 +230,17 @@ class ComposeMainActivity : ComponentActivity() {
private fun openNoteEditor(noteId: String?) {
cameFromEditor = true
val intent = Intent(this, NoteEditorActivity::class.java)
val intent = Intent(this, ComposeNoteEditorActivity::class.java)
noteId?.let {
intent.putExtra(NoteEditorActivity.EXTRA_NOTE_ID, it)
intent.putExtra(ComposeNoteEditorActivity.EXTRA_NOTE_ID, it)
}
startActivity(intent)
}
private fun createNote(noteType: NoteType) {
cameFromEditor = true
val intent = Intent(this, NoteEditorActivity::class.java)
intent.putExtra(NoteEditorActivity.EXTRA_NOTE_TYPE, noteType.name)
val intent = Intent(this, ComposeNoteEditorActivity::class.java)
intent.putExtra(ComposeNoteEditorActivity.EXTRA_NOTE_TYPE, noteType.name)
startActivity(intent)
}

View File

@@ -19,6 +19,7 @@
<string name="content">Inhalt</string>
<string name="save">Speichern</string>
<string name="delete">Löschen</string>
<string name="back">Zurück</string>
<!-- Note List Item (Preview placeholders) -->
<string name="note_title_placeholder">Note Title</string>
@@ -81,6 +82,7 @@
<string name="add_item">Element hinzufügen</string>
<string name="item_placeholder">Neues Element…</string>
<string name="reorder_item">Element verschieben</string>
<string name="drag_to_reorder">Ziehen zum Sortieren</string>
<string name="delete_item">Element löschen</string>
<string name="note_is_empty">Notiz ist leer</string>
<string name="note_saved">Notiz gespeichert</string>