feat(v1.8.0): IMPL_020 Note & Checklist Sorting

- Add SortOption enum for note sorting (Updated, Created, Title, Type)
- Add SortDirection enum with ASCENDING/DESCENDING and toggle()
- Add ChecklistSortOption enum for in-editor sorting (Manual, Alphabetical, Unchecked/Checked First)
- Implement persistent note sort preferences in SharedPreferences
- Add SortDialog for main screen with sort option and direction selection
- Add ChecklistSortDialog for editor screen with current sort option state
- Implement sort logic in MainViewModel with combined sortedNotes StateFlow
- Implement sort logic in NoteEditorViewModel with auto-sort for MANUAL and UNCHECKED_FIRST
- Add separator display logic for MANUAL and UNCHECKED_FIRST sort options
- Add 16 sorting-related strings (English and German)
- Update Constants.kt with sort preference keys
- Update MainScreen.kt, NoteEditorScreen.kt with sort UI integration
This commit is contained in:
inventory69
2026-02-10 11:30:06 +01:00
parent 539987f2ed
commit 96c819b154
13 changed files with 613 additions and 16 deletions

View File

@@ -0,0 +1,21 @@
package dev.dettmer.simplenotes.models
/**
* 🆕 v1.8.0: Sortieroptionen für Checklist-Items im Editor
*/
enum class ChecklistSortOption {
/** Manuelle Reihenfolge (Drag & Drop) — kein Re-Sort */
MANUAL,
/** Alphabetisch A→Z */
ALPHABETICAL_ASC,
/** Alphabetisch Z→A */
ALPHABETICAL_DESC,
/** Unchecked zuerst, dann Checked */
UNCHECKED_FIRST,
/** Checked zuerst, dann Unchecked */
CHECKED_FIRST
}

View File

@@ -0,0 +1,20 @@
package dev.dettmer.simplenotes.models
/**
* 🆕 v1.8.0: Sortierrichtung
*/
enum class SortDirection(val prefsValue: String) {
ASCENDING("asc"),
DESCENDING("desc");
fun toggle(): SortDirection = when (this) {
ASCENDING -> DESCENDING
DESCENDING -> ASCENDING
}
companion object {
fun fromPrefsValue(value: String): SortDirection {
return entries.find { it.prefsValue == value } ?: DESCENDING
}
}
}

View File

@@ -0,0 +1,24 @@
package dev.dettmer.simplenotes.models
/**
* 🆕 v1.8.0: Sortieroptionen für die Notizliste
*/
enum class SortOption(val prefsValue: String) {
/** Zuletzt bearbeitete zuerst (Default) */
UPDATED_AT("updatedAt"),
/** Zuletzt erstellte zuerst */
CREATED_AT("createdAt"),
/** Alphabetisch nach Titel */
TITLE("title"),
/** Nach Notiz-Typ (Text / Checkliste) */
NOTE_TYPE("noteType");
companion object {
fun fromPrefsValue(value: String): SortOption {
return entries.find { it.prefsValue == value } ?: UPDATED_AT
}
}
}

View File

@@ -35,11 +35,16 @@ class NotesStorage(private val context: Context) {
} }
} }
/**
* Lädt alle Notizen aus dem lokalen Speicher.
*
* 🔀 v1.8.0: Sortierung entfernt — wird jetzt im ViewModel durchgeführt,
* damit der User die Sortierung konfigurieren kann.
*/
fun loadAllNotes(): List<Note> { fun loadAllNotes(): List<Note> {
return notesDir.listFiles() return notesDir.listFiles()
?.filter { it.extension == "json" } ?.filter { it.extension == "json" }
?.mapNotNull { Note.fromJson(it.readText()) } ?.mapNotNull { Note.fromJson(it.readText()) }
?.sortedByDescending { it.updatedAt }
?: emptyList() ?: emptyList()
} }

View File

