diff --git a/android/app/src/debug/res/drawable/ic_launcher_foreground_debug.xml b/android/app/src/debug/res/drawable/ic_launcher_foreground_debug.xml
new file mode 100644
index 0000000..50f6b6d
--- /dev/null
+++ b/android/app/src/debug/res/drawable/ic_launcher_foreground_debug.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+ -
+
+
+
+
-
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..1cdebeb
--- /dev/null
+++ b/android/app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/android/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..10807ae
--- /dev/null
+++ b/android/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/android/app/src/debug/res/values/colors_debug.xml b/android/app/src/debug/res/values/colors_debug.xml
new file mode 100644
index 0000000..e52d644
--- /dev/null
+++ b/android/app/src/debug/res/values/colors_debug.xml
@@ -0,0 +1,5 @@
+
+
+
+ #FFB74D
+
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 7c8e354..ea90fd7 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -44,12 +44,19 @@
android:exported="false"
android:theme="@style/Theme.SimpleNotes" />
-
+
+
+
+
create(
+ key: String,
+ modelClass: Class,
+ 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
+ }
+}
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/DragDropState.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/DragDropState.kt
new file mode 100644
index 0000000..0889ac0
--- /dev/null
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/DragDropState.kt
@@ -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(null)
+ private set
+
+ private var draggingItemDraggedDelta by mutableFloatStateOf(0f)
+ private var draggingItemInitialOffset by mutableFloatStateOf(0f)
+ private var overscrollJob by mutableStateOf(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)
+ }
+ )
+ }
+}
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorScreen.kt
new file mode 100644
index 0000000..58f207d
--- /dev/null
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorScreen.kt
@@ -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(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,
+ 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/
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorViewModel.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorViewModel.kt
new file mode 100644
index 0000000..489a068
--- /dev/null
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorViewModel.kt
@@ -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 = _uiState.asStateFlow()
+
+ private val _checklistItems = MutableStateFlow>(emptyList())
+ val checklistItems: StateFlow> = _checklistItems.asStateFlow()
+
+ // ═══════════════════════════════════════════════════════════════════════
+ // Events
+ // ═══════════════════════════════════════════════════════════════════════
+
+ private val _events = MutableSharedFlow()
+ val events: SharedFlow = _events.asSharedFlow()
+
+ // Internal state
+ private var existingNote: Note? = null
+ private var currentNoteType: NoteType = NoteType.TEXT
+
+ init {
+ loadNote()
+ }
+
+ private fun loadNote() {
+ val noteId = savedStateHandle.get(ARG_NOTE_ID)
+ val noteTypeString = savedStateHandle.get(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
+}
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/components/ChecklistItemRow.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/components/ChecklistItemRow.kt
new file mode 100644
index 0000000..f645c9e
--- /dev/null
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/components/ChecklistItemRow.kt
@@ -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)
+ )
+ }
+ }
+}
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/ComposeMainActivity.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/ComposeMainActivity.kt
index 975369e..bc57b73 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/ComposeMainActivity.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/ComposeMainActivity.kt
@@ -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)
}
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index b390b2e..aaebe35 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -19,6 +19,7 @@
Inhalt
Speichern
Löschen
+ Zurück
Note Title
@@ -81,6 +82,7 @@
Element hinzufügen
Neues Element…
Element verschieben
+ Ziehen zum Sortieren
Element löschen
Notiz ist leer
Notiz gespeichert