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