@@ -10,6 +10,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -23,6 +24,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.outlined.Sort
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Save import androidx.compose.material.icons.filled.Save
@@ -57,9 +59,11 @@ import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import dev.dettmer.simplenotes.R import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.models.ChecklistSortOption
import dev.dettmer.simplenotes.models.NoteType import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.ui.editor.components.CheckedItemsSeparator import dev.dettmer.simplenotes.ui.editor.components.CheckedItemsSeparator
import dev.dettmer.simplenotes.ui.editor.components.ChecklistItemRow import dev.dettmer.simplenotes.ui.editor.components.ChecklistItemRow
import dev.dettmer.simplenotes.ui.editor.components.ChecklistSortDialog
import dev.dettmer.simplenotes.ui.main.components.DeleteConfirmationDialog import dev.dettmer.simplenotes.ui.main.components.DeleteConfirmationDialog
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import dev.dettmer.simplenotes.utils.showToast import dev.dettmer.simplenotes.utils.showToast
@@ -87,6 +91,8 @@ fun NoteEditorScreen(
val isOfflineMode by viewModel.isOfflineMode.collectAsState() val isOfflineMode by viewModel.isOfflineMode.collectAsState()
var showDeleteDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) }
var showChecklistSortDialog by remember { mutableStateOf(false) } // 🔀 v1.8.0
val lastChecklistSortOption by viewModel.lastChecklistSortOption.collectAsState() // 🔀 v1.8.0
var focusNewItemId by remember { mutableStateOf<String?>(null) } var focusNewItemId by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -222,6 +228,7 @@ fun NoteEditorScreen(
items = checklistItems, items = checklistItems,
scope = scope, scope = scope,
focusNewItemId = focusNewItemId, focusNewItemId = focusNewItemId,
currentSortOption = lastChecklistSortOption, // 🔀 v1.8.0
onTextChange = { id, text -> viewModel.updateChecklistItemText(id, text) }, onTextChange = { id, text -> viewModel.updateChecklistItemText(id, text) },
onCheckedChange = { id, checked -> viewModel.updateChecklistItemChecked(id, checked) }, onCheckedChange = { id, checked -> viewModel.updateChecklistItemChecked(id, checked) },
onDelete = { id -> viewModel.deleteChecklistItem(id) }, onDelete = { id -> viewModel.deleteChecklistItem(id) },
@@ -235,6 +242,7 @@ fun NoteEditorScreen(
}, },
onMove = { from, to -> viewModel.moveChecklistItem(from, to) }, onMove = { from, to -> viewModel.moveChecklistItem(from, to) },
onFocusHandled = { focusNewItemId = null }, onFocusHandled = { focusNewItemId = null },
onSortClick = { showChecklistSortDialog = true }, // 🔀 v1.8.0
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(1f) .weight(1f)
@@ -260,6 +268,18 @@ fun NoteEditorScreen(
} }
) )
} }
// 🔀 v1.8.0: Checklist Sort Dialog
if (showChecklistSortDialog) {
ChecklistSortDialog(
currentOption = lastChecklistSortOption,
onOptionSelected = { option ->
viewModel.sortChecklistItems(option)
showChecklistSortDialog = false
},
onDismiss = { showChecklistSortDialog = false }
)
}
} }
@Composable @Composable
@@ -309,6 +329,7 @@ private fun ChecklistEditor(
items: List<ChecklistItemState>, items: List<ChecklistItemState>,
scope: kotlinx.coroutines.CoroutineScope, scope: kotlinx.coroutines.CoroutineScope,
focusNewItemId: String?, focusNewItemId: String?,
currentSortOption: ChecklistSortOption, // 🔀 v1.8.0: Aktuelle Sortierung
onTextChange: (String, String) -> Unit, onTextChange: (String, String) -> Unit,
onCheckedChange: (String, Boolean) -> Unit, onCheckedChange: (String, Boolean) -> Unit,
onDelete: (String) -> Unit, onDelete: (String) -> Unit,
@@ -316,6 +337,7 @@ private fun ChecklistEditor(
onAddItemAtEnd: () -> Unit, onAddItemAtEnd: () -> Unit,
onMove: (Int, Int) -> Unit, onMove: (Int, Int) -> Unit,
onFocusHandled: () -> Unit, onFocusHandled: () -> Unit,
onSortClick: () -> Unit, // 🔀 v1.8.0
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
@@ -325,10 +347,12 @@ private fun ChecklistEditor(
onMove = onMove onMove = onMove
) )
// 🆕 v1.8.0 (IMPL_017): Separator-Position berechnen // 🆕 v1.8.0 (IMPL_017 + IMPL_020): Separator nur bei MANUAL und UNCHECKED_FIRST anzeigen
val uncheckedCount = items.count { !it.isChecked } val uncheckedCount = items.count { !it.isChecked }
val checkedCount = items.count { it.isChecked } val checkedCount = items.count { it.isChecked }
val showSeparator = uncheckedCount > 0 && checkedCount > 0 val shouldShowSeparator = currentSortOption == ChecklistSortOption.MANUAL ||
currentSortOption == ChecklistSortOption.UNCHECKED_FIRST
val showSeparator = shouldShowSeparator && uncheckedCount > 0 && checkedCount > 0
Column(modifier = modifier) { Column(modifier = modifier) {
LazyColumn( LazyColumn(
@@ -396,11 +420,15 @@ private fun ChecklistEditor(
} }
} }
// Add Item Button // 🔀 v1.8.0: Add Item Button + Sort Button
TextButton( Row(
onClick = onAddItemAtEnd, modifier = Modifier
modifier = Modifier.padding(start = 8.dp) .fillMaxWidth()
.padding(start = 8.dp, end = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
) { ) {
TextButton(onClick = onAddItemAtEnd) {
Icon( Icon(
imageVector = Icons.Default.Add, imageVector = Icons.Default.Add,
contentDescription = null, contentDescription = null,
@@ -408,6 +436,15 @@ private fun ChecklistEditor(
) )
Text(stringResource(R.string.add_item)) Text(stringResource(R.string.add_item))
} }
IconButton(onClick = onSortClick) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.Sort,
contentDescription = stringResource(R.string.sort_checklist),
modifier = androidx.compose.ui.Modifier.padding(4.dp)
)
}
}
} }
} }

