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:
+1
-1
@@ -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") })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user