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") }) + } +}