View File

@@ -8,6 +8,7 @@ import androidx.lifecycle.viewModelScope
import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import dev.dettmer.simplenotes.models.ChecklistItem import dev.dettmer.simplenotes.models.ChecklistItem
import dev.dettmer.simplenotes.models.ChecklistSortOption
import dev.dettmer.simplenotes.models.Note import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.NoteType import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.models.SyncStatus import dev.dettmer.simplenotes.models.SyncStatus
@@ -65,6 +66,10 @@ class NoteEditorViewModel(
) )
val isOfflineMode: StateFlow<Boolean> = _isOfflineMode.asStateFlow() val isOfflineMode: StateFlow<Boolean> = _isOfflineMode.asStateFlow()
// 🔀 v1.8.0 (IMPL_020): Letzte Checklist-Sortierung (Session-Scope)
private val _lastChecklistSortOption = MutableStateFlow(ChecklistSortOption.MANUAL)
val lastChecklistSortOption: StateFlow<ChecklistSortOption> = _lastChecklistSortOption.asStateFlow()
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// Events // Events
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
@@ -182,8 +187,14 @@ class NoteEditorViewModel(
val updatedItems = items.map { item -> val updatedItems = items.map { item ->
if (item.id == itemId) item.copy(isChecked = isChecked) else item if (item.id == itemId) item.copy(isChecked = isChecked) else item
} }
// 🆕 v1.8.0 (IMPL_017): Nach Toggle sortieren // 🆕 v1.8.0 (IMPL_017 + IMPL_020): Auto-Sort nur bei MANUAL und UNCHECKED_FIRST
val currentSort = _lastChecklistSortOption.value
if (currentSort == ChecklistSortOption.MANUAL || currentSort == ChecklistSortOption.UNCHECKED_FIRST) {
sortChecklistItems(updatedItems) sortChecklistItems(updatedItems)
} else {
// Bei anderen Sortierungen (alphabetisch, checked first) nicht auto-sortieren
updatedItems.mapIndexed { index, item -> item.copy(order = index) }
}
} }
} }
@@ -241,6 +252,37 @@ class NoteEditorViewModel(
} }
} }
/**
* 🔀 v1.8.0 (IMPL_020): Sortiert Checklist-Items nach gewählter Option.
* Einmalige Aktion (nicht persistiert) — User kann danach per Drag & Drop feinjustieren.
*/
fun sortChecklistItems(option: ChecklistSortOption) {
// Merke die Auswahl für diesen Editor-Session
_lastChecklistSortOption.value = option
_checklistItems.update { items ->
val sorted = when (option) {
// Bei MANUAL: Sortiere nach checked/unchecked, damit Separator korrekt platziert wird
ChecklistSortOption.MANUAL -> items.sortedBy { it.isChecked }
ChecklistSortOption.ALPHABETICAL_ASC ->
items.sortedBy { it.text.lowercase() }
ChecklistSortOption.ALPHABETICAL_DESC ->
items.sortedByDescending { it.text.lowercase() }
ChecklistSortOption.UNCHECKED_FIRST ->
items.sortedBy { it.isChecked }
ChecklistSortOption.CHECKED_FIRST ->
items.sortedByDescending { it.isChecked }
}
// Order-Werte neu zuweisen
sorted.mapIndexed { index, item -> item.copy(order = index) }
}
}
fun saveNote() { fun saveNote() {
viewModelScope.launch { viewModelScope.launch {
val state = _uiState.value val state = _uiState.value

View File

@@ -0,0 +1,123 @@
package dev.dettmer.simplenotes.ui.editor.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
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.res.stringResource
import androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.models.ChecklistSortOption
/**
* 🔀 v1.8.0: Dialog zur Auswahl der Checklist-Sortierung.
*
* Einmalige Sortier-Aktion (nicht persistiert).
* User kann danach per Drag & Drop feinjustieren.
*
* ┌─────────────────────────────────┐
* │ Sort Checklist │
* ├─────────────────────────────────┤
* │ ( ) Manual │
* │ ( ) A → Z │
* │ ( ) Z → A │
* │ (●) Unchecked first │
* │ ( ) Checked first │
* ├─────────────────────────────────┤
* │ [Cancel] [Apply] │
* └─────────────────────────────────┘
*/
@Composable
fun ChecklistSortDialog(
currentOption: ChecklistSortOption, // 🔀 v1.8.0: Aktuelle Auswahl merken
onOptionSelected: (ChecklistSortOption) -> Unit,
onDismiss: () -> Unit
) {
var selectedOption by remember { mutableStateOf(currentOption) }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = stringResource(R.string.sort_checklist),
style = MaterialTheme.typography.headlineSmall
)
},
text = {
Column {
ChecklistSortOption.entries.forEach { option ->
SortOptionRow(
label = stringResource(option.toStringRes()),
isSelected = selectedOption == option,
onClick = { selectedOption = option }
)
}
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
},
confirmButton = {
TextButton(
onClick = {
onOptionSelected(selectedOption)
}
) {
Text(stringResource(R.string.apply))
}
}
)
}
@Composable
private fun SortOptionRow(
label: String,
isSelected: Boolean,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = isSelected,
onClick = onClick
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = label,
style = MaterialTheme.typography.bodyLarge
)
}
}
/**
* Extension: ChecklistSortOption → String-Resource-ID
*/
fun ChecklistSortOption.toStringRes(): Int = when (this) {
ChecklistSortOption.MANUAL -> R.string.sort_checklist_manual
ChecklistSortOption.ALPHABETICAL_ASC -> R.string.sort_checklist_alpha_asc
ChecklistSortOption.ALPHABETICAL_DESC -> R.string.sort_checklist_alpha_desc
ChecklistSortOption.UNCHECKED_FIRST -> R.string.sort_checklist_unchecked_first
ChecklistSortOption.CHECKED_FIRST -> R.string.sort_checklist_checked_first
}

