diff --git a/.gitignore b/.gitignore
index 0f3cc3d..f9d3f7e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,3 +25,8 @@ composeApp/schemas/
index.html
script.js
styles.css
+
+# Dev artifacts
+PLAN.md
+Screenshot_*.png
+logo-*.svg
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..328ea0a
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Avinal Kumar
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/LOGO.md b/LOGO.md
new file mode 100644
index 0000000..13b6127
--- /dev/null
+++ b/LOGO.md
@@ -0,0 +1,130 @@
+# MemosApp Logo Specification
+
+## Concept
+
+The logo is inspired by the Google Foobar challenge logo, adapted into a circular form. A large pink circle carries a 47-degree angular notch on the right side, split into black (inside the circle) and teal (extending beyond).
+
+## Colors
+
+| Element | Hex | Usage |
+|-----------------|-----------|----------------------------------------|
+| Pink | `#EE67A4` | Main circle fill |
+| Black | `#231F20` | Wedge section inside the circle |
+| Teal | `#35BEB8` | Wedge section outside the circle |
+| Background | `#1F1F1F` | Launcher icon background |
+
+## Geometry (Circle Variant — Primary)
+
+All measurements relative to a 108x108dp Android adaptive icon viewport, center at (54, 54).
+
+### Base Shape
+
+- **Pink circle**: radius = 28, centered at (54, 54)
+
+### 47-Degree Wedge
+
+The wedge is an annular sector — it does NOT originate from the center. It spans between two imaginary concentric circles:
+
+- **Imaginary inner circle**: radius = 20 (where the wedge starts)
+- **Imaginary outer circle**: radius = 35 (where the wedge ends)
+- **Wedge angle**: 47 degrees, centered on the horizontal-right axis (east)
+- **Half-angle**: 23.5 degrees above and below the horizontal
+
+### Wedge Sections
+
+1. **Black section** — the portion of the wedge that falls inside the pink circle
+ - Annular sector from r=20 to r=28
+ - Creates a dark notch visible against the pink fill
+
+2. **Teal section** — the portion of the wedge that extends beyond the pink circle
+ - Annular sector from r=28 to r=35
+ - Creates a colored protrusion outside the main circle
+
+### Key Coordinates
+
+Using center (54, 54), half-angle 23.5 degrees:
+
+```
+cos(23.5°) = 0.91706
+sin(23.5°) = 0.39875
+```
+
+| Point | X | Y |
+|---------------------------|--------|--------|
+| Inner upper (r=20) | 72.34 | 46.02 |
+| Inner lower (r=20) | 72.34 | 61.98 |
+| Circle upper (r=28) | 79.68 | 42.84 |
+| Circle lower (r=28) | 79.68 | 65.16 |
+| Outer upper (r=35) | 86.10 | 40.04 |
+| Outer lower (r=35) | 86.10 | 67.96 |
+
+### Construction Steps
+
+1. Draw a filled circle at (54, 54) with radius 28, color `#EE67A4`
+2. Draw the black annular sector:
+ - Path from inner-upper → circle-upper → arc along r=28 to circle-lower → inner-lower → arc along r=20 back
+3. Draw the teal annular sector:
+ - Path from circle-upper → outer-upper → arc along r=35 to outer-lower → circle-lower → arc along r=28 back
+
+### Android Vector Drawable Paths
+
+```xml
+
+
+
+
+
+
+
+
+```
+
+## Geometry (Triangle Variant — Alternate)
+
+Three concentric equilateral triangles pointing upward, with a 47-degree wedge cutting through the right edge.
+
+### Triangle Circumradii (200x200 SVG, center at 100,100)
+
+| Ring | Outer R | Inner R | Fill |
+|----------------|---------|---------|--------------|
+| Outer ring | 80 | 65 | Pink |
+| Middle ring | 55 | 40 | Pink |
+| Inner triangle | 30 | — | Pink (solid) |
+| Gap | 65→55 | — | Background |
+| Gap | 40→30 | — | Background |
+
+### Wedge Intersections
+
+The 47-degree wedge (centered on horizontal-right) intersects each triangle's right edge. The intersection points are computed by solving the parametric line-line intersection of the wedge rays with each triangle edge.
+
+- **Black section**: wedge intersection with the middle ring (R=40 to R=55)
+- **Teal section**: wedge intersection with the outer ring (R=65 to R=80)
+- **Inner triangle**: stays fully pink
+
+## Adaptive Icon Layers
+
+| File | Purpose |
+|-----------------------------|-----------------------------|
+| `ic_launcher_foreground.xml`| Colored logo (pink/black/teal) |
+| `ic_launcher_background.xml`| Solid `#1F1F1F` background |
+| `ic_launcher_monochrome.xml`| White silhouette for themed icons |
+
+### Safe Zone
+
+Android adaptive icons use a 108dp canvas. The recommended safe zone is a 66dp diameter circle (radius 33 from center). All critical logo elements should fall within this zone.
+
+- Pink circle (r=28): within safe zone
+- Teal extension (r=35): at safe zone boundary, may be slightly clipped on some launchers — this is intentional as it creates a "bleeding edge" effect
+
+## Files
+
+- `logo-circle.svg` — Circle variant preview
+- `logo-triangle.svg` — Triangle variant preview
+- `androidApp/src/main/res/drawable/ic_launcher_foreground.xml` — Production circle logo
+- `androidApp/src/main/res/drawable/ic_launcher_monochrome.xml` — Themed icon silhouette
+- `androidApp/src/main/res/drawable/ic_launcher_background.xml` — Dark background
diff --git a/androidApp/src/main/res/drawable/ic_launcher_background.xml b/androidApp/src/main/res/drawable/ic_launcher_background.xml
index 07d5da9..f990b07 100644
--- a/androidApp/src/main/res/drawable/ic_launcher_background.xml
+++ b/androidApp/src/main/res/drawable/ic_launcher_background.xml
@@ -1,170 +1,9 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ android:fillColor="#1F1F1F"
+ android:pathData="M0,0h108v108H0z" />
diff --git a/androidApp/src/main/res/drawable/ic_launcher_foreground.xml b/androidApp/src/main/res/drawable/ic_launcher_foreground.xml
index 2b068d1..a215b4e 100644
--- a/androidApp/src/main/res/drawable/ic_launcher_foreground.xml
+++ b/androidApp/src/main/res/drawable/ic_launcher_foreground.xml
@@ -1,30 +1,22 @@
-
-
-
-
-
-
-
-
+
+
-
\ No newline at end of file
+ android:fillColor="#EE67A4"
+ android:pathData="M26,54 A28,28 0 1,1 82,54 A28,28 0 1,1 26,54 Z" />
+
+
+
+
+
+
+
+
diff --git a/androidApp/src/main/res/drawable/ic_launcher_monochrome.xml b/androidApp/src/main/res/drawable/ic_launcher_monochrome.xml
new file mode 100644
index 0000000..8663803
--- /dev/null
+++ b/androidApp/src/main/res/drawable/ic_launcher_monochrome.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
diff --git a/androidApp/src/main/res/mipmap-anydpi/ic_launcher.xml b/androidApp/src/main/res/mipmap-anydpi/ic_launcher.xml
index 6f3b755..8fde456 100644
--- a/androidApp/src/main/res/mipmap-anydpi/ic_launcher.xml
+++ b/androidApp/src/main/res/mipmap-anydpi/ic_launcher.xml
@@ -2,5 +2,5 @@
-
-
\ No newline at end of file
+
+
diff --git a/androidApp/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/androidApp/src/main/res/mipmap-anydpi/ic_launcher_round.xml
index 6f3b755..8fde456 100644
--- a/androidApp/src/main/res/mipmap-anydpi/ic_launcher_round.xml
+++ b/androidApp/src/main/res/mipmap-anydpi/ic_launcher_round.xml
@@ -2,5 +2,5 @@
-
-
\ No newline at end of file
+
+
diff --git a/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/DirectAlarmScheduler.kt b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/DirectAlarmScheduler.kt
index 0394ba7..3b09165 100644
--- a/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/DirectAlarmScheduler.kt
+++ b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/DirectAlarmScheduler.kt
@@ -13,7 +13,7 @@ import kotlinx.datetime.TimeZone
object DirectAlarmScheduler {
fun scheduleFromMemos(context: Context, memos: List) {
- 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()
diff --git a/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskCheckWorker.kt b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskCheckWorker.kt
index 3657758..9f5c8db 100644
--- a/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskCheckWorker.kt
+++ b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskCheckWorker.kt
@@ -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()
diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/AppDependencies.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/AppDependencies.kt
index 5c94546..75ab4d7 100644
--- a/composeApp/src/commonMain/kotlin/com/avinal/memos/AppDependencies.kt
+++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/AppDependencies.kt
@@ -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()
}
}
diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/db/MemosDatabase.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/db/MemosDatabase.kt
index 385ed44..112d65f 100644
--- a/composeApp/src/commonMain/kotlin/com/avinal/memos/db/MemosDatabase.kt
+++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/db/MemosDatabase.kt
@@ -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
}
diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/db/dao/PendingSyncDao.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/db/dao/PendingSyncDao.kt
new file mode 100644
index 0000000..a7cb586
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/db/dao/PendingSyncDao.kt
@@ -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
+
+ @Query("SELECT COUNT(*) FROM pending_sync")
+ fun observeCount(): Flow
+
+ @Query("DELETE FROM pending_sync WHERE id = :id")
+ suspend fun deleteById(id: Long)
+
+ @Query("DELETE FROM pending_sync")
+ suspend fun deleteAll()
+}
diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/db/entity/PendingSyncEntity.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/db/entity/PendingSyncEntity.kt
new file mode 100644
index 0000000..ae4c750
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/db/entity/PendingSyncEntity.kt
@@ -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,
+)
diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/MemoRepository.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/MemoRepository.kt
index 4aa24fb..09598f9 100644
--- a/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/MemoRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/domain/MemoRepository.kt
@@ -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 = _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> {
+ drainPendingSync()
+
nextPageToken = ""
hasMorePages = true
val allFetched = mutableListOf()
@@ -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
diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/parser/ParseResult.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/parser/ParseResult.kt
new file mode 100644
index 0000000..7e4dd15
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/parser/ParseResult.kt
@@ -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,
+)
diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/parser/TaskParser.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/parser/TaskParser.kt
index 5e38c30..fe09d66 100644
--- a/composeApp/src/commonMain/kotlin/com/avinal/memos/parser/TaskParser.kt
+++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/parser/TaskParser.kt
@@ -27,7 +27,7 @@ object TaskParser {
private val priorityRegex = Regex("""\bp([1-3])\b""")
private val listRegex = Regex("""(? {
+ fun extractTasks(memoId: String, content: String, memoTags: List = emptyList()): List {
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 =
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 {
+ val warnings = mutableListOf()
+ 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, "")
diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MainScreen.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MainScreen.kt
index 94f7a77..e36b91e 100644
--- a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MainScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MainScreen.kt
@@ -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() ?: "" }
}
diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoDetailViewModel.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoDetailViewModel.kt
index 1ed3746..00376eb 100644
--- a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoDetailViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoDetailViewModel.kt
@@ -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)
diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoListScreen.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoListScreen.kt
index 37237f7..809330d 100644
--- a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoListScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoListScreen.kt
@@ -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) {
diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoListViewModel.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoListViewModel.kt
index 74c0b1c..826824d 100644
--- a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoListViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/memos/MemoListViewModel.kt
@@ -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 = 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)
diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/settings/SettingsScreen.kt
index 818fb59..9a5af0e 100644
--- a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/settings/SettingsScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/settings/SettingsScreen.kt
@@ -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(
diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskListScreen.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskListScreen.kt
index 3b4ec20..0a27c14 100644
--- a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskListScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskListScreen.kt
@@ -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()
+ 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,
+ 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>()
+ val seen = mutableSetOf()
+ 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>, 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()
+ 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)
+ }
}
}
diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskListViewModel.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskListViewModel.kt
index 1349a4c..4aca155 100644
--- a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskListViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/tasks/TaskListViewModel.kt
@@ -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 = emptyList(),
val availableLists: List = emptyList(),
+ val warnings: List = 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, filters: TaskFilterState, collapsed: Set): 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,
)
}
diff --git a/composeApp/src/commonTest/kotlin/com/avinal/memos/ParserDoctorTest.kt b/composeApp/src/commonTest/kotlin/com/avinal/memos/ParserDoctorTest.kt
new file mode 100644
index 0000000..99df913
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/com/avinal/memos/ParserDoctorTest.kt
@@ -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") })
+ }
+}