1
0
mirror of https://github.com/avinal/nikki.git synced 2026-07-03 21:40:09 +05:30

Add undo snackbar, haptic feedback, pull-to-refresh, empty states

Tasks tab:
- Undo snackbar on task toggle with 3s window
- Haptic feedback on checkbox toggle
- Pull-to-refresh triggers memo sync
- Empty state: "all clear" + subtitle

Memos tab:
- Haptic feedback on memo post
- Scroll to top after posting
- Empty state: "nothing here yet" + subtitle

Editor crash fix:
- Robust backspace/enter handling for auto-inserted task lines
- Guard against dropLast on short strings

Signed-off-by: Avinal Kumar <avinal.xlvii@gmail.com>
Co-Authored-By: Claude Opus 4.6 (1M context)
This commit is contained in:
2026-06-05 15:04:41 +05:30
parent f788cb8162
commit 5f8d90c94a
3 changed files with 112 additions and 44 deletions
@@ -273,27 +273,26 @@ private fun InlineEditor(
TextField(
value = content,
onValueChange = { newText ->
// Backspace on empty auto-inserted line: remove it
if (newText.length < content.length && content.endsWith("- [ ] ") && newText == content.dropLast(6).trimEnd() + "\n") {
onContentChange(newText.trimEnd('\n'))
val oldLines = content.lines()
val lastLine = oldLines.lastOrNull() ?: ""
// Backspace on empty auto-inserted task line: remove it
if (newText.length < content.length && lastLine.trim() == "- [ ]" && oldLines.size > 1) {
val withoutLast = oldLines.dropLast(1).joinToString("\n")
if (newText.trimEnd() == withoutLast.trimEnd()) {
onContentChange(withoutLast)
return@TextField
}
}
// Enter on empty auto-inserted task line: remove it
if (newText.length > content.length && newText.endsWith("\n") && lastLine.trim() == "- [ ]" && oldLines.size > 1) {
onContentChange(oldLines.dropLast(1).joinToString("\n") + "\n")
return@TextField
}
// Enter on empty auto-inserted line: remove it
if (newText.length > content.length && newText.endsWith("\n") && content.endsWith("- [ ] ")) {
val lastLine = content.lines().last()
if (lastLine.trim() == "- [ ]") {
onContentChange(content.dropLast(lastLine.length + 1).trimEnd('\n') + "\n")
return@TextField
}
}
// Auto-checklist: continue task list on enter
if (newText.length > content.length && newText.endsWith("\n")) {
val beforeNewline = newText.dropLast(1)
val lastLine = beforeNewline.lines().lastOrNull() ?: ""
if (lastLine.trimStart().startsWith("- [")) {
onContentChange(newText + "- [ ] ")
return@TextField
}
if (newText.length > content.length && newText.endsWith("\n") && lastLine.trimStart().startsWith("- [") && lastLine.trim() != "- [ ]") {
onContentChange(newText + "- [ ] ")
return@TextField
}
onContentChange(newText)
},
@@ -53,6 +53,8 @@ import com.avinal.memos.domain.MemoVisibility
import com.avinal.memos.ui.components.MemoCard
import com.avinal.memos.ui.theme.LocalAccentColor
import com.avinal.memos.util.rememberFilePicker
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import kotlinx.coroutines.flow.first
@@ -66,6 +68,7 @@ import kotlinx.datetime.todayIn
@Composable
fun MemoListScreen(
deps: AppDependencies,
sharedText: String? = null,
onMemoClick: (String) -> Unit,
onCreateMemo: () -> Unit,
dateFilter: String? = null,
@@ -130,7 +133,7 @@ fun MemoListScreen(
val textColor = MaterialTheme.colorScheme.onBackground
val subtleColor = MaterialTheme.colorScheme.onSurfaceVariant
var composeField by remember { mutableStateOf(TextFieldValue("")) }
var composeField by remember { mutableStateOf(TextFieldValue(sharedText ?: "")) }
val defaultVis by produceState(MemoVisibility.PRIVATE) {
deps.tokenStore.defaultVisibility.first().let { value = MemoVisibility.fromApiString(it) }
}
@@ -139,6 +142,7 @@ fun MemoListScreen(
var uploadedAttachmentNames by remember { mutableStateOf<List<String>>(emptyList()) }
var isUploading by remember { mutableStateOf(false) }
val uploadScope = rememberCoroutineScope()
val haptics = LocalHapticFeedback.current
val launchFilePicker = rememberFilePicker { pickedFile ->
isUploading = true
@@ -254,30 +258,28 @@ fun MemoListScreen(
onValueChange = { newField ->
val newText = newField.text
val oldText = composeField.text
// Backspace on empty auto-inserted line: remove it
if (newText.length < oldText.length && oldText.endsWith("- [ ] ") && newText == oldText.dropLast(6).trimEnd() + "\n") {
val cleaned = newText.trimEnd('\n')
composeField = TextFieldValue(cleaned, TextRange(cleaned.length))
val oldLines = oldText.lines()
val lastLine = oldLines.lastOrNull() ?: ""
// Backspace on empty auto-inserted task line: remove it
if (newText.length < oldText.length && lastLine.trim() == "- [ ]" && oldLines.size > 1) {
val withoutLast = oldLines.dropLast(1).joinToString("\n")
if (newText.trimEnd() == withoutLast.trimEnd()) {
composeField = TextFieldValue(withoutLast, TextRange(withoutLast.length))
return@TextField
}
}
// Enter on empty auto-inserted task line: remove it and exit task mode
if (newText.length > oldText.length && newText.endsWith("\n") && lastLine.trim() == "- [ ]" && oldLines.size > 1) {
val withoutLast = oldLines.dropLast(1).joinToString("\n") + "\n"
composeField = TextFieldValue(withoutLast, TextRange(withoutLast.length))
return@TextField
}
// Enter on empty auto-inserted line: remove it
if (newText.length > oldText.length && newText.endsWith("\n") && oldText.endsWith("- [ ] ")) {
val lastLine = oldText.lines().last()
if (lastLine.trim() == "- [ ]") {
val cleaned = oldText.dropLast(lastLine.length + 1).trimEnd('\n') + "\n"
composeField = TextFieldValue(cleaned, TextRange(cleaned.length))
return@TextField
}
}
// Auto-checklist: if user pressed enter after a task line, auto-insert "- [ ] "
if (newText.length > oldText.length && newText.endsWith("\n")) {
val beforeNewline = newText.dropLast(1)
val lastLine = beforeNewline.lines().lastOrNull() ?: ""
if (lastLine.trimStart().startsWith("- [")) {
val result = newText + "- [ ] "
composeField = TextFieldValue(result, TextRange(result.length))
return@TextField
}
// Auto-checklist: continue task list on enter
if (newText.length > oldText.length && newText.endsWith("\n") && lastLine.trimStart().startsWith("- [") && lastLine.trim() != "- [ ]") {
val result = newText + "- [ ] "
composeField = TextFieldValue(result, TextRange(result.length))
return@TextField
}
composeField = newField
},
@@ -444,9 +446,11 @@ fun MemoListScreen(
modifier = Modifier
.then(
if (composeField.text.isNotBlank()) Modifier.clickable {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
viewModel.createMemo(composeField.text, composeVisibility, uploadedAttachmentNames)
composeField = TextFieldValue("")
uploadedAttachmentNames = emptyList()
uploadScope.launch { listState.animateScrollToItem(0) }
} else Modifier
)
.padding(horizontal = 4.dp, vertical = 4.dp),
@@ -489,7 +493,16 @@ fun MemoListScreen(
} else if (memos.isEmpty() && !uiState.isRefreshing) {
item {
Box(Modifier.fillMaxWidth().padding(top = 48.dp), contentAlignment = Alignment.Center) {
Text(if (showArchived) "no archived memos" else "no memos yet", fontSize = 15.sp, color = subtleColor)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
if (showArchived) "no archived memos" else "nothing here yet",
fontSize = 17.sp, fontWeight = FontWeight.Light, color = MaterialTheme.colorScheme.onBackground,
)
if (!showArchived) {
Spacer(Modifier.height(4.dp))
Text("tap above to write your first memo", fontSize = 13.sp, color = subtleColor)
}
}
}
}
}
@@ -20,18 +20,28 @@ import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
@@ -45,10 +55,12 @@ import com.avinal.memos.ui.theme.PriorityP1
import com.avinal.memos.ui.theme.PriorityP2
import com.avinal.memos.ui.theme.PriorityP3
import kotlin.time.Clock
import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone
import kotlinx.datetime.todayIn
import kotlinx.datetime.daysUntil
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TaskListScreen(
deps: AppDependencies,
@@ -61,6 +73,10 @@ fun TaskListScreen(
val textColor = MaterialTheme.colorScheme.onBackground
val subtleColor = MaterialTheme.colorScheme.onSurfaceVariant
var selectedTask by remember { mutableStateOf<Task?>(null) }
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val haptics = LocalHapticFeedback.current
var isRefreshing by remember { mutableStateOf(false) }
selectedTask?.let { task ->
TaskDetailSheet(
@@ -71,6 +87,30 @@ fun TaskListScreen(
)
}
Scaffold(
snackbarHost = {
SnackbarHost(snackbarHostState) { data ->
Snackbar(
snackbarData = data,
containerColor = MaterialTheme.colorScheme.surfaceContainer,
contentColor = textColor,
actionColor = accent,
)
}
},
containerColor = Color.Transparent,
) { padding ->
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = {
isRefreshing = true
scope.launch {
deps.memoRepository.refreshMemos()
isRefreshing = false
}
},
modifier = Modifier.padding(padding),
) {
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier
@@ -187,7 +227,17 @@ fun TaskListScreen(
dotColor = dotColor,
textColor = textColor,
subtleColor = subtleColor,
onToggle = { viewModel.toggleTask(task) },
onToggle = {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
viewModel.toggleTask(task)
scope.launch {
val label = if (task.isCompleted) "task reopened" else "task completed"
val result = snackbarHostState.showSnackbar(label, actionLabel = "undo", withDismissAction = true)
if (result == SnackbarResult.ActionPerformed) {
viewModel.toggleTask(task)
}
}
},
onClick = { selectedTask = task },
)
}
@@ -197,12 +247,18 @@ fun TaskListScreen(
if (grouped.groups.isEmpty() || grouped.groups.all { it.tasks.isEmpty() }) {
item {
Box(Modifier.fillMaxWidth().padding(top = 48.dp), contentAlignment = Alignment.Center) {
Text("no tasks", fontSize = 15.sp, color = subtleColor)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("all clear", fontSize = 17.sp, fontWeight = FontWeight.Light, color = textColor)
Spacer(Modifier.height(4.dp))
Text("tasks from your memos will appear here", fontSize = 13.sp, color = subtleColor)
}
}
}
}
}
}
}
}
}
@Composable