View File

@@ -19,6 +19,7 @@ import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.SelectAll import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.automirrored.outlined.HelpOutline import androidx.compose.material.icons.automirrored.outlined.HelpOutline
import androidx.compose.material.icons.automirrored.outlined.Sort
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
// FabPosition nicht mehr benötigt - FAB wird manuell platziert // FabPosition nicht mehr benötigt - FAB wird manuell platziert
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -48,6 +49,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import dev.dettmer.simplenotes.R import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.models.NoteType import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.ui.main.components.SortDialog
import dev.dettmer.simplenotes.sync.SyncStateManager import dev.dettmer.simplenotes.sync.SyncStateManager
import dev.dettmer.simplenotes.ui.main.components.DeleteConfirmationDialog import dev.dettmer.simplenotes.ui.main.components.DeleteConfirmationDialog
import dev.dettmer.simplenotes.ui.main.components.EmptyState import dev.dettmer.simplenotes.ui.main.components.EmptyState
@@ -77,7 +79,7 @@ fun MainScreen(
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onCreateNote: (NoteType) -> Unit onCreateNote: (NoteType) -> Unit
) { ) {
val notes by viewModel.notes.collectAsState() val notes by viewModel.sortedNotes.collectAsState()
val syncState by viewModel.syncState.collectAsState() val syncState by viewModel.syncState.collectAsState()
val scrollToTop by viewModel.scrollToTop.collectAsState() val scrollToTop by viewModel.scrollToTop.collectAsState()
@@ -100,6 +102,11 @@ fun MainScreen(
// 🆕 v1.8.0: Sync status legend dialog // 🆕 v1.8.0: Sync status legend dialog
var showSyncLegend by remember { mutableStateOf(false) } var showSyncLegend by remember { mutableStateOf(false) }
// 🔀 v1.8.0: Sort dialog state
var showSortDialog by remember { mutableStateOf(false) }
val sortOption by viewModel.sortOption.collectAsState()
val sortDirection by viewModel.sortDirection.collectAsState()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val listState = rememberLazyListState() val listState = rememberLazyListState()
@@ -180,6 +187,7 @@ fun MainScreen(
syncEnabled = canSync, syncEnabled = canSync,
showSyncLegend = isSyncAvailable, // 🆕 v1.8.0: Nur wenn Sync verfügbar showSyncLegend = isSyncAvailable, // 🆕 v1.8.0: Nur wenn Sync verfügbar
onSyncLegendClick = { showSyncLegend = true }, // 🆕 v1.8.0 onSyncLegendClick = { showSyncLegend = true }, // 🆕 v1.8.0
onSortClick = { showSortDialog = true }, // 🔀 v1.8.0
onSyncClick = { viewModel.triggerManualSync("toolbar") }, onSyncClick = { viewModel.triggerManualSync("toolbar") },
onSettingsClick = onOpenSettings onSettingsClick = onOpenSettings
) )
@@ -293,6 +301,21 @@ fun MainScreen(
onDismiss = { showSyncLegend = false } onDismiss = { showSyncLegend = false }
) )
} }
// 🔀 v1.8.0: Sort Dialog
if (showSortDialog) {
SortDialog(
currentOption = sortOption,
currentDirection = sortDirection,
onOptionSelected = { option ->
viewModel.setSortOption(option)
},
onDirectionToggled = {
viewModel.toggleSortDirection()
},
onDismiss = { showSortDialog = false }
)
}
} }
} }
@@ -302,6 +325,7 @@ private fun MainTopBar(
syncEnabled: Boolean, syncEnabled: Boolean,
showSyncLegend: Boolean, // 🆕 v1.8.0: Ob der Hilfe-Button sichtbar sein soll showSyncLegend: Boolean, // 🆕 v1.8.0: Ob der Hilfe-Button sichtbar sein soll
onSyncLegendClick: () -> Unit, // 🆕 v1.8.0 onSyncLegendClick: () -> Unit, // 🆕 v1.8.0
onSortClick: () -> Unit, // 🔀 v1.8.0: Sort-Button
onSyncClick: () -> Unit, onSyncClick: () -> Unit,
onSettingsClick: () -> Unit onSettingsClick: () -> Unit
) { ) {
@@ -313,6 +337,14 @@ private fun MainTopBar(
) )
}, },
actions = { actions = {
// 🔀 v1.8.0: Sort Button
IconButton(onClick = onSortClick) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.Sort,
contentDescription = stringResource(R.string.sort_notes)
)
}
// 🆕 v1.8.0: Sync Status Legend Button (nur wenn Sync verfügbar) // 🆕 v1.8.0: Sync Status Legend Button (nur wenn Sync verfügbar)
if (showSyncLegend) { if (showSyncLegend) {
IconButton(onClick = onSyncLegendClick) { IconButton(onClick = onSyncLegendClick) {

View File

@@ -5,6 +5,8 @@ import android.content.Context
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dev.dettmer.simplenotes.models.Note import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.SortDirection
import dev.dettmer.simplenotes.models.SortOption
import dev.dettmer.simplenotes.R import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.storage.NotesStorage import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.sync.SyncProgress import dev.dettmer.simplenotes.sync.SyncProgress
@@ -20,6 +22,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -102,6 +105,40 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
Logger.d(TAG, "🔄 refreshDisplayMode: displayMode=${_displayMode.value}$newValue") Logger.d(TAG, "🔄 refreshDisplayMode: displayMode=${_displayMode.value}$newValue")
} }
// ═══════════════════════════════════════════════════════════════════════
// 🔀 v1.8.0: Sort State
// ═══════════════════════════════════════════════════════════════════════
private val _sortOption = MutableStateFlow(
SortOption.fromPrefsValue(
prefs.getString(Constants.KEY_SORT_OPTION, Constants.DEFAULT_SORT_OPTION) ?: Constants.DEFAULT_SORT_OPTION
)
)
val sortOption: StateFlow<SortOption> = _sortOption.asStateFlow()
private val _sortDirection = MutableStateFlow(
SortDirection.fromPrefsValue(
prefs.getString(Constants.KEY_SORT_DIRECTION, Constants.DEFAULT_SORT_DIRECTION) ?: Constants.DEFAULT_SORT_DIRECTION
)
)
val sortDirection: StateFlow<SortDirection> = _sortDirection.asStateFlow()
/**
* 🔀 v1.8.0: Sortierte Notizen — kombiniert aus Notes + SortOption + SortDirection.
* Reagiert automatisch auf Änderungen in allen drei Flows.
*/
val sortedNotes: StateFlow<List<Note>> = combine(
_notes,
_sortOption,
_sortDirection
) { notes, option, direction ->
sortNotes(notes, option, direction)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// Sync State // Sync State
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
@@ -688,6 +725,58 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return true return true
} }
// ═══════════════════════════════════════════════════════════════════════
// 🔀 v1.8.0: Sortierung
// ═══════════════════════════════════════════════════════════════════════
/**
* 🔀 v1.8.0: Sortiert Notizen nach gewählter Option und Richtung.
*/
private fun sortNotes(
notes: List<Note>,
option: SortOption,
direction: SortDirection
): List<Note> {
val comparator: Comparator<Note> = when (option) {
SortOption.UPDATED_AT -> compareBy { it.updatedAt }
SortOption.CREATED_AT -> compareBy { it.createdAt }
SortOption.TITLE -> compareBy(String.CASE_INSENSITIVE_ORDER) { it.title }
SortOption.NOTE_TYPE -> compareBy<Note> { it.noteType.ordinal }
.thenByDescending { it.updatedAt } // Sekundär: Datum innerhalb gleicher Typen
}
return when (direction) {
SortDirection.ASCENDING -> notes.sortedWith(comparator)
SortDirection.DESCENDING -> notes.sortedWith(comparator.reversed())
}
}
/**
* 🔀 v1.8.0: Setzt die Sortieroption und speichert in SharedPreferences.
*/
fun setSortOption(option: SortOption) {
_sortOption.value = option
prefs.edit().putString(Constants.KEY_SORT_OPTION, option.prefsValue).apply()
Logger.d(TAG, "🔀 Sort option changed to: ${option.prefsValue}")
}
/**
* 🔀 v1.8.0: Setzt die Sortierrichtung und speichert in SharedPreferences.
*/
fun setSortDirection(direction: SortDirection) {
_sortDirection.value = direction
prefs.edit().putString(Constants.KEY_SORT_DIRECTION, direction.prefsValue).apply()
Logger.d(TAG, "🔀 Sort direction changed to: ${direction.prefsValue}")
}
/**
* 🔀 v1.8.0: Toggelt die Sortierrichtung.
*/
fun toggleSortDirection() {
val newDirection = _sortDirection.value.toggle()
setSortDirection(newDirection)
}
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// Helpers // Helpers
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════

View File

@@ -0,0 +1,160 @@
package dev.dettmer.simplenotes.ui.main.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.models.SortDirection
import dev.dettmer.simplenotes.models.SortOption
/**
* 🔀 v1.8.0: Dialog zur Auswahl der Sortierung für die Notizliste.
*
* Zeigt RadioButtons für die Sortieroption und einen Toggle für die Richtung.
*
* ┌─────────────────────────────────┐
* │ Sort Notes │
* ├─────────────────────────────────┤
* │ (●) Last modified ↓↑ │
* │ ( ) Date created │
* │ ( ) Name │
* │ ( ) Type │
* ├─────────────────────────────────┤
* │ [Close] │
* └─────────────────────────────────┘
*/
@Composable
fun SortDialog(
currentOption: SortOption,
currentDirection: SortDirection,
onOptionSelected: (SortOption) -> Unit,
onDirectionToggled: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.sort_notes),
style = MaterialTheme.typography.headlineSmall
)
// Direction Toggle Button
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
IconButton(onClick = onDirectionToggled) {
Icon(
imageVector = if (currentDirection == SortDirection.DESCENDING) {
Icons.Default.ArrowDownward
} else {
Icons.Default.ArrowUpward
},
contentDescription = stringResource(
if (currentDirection == SortDirection.DESCENDING) {
R.string.sort_descending
} else {
R.string.sort_ascending
}
),
modifier = Modifier.size(24.dp)
)
}
Text(
text = stringResource(
if (currentDirection == SortDirection.DESCENDING) {
R.string.sort_descending
} else {
R.string.sort_ascending
}
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
},
text = {
Column {
HorizontalDivider()
Spacer(modifier = Modifier.height(8.dp))
SortOption.entries.forEach { option ->
SortOptionRow(
label = stringResource(option.toStringRes()),
isSelected = currentOption == option,
onClick = { onOptionSelected(option) }
)
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.close))
}
}
)
}
@Composable
private fun SortOptionRow(
label: String,
isSelected: Boolean,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = isSelected,
onClick = onClick
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = label,
style = MaterialTheme.typography.bodyLarge
)
}
}
/**
* Extension: SortOption → String-Resource-ID
*/
fun SortOption.toStringRes(): Int = when (this) {
SortOption.UPDATED_AT -> R.string.sort_by_updated
SortOption.CREATED_AT -> R.string.sort_by_created
SortOption.TITLE -> R.string.sort_by_name
SortOption.NOTE_TYPE -> R.string.sort_by_type
}

