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:
@@ -273,27 +273,26 @@ 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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
|
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
|
// 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)
|
onContentChange(newText + "- [ ] ")
|
||||||
val lastLine = beforeNewline.lines().lastOrNull() ?: ""
|
return@TextField
|
||||||
if (lastLine.trimStart().startsWith("- [")) {
|
|
||||||
onContentChange(newText + "- [ ] ")
|
|
||||||
return@TextField
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
onContentChange(newText)
|
onContentChange(newText)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,30 +258,28 @@ 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
|
||||||
|
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
|
return@TextField
|
||||||
}
|
}
|
||||||
// Enter on empty auto-inserted line: remove it
|
// Auto-checklist: continue task list on enter
|
||||||
if (newText.length > oldText.length && newText.endsWith("\n") && oldText.endsWith("- [ ] ")) {
|
if (newText.length > oldText.length && newText.endsWith("\n") && lastLine.trimStart().startsWith("- [") && lastLine.trim() != "- [ ]") {
|
||||||
val lastLine = oldText.lines().last()
|
val result = newText + "- [ ] "
|
||||||
if (lastLine.trim() == "- [ ]") {
|
composeField = TextFieldValue(result, TextRange(result.length))
|
||||||
val cleaned = oldText.dropLast(lastLine.length + 1).trimEnd('\n') + "\n"
|
return@TextField
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
composeField = newField
|
composeField = newField
|
||||||
},
|
},
|
||||||
@@ -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,12 +247,18 @@ 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
Reference in New Issue
Block a user