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:
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
5
android/app/src/debug/res/values/colors_debug.xml
Normal file
5
android/app/src/debug/res/values/colors_debug.xml
Normal 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>
|
||||||
@@ -44,12 +44,19 @@
|
|||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:theme="@style/Theme.SimpleNotes" />
|
android:theme="@style/Theme.SimpleNotes" />
|
||||||
|
|
||||||
<!-- Editor Activity -->
|
<!-- Editor Activity (Legacy - XML-based) -->
|
||||||
<activity
|
<activity
|
||||||
android:name=".NoteEditorActivity"
|
android:name=".NoteEditorActivity"
|
||||||
android:windowSoftInputMode="adjustResize"
|
android:windowSoftInputMode="adjustResize"
|
||||||
android:parentActivityName=".ui.main.ComposeMainActivity" />
|
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) -->
|
<!-- Settings Activity (Legacy - XML-based) -->
|
||||||
<activity
|
<activity
|
||||||
android:name=".SettingsActivity"
|
android:name=".SettingsActivity"
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
|||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||||
import com.google.android.material.color.DynamicColors
|
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.NoteType
|
||||||
import dev.dettmer.simplenotes.models.SyncStatus
|
import dev.dettmer.simplenotes.models.SyncStatus
|
||||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||||
@@ -230,17 +230,17 @@ class ComposeMainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
private fun openNoteEditor(noteId: String?) {
|
private fun openNoteEditor(noteId: String?) {
|
||||||
cameFromEditor = true
|
cameFromEditor = true
|
||||||
val intent = Intent(this, NoteEditorActivity::class.java)
|
val intent = Intent(this, ComposeNoteEditorActivity::class.java)
|
||||||
noteId?.let {
|
noteId?.let {
|
||||||
intent.putExtra(NoteEditorActivity.EXTRA_NOTE_ID, it)
|
intent.putExtra(ComposeNoteEditorActivity.EXTRA_NOTE_ID, it)
|
||||||
}
|
}
|
||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createNote(noteType: NoteType) {
|
private fun createNote(noteType: NoteType) {
|
||||||
cameFromEditor = true
|
cameFromEditor = true
|
||||||
val intent = Intent(this, NoteEditorActivity::class.java)
|
val intent = Intent(this, ComposeNoteEditorActivity::class.java)
|
||||||
intent.putExtra(NoteEditorActivity.EXTRA_NOTE_TYPE, noteType.name)
|
intent.putExtra(ComposeNoteEditorActivity.EXTRA_NOTE_TYPE, noteType.name)
|
||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
<string name="content">Inhalt</string>
|
<string name="content">Inhalt</string>
|
||||||
<string name="save">Speichern</string>
|
<string name="save">Speichern</string>
|
||||||
<string name="delete">Löschen</string>
|
<string name="delete">Löschen</string>
|
||||||
|
<string name="back">Zurück</string>
|
||||||
|
|
||||||
<!-- Note List Item (Preview placeholders) -->
|
<!-- Note List Item (Preview placeholders) -->
|
||||||
<string name="note_title_placeholder">Note Title</string>
|
<string name="note_title_placeholder">Note Title</string>
|
||||||
@@ -81,6 +82,7 @@
|
|||||||
<string name="add_item">Element hinzufügen</string>
|
<string name="add_item">Element hinzufügen</string>
|
||||||
<string name="item_placeholder">Neues Element…</string>
|
<string name="item_placeholder">Neues Element…</string>
|
||||||
<string name="reorder_item">Element verschieben</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="delete_item">Element löschen</string>
|
||||||
<string name="note_is_empty">Notiz ist leer</string>
|
<string name="note_is_empty">Notiz ist leer</string>
|
||||||
<string name="note_saved">Notiz gespeichert</string>
|
<string name="note_saved">Notiz gespeichert</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user