View File

@@ -73,4 +73,10 @@ object Constants {
const val DEFAULT_MAX_PARALLEL_DOWNLOADS = 5 const val DEFAULT_MAX_PARALLEL_DOWNLOADS = 5
const val MIN_PARALLEL_DOWNLOADS = 1 const val MIN_PARALLEL_DOWNLOADS = 1
const val MAX_PARALLEL_DOWNLOADS = 10 const val MAX_PARALLEL_DOWNLOADS = 10
// 🔀 v1.8.0: Sortierung
const val KEY_SORT_OPTION = "sort_option"
const val KEY_SORT_DIRECTION = "sort_direction"
const val DEFAULT_SORT_OPTION = "updatedAt"
const val DEFAULT_SORT_DIRECTION = "desc"
} }

View File

@@ -86,6 +86,25 @@
<string name="sync_phase_completed">Sync abgeschlossen</string> <string name="sync_phase_completed">Sync abgeschlossen</string>
<string name="sync_phase_error">Sync fehlgeschlagen</string> <string name="sync_phase_error">Sync fehlgeschlagen</string>
<!-- 🔀 v1.8.0 (IMPL_020): Sortierung: Notizliste -->
<string name="sort_notes">Notizen sortieren</string>
<string name="sort_ascending">Aufsteigend</string>
<string name="sort_descending">Absteigend</string>
<string name="sort_by_updated">Zuletzt bearbeitet</string>
<string name="sort_by_created">Erstelldatum</string>
<string name="sort_by_name">Name</string>
<string name="sort_by_type">Typ</string>
<string name="close">Schließen</string>
<!-- 🔀 v1.8.0 (IMPL_020): Sortierung: Checkliste -->
<string name="sort_checklist">Checkliste sortieren</string>
<string name="sort_checklist_manual">Manuell</string>
<string name="sort_checklist_alpha_asc">A → Z</string>
<string name="sort_checklist_alpha_desc">Z → A</string>
<string name="sort_checklist_unchecked_first">Unerledigte zuerst</string>
<string name="sort_checklist_checked_first">Erledigte zuerst</string>
<string name="apply">Anwenden</string>
<!-- ============================= --> <!-- ============================= -->
<!-- DELETE DIALOGS --> <!-- DELETE DIALOGS -->
<!-- ============================= --> <!-- ============================= -->

