1
0
mirror of https://github.com/avinal/nikki.git synced 2026-07-04 05:50:10 +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,28 +273,27 @@ private fun InlineEditor(
TextField( TextField(
value = content, value = content,
onValueChange = { newText -> onValueChange = { newText ->
// Backspace on empty auto-inserted line: remove it val oldLines = content.lines()
if (newText.length < content.length && content.endsWith("- [ ] ") && newText == content.dropLast(6).trimEnd() + "\n") { val lastLine = oldLines.lastOrNull() ?: ""
onContentChange(newText.trimEnd('\n'))
// 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 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
} }
// 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
} }
// Auto-checklist: continue task list on enter // Auto-checklist: continue task list on enter
if (newText.length > content.length && newText.endsWith("\n")) { if (newText.length > content.length && newText.endsWith("\n") && lastLine.trimStart().startsWith("- [") && lastLine.trim() != "- [ ]") {
val beforeNewline = newText.dropLast(1)
val lastLine = beforeNewline.lines().lastOrNull() ?: ""
if (lastLine.trimStart().startsWith("- [")) {
onContentChange(newText + "- [ ] ") onContentChange(newText + "- [ ] ")
return@TextField return@TextField
} }
}
onContentChange(newText) onContentChange(newText)
}, },
modifier = Modifier.fillMaxWidth().height(180.dp), modifier = Modifier.fillMaxWidth().height(180.dp),
@@ -53,6 +53,8 @@ import com.avinal.memos.domain.MemoVisibility
import com.avinal.memos.ui.components.MemoCard import com.avinal.memos.ui.components.MemoCard
import com.avinal.memos.ui.theme.LocalAccentColor import com.avinal.memos.ui.theme.LocalAccentColor
import com.avinal.memos.util.rememberFilePicker 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.TextRange
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@@ -66,6 +68,7 @@ import kotlinx.datetime.todayIn
@Composable @Composable
fun MemoListScreen( fun MemoListScreen(
deps: AppDependencies, deps: AppDependencies,
sharedText: String? = null,
onMemoClick: (String) -> Unit, onMemoClick: (String) -> Unit,
onCreateMemo: () -> Unit, onCreateMemo: () -> Unit,
dateFilter: String? = null, dateFilter: String? = null,
@@ -130,7 +133,7 @@ fun MemoListScreen(
val textColor = MaterialTheme.colorScheme.onBackground val textColor = MaterialTheme.colorScheme.onBackground
val subtleColor = MaterialTheme.colorScheme.onSurfaceVariant val subtleColor = MaterialTheme.colorScheme.onSurfaceVariant
var composeField by remember { mutableStateOf(TextFieldValue("")) } var composeField by remember { mutableStateOf(TextFieldValue(sharedText ?: "")) }
val defaultVis by produceState(MemoVisibility.PRIVATE) { val defaultVis by produceState(MemoVisibility.PRIVATE) {
deps.tokenStore.defaultVisibility.first().let { value = MemoVisibility.fromApiString(it) } deps.tokenStore.defaultVisibility.first().let { value = MemoVisibility.fromApiString(it) }
} }
@@ -139,6 +142,7 @@ fun MemoListScreen(
var uploadedAttachmentNames by remember { mutableStateOf<List<String>>(emptyList()) } var uploadedAttachmentNames by remember { mutableStateOf<List<String>>(emptyList()) }
var isUploading by remember { mutableStateOf(false) } var isUploading by remember { mutableStateOf(false) }
val uploadScope = rememberCoroutineScope() val uploadScope = rememberCoroutineScope()
val haptics = LocalHapticFeedback.current
val launchFilePicker = rememberFilePicker { pickedFile -> val launchFilePicker = rememberFilePicker { pickedFile ->
isUploading = true isUploading = true
@@ -254,31 +258,29 @@ fun MemoListScreen(
onValueChange = { newField -> onValueChange = { newField ->
val newText = newField.text val newText = newField.text
val oldText = composeField.text val oldText = composeField.text
// Backspace on empty auto-inserted line: remove it val oldLines = oldText.lines()
if (newText.length < oldText.length && oldText.endsWith("- [ ] ") && newText == oldText.dropLast(6).trimEnd() + "\n") { val lastLine = oldLines.lastOrNull() ?: ""
val cleaned = newText.trimEnd('\n')
composeField = TextFieldValue(cleaned, TextRange(cleaned.length)) // Backspace on empty auto-inserted task line: remove it
return@TextField if (newText.length < oldText.length && lastLine.trim() == "- [ ]" && oldLines.size > 1) {
} val withoutLast = oldLines.dropLast(1).joinToString("\n")
// Enter on empty auto-inserted line: remove it if (newText.trimEnd() == withoutLast.trimEnd()) {
if (newText.length > oldText.length && newText.endsWith("\n") && oldText.endsWith("- [ ] ")) { composeField = TextFieldValue(withoutLast, TextRange(withoutLast.length))
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 return@TextField
} }
} }
// Auto-checklist: if user pressed enter after a task line, auto-insert "- [ ] " // Enter on empty auto-inserted task line: remove it and exit task mode
if (newText.length > oldText.length && newText.endsWith("\n")) { if (newText.length > oldText.length && newText.endsWith("\n") && lastLine.trim() == "- [ ]" && oldLines.size > 1) {
val beforeNewline = newText.dropLast(1) val withoutLast = oldLines.dropLast(1).joinToString("\n") + "\n"
val lastLine = beforeNewline.lines().lastOrNull() ?: "" composeField = TextFieldValue(withoutLast, TextRange(withoutLast.length))
if (lastLine.trimStart().startsWith("- [")) { 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 + "- [ ] " val result = newText + "- [ ] "
composeField = TextFieldValue(result, TextRange(result.length)) composeField = TextFieldValue(result, TextRange(result.length))
return@TextField return@TextField
} }
}
composeField = newField composeField = newField
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -444,9 +446,11 @@ fun MemoListScreen(
modifier = Modifier modifier = Modifier
.then( .then(
if (composeField.text.isNotBlank()) Modifier.clickable { if (composeField.text.isNotBlank()) Modifier.clickable {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
viewModel.createMemo(composeField.text, composeVisibility, uploadedAttachmentNames) viewModel.createMemo(composeField.text, composeVisibility, uploadedAttachmentNames)
composeField = TextFieldValue("") composeField = TextFieldValue("")
uploadedAttachmentNames = emptyList() uploadedAttachmentNames = emptyList()
uploadScope.launch { listState.animateScrollToItem(0) }
} else Modifier } else Modifier
) )
.padding(horizontal = 4.dp, vertical = 4.dp), .padding(horizontal = 4.dp, vertical = 4.dp),
@@ -489,7 +493,16 @@ fun MemoListScreen(
} else if (memos.isEmpty() && !uiState.isRefreshing) { } else if (memos.isEmpty() && !uiState.isRefreshing) {
item { item {
Box(Modifier.fillMaxWidth().padding(top = 48.dp), contentAlignment = Alignment.Center) { 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.material.icons.filled.KeyboardArrowUp
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme 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.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp 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.PriorityP2
import com.avinal.memos.ui.theme.PriorityP3 import com.avinal.memos.ui.theme.PriorityP3
import kotlin.time.Clock import kotlin.time.Clock
import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.todayIn import kotlinx.datetime.todayIn
import kotlinx.datetime.daysUntil import kotlinx.datetime.daysUntil
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun TaskListScreen( fun TaskListScreen(
deps: AppDependencies, deps: AppDependencies,
@@ -61,6 +73,10 @@ fun TaskListScreen(
val textColor = MaterialTheme.colorScheme.onBackground val textColor = MaterialTheme.colorScheme.onBackground
val subtleColor = MaterialTheme.colorScheme.onSurfaceVariant val subtleColor = MaterialTheme.colorScheme.onSurfaceVariant
var selectedTask by remember { mutableStateOf<Task?>(null) } 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 -> selectedTask?.let { task ->
TaskDetailSheet( 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()) { Column(modifier = Modifier.fillMaxSize()) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -187,7 +227,17 @@ fun TaskListScreen(
dotColor = dotColor, dotColor = dotColor,
textColor = textColor, textColor = textColor,
subtleColor = subtleColor, 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 }, onClick = { selectedTask = task },
) )
} }
@@ -197,7 +247,13 @@ fun TaskListScreen(
if (grouped.groups.isEmpty() || grouped.groups.all { it.tasks.isEmpty() }) { if (grouped.groups.isEmpty() || grouped.groups.all { it.tasks.isEmpty() }) {
item { item {
Box(Modifier.fillMaxWidth().padding(top = 48.dp), contentAlignment = Alignment.Center) { 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)
}
}
}
} }
} }
} }