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

Add parser doctor, offline queue, tag inheritance, logo, MIT license

Parser doctor:
- validateContent() with error/warning severity levels
- Errors: invalid date, invalid priority, reminder without due,
  invalid reminder unit
- Warnings: time without date, past date, multiple metadata,
  typo detection with correction suggestions
- 21 new tests in ParserDoctorTest

Tag inheritance:
- extractTasks() accepts memoTags parameter
- Tasks without #tags inherit memo-level tags

Offline queue:
- PendingSyncEntity + PendingSyncDao for queued edits
- MemoRepository queues failed API calls, drains on next sync
- Sync status banner: "synced N min ago" / "offline · N pending"

UI:
- Parser doctor banner in tasks tab with inline highlighting
- Error/warning dots on task rows
- Pivot headers with absolute positioning and measured widths
- Logo in settings about section (Canvas-drawn circle variant)

Other:
- MIT license
- Logo design spec (LOGO.md)
- Concentric circle logo: pink circle, black+teal 47° annular wedge
- .gitignore updated for dev artifacts

144 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context)

Signed-off-by: Avinal Kumar <avinal.xlvii@gmail.com>
This commit is contained in:
2026-05-22 16:40:58 +05:30
parent 0512c9a698
commit 91b6a479a4
25 changed files with 942 additions and 278 deletions
@@ -13,7 +13,7 @@ import kotlinx.datetime.TimeZone
object DirectAlarmScheduler {
fun scheduleFromMemos(context: Context, memos: List<Memo>) {
val allTasks = memos.flatMap { memo -> TaskParser.extractTasks(memo.id, memo.content) }
val allTasks = memos.flatMap { memo -> TaskParser.extractTasks(memo.id, memo.content, memo.tags) }
val nowMillis = Clock.System.now().toEpochMilliseconds()
val tz = TimeZone.currentSystemDefault()
@@ -46,7 +46,7 @@ class TaskCheckWorker(
try {
val memos = db.memoDao().getAll().map { it.toDomain() }
val allTasks = memos.flatMap { memo -> TaskParser.extractTasks(memo.id, memo.content) }
val allTasks = memos.flatMap { memo -> TaskParser.extractTasks(memo.id, memo.content, memo.tags) }
val alarmManager = appContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val tz = TimeZone.currentSystemDefault()
@@ -42,6 +42,8 @@ class AppDependencies(
val memoRepository: MemoRepository by lazy {
MemoRepository(apiClient, database.memoDao()) {
com.avinal.memos.util.triggerReminderCheck()
}.also {
it.pendingSyncDao = database.pendingSyncDao()
}
}
@@ -3,9 +3,12 @@ package com.avinal.memos.db
import androidx.room.Database
import androidx.room.RoomDatabase
import com.avinal.memos.db.dao.MemoDao
import com.avinal.memos.db.dao.PendingSyncDao
import com.avinal.memos.db.entity.MemoEntity
import com.avinal.memos.db.entity.PendingSyncEntity
@Database(entities = [MemoEntity::class], version = 4)
@Database(entities = [MemoEntity::class, PendingSyncEntity::class], version = 5)
abstract class MemosDatabase : RoomDatabase() {
abstract fun memoDao(): MemoDao
abstract fun pendingSyncDao(): PendingSyncDao
}
@@ -0,0 +1,25 @@
package com.avinal.memos.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import com.avinal.memos.db.entity.PendingSyncEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface PendingSyncDao {
@Insert
suspend fun insert(entity: PendingSyncEntity)
@Query("SELECT * FROM pending_sync ORDER BY createdAt ASC")
suspend fun getAll(): List<PendingSyncEntity>
@Query("SELECT COUNT(*) FROM pending_sync")
fun observeCount(): Flow<Int>
@Query("DELETE FROM pending_sync WHERE id = :id")
suspend fun deleteById(id: Long)
@Query("DELETE FROM pending_sync")
suspend fun deleteAll()
}
@@ -0,0 +1,13 @@
package com.avinal.memos.db.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "pending_sync")
data class PendingSyncEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val memoId: String? = null,
val action: String,
val payload: String,
val createdAt: Long,
)
@@ -4,6 +4,8 @@ import com.avinal.memos.api.ApiResult
import com.avinal.memos.api.MemosApiClient
import com.avinal.memos.api.model.toDomain
import com.avinal.memos.db.dao.MemoDao
import com.avinal.memos.db.dao.PendingSyncDao
import com.avinal.memos.db.entity.PendingSyncEntity
import com.avinal.memos.db.entity.toEntity
import com.avinal.memos.db.entity.toDomain
import kotlin.time.Clock
@@ -11,8 +13,13 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
class MemoRepository(
private val apiClient: MemosApiClient,
@@ -24,6 +31,12 @@ class MemoRepository(
private var lastFetchTime: Long = 0L
var syncIntervalMinutes: Int = 5
var pendingSyncDao: PendingSyncDao? = null
private val _lastSyncTime = MutableStateFlow(0L)
val lastSyncTime: StateFlow<Long> = _lastSyncTime
@Volatile var isOffline: Boolean = false
private set
private fun nowMillis(): Long = Clock.System.now().toEpochMilliseconds()
private fun isCacheStale(): Boolean = (nowMillis() - lastFetchTime) > (syncIntervalMinutes * 60 * 1000L)
@@ -52,6 +65,8 @@ class MemoRepository(
}
suspend fun refreshMemos(): ApiResult<List<Memo>> {
drainPendingSync()
nextPageToken = ""
hasMorePages = true
val allFetched = mutableListOf<Memo>()
@@ -65,12 +80,14 @@ class MemoRepository(
token = result.data.nextPageToken
}
is ApiResult.Error -> return result
is ApiResult.NetworkError -> return result
is ApiResult.NetworkError -> { isOffline = true; return result }
}
} while (token.isNotEmpty())
isOffline = false
val now = nowMillis()
lastFetchTime = now
_lastSyncTime.value = now
memoDao.deleteAll()
memoDao.upsertAll(allFetched.map { it.toEntity(now) })
nextPageToken = ""
@@ -107,7 +124,14 @@ class MemoRepository(
ApiResult.Success(memo)
}
is ApiResult.Error -> result
is ApiResult.NetworkError -> result
is ApiResult.NetworkError -> {
pendingSyncDao?.insert(PendingSyncEntity(
memoId = null, action = "CREATE",
payload = """{"content":"${content.replace("\"", "\\\"")}","visibility":"${visibility.toApiString()}"}""",
createdAt = nowMillis(),
))
result
}
}
}
@@ -130,7 +154,19 @@ class MemoRepository(
ApiResult.Success(memo)
}
is ApiResult.Error -> result
is ApiResult.NetworkError -> result
is ApiResult.NetworkError -> {
val payloadParts = buildList {
if (content != null) add(""""content":"${content.replace("\"", "\\\"")}"""")
if (visibility != null) add(""""visibility":"${visibility.toApiString()}"""")
if (pinned != null) add(""""pinned":$pinned""")
}
pendingSyncDao?.insert(PendingSyncEntity(
memoId = id, action = "UPDATE",
payload = "{${payloadParts.joinToString(",")}}",
createdAt = nowMillis(),
))
result
}
}
}
@@ -194,6 +230,29 @@ class MemoRepository(
}
}
suspend fun drainPendingSync() {
val dao = pendingSyncDao ?: return
val pending = dao.getAll()
for (op in pending) {
val success = when (op.action) {
"CREATE" -> {
val json = Json.parseToJsonElement(op.payload).jsonObject
val content = json["content"]?.jsonPrimitive?.content ?: continue
val vis = json["visibility"]?.jsonPrimitive?.content ?: "PRIVATE"
apiClient.createMemo(content, vis) is ApiResult.Success
}
"UPDATE" -> {
val json = Json.parseToJsonElement(op.payload).jsonObject
val content = json["content"]?.jsonPrimitive?.content
apiClient.updateMemo(op.memoId ?: continue, content = content) is ApiResult.Success
}
"DELETE" -> apiClient.deleteMemo(op.memoId ?: continue) is ApiResult.Success
else -> false
}
if (success) dao.deleteById(op.id)
}
}
suspend fun clearCache() {
memoDao.deleteAll()
lastFetchTime = 0L
@@ -0,0 +1,12 @@
package com.avinal.memos.parser
enum class IssueSeverity { ERROR, WARNING }
data class ParseWarning(
val memoId: String = "",
val lineIndex: Int,
val taskText: String,
val issue: String,
val highlight: String = "",
val severity: IssueSeverity = IssueSeverity.WARNING,
)
@@ -27,7 +27,7 @@ object TaskParser {
private val priorityRegex = Regex("""\bp([1-3])\b""")
private val listRegex = Regex("""(?<!\w)#([a-zA-Z]\w*)""")
fun extractTasks(memoId: String, content: String): List<Task> {
fun extractTasks(memoId: String, content: String, memoTags: List<String> = emptyList()): List<Task> {
var taskOrdinal = 0
return content.lines().mapIndexedNotNull { index, line ->
val match = taskLineRegex.find(line) ?: return@mapIndexedNotNull null
@@ -36,6 +36,7 @@ object TaskParser {
val cleanText = cleanTaskText(rawText)
taskOrdinal++
val parsedLists = parseLists(rawText)
Task(
id = "${memoId}:${hashContent(cleanText, taskOrdinal)}",
memoId = memoId,
@@ -48,7 +49,7 @@ object TaskParser {
dueTime = parseDueTime(rawText),
reminder = parseReminder(rawText),
priority = parsePriority(rawText),
lists = parseLists(rawText),
lists = parsedLists.ifEmpty { memoTags },
)
}
}
@@ -169,6 +170,122 @@ object TaskParser {
private fun parseLists(text: String): List<String> =
listRegex.findAll(text).map { it.groupValues[1] }.toList()
private val typoMap = mapOf(
"tday" to "today", "todya" to "today", "toaday" to "today", "toady" to "today",
"tmrw" to "tomorrow", "tomorow" to "tomorrow", "tommorow" to "tomorrow", "tomorraww" to "tomorrow", "tomorrw" to "tomorrow",
"yestrday" to "yesterday", "ysterday" to "yesterday", "yesterady" to "yesterday",
"munday" to "monday", "monady" to "monday", "mnday" to "monday",
"tusday" to "tuesday", "tueday" to "tuesday",
"wendsday" to "wednesday", "wensday" to "wednesday", "wednsday" to "wednesday",
"thurday" to "thursday", "thrusday" to "thursday",
"firday" to "friday", "frday" to "friday",
"saterday" to "saturday", "sturday" to "saturday",
"sundie" to "sunday", "sundya" to "sunday", "sunady" to "sunday",
)
fun validateContent(content: String): List<ParseWarning> {
val warnings = mutableListOf<ParseWarning>()
var noDateCount = 0
content.lines().forEachIndexed { index, line ->
val match = taskLineRegex.find(line) ?: return@forEachIndexed
if (match.groupValues[1].lowercase() == "x") return@forEachIndexed
val rawText = match.groupValues[2]
fun err(issue: String, highlight: String = "") { warnings.add(ParseWarning(lineIndex = index, taskText = rawText.trim(), issue = issue, highlight = highlight, severity = IssueSeverity.ERROR)) }
fun warn(issue: String, highlight: String = "") { warnings.add(ParseWarning(lineIndex = index, taskText = rawText.trim(), issue = issue, highlight = highlight, severity = IssueSeverity.WARNING)) }
// --- ERRORS ---
// E1: Invalid date
isoDateRegex.find(rawText)?.let { m ->
try { kotlinx.datetime.LocalDate.parse(m.groupValues[1]) }
catch (_: Exception) { err("invalid date, use YYYY-MM-DD format", m.groupValues[1]) }
}
// E2: Invalid priority
Regex("""\bp([4-9])\b""").find(rawText)?.let {
err("invalid priority, only p1, p2, p3 are supported", "p${it.groupValues[1]}")
}
// E3: Reminder without any due date or time
val cleaned = rawText.replace(reminderRegex, "")
val hasTime = time12Regex.containsMatchIn(cleaned) || time24Regex.containsMatchIn(cleaned)
val hasDate = isoDateRegex.containsMatchIn(rawText) || naturalDateRegex.containsMatchIn(rawText)
val reminderMatch = reminderRegex.find(rawText)
if (reminderMatch != null && !hasDate && !hasTime) {
err("reminder has no due date or time to count back from", reminderMatch.value)
}
// E4: Invalid reminder format
Regex("""!(\d+)\s*([a-zA-Z]+)""").find(rawText)?.let { m ->
val unit = m.groupValues[2].lowercase().removeSuffix("s")
if (unit !in listOf("min", "hr", "day", "week")) {
err("invalid reminder unit \"${m.groupValues[2]}\", use min, hr, day, or week", m.value)
}
val value = m.groupValues[1].toIntOrNull()
if (value == null || value <= 0) {
err("invalid reminder value", m.value)
}
}
// --- WARNINGS ---
// W1: Time but no date
val timeMatch = time12Regex.find(cleaned) ?: time24Regex.find(cleaned)
if (timeMatch != null && !hasDate) {
warn("time without date, using today", timeMatch.value)
}
// W2: Date/time in past
isoDateRegex.find(rawText)?.let { m ->
try {
val date = kotlinx.datetime.LocalDate.parse(m.groupValues[1])
val today = kotlin.time.Clock.System.todayIn(kotlinx.datetime.TimeZone.currentSystemDefault())
if (date < today) warn("date is in the past", m.groupValues[1])
} catch (_: Exception) {}
}
// W3: Multiple priorities
val pMatches = Regex("""\bp[1-3]\b""").findAll(rawText).toList()
if (pMatches.size > 1) {
warn("multiple priorities, using first (${pMatches[0].value})", pMatches.drop(1).joinToString(" ") { it.value })
}
// W3: Multiple dates
val dateMatches = isoDateRegex.findAll(rawText).toList()
val naturalMatches = naturalDateRegex.findAll(rawText).toList()
if (dateMatches.size + naturalMatches.size > 1) {
warn("multiple dates found, using first", (dateMatches + naturalMatches).drop(1).joinToString(" ") { it.value })
}
// W3: Multiple reminders
val reminderMatches = reminderRegex.findAll(rawText).toList()
if (reminderMatches.size > 1) {
warn("multiple reminders, using first", reminderMatches.drop(1).joinToString(" ") { it.value })
}
// W5: Typo detection
Regex("""\b\w+\b""").findAll(rawText).forEach { wordMatch ->
val word = wordMatch.value.lowercase()
typoMap[word]?.let { correction ->
warn("did you mean \"$correction\"?", wordMatch.value)
}
}
// Track tasks without date for W4
if (!hasDate && !hasTime) noDateCount++
}
// W4: Combined warning for tasks without dates
if (noDateCount > 1) {
warnings.add(ParseWarning(lineIndex = -1, taskText = "", issue = "$noDateCount tasks have no due date or time set"))
}
return warnings
}
private fun cleanTaskText(text: String): String {
var clean = text
clean = priorityRegex.replace(clean, "")
@@ -34,6 +34,7 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -62,7 +63,6 @@ import kotlinx.datetime.toLocalDateTime
private val pivotTitles = listOf("explore", "memos", "tasks", "settings")
private const val START_PAGE = 1
private const val PARALLAX_FACTOR = 0.5f
@Composable
fun MainScreen(
@@ -89,81 +89,135 @@ fun MainScreen(
.background(MaterialTheme.colorScheme.background)
.statusBarsPadding(),
) {
val screenWidthPx = with(density) { androidx.compose.ui.platform.LocalConfiguration.current.screenWidthDp.dp.toPx() }
val startOffset = screenWidthPx * 0.16f
val gapPx = with(density) { 32.dp.toPx() }
// Measure title widths to position them with consistent visual gaps
val textMeasurer = androidx.compose.ui.text.rememberTextMeasurer()
val titleWidths = remember(pivotTitles) {
pivotTitles.map { title ->
textMeasurer.measure(title, style = androidx.compose.ui.text.TextStyle(fontSize = 42.sp, fontWeight = FontWeight.Light)).size.width.toFloat()
}
}
// Cumulative x positions: each title starts after previous title + gap
val titlePositions = remember(titleWidths) {
val positions = mutableListOf(0f)
for (i in 1 until titleWidths.size) {
positions.add(positions[i - 1] + titleWidths[i - 1] + gapPx)
}
positions
}
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp, bottom = 6.dp),
) {
val scrollFraction = pagerState.currentPage + pagerState.currentPageOffsetFraction
val parallaxOffset = with(density) { (-scrollFraction * PARALLAX_FACTOR * 100.dp.toPx()).toInt() }
// Shift so the active title's position aligns to startOffset
val activePos = if (scrollFraction >= 0) {
val idx = scrollFraction.toInt().coerceIn(0, titlePositions.size - 1)
val frac = scrollFraction - idx
val nextIdx = (idx + 1).coerceAtMost(titlePositions.size - 1)
titlePositions[idx] * (1 - frac) + titlePositions[nextIdx] * frac
} else 0f
val baseShift = startOffset - activePos
Row(
modifier = Modifier
.offset { IntOffset(parallaxOffset, 0) }
.padding(start = 24.dp),
horizontalArrangement = Arrangement.spacedBy(20.dp),
) {
pivotTitles.forEachIndexed { index, title ->
val distance = kotlin.math.abs(scrollFraction - index)
val alpha = (1f - distance * 0.5f).coerceIn(0.2f, 1f)
val isSelected = pagerState.currentPage == index
pivotTitles.forEachIndexed { index, title ->
val offsetPx = (titlePositions[index] + baseShift).toInt()
val distance = kotlin.math.abs(scrollFraction - index)
val alpha = (1f - distance * 0.5f).coerceIn(0.15f, 1f)
val isSelected = pagerState.currentPage == index
Text(
text = title,
fontSize = 28.sp,
fontWeight = FontWeight.Light,
color = if (isSelected) accent.copy(alpha = alpha)
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha * 0.5f),
maxLines = 1,
overflow = TextOverflow.Visible,
softWrap = false,
modifier = Modifier
.clickable { scope.launch { pagerState.animateScrollToPage(index) } }
.padding(vertical = 4.dp),
)
Text(
text = title,
fontSize = 42.sp,
fontWeight = FontWeight.Light,
color = if (isSelected) accent.copy(alpha = alpha)
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha * 0.4f),
maxLines = 1,
modifier = Modifier
.offset { IntOffset(offsetPx, 0) }
.clickable { scope.launch { pagerState.animateScrollToPage(index) } }
.padding(vertical = 4.dp),
)
}
}
val lastSync by deps.memoRepository.lastSyncTime.collectAsState()
val pendingCount by deps.memoRepository.pendingSyncDao?.observeCount()?.collectAsState(initial = 0) ?: remember { mutableStateOf(0) }
val syncAge = if (lastSync > 0L) ((kotlin.time.Clock.System.now().toEpochMilliseconds() - lastSync) / 60000).toInt() else -1
val isOffline = deps.memoRepository.isOffline
Box(modifier = Modifier.weight(1f)) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
beyondViewportPageCount = 1,
) { page ->
Box(modifier = Modifier.fillMaxSize()) {
when (page) {
0 -> ExplorerPage(
deps = deps,
onMemoClick = onMemoClick,
onDateSelected = { date ->
dateFilter = date; tagFilter = null; searchFilter = null; showArchived = false
navigateToMemosWithFilter()
},
onTagSelected = { tag ->
tagFilter = tag; dateFilter = null; searchFilter = null; showArchived = false
navigateToMemosWithFilter()
},
onSearchSubmit = { query ->
searchFilter = query; dateFilter = null; tagFilter = null; showArchived = false
navigateToMemosWithFilter()
},
onShowArchived = {
showArchived = true; dateFilter = null; tagFilter = null; searchFilter = null
navigateToMemosWithFilter()
},
)
1 -> MemoListScreen(
deps = deps,
onMemoClick = onMemoClick,
onCreateMemo = onCreateMemo,
dateFilter = dateFilter,
tagFilter = tagFilter,
searchFilter = searchFilter,
showArchived = showArchived,
onClearFilter = { dateFilter = null; tagFilter = null; searchFilter = null; showArchived = false },
)
2 -> TaskListScreen(deps = deps, onMemoClick = onMemoClick)
3 -> SettingsScreen(deps = deps, onLogout = onLogout)
}
}
}
}
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
beyondViewportPageCount = 1,
) { page ->
Box(modifier = Modifier.fillMaxSize()) {
when (page) {
0 -> ExplorerPage(
deps = deps,
onMemoClick = onMemoClick,
onDateSelected = { date ->
dateFilter = date; tagFilter = null; searchFilter = null; showArchived = false
navigateToMemosWithFilter()
},
onTagSelected = { tag ->
tagFilter = tag; dateFilter = null; searchFilter = null; showArchived = false
navigateToMemosWithFilter()
},
onSearchSubmit = { query ->
searchFilter = query; dateFilter = null; tagFilter = null; showArchived = false
navigateToMemosWithFilter()
},
onShowArchived = {
showArchived = true; dateFilter = null; tagFilter = null; searchFilter = null
navigateToMemosWithFilter()
},
// Bottom status banner
if (isOffline || pendingCount > 0 || syncAge > 5) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(
if (isOffline) MaterialTheme.colorScheme.error.copy(alpha = 0.15f)
else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
)
1 -> MemoListScreen(
deps = deps,
onMemoClick = onMemoClick,
onCreateMemo = onCreateMemo,
dateFilter = dateFilter,
tagFilter = tagFilter,
searchFilter = searchFilter,
showArchived = showArchived,
onClearFilter = { dateFilter = null; tagFilter = null; searchFilter = null; showArchived = false },
)
2 -> TaskListScreen(deps = deps, onMemoClick = onMemoClick)
3 -> SettingsScreen(deps = deps, onLogout = onLogout)
.padding(horizontal = 24.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
val statusText = buildString {
if (isOffline) append("offline")
else if (syncAge < 0) append("not synced")
else if (syncAge == 0) append("synced just now")
else append("synced ${syncAge}m ago")
}
Text(statusText, fontSize = 11.sp, color = if (isOffline) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant)
if (pendingCount > 0) {
Text("$pendingCount pending", fontSize = 11.sp, color = MaterialTheme.colorScheme.error.copy(alpha = 0.8f))
}
}
}
@@ -255,8 +309,17 @@ private fun ExplorerPage(
}
Spacer(Modifier.height(12.dp))
var archivedCount by remember { mutableStateOf(0) }
LaunchedEffect(Unit) {
when (val result = deps.apiClient.listArchivedMemos()) {
is com.avinal.memos.api.ApiResult.Success -> archivedCount = result.data.memos.size
else -> {}
}
}
Text(
"view archived memos",
"view archived memos${if (archivedCount > 0) " ($archivedCount)" else ""}",
fontSize = 14.sp,
color = accent,
modifier = Modifier.clickable { onShowArchived() }.padding(vertical = 4.dp),
@@ -360,7 +423,7 @@ private fun ExplorerPage(
if (allTags.isNotEmpty()) {
val tasksByTag = remember(memos) {
val parser = com.avinal.memos.parser.TaskParser
val allTasks = memos.flatMap { memo -> parser.extractTasks(memo.id, memo.content) }
val allTasks = memos.flatMap { memo -> parser.extractTasks(memo.id, memo.content, memo.tags) }
allTasks.filter { !it.isCompleted }.groupBy { it.lists.firstOrNull() ?: "" }
}
@@ -37,7 +37,7 @@ class MemoDetailViewModel(
fun toggleTask(lineIndex: Int, checked: Boolean) {
val current = memo.value ?: return
val tasks = com.avinal.memos.parser.TaskParser.extractTasks(memoId, current.content)
val tasks = com.avinal.memos.parser.TaskParser.extractTasks(memoId, current.content, current.tags)
val task = tasks.find { it.lineIndex == lineIndex } ?: return
viewModelScope.launch {
val newContent = com.avinal.memos.parser.TaskParser.toggleTaskInContent(current.content, task)
@@ -313,6 +313,17 @@ fun MemoListScreen(
}
}
val parseWarnings = remember(composeText) { com.avinal.memos.parser.TaskParser.validateContent(composeText) }
if (parseWarnings.isNotEmpty()) {
parseWarnings.forEach { warning ->
Text(
"${warning.taskText}: ${warning.issue}",
fontSize = 11.sp, color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 2.dp),
)
}
}
if (uploadedAttachmentNames.isNotEmpty()) {
Text(
"${uploadedAttachmentNames.size} attachment(s) ready",
@@ -373,6 +384,17 @@ fun MemoListScreen(
}
}
if (uiState.statusMessage != null) {
item {
Text(
uiState.statusMessage!!,
fontSize = 12.sp,
color = accent,
modifier = Modifier.padding(start = 24.dp, end = 12.dp, top = 6.dp, bottom = 6.dp),
)
}
}
if (showArchived && isLoadingArchived) {
item {
Box(Modifier.fillMaxWidth().padding(top = 48.dp), contentAlignment = Alignment.Center) {
@@ -393,14 +415,7 @@ fun MemoListScreen(
}
}
if (uiState.error != null) {
item {
Text(
uiState.error!!, fontSize = 12.sp, color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(start = 24.dp, end = 12.dp, top = 4.dp, bottom = 4.dp),
)
}
}
// Errors shown via bottom banner in MainScreen, not inline
items(memos, key = { it.id }) { memo ->
if (showArchived) {
@@ -24,6 +24,7 @@ data class MemoListUiState(
val isSearching: Boolean = false,
val error: String? = null,
val isInitialLoading: Boolean = true,
val statusMessage: String? = null,
)
class MemoListViewModel(private val memoRepository: MemoRepository) : ViewModel() {
@@ -93,7 +94,22 @@ class MemoListViewModel(private val memoRepository: MemoRepository) : ViewModel(
}
fun createMemo(content: String, visibility: com.avinal.memos.domain.MemoVisibility, attachmentNames: List<String> = emptyList()) {
viewModelScope.launch { memoRepository.createMemo(content, visibility, attachmentNames) }
viewModelScope.launch {
val result = memoRepository.createMemo(content, visibility, attachmentNames)
when (result) {
is com.avinal.memos.api.ApiResult.NetworkError -> {
_uiState.update { it.copy(statusMessage = "saved offline — will sync when back online") }
kotlinx.coroutines.delay(3000)
_uiState.update { it.copy(statusMessage = null) }
}
is com.avinal.memos.api.ApiResult.Error -> {
_uiState.update { it.copy(statusMessage = "failed: ${result.message}") }
kotlinx.coroutines.delay(3000)
_uiState.update { it.copy(statusMessage = null) }
}
else -> {}
}
}
}
fun deleteMemo(id: String) {
@@ -109,13 +125,20 @@ class MemoListViewModel(private val memoRepository: MemoRepository) : ViewModel(
}
fun updateMemo(id: String, content: String, visibility: com.avinal.memos.domain.MemoVisibility) {
viewModelScope.launch { memoRepository.updateMemo(id, content = content, visibility = visibility) }
viewModelScope.launch {
val result = memoRepository.updateMemo(id, content = content, visibility = visibility)
if (result is com.avinal.memos.api.ApiResult.NetworkError) {
_uiState.update { it.copy(statusMessage = "saved offline") }
kotlinx.coroutines.delay(3000)
_uiState.update { it.copy(statusMessage = null) }
}
}
}
fun toggleTask(memoId: String, lineIndex: Int, checked: Boolean) {
viewModelScope.launch {
val memo = memoRepository.getMemo(memoId) ?: return@launch
val tasks = com.avinal.memos.parser.TaskParser.extractTasks(memoId, memo.content)
val tasks = com.avinal.memos.parser.TaskParser.extractTasks(memoId, memo.content, memo.tags)
val task = tasks.find { it.lineIndex == lineIndex } ?: return@launch
val newContent = com.avinal.memos.parser.TaskParser.toggleTaskInContent(memo.content, task)
if (newContent != memo.content) memoRepository.updateMemo(memoId, content = newContent)
@@ -1,5 +1,6 @@
package com.avinal.memos.ui.settings
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -31,7 +32,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -225,7 +230,16 @@ fun SettingsScreen(
Spacer(Modifier.height(36.dp))
SectionHeader("about")
SettingsItem("version", "1.0.0")
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AppLogo(size = 64f)
Spacer(Modifier.height(10.dp))
Text("memosapp", fontSize = 18.sp, fontWeight = FontWeight.Light, color = MaterialTheme.colorScheme.onBackground)
Text("version 1.0.0", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Spacer(Modifier.height(36.dp))
@@ -257,6 +271,53 @@ private fun SettingsItem(label: String, value: String) {
}
}
@Composable
private fun AppLogo(size: Float) {
val pink = Color(0xFFEE67A4)
val black = Color(0xFF231F20)
val teal = Color(0xFF35BEB8)
Canvas(modifier = Modifier.size(size.dp)) {
val cx = this.size.width / 2f
val cy = this.size.height / 2f
val scale = this.size.width / 108f
val rCircle = 28f * scale
val rInner = 20f * scale
val rOuter = 35f * scale
val halfAngle = 23.5f
drawCircle(pink, radius = rCircle, center = Offset(cx, cy))
drawAnnularSector(cx, cy, rInner, rCircle, -halfAngle, halfAngle * 2f, black)
drawAnnularSector(cx, cy, rCircle, rOuter, -halfAngle, halfAngle * 2f, teal)
}
}
private fun DrawScope.drawAnnularSector(
cx: Float, cy: Float,
innerR: Float, outerR: Float,
startAngle: Float, sweepAngle: Float,
color: Color,
) {
val path = Path().apply {
arcTo(
rect = androidx.compose.ui.geometry.Rect(cx - outerR, cy - outerR, cx + outerR, cy + outerR),
startAngleDegrees = startAngle,
sweepAngleDegrees = sweepAngle,
forceMoveTo = true,
)
arcTo(
rect = androidx.compose.ui.geometry.Rect(cx - innerR, cy - innerR, cx + innerR, cy + innerR),
startAngleDegrees = startAngle + sweepAngle,
sweepAngleDegrees = -sweepAngle,
forceMoveTo = false,
)
close()
}
drawPath(path, color)
}
@Composable
private fun SettingToggle(label: String, value: String, accent: androidx.compose.ui.graphics.Color, subtleColor: androidx.compose.ui.graphics.Color, onClick: () -> Unit) {
Row(
@@ -92,6 +92,46 @@ fun TaskListScreen(
.background(subtleColor.copy(alpha = 0.15f))
)
// Parser doctor banner
val allIssues = grouped.warnings
val errorColor = Color(0xFFE51400)
val warnColor = Color(0xFFF0A30A)
val errors = allIssues.filter { it.severity == com.avinal.memos.parser.IssueSeverity.ERROR }
val warns = allIssues.filter { it.severity == com.avinal.memos.parser.IssueSeverity.WARNING }
var showWarningDetails by remember { mutableStateOf(false) }
// Composite keys (memoId:lineIndex) for dot indicators
val errorKeys = remember(allIssues) { allIssues.filter { it.severity == com.avinal.memos.parser.IssueSeverity.ERROR && it.taskText.isNotEmpty() }.map { "${it.memoId}:${it.lineIndex}" }.toSet() }
val warnKeys = remember(allIssues) { allIssues.filter { it.severity == com.avinal.memos.parser.IssueSeverity.WARNING && it.taskText.isNotEmpty() }.map { "${it.memoId}:${it.lineIndex}" }.toSet() }
if (allIssues.isNotEmpty()) {
Column(
modifier = Modifier
.fillMaxWidth()
.clickable { showWarningDetails = !showWarningDetails },
) {
val bannerBg = if (errors.isNotEmpty()) errorColor.copy(alpha = 0.08f) else warnColor.copy(alpha = 0.08f)
val summaryText = buildString {
append("${allIssues.size} issue${if (allIssues.size > 1) "s" else ""} found")
val parts = mutableListOf<String>()
if (errors.isNotEmpty()) parts.add("${errors.size} error${if (errors.size > 1) "s" else ""}")
if (warns.isNotEmpty()) parts.add("${warns.size} warning${if (warns.size > 1) "s" else ""}")
append(": ${parts.joinToString(", ")}")
}
Column(modifier = Modifier.fillMaxWidth().background(bannerBg)) {
Text(summaryText, fontSize = 12.sp,
color = if (errors.isNotEmpty()) errorColor else warnColor,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 6.dp))
if (showWarningDetails) {
Column(modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 6.dp)) {
MixedIssuesList(allIssues, errorColor, warnColor, textColor)
}
}
}
}
}
LazyColumn(modifier = Modifier.weight(1f)) {
grouped.groups.forEachIndexed { groupIndex, group ->
item(key = "header_${group.title}") {
@@ -134,9 +174,17 @@ fun TaskListScreen(
if (!group.collapsed) {
items(group.tasks, key = { it.id }) { task ->
val taskKey = "${task.memoId}:${task.lineIndex}"
val dotColor = when {
task.isCompleted -> null
taskKey in errorKeys -> errorColor
taskKey in warnKeys -> warnColor
else -> null
}
MetroTaskRow(
task = task,
accent = accent,
dotColor = dotColor,
textColor = textColor,
subtleColor = subtleColor,
onToggle = { viewModel.toggleTask(task) },
@@ -199,6 +247,7 @@ private fun MetroTaskRow(
subtleColor: Color,
onToggle: () -> Unit,
onClick: () -> Unit,
dotColor: Color? = null,
) {
val today = Clock.System.todayIn(TimeZone.currentSystemDefault())
@@ -254,6 +303,97 @@ private fun MetroTaskRow(
}
}
}
if (dotColor != null) {
Spacer(Modifier.width(6.dp))
Box(Modifier.size(5.dp).background(dotColor, androidx.compose.foundation.shape.CircleShape))
}
}
}
@Composable
private fun MixedIssuesList(
issues: List<com.avinal.memos.parser.ParseWarning>,
errorColor: Color, warnColor: Color, textColor: Color,
) {
val combined = issues.filter { it.taskText.isEmpty() }
val taskIssues = issues.filter { it.taskText.isNotEmpty() }
// Group by memo+line preserving document order
val grouped = mutableListOf<List<com.avinal.memos.parser.ParseWarning>>()
val seen = mutableSetOf<String>()
taskIssues.forEach { w ->
val key = "${w.memoId}:${w.lineIndex}"
if (key !in seen) {
seen.add(key)
grouped.add(taskIssues.filter { it.memoId == w.memoId && it.lineIndex == w.lineIndex })
}
}
// Task issues first, then combined at the end
var num = 0
grouped.forEach { group ->
num++
val first = group.first()
val allHighlights = group.map { w ->
w.highlight to (if (w.severity == com.avinal.memos.parser.IssueSeverity.ERROR) errorColor else warnColor)
}.filter { it.first.isNotEmpty() }
Column(modifier = Modifier.padding(top = 4.dp, bottom = 2.dp)) {
Row {
Text("$num. ", fontSize = 11.sp, color = textColor.copy(alpha = 0.5f))
HighlightedTextMultiColor(first.taskText, allHighlights, textColor)
}
group.forEach { w ->
val color = if (w.severity == com.avinal.memos.parser.IssueSeverity.ERROR) errorColor else warnColor
Text(w.issue, fontSize = 11.sp, color = color.copy(alpha = 0.8f),
modifier = Modifier.padding(start = 16.dp, top = 1.dp))
}
}
}
combined.forEach { w ->
num++
val color = if (w.severity == com.avinal.memos.parser.IssueSeverity.ERROR) errorColor else warnColor
Text("$num. ${w.issue}", fontSize = 11.sp, color = color.copy(alpha = 0.8f), modifier = Modifier.padding(top = 3.dp))
}
}
@Composable
private fun HighlightedTextMultiColor(text: String, highlights: List<Pair<String, Color>>, normalColor: Color) {
if (highlights.isEmpty()) {
Text(text, fontSize = 11.sp, color = normalColor)
return
}
data class Span(val start: Int, val end: Int, val color: Color)
val spans = highlights.flatMap { (h, color) ->
val results = mutableListOf<Span>()
var searchFrom = 0
while (true) {
val idx = text.indexOf(h, searchFrom, ignoreCase = true)
if (idx < 0) break
results.add(Span(idx, idx + h.length, color))
searchFrom = idx + h.length
}
results
}.sortedBy { it.start }
var pos = 0
Row {
spans.forEach { span ->
if (span.start > pos) {
Text(text.substring(pos, span.start), fontSize = 11.sp, color = normalColor)
}
if (span.start >= pos) {
Text(text.substring(span.start, span.end), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, color = span.color,
modifier = Modifier.background(span.color.copy(alpha = 0.12f), androidx.compose.foundation.shape.RoundedCornerShape(2.dp)).padding(horizontal = 2.dp))
pos = span.end
}
}
if (pos < text.length) {
Text(text.substring(pos), fontSize = 11.sp, color = normalColor)
}
}
}
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import com.avinal.memos.domain.Memo
import com.avinal.memos.domain.MemoRepository
import com.avinal.memos.domain.Task
import com.avinal.memos.parser.ParseWarning
import com.avinal.memos.parser.TaskParser
import kotlin.time.Clock
import kotlinx.coroutines.flow.MutableStateFlow
@@ -47,6 +48,7 @@ data class TaskGroup(
data class GroupedTasksResult(
val groups: List<TaskGroup> = emptyList(),
val availableLists: List<String> = emptyList(),
val warnings: List<ParseWarning> = emptyList(),
)
class TaskListViewModel(private val memoRepository: MemoRepository) : ViewModel() {
@@ -65,7 +67,8 @@ class TaskListViewModel(private val memoRepository: MemoRepository) : ViewModel(
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), GroupedTasksResult())
private fun buildGroups(memos: List<Memo>, filters: TaskFilterState, collapsed: Set<String>): GroupedTasksResult {
val allTasks = memos.flatMap { memo -> TaskParser.extractTasks(memo.id, memo.content) }
val allTasks = memos.flatMap { memo -> TaskParser.extractTasks(memo.id, memo.content, memo.tags) }
val allWarnings = memos.flatMap { memo -> TaskParser.validateContent(memo.content).map { it.copy(memoId = memo.id) } }
val availableLists = allTasks.flatMap { it.lists }.distinct().sorted()
val today = Clock.System.todayIn(TimeZone.currentSystemDefault())
@@ -179,6 +182,7 @@ class TaskListViewModel(private val memoRepository: MemoRepository) : ViewModel(
return GroupedTasksResult(
groups = groups.map { it.copy(collapsed = it.title in collapsed) },
availableLists = availableLists,
warnings = allWarnings,
)
}
@@ -0,0 +1,123 @@
package com.avinal.memos
import com.avinal.memos.parser.TaskParser
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ParserDoctorTest {
// --- Errors ---
@Test fun errorInvalidDate() {
val w = TaskParser.validateContent("- [ ] Fix 2026-13-45")
assertTrue(w.any { it.issue.contains("invalid date") })
assertEquals("2026-13-45", w.first { it.issue.contains("invalid date") }.highlight)
}
@Test fun errorInvalidPriority() {
val w = TaskParser.validateContent("- [ ] Fix p5")
assertTrue(w.any { it.issue.contains("invalid priority") })
assertEquals("p5", w.first { it.issue.contains("invalid priority") }.highlight)
}
@Test fun errorReminderWithoutDue() {
val w = TaskParser.validateContent("- [ ] Task !30min")
assertTrue(w.any { it.issue.contains("no due date") })
}
@Test fun errorInvalidReminderUnit() {
val w = TaskParser.validateContent("- [ ] Task !5blah today")
assertTrue(w.any { it.issue.contains("invalid reminder unit") })
}
// --- Warnings ---
@Test fun warnTimeWithoutDate() {
val w = TaskParser.validateContent("- [ ] Call 5pm")
assertTrue(w.any { it.issue.contains("time without date") })
assertEquals("5pm", w.first { it.issue.contains("time without date") }.highlight)
}
@Test fun warnDateInPast() {
val w = TaskParser.validateContent("- [ ] Old 2020-01-01")
assertTrue(w.any { it.issue.contains("in the past") })
}
@Test fun warnMultiplePriorities() {
val w = TaskParser.validateContent("- [ ] Fix p1 p2")
assertTrue(w.any { it.issue.contains("multiple priorities") })
}
@Test fun warnMultipleDates() {
val w = TaskParser.validateContent("- [ ] Fix 2026-06-01 2026-07-01")
assertTrue(w.any { it.issue.contains("multiple dates") })
}
@Test fun warnMultipleReminders() {
val w = TaskParser.validateContent("- [ ] Fix !30min !1hr today")
assertTrue(w.any { it.issue.contains("multiple reminders") })
}
@Test fun warnTypoToday() {
val w = TaskParser.validateContent("- [ ] Call tday")
assertTrue(w.any { it.issue.contains("today") })
assertEquals("tday", w.first { it.issue.contains("today") }.highlight)
}
@Test fun warnTypoTomorrow() {
val w = TaskParser.validateContent("- [ ] Fix tomorow")
assertTrue(w.any { it.issue.contains("tomorrow") })
}
@Test fun warnTypoSunday() {
val w = TaskParser.validateContent("- [ ] Meet sundie")
assertTrue(w.any { it.issue.contains("sunday") })
}
@Test fun warnCombinedNoDates() {
val content = "- [ ] Task A\n- [ ] Task B\n- [ ] Task C"
val w = TaskParser.validateContent(content)
assertTrue(w.any { it.issue.contains("tasks have no due date") })
}
// --- No false positives ---
@Test fun noWarningsForValid() {
assertTrue(TaskParser.validateContent("- [ ] Buy milk today 5pm !30min p1 #work").isEmpty())
}
@Test fun skipsCompletedTasks() {
assertTrue(TaskParser.validateContent("- [x] Done p5 2099-99-99").isEmpty())
}
@Test fun noWarningPlainText() {
assertTrue(TaskParser.validateContent("Just text").isEmpty())
}
@Test fun noWarningTodayTime() {
assertTrue(TaskParser.validateContent("- [ ] Call today 5pm").isEmpty())
}
@Test fun noWarningTomorrowReminder() {
assertTrue(TaskParser.validateContent("- [ ] Call tomorrow !1hr").isEmpty())
}
// --- Metadata ---
@Test fun taskTextIncluded() {
val w = TaskParser.validateContent("- [ ] Buy groceries 2020-01-01")
assertTrue(w.first().taskText.contains("Buy groceries"))
}
@Test fun correctLineIndex() {
val w = TaskParser.validateContent("header\n\n- [ ] Task 2026-99-99")
assertEquals(2, w[0].lineIndex)
}
@Test fun singleTaskNoDateNotWarned() {
// Only 1 task without date should not trigger the combined warning
val w = TaskParser.validateContent("- [ ] Single task")
assertTrue(w.none { it.issue.contains("tasks have no due date") })
}
}