View File

@@ -88,6 +88,25 @@
<string name="sync_phase_checking">Checking server…</string> <string name="sync_phase_checking">Checking server…</string>
<string name="sync_phase_uploading">Uploading…</string> <string name="sync_phase_uploading">Uploading…</string>
<string name="sync_phase_downloading">Downloading…</string> <string name="sync_phase_downloading">Downloading…</string>
<!-- 🔀 v1.8.0 (IMPL_020): Sort: Note List -->
<string name="sort_notes">Sort notes</string>
<string name="sort_ascending">Ascending</string>
<string name="sort_descending">Descending</string>
<string name="sort_by_updated">Last modified</string>
<string name="sort_by_created">Date created</string>
<string name="sort_by_name">Name</string>
<string name="sort_by_type">Type</string>
<string name="close">Close</string>
<!-- 🔀 v1.8.0 (IMPL_020): Sort: Checklist -->
<string name="sort_checklist">Sort checklist</string>
<string name="sort_checklist_manual">Manual</string>
<string name="sort_checklist_alpha_asc">A → Z</string>
<string name="sort_checklist_alpha_desc">Z → A</string>
<string name="sort_checklist_unchecked_first">Unchecked first</string>
<string name="sort_checklist_checked_first">Checked first</string>
<string name="apply">Apply</string>
<string name="sync_phase_importing_markdown">Importing Markdown…</string> <string name="sync_phase_importing_markdown">Importing Markdown…</string>
<string name="sync_phase_saving">Saving…</string> <string name="sync_phase_saving">Saving…</string>
<string name="sync_phase_completed">Sync complete</string> <string name="sync_phase_completed">Sync complete</string>