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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user