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:
@@ -25,3 +25,8 @@ composeApp/schemas/
|
|||||||
index.html
|
index.html
|
||||||
script.js
|
script.js
|
||||||
styles.css
|
styles.css
|
||||||
|
|
||||||
|
# Dev artifacts
|
||||||
|
PLAN.md
|
||||||
|
Screenshot_*.png
|
||||||
|
logo-*.svg
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="108dp"
|
android:width="108dp"
|
||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="108">
|
||||||
<path
|
<path
|
||||||
android:fillColor="#3DDC84"
|
android:fillColor="#1F1F1F"
|
||||||
android:pathData="M0,0h108v108h-108z" />
|
android:pathData="M0,0h108v108H0z" />
|
||||||
<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" />
|
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
@@ -1,30 +1,22 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:aapt="http://schemas.android.com/aapt"
|
|
||||||
android:width="108dp"
|
android:width="108dp"
|
||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="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">
|
<!-- Pink circle r=28 -->
|
||||||
<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>
|
|
||||||
<path
|
<path
|
||||||
android:fillColor="#FFFFFF"
|
android:fillColor="#EE67A4"
|
||||||
android:fillType="nonZero"
|
android:pathData="M26,54 A28,28 0 1,1 82,54 A28,28 0 1,1 26,54 Z" />
|
||||||
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"
|
<!-- Black 47° annular sector: imaginary inner r=20 to pink edge r=28 -->
|
||||||
android:strokeColor="#00000000" />
|
<path
|
||||||
</vector>
|
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">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ import kotlinx.datetime.TimeZone
|
|||||||
object DirectAlarmScheduler {
|
object DirectAlarmScheduler {
|
||||||
|
|
||||||
fun scheduleFromMemos(context: Context, memos: List<Memo>) {
|
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 nowMillis = Clock.System.now().toEpochMilliseconds()
|
||||||
val tz = TimeZone.currentSystemDefault()
|
val tz = TimeZone.currentSystemDefault()
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class TaskCheckWorker(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
val memos = db.memoDao().getAll().map { it.toDomain() }
|
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 alarmManager = appContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||||
val tz = TimeZone.currentSystemDefault()
|
val tz = TimeZone.currentSystemDefault()
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ class AppDependencies(
|
|||||||
val memoRepository: MemoRepository by lazy {
|
val memoRepository: MemoRepository by lazy {
|
||||||
MemoRepository(apiClient, database.memoDao()) {
|
MemoRepository(apiClient, database.memoDao()) {
|
||||||
com.avinal.memos.util.triggerReminderCheck()
|
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.Database
|
||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
import com.avinal.memos.db.dao.MemoDao
|
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.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 class MemosDatabase : RoomDatabase() {
|
||||||
abstract fun memoDao(): MemoDao
|
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.MemosApiClient
|
||||||
import com.avinal.memos.api.model.toDomain
|
import com.avinal.memos.api.model.toDomain
|
||||||
import com.avinal.memos.db.dao.MemoDao
|
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.toEntity
|
||||||
import com.avinal.memos.db.entity.toDomain
|
import com.avinal.memos.db.entity.toDomain
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
@@ -11,8 +13,13 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.IO
|
import kotlinx.coroutines.IO
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
|
||||||
class MemoRepository(
|
class MemoRepository(
|
||||||
private val apiClient: MemosApiClient,
|
private val apiClient: MemosApiClient,
|
||||||
@@ -24,6 +31,12 @@ class MemoRepository(
|
|||||||
private var lastFetchTime: Long = 0L
|
private var lastFetchTime: Long = 0L
|
||||||
var syncIntervalMinutes: Int = 5
|
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 nowMillis(): Long = Clock.System.now().toEpochMilliseconds()
|
||||||
|
|
||||||
private fun isCacheStale(): Boolean = (nowMillis() - lastFetchTime) > (syncIntervalMinutes * 60 * 1000L)
|
private fun isCacheStale(): Boolean = (nowMillis() - lastFetchTime) > (syncIntervalMinutes * 60 * 1000L)
|
||||||
@@ -52,6 +65,8 @@ class MemoRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun refreshMemos(): ApiResult<List<Memo>> {
|
suspend fun refreshMemos(): ApiResult<List<Memo>> {
|
||||||
|
drainPendingSync()
|
||||||
|
|
||||||
nextPageToken = ""
|
nextPageToken = ""
|
||||||
hasMorePages = true
|
hasMorePages = true
|
||||||
val allFetched = mutableListOf<Memo>()
|
val allFetched = mutableListOf<Memo>()
|
||||||
@@ -65,12 +80,14 @@ class MemoRepository(
|
|||||||
token = result.data.nextPageToken
|
token = result.data.nextPageToken
|
||||||
}
|
}
|
||||||
is ApiResult.Error -> return result
|
is ApiResult.Error -> return result
|
||||||
is ApiResult.NetworkError -> return result
|
is ApiResult.NetworkError -> { isOffline = true; return result }
|
||||||
}
|
}
|
||||||
} while (token.isNotEmpty())
|
} while (token.isNotEmpty())
|
||||||
|
|
||||||
|
isOffline = false
|
||||||
val now = nowMillis()
|
val now = nowMillis()
|
||||||
lastFetchTime = now
|
lastFetchTime = now
|
||||||
|
_lastSyncTime.value = now
|
||||||
memoDao.deleteAll()
|
memoDao.deleteAll()
|
||||||
memoDao.upsertAll(allFetched.map { it.toEntity(now) })
|
memoDao.upsertAll(allFetched.map { it.toEntity(now) })
|
||||||
nextPageToken = ""
|
nextPageToken = ""
|
||||||
@@ -107,7 +124,14 @@ class MemoRepository(
|
|||||||
ApiResult.Success(memo)
|
ApiResult.Success(memo)
|
||||||
}
|
}
|
||||||
is ApiResult.Error -> result
|
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)
|
ApiResult.Success(memo)
|
||||||
}
|
}
|
||||||
is ApiResult.Error -> result
|
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() {
|
suspend fun clearCache() {
|
||||||
memoDao.deleteAll()
|
memoDao.deleteAll()
|
||||||
lastFetchTime = 0L
|
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 priorityRegex = Regex("""\bp([1-3])\b""")
|
||||||
private val listRegex = Regex("""(?<!\w)#([a-zA-Z]\w*)""")
|
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
|
var taskOrdinal = 0
|
||||||
return content.lines().mapIndexedNotNull { index, line ->
|
return content.lines().mapIndexedNotNull { index, line ->
|
||||||
val match = taskLineRegex.find(line) ?: return@mapIndexedNotNull null
|
val match = taskLineRegex.find(line) ?: return@mapIndexedNotNull null
|
||||||
@@ -36,6 +36,7 @@ object TaskParser {
|
|||||||
val cleanText = cleanTaskText(rawText)
|
val cleanText = cleanTaskText(rawText)
|
||||||
taskOrdinal++
|
taskOrdinal++
|
||||||
|
|
||||||
|
val parsedLists = parseLists(rawText)
|
||||||
Task(
|
Task(
|
||||||
id = "${memoId}:${hashContent(cleanText, taskOrdinal)}",
|
id = "${memoId}:${hashContent(cleanText, taskOrdinal)}",
|
||||||
memoId = memoId,
|
memoId = memoId,
|
||||||
@@ -48,7 +49,7 @@ object TaskParser {
|
|||||||
dueTime = parseDueTime(rawText),
|
dueTime = parseDueTime(rawText),
|
||||||
reminder = parseReminder(rawText),
|
reminder = parseReminder(rawText),
|
||||||
priority = parsePriority(rawText),
|
priority = parsePriority(rawText),
|
||||||
lists = parseLists(rawText),
|
lists = parsedLists.ifEmpty { memoTags },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,6 +170,122 @@ object TaskParser {
|
|||||||
private fun parseLists(text: String): List<String> =
|
private fun parseLists(text: String): List<String> =
|
||||||
listRegex.findAll(text).map { it.groupValues[1] }.toList()
|
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 {
|
private fun cleanTaskText(text: String): String {
|
||||||
var clean = text
|
var clean = text
|
||||||
clean = priorityRegex.replace(clean, "")
|
clean = priorityRegex.replace(clean, "")
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import androidx.compose.material3.OutlinedTextField
|
|||||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -62,7 +63,6 @@ import kotlinx.datetime.toLocalDateTime
|
|||||||
|
|
||||||
private val pivotTitles = listOf("explore", "memos", "tasks", "settings")
|
private val pivotTitles = listOf("explore", "memos", "tasks", "settings")
|
||||||
private const val START_PAGE = 1
|
private const val START_PAGE = 1
|
||||||
private const val PARALLAX_FACTOR = 0.5f
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainScreen(
|
fun MainScreen(
|
||||||
@@ -89,81 +89,135 @@ fun MainScreen(
|
|||||||
.background(MaterialTheme.colorScheme.background)
|
.background(MaterialTheme.colorScheme.background)
|
||||||
.statusBarsPadding(),
|
.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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 12.dp, bottom = 6.dp),
|
.padding(top = 12.dp, bottom = 6.dp),
|
||||||
) {
|
) {
|
||||||
val scrollFraction = pagerState.currentPage + pagerState.currentPageOffsetFraction
|
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(
|
pivotTitles.forEachIndexed { index, title ->
|
||||||
modifier = Modifier
|
val offsetPx = (titlePositions[index] + baseShift).toInt()
|
||||||
.offset { IntOffset(parallaxOffset, 0) }
|
val distance = kotlin.math.abs(scrollFraction - index)
|
||||||
.padding(start = 24.dp),
|
val alpha = (1f - distance * 0.5f).coerceIn(0.15f, 1f)
|
||||||
horizontalArrangement = Arrangement.spacedBy(20.dp),
|
val isSelected = pagerState.currentPage == index
|
||||||
) {
|
|
||||||
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
|
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
fontSize = 28.sp,
|
fontSize = 42.sp,
|
||||||
fontWeight = FontWeight.Light,
|
fontWeight = FontWeight.Light,
|
||||||
color = if (isSelected) accent.copy(alpha = alpha)
|
color = if (isSelected) accent.copy(alpha = alpha)
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha * 0.5f),
|
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha * 0.4f),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Visible,
|
modifier = Modifier
|
||||||
softWrap = false,
|
.offset { IntOffset(offsetPx, 0) }
|
||||||
modifier = Modifier
|
.clickable { scope.launch { pagerState.animateScrollToPage(index) } }
|
||||||
.clickable { scope.launch { pagerState.animateScrollToPage(index) } }
|
.padding(vertical = 4.dp),
|
||||||
.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(
|
// Bottom status banner
|
||||||
state = pagerState,
|
if (isOffline || pendingCount > 0 || syncAge > 5) {
|
||||||
modifier = Modifier.fillMaxSize(),
|
Row(
|
||||||
beyondViewportPageCount = 1,
|
modifier = Modifier
|
||||||
) { page ->
|
.fillMaxWidth()
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
.background(
|
||||||
when (page) {
|
if (isOffline) MaterialTheme.colorScheme.error.copy(alpha = 0.15f)
|
||||||
0 -> ExplorerPage(
|
else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||||
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(
|
.padding(horizontal = 24.dp, vertical = 6.dp),
|
||||||
deps = deps,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
onMemoClick = onMemoClick,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
onCreateMemo = onCreateMemo,
|
) {
|
||||||
dateFilter = dateFilter,
|
val statusText = buildString {
|
||||||
tagFilter = tagFilter,
|
if (isOffline) append("offline")
|
||||||
searchFilter = searchFilter,
|
else if (syncAge < 0) append("not synced")
|
||||||
showArchived = showArchived,
|
else if (syncAge == 0) append("synced just now")
|
||||||
onClearFilter = { dateFilter = null; tagFilter = null; searchFilter = null; showArchived = false },
|
else append("synced ${syncAge}m ago")
|
||||||
)
|
}
|
||||||
2 -> TaskListScreen(deps = deps, onMemoClick = onMemoClick)
|
Text(statusText, fontSize = 11.sp, color = if (isOffline) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
3 -> SettingsScreen(deps = deps, onLogout = onLogout)
|
|
||||||
|
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))
|
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(
|
Text(
|
||||||
"view archived memos",
|
"view archived memos${if (archivedCount > 0) " ($archivedCount)" else ""}",
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
color = accent,
|
color = accent,
|
||||||
modifier = Modifier.clickable { onShowArchived() }.padding(vertical = 4.dp),
|
modifier = Modifier.clickable { onShowArchived() }.padding(vertical = 4.dp),
|
||||||
@@ -360,7 +423,7 @@ private fun ExplorerPage(
|
|||||||
if (allTags.isNotEmpty()) {
|
if (allTags.isNotEmpty()) {
|
||||||
val tasksByTag = remember(memos) {
|
val tasksByTag = remember(memos) {
|
||||||
val parser = com.avinal.memos.parser.TaskParser
|
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() ?: "" }
|
allTasks.filter { !it.isCompleted }.groupBy { it.lists.firstOrNull() ?: "" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class MemoDetailViewModel(
|
|||||||
|
|
||||||
fun toggleTask(lineIndex: Int, checked: Boolean) {
|
fun toggleTask(lineIndex: Int, checked: Boolean) {
|
||||||
val current = memo.value ?: return
|
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
|
val task = tasks.find { it.lineIndex == lineIndex } ?: return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val newContent = com.avinal.memos.parser.TaskParser.toggleTaskInContent(current.content, task)
|
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()) {
|
if (uploadedAttachmentNames.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
"${uploadedAttachmentNames.size} attachment(s) ready",
|
"${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) {
|
if (showArchived && isLoadingArchived) {
|
||||||
item {
|
item {
|
||||||
Box(Modifier.fillMaxWidth().padding(top = 48.dp), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxWidth().padding(top = 48.dp), contentAlignment = Alignment.Center) {
|
||||||
@@ -393,14 +415,7 @@ fun MemoListScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uiState.error != null) {
|
// Errors shown via bottom banner in MainScreen, not inline
|
||||||
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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
items(memos, key = { it.id }) { memo ->
|
items(memos, key = { it.id }) { memo ->
|
||||||
if (showArchived) {
|
if (showArchived) {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ data class MemoListUiState(
|
|||||||
val isSearching: Boolean = false,
|
val isSearching: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val isInitialLoading: Boolean = true,
|
val isInitialLoading: Boolean = true,
|
||||||
|
val statusMessage: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
class MemoListViewModel(private val memoRepository: MemoRepository) : ViewModel() {
|
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()) {
|
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) {
|
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) {
|
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) {
|
fun toggleTask(memoId: String, lineIndex: Int, checked: Boolean) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val memo = memoRepository.getMemo(memoId) ?: return@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 task = tasks.find { it.lineIndex == lineIndex } ?: return@launch
|
||||||
val newContent = com.avinal.memos.parser.TaskParser.toggleTaskInContent(memo.content, task)
|
val newContent = com.avinal.memos.parser.TaskParser.toggleTaskInContent(memo.content, task)
|
||||||
if (newContent != memo.content) memoRepository.updateMemo(memoId, content = newContent)
|
if (newContent != memo.content) memoRepository.updateMemo(memoId, content = newContent)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.avinal.memos.ui.settings
|
package com.avinal.memos.ui.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -31,7 +32,11 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.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.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
@@ -225,7 +230,16 @@ fun SettingsScreen(
|
|||||||
|
|
||||||
Spacer(Modifier.height(36.dp))
|
Spacer(Modifier.height(36.dp))
|
||||||
SectionHeader("about")
|
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))
|
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
|
@Composable
|
||||||
private fun SettingToggle(label: String, value: String, accent: androidx.compose.ui.graphics.Color, subtleColor: androidx.compose.ui.graphics.Color, onClick: () -> Unit) {
|
private fun SettingToggle(label: String, value: String, accent: androidx.compose.ui.graphics.Color, subtleColor: androidx.compose.ui.graphics.Color, onClick: () -> Unit) {
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -92,6 +92,46 @@ fun TaskListScreen(
|
|||||||
.background(subtleColor.copy(alpha = 0.15f))
|
.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)) {
|
LazyColumn(modifier = Modifier.weight(1f)) {
|
||||||
grouped.groups.forEachIndexed { groupIndex, group ->
|
grouped.groups.forEachIndexed { groupIndex, group ->
|
||||||
item(key = "header_${group.title}") {
|
item(key = "header_${group.title}") {
|
||||||
@@ -134,9 +174,17 @@ fun TaskListScreen(
|
|||||||
|
|
||||||
if (!group.collapsed) {
|
if (!group.collapsed) {
|
||||||
items(group.tasks, key = { it.id }) { task ->
|
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(
|
MetroTaskRow(
|
||||||
task = task,
|
task = task,
|
||||||
accent = accent,
|
accent = accent,
|
||||||
|
dotColor = dotColor,
|
||||||
textColor = textColor,
|
textColor = textColor,
|
||||||
subtleColor = subtleColor,
|
subtleColor = subtleColor,
|
||||||
onToggle = { viewModel.toggleTask(task) },
|
onToggle = { viewModel.toggleTask(task) },
|
||||||
@@ -199,6 +247,7 @@ private fun MetroTaskRow(
|
|||||||
subtleColor: Color,
|
subtleColor: Color,
|
||||||
onToggle: () -> Unit,
|
onToggle: () -> Unit,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
dotColor: Color? = null,
|
||||||
) {
|
) {
|
||||||
val today = Clock.System.todayIn(TimeZone.currentSystemDefault())
|
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.Memo
|
||||||
import com.avinal.memos.domain.MemoRepository
|
import com.avinal.memos.domain.MemoRepository
|
||||||
import com.avinal.memos.domain.Task
|
import com.avinal.memos.domain.Task
|
||||||
|
import com.avinal.memos.parser.ParseWarning
|
||||||
import com.avinal.memos.parser.TaskParser
|
import com.avinal.memos.parser.TaskParser
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -47,6 +48,7 @@ data class TaskGroup(
|
|||||||
data class GroupedTasksResult(
|
data class GroupedTasksResult(
|
||||||
val groups: List<TaskGroup> = emptyList(),
|
val groups: List<TaskGroup> = emptyList(),
|
||||||
val availableLists: List<String> = emptyList(),
|
val availableLists: List<String> = emptyList(),
|
||||||
|
val warnings: List<ParseWarning> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
class TaskListViewModel(private val memoRepository: MemoRepository) : ViewModel() {
|
class TaskListViewModel(private val memoRepository: MemoRepository) : ViewModel() {
|
||||||
@@ -65,7 +67,8 @@ class TaskListViewModel(private val memoRepository: MemoRepository) : ViewModel(
|
|||||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), GroupedTasksResult())
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), GroupedTasksResult())
|
||||||
|
|
||||||
private fun buildGroups(memos: List<Memo>, filters: TaskFilterState, collapsed: Set<String>): 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 availableLists = allTasks.flatMap { it.lists }.distinct().sorted()
|
||||||
val today = Clock.System.todayIn(TimeZone.currentSystemDefault())
|
val today = Clock.System.todayIn(TimeZone.currentSystemDefault())
|
||||||
|
|
||||||
@@ -179,6 +182,7 @@ class TaskListViewModel(private val memoRepository: MemoRepository) : ViewModel(
|
|||||||
return GroupedTasksResult(
|
return GroupedTasksResult(
|
||||||
groups = groups.map { it.copy(collapsed = it.title in collapsed) },
|
groups = groups.map { it.copy(collapsed = it.title in collapsed) },
|
||||||
availableLists = availableLists,
|
availableLists = availableLists,
|
||||||
|
warnings = allWarnings,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package com.avinal.memos
|
||||||
|
|
||||||
|
import com.avinal.memos.parser.TaskParser
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class ParserDoctorTest {
|
||||||
|
|
||||||
|
// --- Errors ---
|
||||||
|
|
||||||
|
@Test fun errorInvalidDate() {
|
||||||
|
val w = TaskParser.validateContent("- [ ] Fix 2026-13-45")
|
||||||
|
assertTrue(w.any { it.issue.contains("invalid date") })
|
||||||
|
assertEquals("2026-13-45", w.first { it.issue.contains("invalid date") }.highlight)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun errorInvalidPriority() {
|
||||||
|
val w = TaskParser.validateContent("- [ ] Fix p5")
|
||||||
|
assertTrue(w.any { it.issue.contains("invalid priority") })
|
||||||
|
assertEquals("p5", w.first { it.issue.contains("invalid priority") }.highlight)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun errorReminderWithoutDue() {
|
||||||
|
val w = TaskParser.validateContent("- [ ] Task !30min")
|
||||||
|
assertTrue(w.any { it.issue.contains("no due date") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun errorInvalidReminderUnit() {
|
||||||
|
val w = TaskParser.validateContent("- [ ] Task !5blah today")
|
||||||
|
assertTrue(w.any { it.issue.contains("invalid reminder unit") })
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Warnings ---
|
||||||
|
|
||||||
|
@Test fun warnTimeWithoutDate() {
|
||||||
|
val w = TaskParser.validateContent("- [ ] Call 5pm")
|
||||||
|
assertTrue(w.any { it.issue.contains("time without date") })
|
||||||
|
assertEquals("5pm", w.first { it.issue.contains("time without date") }.highlight)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun warnDateInPast() {
|
||||||
|
val w = TaskParser.validateContent("- [ ] Old 2020-01-01")
|
||||||
|
assertTrue(w.any { it.issue.contains("in the past") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun warnMultiplePriorities() {
|
||||||
|
val w = TaskParser.validateContent("- [ ] Fix p1 p2")
|
||||||
|
assertTrue(w.any { it.issue.contains("multiple priorities") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun warnMultipleDates() {
|
||||||
|
val w = TaskParser.validateContent("- [ ] Fix 2026-06-01 2026-07-01")
|
||||||
|
assertTrue(w.any { it.issue.contains("multiple dates") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun warnMultipleReminders() {
|
||||||
|
val w = TaskParser.validateContent("- [ ] Fix !30min !1hr today")
|
||||||
|
assertTrue(w.any { it.issue.contains("multiple reminders") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun warnTypoToday() {
|
||||||
|
val w = TaskParser.validateContent("- [ ] Call tday")
|
||||||
|
assertTrue(w.any { it.issue.contains("today") })
|
||||||
|
assertEquals("tday", w.first { it.issue.contains("today") }.highlight)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun warnTypoTomorrow() {
|
||||||
|
val w = TaskParser.validateContent("- [ ] Fix tomorow")
|
||||||
|
assertTrue(w.any { it.issue.contains("tomorrow") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun warnTypoSunday() {
|
||||||
|
val w = TaskParser.validateContent("- [ ] Meet sundie")
|
||||||
|
assertTrue(w.any { it.issue.contains("sunday") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun warnCombinedNoDates() {
|
||||||
|
val content = "- [ ] Task A\n- [ ] Task B\n- [ ] Task C"
|
||||||
|
val w = TaskParser.validateContent(content)
|
||||||
|
assertTrue(w.any { it.issue.contains("tasks have no due date") })
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- No false positives ---
|
||||||
|
|
||||||
|
@Test fun noWarningsForValid() {
|
||||||
|
assertTrue(TaskParser.validateContent("- [ ] Buy milk today 5pm !30min p1 #work").isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun skipsCompletedTasks() {
|
||||||
|
assertTrue(TaskParser.validateContent("- [x] Done p5 2099-99-99").isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun noWarningPlainText() {
|
||||||
|
assertTrue(TaskParser.validateContent("Just text").isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun noWarningTodayTime() {
|
||||||
|
assertTrue(TaskParser.validateContent("- [ ] Call today 5pm").isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun noWarningTomorrowReminder() {
|
||||||
|
assertTrue(TaskParser.validateContent("- [ ] Call tomorrow !1hr").isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Metadata ---
|
||||||
|
|
||||||
|
@Test fun taskTextIncluded() {
|
||||||
|
val w = TaskParser.validateContent("- [ ] Buy groceries 2020-01-01")
|
||||||
|
assertTrue(w.first().taskText.contains("Buy groceries"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun correctLineIndex() {
|
||||||
|
val w = TaskParser.validateContent("header\n\n- [ ] Task 2026-99-99")
|
||||||
|
assertEquals(2, w[0].lineIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun singleTaskNoDateNotWarned() {
|
||||||
|
// Only 1 task without date should not trigger the combined warning
|
||||||
|
val w = TaskParser.validateContent("- [ ] Single task")
|
||||||
|
assertTrue(w.none { it.issue.contains("tasks have no due date") })
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user