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

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

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

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

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

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

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

144 tests passing.

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

Signed-off-by: Avinal Kumar <avinal.xlvii@gmail.com>
This commit is contained in:
2026-05-22 16:40:58 +05:30
parent 0512c9a698
commit 91b6a479a4
25 changed files with 942 additions and 278 deletions
+5
View File
@@ -25,3 +25,8 @@ composeApp/schemas/
index.html
script.js
styles.css
# Dev artifacts
PLAN.md
Screenshot_*.png
logo-*.svg
+21
View File
@@ -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.
+130
View File
@@ -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
<!-- Pink circle -->
<path android:fillColor="#EE67A4"
android:pathData="M26,54 A28,28 0 1,1 82,54 A28,28 0 1,1 26,54 Z" />
<!-- Black annular sector (r=20 to r=28) -->
<path android:fillColor="#231F20"
android:pathData="M72.34,46.02 L79.68,42.84 A28,28 0 0,1 79.68,65.16
L72.34,61.98 A20,20 0 0,0 72.34,46.02 Z" />
<!-- Teal annular sector (r=28 to r=35) -->
<path android:fillColor="#35BEB8"
android:pathData="M79.68,42.84 L86.1,40.04 A35,35 0 0,1 86.1,67.96
L79.68,65.16 A28,28 0 0,0 79.68,42.84 Z" />
```
## 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
@@ -1,170 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
android:fillColor="#1F1F1F"
android:pathData="M0,0h108v108H0z" />
</vector>
@@ -1,30 +1,22 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<!-- Pink circle r=28 -->
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
android:fillColor="#EE67A4"
android:pathData="M26,54 A28,28 0 1,1 82,54 A28,28 0 1,1 26,54 Z" />
<!-- Black 47° annular sector: imaginary inner r=20 to pink edge r=28 -->
<path
android:fillColor="#231F20"
android:pathData="M72.34,46.02 L79.68,42.84 A28,28 0 0,1 79.68,65.16 L72.34,61.98 A20,20 0 0,0 72.34,46.02 Z" />
<!-- Teal 47° annular sector: pink edge r=28 to imaginary outer r=35 -->
<path
android:fillColor="#35BEB8"
android:pathData="M79.68,42.84 L86.1,40.04 A35,35 0 0,1 86.1,67.96 L79.68,65.16 A28,28 0 0,0 79.68,42.84 Z" />
</vector>
@@ -0,0 +1,17 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Circle silhouette -->
<path
android:fillColor="#FFFFFF"
android:pathData="M26,54 A28,28 0 1,1 82,54 A28,28 0 1,1 26,54 Z" />
<!-- Wedge extension beyond circle -->
<path
android:fillColor="#FFFFFF"
android:pathData="M79.68,42.84 L86.1,40.04 A35,35 0 0,1 86.1,67.96 L79.68,65.16 A28,28 0 0,0 79.68,42.84 Z" />
</vector>
@@ -2,5 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>
@@ -2,5 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>
@@ -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") })
}
}