diff --git a/.playwright-mcp/console-2026-07-30T09-00-39-564Z.log b/.playwright-mcp/console-2026-07-30T09-00-39-564Z.log
new file mode 100644
index 0000000..417a62c
--- /dev/null
+++ b/.playwright-mcp/console-2026-07-30T09-00-39-564Z.log
@@ -0,0 +1,2 @@
+[ 1827ms] [WARNING] THREE.Clock: This module has been deprecated. Please use THREE.Timer instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 1859ms] [WARNING] THREE.WebGLShadowMap: PCFSoftShadowMap has been deprecated. Using PCFShadowMap instead. @ http://127.0.0.1:4321/@vite/client:524
diff --git a/.playwright-mcp/console-2026-07-30T09-01-00-955Z.log b/.playwright-mcp/console-2026-07-30T09-01-00-955Z.log
new file mode 100644
index 0000000..2289b0f
--- /dev/null
+++ b/.playwright-mcp/console-2026-07-30T09-01-00-955Z.log
@@ -0,0 +1,52 @@
+[ 86152ms] [WARNING] THREE.Clock: This module has been deprecated. Please use THREE.Timer instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 86167ms] [WARNING] THREE.WebGLShadowMap: PCFSoftShadowMap has been deprecated. Using PCFShadowMap instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 211449ms] [WARNING] THREE.Clock: This module has been deprecated. Please use THREE.Timer instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 213990ms] [WARNING] THREE.Clock: This module has been deprecated. Please use THREE.Timer instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 214009ms] [WARNING] THREE.WebGLShadowMap: PCFSoftShadowMap has been deprecated. Using PCFShadowMap instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 215656ms] [WARNING] THREE.Clock: This module has been deprecated. Please use THREE.Timer instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 215677ms] [WARNING] THREE.WebGLShadowMap: PCFSoftShadowMap has been deprecated. Using PCFShadowMap instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 500032ms] [WARNING] THREE.Clock: This module has been deprecated. Please use THREE.Timer instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 500048ms] [WARNING] THREE.WebGLShadowMap: PCFSoftShadowMap has been deprecated. Using PCFShadowMap instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 532027ms] [WARNING] THREE.Clock: This module has been deprecated. Please use THREE.Timer instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 532046ms] [WARNING] THREE.WebGLShadowMap: PCFSoftShadowMap has been deprecated. Using PCFShadowMap instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 681898ms] [WARNING] THREE.Clock: This module has been deprecated. Please use THREE.Timer instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 681914ms] [WARNING] THREE.WebGLShadowMap: PCFSoftShadowMap has been deprecated. Using PCFShadowMap instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 697414ms] [WARNING] THREE.Clock: This module has been deprecated. Please use THREE.Timer instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 697431ms] [WARNING] THREE.WebGLShadowMap: PCFSoftShadowMap has been deprecated. Using PCFShadowMap instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 2024767ms] [WARNING] THREE.Clock: This module has been deprecated. Please use THREE.Timer instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 2024794ms] [WARNING] THREE.WebGLShadowMap: PCFSoftShadowMap has been deprecated. Using PCFShadowMap instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 2057341ms] [WARNING] THREE.Clock: This module has been deprecated. Please use THREE.Timer instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 2057368ms] [WARNING] THREE.WebGLShadowMap: PCFSoftShadowMap has been deprecated. Using PCFShadowMap instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 2099829ms] [WARNING] THREE.Clock: This module has been deprecated. Please use THREE.Timer instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 2099890ms] [WARNING] THREE.WebGLShadowMap: PCFSoftShadowMap has been deprecated. Using PCFShadowMap instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 2119866ms] [WARNING] THREE.Clock: This module has been deprecated. Please use THREE.Timer instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 2119889ms] [WARNING] THREE.WebGLShadowMap: PCFSoftShadowMap has been deprecated. Using PCFShadowMap instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 2502136ms] [WARNING] THREE.Clock: This module has been deprecated. Please use THREE.Timer instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 2502157ms] [WARNING] THREE.WebGLShadowMap: PCFSoftShadowMap has been deprecated. Using PCFShadowMap instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 2627518ms] [WARNING] THREE.Clock: This module has been deprecated. Please use THREE.Timer instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 2627543ms] [WARNING] THREE.WebGLShadowMap: PCFSoftShadowMap has been deprecated. Using PCFShadowMap instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 2628871ms] [WARNING] THREE.Clock: This module has been deprecated. Please use THREE.Timer instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 2628889ms] [WARNING] THREE.WebGLShadowMap: PCFSoftShadowMap has been deprecated. Using PCFShadowMap instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 2758279ms] [WARNING] THREE.Clock: This module has been deprecated. Please use THREE.Timer instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 2758303ms] [WARNING] THREE.WebGLShadowMap: PCFSoftShadowMap has been deprecated. Using PCFShadowMap instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 3250354ms] [WARNING] THREE.Clock: This module has been deprecated. Please use THREE.Timer instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 3250369ms] [WARNING] THREE.WebGLShadowMap: PCFSoftShadowMap has been deprecated. Using PCFShadowMap instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 3364464ms] [WARNING] THREE.Clock: This module has been deprecated. Please use THREE.Timer instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 3364479ms] [WARNING] THREE.WebGLShadowMap: PCFSoftShadowMap has been deprecated. Using PCFShadowMap instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 3931045ms] [WARNING] THREE.Clock: This module has been deprecated. Please use THREE.Timer instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 3931068ms] [WARNING] THREE.WebGLShadowMap: PCFSoftShadowMap has been deprecated. Using PCFShadowMap instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 4528588ms] [LOG] [vite] server connection lost. Polling for restart... @ http://127.0.0.1:4321/@vite/client:1726
+[ 4530816ms] [WARNING] THREE.Clock: This module has been deprecated. Please use THREE.Timer instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 4530838ms] [WARNING] THREE.WebGLShadowMap: PCFSoftShadowMap has been deprecated. Using PCFShadowMap instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 4622687ms] [WARNING] THREE.Clock: This module has been deprecated. Please use THREE.Timer instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 4622710ms] [WARNING] THREE.WebGLShadowMap: PCFSoftShadowMap has been deprecated. Using PCFShadowMap instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 5250452ms] [WARNING] THREE.Clock: This module has been deprecated. Please use THREE.Timer instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 5250470ms] [WARNING] THREE.WebGLShadowMap: PCFSoftShadowMap has been deprecated. Using PCFShadowMap instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 5391595ms] [WARNING] THREE.Clock: This module has been deprecated. Please use THREE.Timer instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 5391622ms] [WARNING] THREE.WebGLShadowMap: PCFSoftShadowMap has been deprecated. Using PCFShadowMap instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 6148908ms] [WARNING] THREE.Clock: This module has been deprecated. Please use THREE.Timer instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 6148924ms] [WARNING] THREE.WebGLShadowMap: PCFSoftShadowMap has been deprecated. Using PCFShadowMap instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 6557530ms] [WARNING] THREE.Clock: This module has been deprecated. Please use THREE.Timer instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 6557561ms] [WARNING] THREE.WebGLShadowMap: PCFSoftShadowMap has been deprecated. Using PCFShadowMap instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 6684221ms] [WARNING] THREE.Clock: This module has been deprecated. Please use THREE.Timer instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 6684242ms] [WARNING] THREE.WebGLShadowMap: PCFSoftShadowMap has been deprecated. Using PCFShadowMap instead. @ http://127.0.0.1:4321/@vite/client:524
diff --git a/.playwright-mcp/console-2026-07-30T16-17-27-010Z.log b/.playwright-mcp/console-2026-07-30T16-17-27-010Z.log
new file mode 100644
index 0000000..3ae541c
--- /dev/null
+++ b/.playwright-mcp/console-2026-07-30T16-17-27-010Z.log
@@ -0,0 +1,4 @@
+[ 594ms] [WARNING] THREE.Clock: This module has been deprecated. Please use THREE.Timer instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 621ms] [WARNING] THREE.WebGLShadowMap: PCFSoftShadowMap has been deprecated. Using PCFShadowMap instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 1295985ms] [WARNING] THREE.Clock: This module has been deprecated. Please use THREE.Timer instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 1296006ms] [WARNING] THREE.WebGLShadowMap: PCFSoftShadowMap has been deprecated. Using PCFShadowMap instead. @ http://127.0.0.1:4321/@vite/client:524
diff --git a/.playwright-mcp/console-2026-07-30T16-39-18-624Z.log b/.playwright-mcp/console-2026-07-30T16-39-18-624Z.log
new file mode 100644
index 0000000..b72429d
--- /dev/null
+++ b/.playwright-mcp/console-2026-07-30T16-39-18-624Z.log
@@ -0,0 +1,2 @@
+[ 480ms] [WARNING] THREE.Clock: This module has been deprecated. Please use THREE.Timer instead. @ http://127.0.0.1:4321/@vite/client:524
+[ 518ms] [WARNING] THREE.WebGLShadowMap: PCFSoftShadowMap has been deprecated. Using PCFShadowMap instead. @ http://127.0.0.1:4321/@vite/client:524
diff --git a/.playwright-mcp/page-2026-07-30T16-17-27-365Z.yml b/.playwright-mcp/page-2026-07-30T16-17-27-365Z.yml
new file mode 100644
index 0000000..4ca4300
--- /dev/null
+++ b/.playwright-mcp/page-2026-07-30T16-17-27-365Z.yml
@@ -0,0 +1,217 @@
+- generic [active] [ref=e1]:
+ - banner [ref=e2]:
+ - navigation "Primary navigation" [ref=e3]:
+ - link "Floatick home" [ref=e4] [cursor=pointer]:
+ - /url: /
+ - generic [ref=e5]: Floatick
+ - generic [ref=e6]:
+ - link "Features" [ref=e7] [cursor=pointer]:
+ - /url: "#features"
+ - link "Workflow" [ref=e8] [cursor=pointer]:
+ - /url: "#workflow"
+ - link "Privacy" [ref=e9] [cursor=pointer]:
+ - /url: "#privacy"
+ - link "Changelog" [ref=e10] [cursor=pointer]:
+ - /url: /changelog/
+ - generic [ref=e11]:
+ - link "View Floatick on GitHub" [ref=e12] [cursor=pointer]:
+ - /url: https://github.com/lucaslushuo/floatick
+ - link "Read in Chinese" [ref=e14] [cursor=pointer]:
+ - /url: /zh/
+ - generic [ref=e16]: 中文
+ - link "Download" [ref=e17] [cursor=pointer]:
+ - /url: https://github.com/lucaslushuo/floatick/releases/latest/download/Floatick-macos-universal.dmg
+ - main [ref=e20]:
+ - generic [ref=e21]:
+ - generic [ref=e29]:
+ - paragraph [ref=e30]: Floating todo list for macOS · Local-first · Open source
+ - heading "Your next task, always one click away." [level=1] [ref=e31]:
+ - generic [ref=e32]: Your next task,
+ - generic [ref=e33]: always one click away.
+ - paragraph [ref=e34]: Floatick lives on your Mac as a small, draggable icon. Click to capture a todo, add notes or tags, then collapse it back to the desktop.
+ - generic [ref=e35]:
+ - link "Download for macOS" [ref=e36] [cursor=pointer]:
+ - /url: https://github.com/lucaslushuo/floatick/releases/latest/download/Floatick-macos-universal.dmg
+ - link "Explore on GitHub" [ref=e39] [cursor=pointer]:
+ - /url: https://github.com/lucaslushuo/floatick
+ - paragraph [ref=e40]: macOS 10.15+ · Apple silicon and Intel
+ - img "Interactive three-dimensional Floatick workspace with a todo list, a Sticky Board, an editor, and the floating icon" [ref=e41]
+ - region "Product facts" [ref=e45]:
+ - generic [ref=e46]:
+ - strong [ref=e47]: Free
+ - generic [ref=e48]: No account or subscription
+ - generic [ref=e49]:
+ - strong [ref=e50]: Open source
+ - generic [ref=e51]: MIT-licensed on GitHub
+ - generic [ref=e52]:
+ - strong [ref=e53]: Local-first
+ - generic [ref=e54]: Todos stay on your Mac
+ - generic [ref=e55]:
+ - strong [ref=e56]: Maintained
+ - generic [ref=e57]: New releases and fixes
+ - generic [ref=e58]:
+ - generic [ref=e59]:
+ - paragraph [ref=e60]: Core features
+ - heading "Everything you need, close to the desktop." [level=2] [ref=e61]
+ - paragraph [ref=e62]: Capture, organize, and revisit todos without keeping a full-size task manager open.
+ - generic [ref=e63]:
+ - article [ref=e64]:
+ - generic [ref=e65]: "01"
+ - heading "A draggable desktop icon" [level=3] [ref=e66]
+ - paragraph [ref=e67]: Place Floatick anywhere. It opens toward available screen space and collapses back to the same spot.
+ - article [ref=e68]:
+ - generic [ref=e69]: "02"
+ - heading "Titles and Markdown notes" [level=3] [ref=e70]
+ - paragraph [ref=e71]: Keep the list short with titles, then open Markdown details when a task needs more context.
+ - article [ref=e72]:
+ - generic [ref=e73]: "03"
+ - heading "Tags and combined filters" [level=3] [ref=e74]
+ - paragraph [ref=e75]: Create colored tags, assign more than one, and filter the list by several tags at once.
+ - article [ref=e76]:
+ - generic [ref=e77]: "04"
+ - heading "Pinnable Sticky Boards" [level=3] [ref=e78]
+ - paragraph [ref=e79]: Group existing todos on a colored board and pin it to the desktop. A board never owns or deletes its todos.
+ - article [ref=e80]:
+ - generic [ref=e81]: "05"
+ - heading "Archive and restore" [level=3] [ref=e82]
+ - paragraph [ref=e83]: Move finished work out of the main list, search the archive, and restore a todo when you need it again.
+ - article [ref=e84]:
+ - generic [ref=e85]: "06"
+ - heading "Useful macOS controls" [level=3] [ref=e86]
+ - paragraph [ref=e87]: Start at login, stay above other apps, choose a theme, and control whether outside clicks collapse the panel.
+ - generic [ref=e89]:
+ - generic [ref=e90]:
+ - paragraph [ref=e91]: From capture to action
+ - heading "Start with a title. Add context when you need it." [level=2] [ref=e92]
+ - paragraph [ref=e93]: A todo can stay one line or grow into a complete Markdown brief.
+ - list [ref=e94]:
+ - listitem [ref=e95]:
+ - generic [ref=e96]: "01"
+ - generic [ref=e97]:
+ - paragraph [ref=e98]: Capture
+ - heading "Create a todo from the desktop." [level=3] [ref=e99]
+ - paragraph [ref=e100]: Open Floatick, add a title, and get back to what you were doing.
+ - listitem [ref=e101]:
+ - generic [ref=e102]: "02"
+ - generic [ref=e103]:
+ - paragraph [ref=e104]: Organize
+ - heading "Use tags and Sticky Boards." [level=3] [ref=e105]
+ - paragraph [ref=e106]: Group related work without moving or duplicating the original todo.
+ - listitem [ref=e107]:
+ - generic [ref=e108]: "03"
+ - generic [ref=e109]:
+ - paragraph [ref=e110]: Share
+ - heading "Copy the whole task as Markdown." [level=3] [ref=e111]
+ - paragraph [ref=e112]: Send the title and notes to a document, teammate, or chat in one paste.
+ - generic [ref=e113]:
+ - generic [ref=e114]:
+ - paragraph [ref=e115]: One-click Markdown
+ - heading "Copy the whole todo." [level=2] [ref=e116]
+ - paragraph [ref=e117]: Copy the title and notes together, ready to paste into a document, message, or AI agent.
+ - generic [ref=e118]:
+ - article [ref=e119]:
+ - paragraph [ref=e120]: Todo
+ - heading "Prepare tomorrow’s project brief" [level=3] [ref=e123]
+ - paragraph [ref=e124]: Summarize the goal, the open questions, and the first action to take.
+ - generic [ref=e125]:
+ - generic [ref=e126]: planning
+ - generic [ref=e127]: tomorrow
+ - article [ref=e130]:
+ - generic [ref=e131]:
+ - paragraph [ref=e132]: Copied as Markdown
+ - generic [ref=e133]: MD
+ - generic [ref=e134]: "# Prepare tomorrow’s project brief Summarize the goal, the open questions, and the first action to take."
+ - generic [ref=e136]:
+ - generic [ref=e137]:
+ - paragraph [ref=e138]: Local-first
+ - heading "Stored on your Mac." [level=2] [ref=e139]
+ - paragraph [ref=e140]: Floatick saves todos and settings as readable files in ~/.floatick. No account is required.
+ - generic [ref=e141]:
+ - paragraph [ref=e142]: Working directory
+ - code [ref=e143]: ~/.floatick
+ - list [ref=e144]:
+ - listitem [ref=e145]: Your todo data stays in a folder you can inspect and back up.
+ - listitem [ref=e146]: No sign-up, cloud workspace, or telemetry.
+ - listitem [ref=e147]: Floatick connects to the network only to check for app updates.
+ - generic [ref=e148]:
+ - generic [ref=e149]:
+ - paragraph [ref=e150]: Changelog
+ - heading "See what changed." [level=2] [ref=e151]
+ - paragraph [ref=e152]: Each release lists its new features, fixes, and behavior changes.
+ - link "Read the full changelog" [ref=e153] [cursor=pointer]:
+ - /url: /changelog/
+ - text: Read the full changelog
+ - generic [ref=e154]: ↗
+ - article [ref=e155]:
+ - generic [ref=e156]:
+ - generic [ref=e157]: Latest release
+ - time [ref=e158]: July 29, 2026
+ - strong [ref=e159]: v0.3.0
+ - list [ref=e160]:
+ - listitem [ref=e161]: Copy a todo title and notes together as Markdown.
+ - listitem [ref=e162]: Use consistent bottom-sheet actions across the app.
+ - listitem [ref=e163]: Scroll smoothly through larger local lists.
+ - generic [ref=e165]:
+ - generic [ref=e166]:
+ - paragraph [ref=e167]: Open source
+ - heading "Built in public." [level=2] [ref=e168]
+ - paragraph [ref=e169]: Floatick is MIT-licensed. Use it for free, report problems, or help improve the app.
+ - generic [ref=e170]:
+ - link "Contribute on GitHub" [ref=e171] [cursor=pointer]:
+ - /url: https://github.com/lucaslushuo/floatick
+ - link "Suggest a feature" [ref=e172] [cursor=pointer]:
+ - /url: https://github.com/lucaslushuo/floatick/issues/new?labels=enhancement
+ - list [ref=e173]:
+ - listitem [ref=e174]:
+ - generic [ref=e175]: "01"
+ - paragraph [ref=e176]: Report a reproducible bug.
+ - listitem [ref=e177]:
+ - generic [ref=e178]: "02"
+ - paragraph [ref=e179]: Suggest a feature or workflow.
+ - listitem [ref=e180]:
+ - generic [ref=e181]: "03"
+ - paragraph [ref=e182]: Contribute code, tests, documentation, translations, or design.
+ - generic [ref=e183]:
+ - generic [ref=e184]:
+ - paragraph [ref=e185]: FAQ
+ - heading "Before you install." [level=2] [ref=e186]
+ - paragraph [ref=e187]: Quick answers about storage, compatibility, Sticky Boards, and Markdown copy.
+ - generic [ref=e188]:
+ - group [ref=e189]:
+ - generic "What is Floatick?" [ref=e190] [cursor=pointer]: +
+ - group [ref=e193]:
+ - generic "Where does Floatick store my todos?" [ref=e194] [cursor=pointer]: +
+ - group [ref=e197]:
+ - generic "What is a Sticky Board?" [ref=e198] [cursor=pointer]: +
+ - group [ref=e201]:
+ - generic "Does Floatick support Apple silicon and Intel Macs?" [ref=e202] [cursor=pointer]: +
+ - group [ref=e205]:
+ - generic "Can I copy a todo as Markdown?" [ref=e206] [cursor=pointer]: +
+ - generic [ref=e209]:
+ - paragraph [ref=e210]: For macOS
+ - heading "Keep your next todo on the desktop." [level=2] [ref=e211]
+ - paragraph [ref=e212]: Download the latest universal build, or view the source on GitHub.
+ - generic [ref=e213]:
+ - link "Download Floatick" [ref=e214] [cursor=pointer]:
+ - /url: https://github.com/lucaslushuo/floatick/releases/latest/download/Floatick-macos-universal.dmg
+ - link "View source" [ref=e217] [cursor=pointer]:
+ - /url: https://github.com/lucaslushuo/floatick
+ - contentinfo [ref=e218]:
+ - generic [ref=e219]:
+ - generic [ref=e221]:
+ - strong [ref=e222]: Floatick
+ - paragraph [ref=e223]: A local-first floating todo list for macOS.
+ - generic [ref=e224]:
+ - link "Source" [ref=e225] [cursor=pointer]:
+ - /url: https://github.com/lucaslushuo/floatick
+ - link "Releases" [ref=e226] [cursor=pointer]:
+ - /url: /changelog/
+ - link "MIT License" [ref=e227] [cursor=pointer]:
+ - /url: https://github.com/lucaslushuo/floatick/blob/main/LICENSE
+ - link "简体中文" [ref=e228] [cursor=pointer]:
+ - /url: /zh/
+ - generic [ref=e231]:
+ - button [ref=e232]
+ - button [ref=e238]
+ - button [ref=e242]
+ - button [ref=e247]
\ No newline at end of file
diff --git a/.playwright-mcp/page-2026-07-30T16-37-58-715Z.yml b/.playwright-mcp/page-2026-07-30T16-37-58-715Z.yml
new file mode 100644
index 0000000..4ca4300
--- /dev/null
+++ b/.playwright-mcp/page-2026-07-30T16-37-58-715Z.yml
@@ -0,0 +1,217 @@
+- generic [active] [ref=e1]:
+ - banner [ref=e2]:
+ - navigation "Primary navigation" [ref=e3]:
+ - link "Floatick home" [ref=e4] [cursor=pointer]:
+ - /url: /
+ - generic [ref=e5]: Floatick
+ - generic [ref=e6]:
+ - link "Features" [ref=e7] [cursor=pointer]:
+ - /url: "#features"
+ - link "Workflow" [ref=e8] [cursor=pointer]:
+ - /url: "#workflow"
+ - link "Privacy" [ref=e9] [cursor=pointer]:
+ - /url: "#privacy"
+ - link "Changelog" [ref=e10] [cursor=pointer]:
+ - /url: /changelog/
+ - generic [ref=e11]:
+ - link "View Floatick on GitHub" [ref=e12] [cursor=pointer]:
+ - /url: https://github.com/lucaslushuo/floatick
+ - link "Read in Chinese" [ref=e14] [cursor=pointer]:
+ - /url: /zh/
+ - generic [ref=e16]: 中文
+ - link "Download" [ref=e17] [cursor=pointer]:
+ - /url: https://github.com/lucaslushuo/floatick/releases/latest/download/Floatick-macos-universal.dmg
+ - main [ref=e20]:
+ - generic [ref=e21]:
+ - generic [ref=e29]:
+ - paragraph [ref=e30]: Floating todo list for macOS · Local-first · Open source
+ - heading "Your next task, always one click away." [level=1] [ref=e31]:
+ - generic [ref=e32]: Your next task,
+ - generic [ref=e33]: always one click away.
+ - paragraph [ref=e34]: Floatick lives on your Mac as a small, draggable icon. Click to capture a todo, add notes or tags, then collapse it back to the desktop.
+ - generic [ref=e35]:
+ - link "Download for macOS" [ref=e36] [cursor=pointer]:
+ - /url: https://github.com/lucaslushuo/floatick/releases/latest/download/Floatick-macos-universal.dmg
+ - link "Explore on GitHub" [ref=e39] [cursor=pointer]:
+ - /url: https://github.com/lucaslushuo/floatick
+ - paragraph [ref=e40]: macOS 10.15+ · Apple silicon and Intel
+ - img "Interactive three-dimensional Floatick workspace with a todo list, a Sticky Board, an editor, and the floating icon" [ref=e41]
+ - region "Product facts" [ref=e45]:
+ - generic [ref=e46]:
+ - strong [ref=e47]: Free
+ - generic [ref=e48]: No account or subscription
+ - generic [ref=e49]:
+ - strong [ref=e50]: Open source
+ - generic [ref=e51]: MIT-licensed on GitHub
+ - generic [ref=e52]:
+ - strong [ref=e53]: Local-first
+ - generic [ref=e54]: Todos stay on your Mac
+ - generic [ref=e55]:
+ - strong [ref=e56]: Maintained
+ - generic [ref=e57]: New releases and fixes
+ - generic [ref=e58]:
+ - generic [ref=e59]:
+ - paragraph [ref=e60]: Core features
+ - heading "Everything you need, close to the desktop." [level=2] [ref=e61]
+ - paragraph [ref=e62]: Capture, organize, and revisit todos without keeping a full-size task manager open.
+ - generic [ref=e63]:
+ - article [ref=e64]:
+ - generic [ref=e65]: "01"
+ - heading "A draggable desktop icon" [level=3] [ref=e66]
+ - paragraph [ref=e67]: Place Floatick anywhere. It opens toward available screen space and collapses back to the same spot.
+ - article [ref=e68]:
+ - generic [ref=e69]: "02"
+ - heading "Titles and Markdown notes" [level=3] [ref=e70]
+ - paragraph [ref=e71]: Keep the list short with titles, then open Markdown details when a task needs more context.
+ - article [ref=e72]:
+ - generic [ref=e73]: "03"
+ - heading "Tags and combined filters" [level=3] [ref=e74]
+ - paragraph [ref=e75]: Create colored tags, assign more than one, and filter the list by several tags at once.
+ - article [ref=e76]:
+ - generic [ref=e77]: "04"
+ - heading "Pinnable Sticky Boards" [level=3] [ref=e78]
+ - paragraph [ref=e79]: Group existing todos on a colored board and pin it to the desktop. A board never owns or deletes its todos.
+ - article [ref=e80]:
+ - generic [ref=e81]: "05"
+ - heading "Archive and restore" [level=3] [ref=e82]
+ - paragraph [ref=e83]: Move finished work out of the main list, search the archive, and restore a todo when you need it again.
+ - article [ref=e84]:
+ - generic [ref=e85]: "06"
+ - heading "Useful macOS controls" [level=3] [ref=e86]
+ - paragraph [ref=e87]: Start at login, stay above other apps, choose a theme, and control whether outside clicks collapse the panel.
+ - generic [ref=e89]:
+ - generic [ref=e90]:
+ - paragraph [ref=e91]: From capture to action
+ - heading "Start with a title. Add context when you need it." [level=2] [ref=e92]
+ - paragraph [ref=e93]: A todo can stay one line or grow into a complete Markdown brief.
+ - list [ref=e94]:
+ - listitem [ref=e95]:
+ - generic [ref=e96]: "01"
+ - generic [ref=e97]:
+ - paragraph [ref=e98]: Capture
+ - heading "Create a todo from the desktop." [level=3] [ref=e99]
+ - paragraph [ref=e100]: Open Floatick, add a title, and get back to what you were doing.
+ - listitem [ref=e101]:
+ - generic [ref=e102]: "02"
+ - generic [ref=e103]:
+ - paragraph [ref=e104]: Organize
+ - heading "Use tags and Sticky Boards." [level=3] [ref=e105]
+ - paragraph [ref=e106]: Group related work without moving or duplicating the original todo.
+ - listitem [ref=e107]:
+ - generic [ref=e108]: "03"
+ - generic [ref=e109]:
+ - paragraph [ref=e110]: Share
+ - heading "Copy the whole task as Markdown." [level=3] [ref=e111]
+ - paragraph [ref=e112]: Send the title and notes to a document, teammate, or chat in one paste.
+ - generic [ref=e113]:
+ - generic [ref=e114]:
+ - paragraph [ref=e115]: One-click Markdown
+ - heading "Copy the whole todo." [level=2] [ref=e116]
+ - paragraph [ref=e117]: Copy the title and notes together, ready to paste into a document, message, or AI agent.
+ - generic [ref=e118]:
+ - article [ref=e119]:
+ - paragraph [ref=e120]: Todo
+ - heading "Prepare tomorrow’s project brief" [level=3] [ref=e123]
+ - paragraph [ref=e124]: Summarize the goal, the open questions, and the first action to take.
+ - generic [ref=e125]:
+ - generic [ref=e126]: planning
+ - generic [ref=e127]: tomorrow
+ - article [ref=e130]:
+ - generic [ref=e131]:
+ - paragraph [ref=e132]: Copied as Markdown
+ - generic [ref=e133]: MD
+ - generic [ref=e134]: "# Prepare tomorrow’s project brief Summarize the goal, the open questions, and the first action to take."
+ - generic [ref=e136]:
+ - generic [ref=e137]:
+ - paragraph [ref=e138]: Local-first
+ - heading "Stored on your Mac." [level=2] [ref=e139]
+ - paragraph [ref=e140]: Floatick saves todos and settings as readable files in ~/.floatick. No account is required.
+ - generic [ref=e141]:
+ - paragraph [ref=e142]: Working directory
+ - code [ref=e143]: ~/.floatick
+ - list [ref=e144]:
+ - listitem [ref=e145]: Your todo data stays in a folder you can inspect and back up.
+ - listitem [ref=e146]: No sign-up, cloud workspace, or telemetry.
+ - listitem [ref=e147]: Floatick connects to the network only to check for app updates.
+ - generic [ref=e148]:
+ - generic [ref=e149]:
+ - paragraph [ref=e150]: Changelog
+ - heading "See what changed." [level=2] [ref=e151]
+ - paragraph [ref=e152]: Each release lists its new features, fixes, and behavior changes.
+ - link "Read the full changelog" [ref=e153] [cursor=pointer]:
+ - /url: /changelog/
+ - text: Read the full changelog
+ - generic [ref=e154]: ↗
+ - article [ref=e155]:
+ - generic [ref=e156]:
+ - generic [ref=e157]: Latest release
+ - time [ref=e158]: July 29, 2026
+ - strong [ref=e159]: v0.3.0
+ - list [ref=e160]:
+ - listitem [ref=e161]: Copy a todo title and notes together as Markdown.
+ - listitem [ref=e162]: Use consistent bottom-sheet actions across the app.
+ - listitem [ref=e163]: Scroll smoothly through larger local lists.
+ - generic [ref=e165]:
+ - generic [ref=e166]:
+ - paragraph [ref=e167]: Open source
+ - heading "Built in public." [level=2] [ref=e168]
+ - paragraph [ref=e169]: Floatick is MIT-licensed. Use it for free, report problems, or help improve the app.
+ - generic [ref=e170]:
+ - link "Contribute on GitHub" [ref=e171] [cursor=pointer]:
+ - /url: https://github.com/lucaslushuo/floatick
+ - link "Suggest a feature" [ref=e172] [cursor=pointer]:
+ - /url: https://github.com/lucaslushuo/floatick/issues/new?labels=enhancement
+ - list [ref=e173]:
+ - listitem [ref=e174]:
+ - generic [ref=e175]: "01"
+ - paragraph [ref=e176]: Report a reproducible bug.
+ - listitem [ref=e177]:
+ - generic [ref=e178]: "02"
+ - paragraph [ref=e179]: Suggest a feature or workflow.
+ - listitem [ref=e180]:
+ - generic [ref=e181]: "03"
+ - paragraph [ref=e182]: Contribute code, tests, documentation, translations, or design.
+ - generic [ref=e183]:
+ - generic [ref=e184]:
+ - paragraph [ref=e185]: FAQ
+ - heading "Before you install." [level=2] [ref=e186]
+ - paragraph [ref=e187]: Quick answers about storage, compatibility, Sticky Boards, and Markdown copy.
+ - generic [ref=e188]:
+ - group [ref=e189]:
+ - generic "What is Floatick?" [ref=e190] [cursor=pointer]: +
+ - group [ref=e193]:
+ - generic "Where does Floatick store my todos?" [ref=e194] [cursor=pointer]: +
+ - group [ref=e197]:
+ - generic "What is a Sticky Board?" [ref=e198] [cursor=pointer]: +
+ - group [ref=e201]:
+ - generic "Does Floatick support Apple silicon and Intel Macs?" [ref=e202] [cursor=pointer]: +
+ - group [ref=e205]:
+ - generic "Can I copy a todo as Markdown?" [ref=e206] [cursor=pointer]: +
+ - generic [ref=e209]:
+ - paragraph [ref=e210]: For macOS
+ - heading "Keep your next todo on the desktop." [level=2] [ref=e211]
+ - paragraph [ref=e212]: Download the latest universal build, or view the source on GitHub.
+ - generic [ref=e213]:
+ - link "Download Floatick" [ref=e214] [cursor=pointer]:
+ - /url: https://github.com/lucaslushuo/floatick/releases/latest/download/Floatick-macos-universal.dmg
+ - link "View source" [ref=e217] [cursor=pointer]:
+ - /url: https://github.com/lucaslushuo/floatick
+ - contentinfo [ref=e218]:
+ - generic [ref=e219]:
+ - generic [ref=e221]:
+ - strong [ref=e222]: Floatick
+ - paragraph [ref=e223]: A local-first floating todo list for macOS.
+ - generic [ref=e224]:
+ - link "Source" [ref=e225] [cursor=pointer]:
+ - /url: https://github.com/lucaslushuo/floatick
+ - link "Releases" [ref=e226] [cursor=pointer]:
+ - /url: /changelog/
+ - link "MIT License" [ref=e227] [cursor=pointer]:
+ - /url: https://github.com/lucaslushuo/floatick/blob/main/LICENSE
+ - link "简体中文" [ref=e228] [cursor=pointer]:
+ - /url: /zh/
+ - generic [ref=e231]:
+ - button [ref=e232]
+ - button [ref=e238]
+ - button [ref=e242]
+ - button [ref=e247]
\ No newline at end of file
diff --git a/.playwright-mcp/page-2026-07-30T16-39-18-964Z.yml b/.playwright-mcp/page-2026-07-30T16-39-18-964Z.yml
new file mode 100644
index 0000000..4ca4300
--- /dev/null
+++ b/.playwright-mcp/page-2026-07-30T16-39-18-964Z.yml
@@ -0,0 +1,217 @@
+- generic [active] [ref=e1]:
+ - banner [ref=e2]:
+ - navigation "Primary navigation" [ref=e3]:
+ - link "Floatick home" [ref=e4] [cursor=pointer]:
+ - /url: /
+ - generic [ref=e5]: Floatick
+ - generic [ref=e6]:
+ - link "Features" [ref=e7] [cursor=pointer]:
+ - /url: "#features"
+ - link "Workflow" [ref=e8] [cursor=pointer]:
+ - /url: "#workflow"
+ - link "Privacy" [ref=e9] [cursor=pointer]:
+ - /url: "#privacy"
+ - link "Changelog" [ref=e10] [cursor=pointer]:
+ - /url: /changelog/
+ - generic [ref=e11]:
+ - link "View Floatick on GitHub" [ref=e12] [cursor=pointer]:
+ - /url: https://github.com/lucaslushuo/floatick
+ - link "Read in Chinese" [ref=e14] [cursor=pointer]:
+ - /url: /zh/
+ - generic [ref=e16]: 中文
+ - link "Download" [ref=e17] [cursor=pointer]:
+ - /url: https://github.com/lucaslushuo/floatick/releases/latest/download/Floatick-macos-universal.dmg
+ - main [ref=e20]:
+ - generic [ref=e21]:
+ - generic [ref=e29]:
+ - paragraph [ref=e30]: Floating todo list for macOS · Local-first · Open source
+ - heading "Your next task, always one click away." [level=1] [ref=e31]:
+ - generic [ref=e32]: Your next task,
+ - generic [ref=e33]: always one click away.
+ - paragraph [ref=e34]: Floatick lives on your Mac as a small, draggable icon. Click to capture a todo, add notes or tags, then collapse it back to the desktop.
+ - generic [ref=e35]:
+ - link "Download for macOS" [ref=e36] [cursor=pointer]:
+ - /url: https://github.com/lucaslushuo/floatick/releases/latest/download/Floatick-macos-universal.dmg
+ - link "Explore on GitHub" [ref=e39] [cursor=pointer]:
+ - /url: https://github.com/lucaslushuo/floatick
+ - paragraph [ref=e40]: macOS 10.15+ · Apple silicon and Intel
+ - img "Interactive three-dimensional Floatick workspace with a todo list, a Sticky Board, an editor, and the floating icon" [ref=e41]
+ - region "Product facts" [ref=e45]:
+ - generic [ref=e46]:
+ - strong [ref=e47]: Free
+ - generic [ref=e48]: No account or subscription
+ - generic [ref=e49]:
+ - strong [ref=e50]: Open source
+ - generic [ref=e51]: MIT-licensed on GitHub
+ - generic [ref=e52]:
+ - strong [ref=e53]: Local-first
+ - generic [ref=e54]: Todos stay on your Mac
+ - generic [ref=e55]:
+ - strong [ref=e56]: Maintained
+ - generic [ref=e57]: New releases and fixes
+ - generic [ref=e58]:
+ - generic [ref=e59]:
+ - paragraph [ref=e60]: Core features
+ - heading "Everything you need, close to the desktop." [level=2] [ref=e61]
+ - paragraph [ref=e62]: Capture, organize, and revisit todos without keeping a full-size task manager open.
+ - generic [ref=e63]:
+ - article [ref=e64]:
+ - generic [ref=e65]: "01"
+ - heading "A draggable desktop icon" [level=3] [ref=e66]
+ - paragraph [ref=e67]: Place Floatick anywhere. It opens toward available screen space and collapses back to the same spot.
+ - article [ref=e68]:
+ - generic [ref=e69]: "02"
+ - heading "Titles and Markdown notes" [level=3] [ref=e70]
+ - paragraph [ref=e71]: Keep the list short with titles, then open Markdown details when a task needs more context.
+ - article [ref=e72]:
+ - generic [ref=e73]: "03"
+ - heading "Tags and combined filters" [level=3] [ref=e74]
+ - paragraph [ref=e75]: Create colored tags, assign more than one, and filter the list by several tags at once.
+ - article [ref=e76]:
+ - generic [ref=e77]: "04"
+ - heading "Pinnable Sticky Boards" [level=3] [ref=e78]
+ - paragraph [ref=e79]: Group existing todos on a colored board and pin it to the desktop. A board never owns or deletes its todos.
+ - article [ref=e80]:
+ - generic [ref=e81]: "05"
+ - heading "Archive and restore" [level=3] [ref=e82]
+ - paragraph [ref=e83]: Move finished work out of the main list, search the archive, and restore a todo when you need it again.
+ - article [ref=e84]:
+ - generic [ref=e85]: "06"
+ - heading "Useful macOS controls" [level=3] [ref=e86]
+ - paragraph [ref=e87]: Start at login, stay above other apps, choose a theme, and control whether outside clicks collapse the panel.
+ - generic [ref=e89]:
+ - generic [ref=e90]:
+ - paragraph [ref=e91]: From capture to action
+ - heading "Start with a title. Add context when you need it." [level=2] [ref=e92]
+ - paragraph [ref=e93]: A todo can stay one line or grow into a complete Markdown brief.
+ - list [ref=e94]:
+ - listitem [ref=e95]:
+ - generic [ref=e96]: "01"
+ - generic [ref=e97]:
+ - paragraph [ref=e98]: Capture
+ - heading "Create a todo from the desktop." [level=3] [ref=e99]
+ - paragraph [ref=e100]: Open Floatick, add a title, and get back to what you were doing.
+ - listitem [ref=e101]:
+ - generic [ref=e102]: "02"
+ - generic [ref=e103]:
+ - paragraph [ref=e104]: Organize
+ - heading "Use tags and Sticky Boards." [level=3] [ref=e105]
+ - paragraph [ref=e106]: Group related work without moving or duplicating the original todo.
+ - listitem [ref=e107]:
+ - generic [ref=e108]: "03"
+ - generic [ref=e109]:
+ - paragraph [ref=e110]: Share
+ - heading "Copy the whole task as Markdown." [level=3] [ref=e111]
+ - paragraph [ref=e112]: Send the title and notes to a document, teammate, or chat in one paste.
+ - generic [ref=e113]:
+ - generic [ref=e114]:
+ - paragraph [ref=e115]: One-click Markdown
+ - heading "Copy the whole todo." [level=2] [ref=e116]
+ - paragraph [ref=e117]: Copy the title and notes together, ready to paste into a document, message, or AI agent.
+ - generic [ref=e118]:
+ - article [ref=e119]:
+ - paragraph [ref=e120]: Todo
+ - heading "Prepare tomorrow’s project brief" [level=3] [ref=e123]
+ - paragraph [ref=e124]: Summarize the goal, the open questions, and the first action to take.
+ - generic [ref=e125]:
+ - generic [ref=e126]: planning
+ - generic [ref=e127]: tomorrow
+ - article [ref=e130]:
+ - generic [ref=e131]:
+ - paragraph [ref=e132]: Copied as Markdown
+ - generic [ref=e133]: MD
+ - generic [ref=e134]: "# Prepare tomorrow’s project brief Summarize the goal, the open questions, and the first action to take."
+ - generic [ref=e136]:
+ - generic [ref=e137]:
+ - paragraph [ref=e138]: Local-first
+ - heading "Stored on your Mac." [level=2] [ref=e139]
+ - paragraph [ref=e140]: Floatick saves todos and settings as readable files in ~/.floatick. No account is required.
+ - generic [ref=e141]:
+ - paragraph [ref=e142]: Working directory
+ - code [ref=e143]: ~/.floatick
+ - list [ref=e144]:
+ - listitem [ref=e145]: Your todo data stays in a folder you can inspect and back up.
+ - listitem [ref=e146]: No sign-up, cloud workspace, or telemetry.
+ - listitem [ref=e147]: Floatick connects to the network only to check for app updates.
+ - generic [ref=e148]:
+ - generic [ref=e149]:
+ - paragraph [ref=e150]: Changelog
+ - heading "See what changed." [level=2] [ref=e151]
+ - paragraph [ref=e152]: Each release lists its new features, fixes, and behavior changes.
+ - link "Read the full changelog" [ref=e153] [cursor=pointer]:
+ - /url: /changelog/
+ - text: Read the full changelog
+ - generic [ref=e154]: ↗
+ - article [ref=e155]:
+ - generic [ref=e156]:
+ - generic [ref=e157]: Latest release
+ - time [ref=e158]: July 29, 2026
+ - strong [ref=e159]: v0.3.0
+ - list [ref=e160]:
+ - listitem [ref=e161]: Copy a todo title and notes together as Markdown.
+ - listitem [ref=e162]: Use consistent bottom-sheet actions across the app.
+ - listitem [ref=e163]: Scroll smoothly through larger local lists.
+ - generic [ref=e165]:
+ - generic [ref=e166]:
+ - paragraph [ref=e167]: Open source
+ - heading "Built in public." [level=2] [ref=e168]
+ - paragraph [ref=e169]: Floatick is MIT-licensed. Use it for free, report problems, or help improve the app.
+ - generic [ref=e170]:
+ - link "Contribute on GitHub" [ref=e171] [cursor=pointer]:
+ - /url: https://github.com/lucaslushuo/floatick
+ - link "Suggest a feature" [ref=e172] [cursor=pointer]:
+ - /url: https://github.com/lucaslushuo/floatick/issues/new?labels=enhancement
+ - list [ref=e173]:
+ - listitem [ref=e174]:
+ - generic [ref=e175]: "01"
+ - paragraph [ref=e176]: Report a reproducible bug.
+ - listitem [ref=e177]:
+ - generic [ref=e178]: "02"
+ - paragraph [ref=e179]: Suggest a feature or workflow.
+ - listitem [ref=e180]:
+ - generic [ref=e181]: "03"
+ - paragraph [ref=e182]: Contribute code, tests, documentation, translations, or design.
+ - generic [ref=e183]:
+ - generic [ref=e184]:
+ - paragraph [ref=e185]: FAQ
+ - heading "Before you install." [level=2] [ref=e186]
+ - paragraph [ref=e187]: Quick answers about storage, compatibility, Sticky Boards, and Markdown copy.
+ - generic [ref=e188]:
+ - group [ref=e189]:
+ - generic "What is Floatick?" [ref=e190] [cursor=pointer]: +
+ - group [ref=e193]:
+ - generic "Where does Floatick store my todos?" [ref=e194] [cursor=pointer]: +
+ - group [ref=e197]:
+ - generic "What is a Sticky Board?" [ref=e198] [cursor=pointer]: +
+ - group [ref=e201]:
+ - generic "Does Floatick support Apple silicon and Intel Macs?" [ref=e202] [cursor=pointer]: +
+ - group [ref=e205]:
+ - generic "Can I copy a todo as Markdown?" [ref=e206] [cursor=pointer]: +
+ - generic [ref=e209]:
+ - paragraph [ref=e210]: For macOS
+ - heading "Keep your next todo on the desktop." [level=2] [ref=e211]
+ - paragraph [ref=e212]: Download the latest universal build, or view the source on GitHub.
+ - generic [ref=e213]:
+ - link "Download Floatick" [ref=e214] [cursor=pointer]:
+ - /url: https://github.com/lucaslushuo/floatick/releases/latest/download/Floatick-macos-universal.dmg
+ - link "View source" [ref=e217] [cursor=pointer]:
+ - /url: https://github.com/lucaslushuo/floatick
+ - contentinfo [ref=e218]:
+ - generic [ref=e219]:
+ - generic [ref=e221]:
+ - strong [ref=e222]: Floatick
+ - paragraph [ref=e223]: A local-first floating todo list for macOS.
+ - generic [ref=e224]:
+ - link "Source" [ref=e225] [cursor=pointer]:
+ - /url: https://github.com/lucaslushuo/floatick
+ - link "Releases" [ref=e226] [cursor=pointer]:
+ - /url: /changelog/
+ - link "MIT License" [ref=e227] [cursor=pointer]:
+ - /url: https://github.com/lucaslushuo/floatick/blob/main/LICENSE
+ - link "简体中文" [ref=e228] [cursor=pointer]:
+ - /url: /zh/
+ - generic [ref=e231]:
+ - button [ref=e232]
+ - button [ref=e238]
+ - button [ref=e242]
+ - button [ref=e247]
\ No newline at end of file
diff --git a/README.md b/README.md
index c495981..e79fba7 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
Floatick — Floating Todos and Notes for macOS
An open-source, local-first macOS app for lightweight tasks and notes.
@@ -24,19 +24,27 @@
-Floatick is an offline-friendly desktop todo and notes app that rests above your
+Floatick is an offline-friendly desktop todo and quick notes app that rests above your
workspace as a small, draggable icon. Click it and the icon expands into a
focused panel; collapse it and the icon returns to the same anchor. The panel chooses
its expansion direction from the available screen space, so it stays useful
-near any display edge.
+near any display edge. Tasks move through a deliberately small flow—Todo,
+Doing, and Done—with optional deadlines and quiet in-app reminders.
## A focused macOS space for tasks and notes
- **Always within reach** — drag the floating icon anywhere, then click to open.
- **Fast task flow** — create, edit, complete, search, archive, restore, and
organize tasks in automatic daily sections.
-- **Lightweight notes** — capture ideas, logs, and snippets with search, pinning,
- archiving, shared tags, autosave, and Markdown preview.
+- **Visible focus** — mark the task you are actively working on as Doing, give
+ it a calm visual emphasis, and filter the list to active work.
+- **Deadlines without noise** — set a due time while creating or editing a todo,
+ or directly from the list; Floatick surfaces a compact in-app reminder and
+ keeps overdue tasks visible without relying on system notifications.
+- **Lightweight quick notes** — capture ideas, logs, and snippets grouped by
+ date, with search, pinning, archiving, shared tags, autosave, and Markdown preview.
+- **Shared organization** — reuse color-coded tags across todos and notes,
+ combine filters, and copy a complete todo as Markdown.
- **Local by default** — no account, cloud service, or telemetry. Your todo and
note data remains in `~/.floatick`.
- **Made for macOS** — transparent AppKit window behavior, keyboard shortcuts,
@@ -79,6 +87,9 @@ Only download Floatick from this repository's Releases page.
| Create a note | Open Notes and choose New |
| Search the current workspace | Press `⌘F` |
| Edit a todo | Hover over the item and choose Edit |
+| Start or stop Doing | Hover over a todo and use its play control |
+| Show only active work | Use the Doing filter beside search |
+| Set or edit a deadline | Use the alarm control on a todo row or in the todo editor |
| Complete a todo | Select its checkbox |
| Archive or restore | Use the action at the end of the item |
| Quit Floatick | Right-click the floating icon and choose Quit |
@@ -89,7 +100,7 @@ Floatick creates its working directory on first launch:
| Path | Purpose |
| --- | --- |
-| `~/.floatick/todos.json` | Todos, completion state, and archive state |
+| `~/.floatick/todos.json` | Todos, Doing/completion state, deadlines, reminders, and archive state |
| `~/.floatick/notes.json` | Notes, tags, pinning, and archive state |
| `~/.floatick/tags.json` | Reusable tags shared by todos and notes |
| `~/.floatick/settings.json` | Theme and language preferences |
diff --git a/README.zh-CN.md b/README.zh-CN.md
index f3d6566..f19cebd 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -1,8 +1,8 @@
Floatick — macOS 悬浮 Todo 与笔记
一款开源、本地优先的 macOS 轻量 Todo 与笔记应用。
@@ -24,17 +24,23 @@
-Floatick 是一款支持离线使用的 macOS 桌面 Todo 与笔记应用。它平时以一个小巧、
+Floatick 是一款支持离线使用的 macOS 桌面 Todo 与 Quick Notes 应用。它平时以一个小巧、
可拖动的图标悬浮在工作区上方;点击图标会展开专注的面板,收起后则会准确
回到原来的锚点。面板还会根据图标附近的屏幕空间自动选择展开方向,因此放在
-屏幕边缘也能自然使用。
+屏幕边缘也能自然使用。任务只保留 Todo、Doing 和 Done 三个明确状态,并可按需
+设置 DDL 与克制的应用内提醒。
## 专注而轻量的 macOS Todo 与笔记空间
- **随时可用**——图标可以拖到任意位置,点击即可展开。
- **完整待办流程**——支持创建、编辑、完成、搜索、归档和恢复,并按天自动分组。
-- **轻量笔记空间**——记录灵感、日志和片段,支持搜索、置顶、归档、共享标签、
- 自动保存与 Markdown 预览。
+- **看得见的专注状态**——把当前任务标记为 Doing,用克制的视觉效果突出显示,
+ 也可以直接筛选所有进行中的任务。
+- **不打扰的 DDL**——新建、编辑或直接从列表设置截止时间;到点后显示紧凑的
+ 应用内提醒,并持续标记逾期任务,不依赖系统通知。
+- **轻量 Quick Notes**——按日期记录灵感、日志和片段,支持搜索、置顶、归档、
+ 共享标签、自动保存与 Markdown 预览。
+- **共享整理能力**——Todo 与笔记复用彩色标签、组合筛选,并支持完整复制 Markdown。
- **默认仅存本地**——不需要账号、云服务或遥测,Todo 与笔记数据保存在 `~/.floatick`。
- **贴合 macOS**——使用 AppKit 管理透明悬浮窗口,支持快捷键、右键退出和
“减少动态效果”。
@@ -73,6 +79,9 @@ Floatick 目前仍处于早期预览阶段,下载包暂未使用 Apple Develop
| 创建笔记 | 打开 Notes 并点击新建 |
| 搜索当前空间 | 按 `⌘F` |
| 编辑待办 | 将鼠标悬浮到待办上并点击编辑 |
+| 开始或停止 Doing | 将鼠标悬浮到待办上并使用播放按钮 |
+| 只查看进行中的任务 | 使用搜索框旁边的 Doing 筛选 |
+| 设置或修改 DDL | 使用 Todo 行内的闹钟按钮,或在 Todo 编辑器中设置 |
| 完成待办 | 点击待办前的复选框 |
| 归档或恢复 | 使用待办末尾的操作按钮 |
| 退出 Floatick | 右键点击悬浮图标并选择退出 |
@@ -83,7 +92,7 @@ Floatick 第一次启动时会自动创建工作目录:
| 路径 | 用途 |
| --- | --- |
-| `~/.floatick/todos.json` | 待办、完成状态与归档状态 |
+| `~/.floatick/todos.json` | 待办、Doing/完成状态、DDL、提醒与归档状态 |
| `~/.floatick/notes.json` | 笔记、标签、置顶与归档状态 |
| `~/.floatick/tags.json` | Todo 与笔记共享的可复用标签 |
| `~/.floatick/settings.json` | 主题与语言设置 |
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 4c6f341..641b416 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -35,7 +35,6 @@ tool/test/run_ui_tests.sh
| Tag 创建、Todo 关联、多选 OR 筛选和清空 | Integration |
| Notes 切换、创建、标签入口与空草稿处理 | Widget |
| Note 搜索、置顶、归档、恢复、共享标签与失败回滚 | Unit + Widget |
-| Sticky Board 创建、添加现有 Todo、Pin/Unpin | Integration |
| 置顶、登录启动、主题等设置与原生调用边界 | Integration |
| 悬浮图标的 macOS Accessibility button/press contract | XCTest |
| Release 应用启动、独立首次工作区和 JSON 有效性 | Release smoke |
diff --git a/docs/assets/floatick-product-hero-v6.png b/docs/assets/floatick-product-hero-v6.png
new file mode 100644
index 0000000..1c5734a
Binary files /dev/null and b/docs/assets/floatick-product-hero-v6.png differ
diff --git a/floatick-frame-track-final.png b/floatick-frame-track-final.png
new file mode 100644
index 0000000..ed22ecb
Binary files /dev/null and b/floatick-frame-track-final.png differ
diff --git a/floatick-rounded-frame-track-hover.png b/floatick-rounded-frame-track-hover.png
new file mode 100644
index 0000000..85f44b0
Binary files /dev/null and b/floatick-rounded-frame-track-hover.png differ
diff --git a/floatick-rounded-frame-track.png b/floatick-rounded-frame-track.png
new file mode 100644
index 0000000..4cc327f
Binary files /dev/null and b/floatick-rounded-frame-track.png differ
diff --git a/integration_test/floatick_ui_test.dart b/integration_test/floatick_ui_test.dart
index ae7f2c3..64e950e 100644
--- a/integration_test/floatick_ui_test.dart
+++ b/integration_test/floatick_ui_test.dart
@@ -6,9 +6,6 @@ import 'package:floatick/features/settings/data/login_item_repository.dart';
import 'package:floatick/features/settings/data/settings_repository.dart';
import 'package:floatick/features/settings/domain/login_item_status.dart';
import 'package:floatick/features/settings/presentation/settings_view_model.dart';
-import 'package:floatick/features/sticky_boards/data/sticky_board_repository.dart';
-import 'package:floatick/features/sticky_boards/presentation/sticky_board_view_model.dart';
-import 'package:floatick/features/sticky_boards/presentation/sticky_board_window_coordinator.dart';
import 'package:floatick/features/todos/data/first_run_workspace_seeder.dart';
import 'package:floatick/features/todos/data/tag_repository.dart';
import 'package:floatick/features/todos/data/todo_repository.dart';
@@ -211,57 +208,11 @@ void main() {
expect(find.text('Unfiltered task').hitTestable(), findsOneWidget);
});
- testWidgets('sticky boards and settings retain their native boundaries', (
- tester,
- ) async {
+ testWidgets('settings retain their native boundaries', (tester) async {
final harness = await _UiTestHarness.create(seedWelcomeWorkspace: false);
addTearDown(() => harness.dispose(tester));
- await harness.todoController.create('Review the launch checklist');
await harness.pumpApp(tester);
- await tester.tap(find.byKey(const Key('sticky-boards-button')));
- await tester.pumpAndSettle();
- await tester.enterText(
- find.byKey(const Key('sticky-board-search-create-field')),
- 'Launch',
- );
- await tester.tap(find.byKey(const Key('submit-sticky-board')));
- await tester.pumpAndSettle();
- expect(
- find.byKey(const Key('sticky-board-thumbnail-ui-board-1')),
- findsOneWidget,
- );
-
- await tester.tap(find.byKey(const Key('sticky-board-ui-board-1')));
- await tester.pumpAndSettle();
- await tester.tap(find.byKey(const Key('sticky-board-add-existing')));
- await tester.pumpAndSettle();
- await tester.tap(find.byKey(const Key('sticky-board-picker-ui-todo-1')));
- await tester.pumpAndSettle();
- await tester.tap(find.byTooltip('Back to Sticky Boards'));
- await tester.pumpAndSettle();
- expect(
- harness.stickyBoardController.todoIdsForBoard('ui-board-1'),
- ['ui-todo-1'],
- );
-
- await tester.tap(find.byKey(const Key('sticky-board-pin')));
- await tester.pumpAndSettle();
- expect(harness.launchedBoards, ['ui-board-1']);
- expect(
- harness.stickyBoardController.boardById('ui-board-1')?.isPinned,
- isTrue,
- );
- await tester.tap(find.byKey(const Key('sticky-board-pin')));
- await tester.pumpAndSettle();
- expect(harness.hiddenBoards, ['ui-board-1']);
- expect(
- harness.stickyBoardController.boardById('ui-board-1')?.isPinned,
- isFalse,
- );
-
- await tester.tap(find.byTooltip('Close Sticky Boards'));
- await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('settings-button')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('always-on-top-setting')));
@@ -318,12 +269,8 @@ class _UiTestHarness {
required this.todoController,
required this.settingsController,
required this.updateController,
- required this.stickyBoardController,
- required this.stickyBoardWindowCoordinator,
required this.windowBridge,
required this.loginItemRepository,
- required this.launchedBoards,
- required this.hiddenBoards,
});
static Future<_UiTestHarness> create({
@@ -336,7 +283,6 @@ class _UiTestHarness {
final tagRepository = LocalTagRepository(rootDirectory: rootDirectory);
var todoSequence = 0;
var tagSequence = 0;
- var boardSequence = 0;
final todoController = TodoViewModel(
todoRepository: todoRepository,
tagRepository: tagRepository,
@@ -359,26 +305,11 @@ class _UiTestHarness {
final updateController = UpdateViewModel(
updateRepository: _UiTestUpdateRepository(),
);
- final stickyBoardController = StickyBoardViewModel(
- repository: LocalStickyBoardRepository(rootDirectory: rootDirectory),
- clock: () => DateTime.utc(2026, 7, 28, 10, boardSequence),
- idGenerator: () => 'ui-board-${++boardSequence}',
- );
final windowBridge = _UiTestWindowBridge();
- final launchedBoards = [];
- final hiddenBoards = [];
- final stickyBoardWindowCoordinator = StickyBoardWindowCoordinator(
- boardController: stickyBoardController,
- todoController: todoController,
- windowBridge: windowBridge,
- windowLauncher: (boardId) async => launchedBoards.add(boardId),
- windowHider: (boardId) async => hiddenBoards.add(boardId),
- );
await Future.wait(>[
todoController.load(),
settingsController.load(),
updateController.load(),
- stickyBoardController.load(),
]);
return _UiTestHarness._(
rootDirectory: rootDirectory,
@@ -387,13 +318,9 @@ class _UiTestHarness {
todoController: todoController,
settingsController: settingsController,
updateController: updateController,
- stickyBoardController: stickyBoardController,
- stickyBoardWindowCoordinator: stickyBoardWindowCoordinator,
windowBridge: windowBridge,
loginItemRepository:
settingsController.loginItemRepository as _UiTestLoginItemRepository,
- launchedBoards: launchedBoards,
- hiddenBoards: hiddenBoards,
);
}
@@ -403,12 +330,8 @@ class _UiTestHarness {
final TodoViewModel todoController;
final SettingsViewModel settingsController;
final UpdateViewModel updateController;
- final StickyBoardViewModel stickyBoardController;
- final StickyBoardWindowCoordinator stickyBoardWindowCoordinator;
final _UiTestWindowBridge windowBridge;
final _UiTestLoginItemRepository loginItemRepository;
- final List launchedBoards;
- final List hiddenBoards;
Future pumpApp(WidgetTester tester) async {
tester.view.physicalSize = const Size(500, 760);
@@ -418,8 +341,6 @@ class _UiTestHarness {
controller: todoController,
settingsController: settingsController,
updateController: updateController,
- stickyBoardController: stickyBoardController,
- stickyBoardWindowCoordinator: stickyBoardWindowCoordinator,
windowBridge: windowBridge,
locale: const Locale('en'),
),
@@ -437,7 +358,6 @@ class _UiTestHarness {
todoController.dispose();
settingsController.dispose();
updateController.dispose();
- stickyBoardController.dispose();
if (await rootDirectory.exists()) {
await rootDirectory.delete(recursive: true);
}
@@ -489,15 +409,6 @@ class _UiTestWindowBridge implements WindowBridge {
Future setAlwaysOnTop(bool alwaysOnTop) async {
alwaysOnTopValues.add(alwaysOnTop);
}
-
- @override
- Future configureBorderlessSecondaryWindow(
- int viewId, {
- bool positionAdjacentToMainWindow = false,
- }) async {}
-
- @override
- Future revealBorderlessSecondaryWindow(int viewId) async {}
}
class _UiTestLoginItemRepository implements LoginItemRepository {
diff --git a/integration_test/todo_scroll_performance_test.dart b/integration_test/todo_scroll_performance_test.dart
index 42a28b2..e80e66e 100644
--- a/integration_test/todo_scroll_performance_test.dart
+++ b/integration_test/todo_scroll_performance_test.dart
@@ -7,10 +7,6 @@ import 'package:floatick/features/settings/data/settings_repository.dart';
import 'package:floatick/features/settings/domain/app_settings.dart';
import 'package:floatick/features/settings/domain/login_item_status.dart';
import 'package:floatick/features/settings/presentation/settings_view_model.dart';
-import 'package:floatick/features/sticky_boards/data/sticky_board_repository.dart';
-import 'package:floatick/features/sticky_boards/domain/sticky_board_workspace.dart';
-import 'package:floatick/features/sticky_boards/presentation/sticky_board_view_model.dart';
-import 'package:floatick/features/sticky_boards/presentation/sticky_board_window_coordinator.dart';
import 'package:floatick/features/todos/data/tag_repository.dart';
import 'package:floatick/features/todos/data/todo_repository.dart';
import 'package:floatick/features/todos/domain/tag_workspace.dart';
@@ -62,25 +58,15 @@ void main() {
final updateController = UpdateViewModel(
updateRepository: _BenchmarkUpdateRepository(),
);
- final stickyBoardController = StickyBoardViewModel(
- repository: _BenchmarkStickyBoardRepository(),
- );
final windowBridge = _BenchmarkWindowBridge();
- final stickyBoardWindowCoordinator = StickyBoardWindowCoordinator(
- boardController: stickyBoardController,
- todoController: todoController,
- windowBridge: windowBridge,
- );
addTearDown(todoController.dispose);
addTearDown(settingsController.dispose);
addTearDown(updateController.dispose);
- addTearDown(stickyBoardController.dispose);
await Future.wait(>[
todoController.load(),
settingsController.load(),
updateController.load(),
- stickyBoardController.load(),
]);
await tester.pumpWidget(
MaterialApp(
@@ -94,12 +80,8 @@ void main() {
controller: todoController,
settingsController: settingsController,
updateController: updateController,
- stickyBoardController: stickyBoardController,
- stickyBoardWindowCoordinator: stickyBoardWindowCoordinator,
windowBridge: windowBridge,
expansionAnchor: WindowExpansionAnchor.topRight,
- stickyBoardRequest: null,
- stickyBoardRequestSerial: 0,
onCollapse: () {},
),
),
@@ -190,17 +172,6 @@ class _BenchmarkUpdateRepository implements UpdateRepository {
Future checkForUpdates() async {}
}
-class _BenchmarkStickyBoardRepository implements StickyBoardRepository {
- @override
- String get storagePath => '/tmp/floatick-scroll-benchmark/sticky_boards.json';
-
- @override
- Future load() async => StickyBoardWorkspace.empty();
-
- @override
- Future save(StickyBoardWorkspace workspace) async {}
-}
-
class _BenchmarkWindowBridge implements WindowBridge {
@override
void setExpandRequestHandler(ExpandRequestHandler? handler) {}
@@ -230,13 +201,4 @@ class _BenchmarkWindowBridge implements WindowBridge {
@override
Future setAlwaysOnTop(bool alwaysOnTop) async {}
-
- @override
- Future configureBorderlessSecondaryWindow(
- int viewId, {
- bool positionAdjacentToMainWindow = false,
- }) async {}
-
- @override
- Future revealBorderlessSecondaryWindow(int viewId) async {}
}
diff --git a/lib/app/floatick_app.dart b/lib/app/floatick_app.dart
index 062592d..0eae8da 100644
--- a/lib/app/floatick_app.dart
+++ b/lib/app/floatick_app.dart
@@ -8,9 +8,8 @@ import '../core/ui/floatick_surface_metrics.dart';
import '../features/notes/presentation/note_view_model.dart';
import '../features/settings/domain/app_settings.dart';
import '../features/settings/presentation/settings_view_model.dart';
-import '../features/sticky_boards/presentation/sticky_board_view_model.dart';
-import '../features/sticky_boards/presentation/sticky_board_window_coordinator.dart';
import '../features/todos/presentation/todo_panel.dart';
+import '../features/todos/presentation/deadline_reminder_scheduler.dart';
import '../features/todos/presentation/todo_view_model.dart';
import '../features/updates/presentation/update_view_model.dart';
import '../l10n/app_localizations.dart';
@@ -22,9 +21,8 @@ class FloatickApp extends StatelessWidget {
this.noteController,
required this.settingsController,
required this.updateController,
- required this.stickyBoardController,
- required this.stickyBoardWindowCoordinator,
required this.windowBridge,
+ this.deadlineReminderBridge,
this.locale,
super.key,
});
@@ -33,9 +31,8 @@ class FloatickApp extends StatelessWidget {
final NoteViewModel? noteController;
final SettingsViewModel settingsController;
final UpdateViewModel updateController;
- final StickyBoardViewModel stickyBoardController;
- final StickyBoardWindowCoordinator stickyBoardWindowCoordinator;
final WindowBridge windowBridge;
+ final DeadlineReminderBridge? deadlineReminderBridge;
final Locale? locale;
@override
@@ -67,9 +64,8 @@ class FloatickApp extends StatelessWidget {
noteController: noteController,
settingsController: settingsController,
updateController: updateController,
- stickyBoardController: stickyBoardController,
- stickyBoardWindowCoordinator: stickyBoardWindowCoordinator,
windowBridge: windowBridge,
+ deadlineReminderBridge: deadlineReminderBridge,
),
);
},
@@ -83,18 +79,16 @@ class _FloatickShell extends StatefulWidget {
required this.noteController,
required this.settingsController,
required this.updateController,
- required this.stickyBoardController,
- required this.stickyBoardWindowCoordinator,
required this.windowBridge,
+ required this.deadlineReminderBridge,
});
final TodoViewModel controller;
final NoteViewModel? noteController;
final SettingsViewModel settingsController;
final UpdateViewModel updateController;
- final StickyBoardViewModel stickyBoardController;
- final StickyBoardWindowCoordinator stickyBoardWindowCoordinator;
final WindowBridge windowBridge;
+ final DeadlineReminderBridge? deadlineReminderBridge;
@override
State<_FloatickShell> createState() => _FloatickShellState();
@@ -116,10 +110,11 @@ class _FloatickShellState extends State<_FloatickShell> {
bool? _lastSyncedAlwaysOnTop;
int? _lastSyncedFloatingIconCount;
WindowExpansionAnchor _expansionAnchor = WindowExpansionAnchor.topRight;
- StickyBoardMainWindowRequest? _stickyBoardRequest;
- int _stickyBoardRequestSerial = 0;
Future? _rendererWarmUpFuture;
Future? _panelPreparationFuture;
+ DeadlineReminderScheduler? _deadlineReminderScheduler;
+ String? _requestedTodoId;
+ int _requestedTodoSerial = 0;
@override
void initState() {
@@ -128,15 +123,13 @@ class _FloatickShellState extends State<_FloatickShell> {
widget.windowBridge.setCollapseRequestHandler(_handleNativeCollapseRequest);
widget.controller.addListener(_handleTodoStateChanged);
widget.settingsController.addListener(_handleSettingsChanged);
- widget.stickyBoardWindowCoordinator.setMainWindowRequestHandler(
- _handleStickyBoardWindowRequest,
- );
+ _configureDeadlineReminderScheduler();
unawaited(_syncPreferredLanguage());
unawaited(_syncPreferredTheme());
unawaited(_syncAlwaysOnTop());
unawaited(_syncFloatingIconCount());
WidgetsBinding.instance.addPostFrameCallback((_) {
- unawaited(_preparePanelAndRestorePinnedBoards());
+ unawaited(_ensurePanelPrepared());
});
}
@@ -155,6 +148,10 @@ class _FloatickShellState extends State<_FloatickShell> {
_hasSyncedAlwaysOnTop = false;
_lastSyncedFloatingIconCount = null;
}
+ if (oldWidget.deadlineReminderBridge != widget.deadlineReminderBridge ||
+ oldWidget.controller != widget.controller) {
+ _configureDeadlineReminderScheduler();
+ }
if (oldWidget.controller != widget.controller) {
oldWidget.controller.removeListener(_handleTodoStateChanged);
widget.controller.addListener(_handleTodoStateChanged);
@@ -166,13 +163,6 @@ class _FloatickShellState extends State<_FloatickShell> {
_hasSyncedPreferredLanguage = false;
_hasSyncedAlwaysOnTop = false;
}
- if (oldWidget.stickyBoardWindowCoordinator !=
- widget.stickyBoardWindowCoordinator) {
- oldWidget.stickyBoardWindowCoordinator.setMainWindowRequestHandler(null);
- widget.stickyBoardWindowCoordinator.setMainWindowRequestHandler(
- _handleStickyBoardWindowRequest,
- );
- }
if (!_hasSyncedPreferredLanguage) {
unawaited(_syncPreferredLanguage());
}
@@ -193,14 +183,29 @@ class _FloatickShellState extends State<_FloatickShell> {
widget.windowBridge.setCollapseRequestHandler(null);
widget.controller.removeListener(_handleTodoStateChanged);
widget.settingsController.removeListener(_handleSettingsChanged);
- widget.stickyBoardWindowCoordinator.setMainWindowRequestHandler(null);
+ _deadlineReminderScheduler?.dispose();
super.dispose();
}
- void _handleStickyBoardWindowRequest(StickyBoardMainWindowRequest request) {
+ void _configureDeadlineReminderScheduler() {
+ _deadlineReminderScheduler?.dispose();
+ final bridge = widget.deadlineReminderBridge;
+ _deadlineReminderScheduler = bridge == null
+ ? null
+ : DeadlineReminderScheduler(
+ todoViewModel: widget.controller,
+ bridge: bridge,
+ onOpenTodo: _openTodoFromReminder,
+ );
+ }
+
+ void _openTodoFromReminder(String todoId) {
+ if (!mounted || widget.controller.itemById(todoId) == null) {
+ return;
+ }
setState(() {
- _stickyBoardRequest = request;
- _stickyBoardRequestSerial += 1;
+ _requestedTodoId = todoId;
+ _requestedTodoSerial += 1;
});
unawaited(_setExpanded(true));
}
@@ -231,14 +236,6 @@ class _FloatickShellState extends State<_FloatickShell> {
}
}
- Future _preparePanelAndRestorePinnedBoards() async {
- await _ensurePanelPrepared();
- if (!mounted) {
- return;
- }
- await widget.stickyBoardWindowCoordinator.restorePinnedBoards();
- }
-
Future _ensurePanelPrepared() {
return _panelPreparationFuture ??= _preparePanel();
}
@@ -332,9 +329,16 @@ class _FloatickShellState extends State<_FloatickShell> {
}
void _handleNativeExpandRequest(WindowExpansionAnchor expansionAnchor) {
+ _deadlineReminderScheduler?.refresh();
unawaited(_setExpanded(true, requestedAnchor: expansionAnchor));
}
+ @override
+ void reassemble() {
+ super.reassemble();
+ _deadlineReminderScheduler?.refresh();
+ }
+
void _handleNativeCollapseRequest() {
if (!_isExpanded ||
!widget.settingsController.collapseWhenClickingOutside) {
@@ -370,7 +374,6 @@ class _FloatickShellState extends State<_FloatickShell> {
}
if (_isExpanded == expanded) {
if (expanded) {
- unawaited(widget.stickyBoardWindowCoordinator.restorePinnedBoards());
try {
await widget.windowBridge.setExpanded(true, animated: false);
} on Object catch (error, stackTrace) {
@@ -395,7 +398,6 @@ class _FloatickShellState extends State<_FloatickShell> {
if (!mounted) {
return;
}
- unawaited(widget.stickyBoardWindowCoordinator.restorePinnedBoards());
final expansionAnchor =
requestedAnchor ??
await widget.windowBridge.preferredExpansionAnchor();
@@ -479,14 +481,11 @@ class _FloatickShellState extends State<_FloatickShell> {
noteController: widget.noteController,
settingsController: widget.settingsController,
updateController: widget.updateController,
- stickyBoardController: widget.stickyBoardController,
- stickyBoardWindowCoordinator:
- widget.stickyBoardWindowCoordinator,
windowBridge: widget.windowBridge,
expansionAnchor: _expansionAnchor,
- stickyBoardRequest: _stickyBoardRequest,
- stickyBoardRequestSerial: _stickyBoardRequestSerial,
onCollapse: () => unawaited(_setExpanded(false)),
+ requestedTodoId: _requestedTodoId,
+ requestedTodoSerial: _requestedTodoSerial,
),
),
),
diff --git a/lib/app/theme/floatick_theme.dart b/lib/app/theme/floatick_theme.dart
index c2944f3..35924ee 100644
--- a/lib/app/theme/floatick_theme.dart
+++ b/lib/app/theme/floatick_theme.dart
@@ -8,8 +8,9 @@ abstract final class FloatickColors {
static const orange = Color(0xFFF17842);
static const ink = Color(0xFF172126);
static const mutedInk = Color(0xFF657178);
- static const darkSurface = Color(0xFF182125);
- static const darkSurfaceElevated = Color(0xFF222D31);
+ static const darkSurface = Color(0xFF151B1E);
+ static const darkSurfaceElevated = Color(0xFF1D2529);
+ static const darkOnSurface = Color(0xFFEEF2F1);
static const lightSurface = Color(0xFFF9FBFA);
}
@@ -25,7 +26,7 @@ ThemeData buildFloatickTheme(Brightness brightness) {
surface: isDark
? FloatickColors.darkSurface
: FloatickColors.lightSurface,
- onSurface: isDark ? const Color(0xFFF1F5F3) : FloatickColors.ink,
+ onSurface: isDark ? FloatickColors.darkOnSurface : FloatickColors.ink,
);
return ThemeData(
@@ -91,13 +92,13 @@ ThemeData buildFloatickTheme(Brightness brightness) {
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: isDark
- ? Colors.white.withValues(alpha: 0.055)
+ ? FloatickColors.darkSurfaceElevated
: const Color(0xFFF0F4F2),
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 13, vertical: 12),
hintStyle: TextStyle(
color: isDark
- ? Colors.white.withValues(alpha: 0.44)
+ ? colorScheme.onSurface.withValues(alpha: 0.58)
: FloatickColors.mutedInk,
fontSize: 13,
),
@@ -109,7 +110,7 @@ ThemeData buildFloatickTheme(Brightness brightness) {
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: isDark
- ? Colors.white.withValues(alpha: 0.06)
+ ? colorScheme.onSurface.withValues(alpha: 0.08)
: Colors.black.withValues(alpha: 0.045),
),
),
diff --git a/lib/core/platform/window_bridge.dart b/lib/core/platform/window_bridge.dart
index d5ecbd0..0c32469 100644
--- a/lib/core/platform/window_bridge.dart
+++ b/lib/core/platform/window_bridge.dart
@@ -18,6 +18,103 @@ typedef ExpandRequestHandler =
void Function(WindowExpansionAnchor expansionAnchor);
typedef CollapseRequestHandler = void Function();
+enum DeadlineReminderActionKind { open, dismiss }
+
+enum DeadlineReminderDeliveryKind { snoozed, deadline, advance }
+
+class DeadlineReminderPayload {
+ const DeadlineReminderPayload({
+ required this.notificationId,
+ required this.todoId,
+ required this.title,
+ required this.dueLabel,
+ required this.isOverdue,
+ required this.isAdvanceReminder,
+ this.tagLabel,
+ });
+
+ final String notificationId;
+ final String todoId;
+ final String title;
+ final String dueLabel;
+ final bool isOverdue;
+ final bool isAdvanceReminder;
+ final String? tagLabel;
+
+ Map toMap() {
+ return {
+ 'notificationId': notificationId,
+ 'todoId': todoId,
+ 'title': title,
+ 'dueLabel': dueLabel,
+ 'isOverdue': isOverdue,
+ 'isAdvanceReminder': isAdvanceReminder,
+ 'tagLabel': ?tagLabel,
+ };
+ }
+}
+
+class ScheduledDeadlineReminder {
+ const ScheduledDeadlineReminder({
+ required this.notificationId,
+ required this.triggerAt,
+ required this.deliveryKind,
+ required this.payload,
+ });
+
+ final String notificationId;
+ final DateTime triggerAt;
+ final DeadlineReminderDeliveryKind deliveryKind;
+ final DeadlineReminderPayload payload;
+
+ Map toMap() {
+ return {
+ 'notificationId': notificationId,
+ 'triggerAtMilliseconds': triggerAt.toUtc().millisecondsSinceEpoch,
+ 'deliveryKind': deliveryKind.name,
+ 'payload': payload.toMap(),
+ };
+ }
+}
+
+class DeadlineReminderDelivery {
+ const DeadlineReminderDelivery({
+ required this.notificationId,
+ required this.todoId,
+ required this.kind,
+ });
+
+ final String notificationId;
+ final String todoId;
+ final DeadlineReminderDeliveryKind kind;
+}
+
+class DeadlineReminderAction {
+ const DeadlineReminderAction({required this.kind, required this.todoId});
+
+ final DeadlineReminderActionKind kind;
+ final String todoId;
+}
+
+typedef DeadlineReminderActionHandler =
+ void Function(DeadlineReminderAction action);
+typedef DeadlineReminderDeliveryHandler =
+ void Function(DeadlineReminderDelivery delivery);
+
+abstract interface class DeadlineReminderBridge {
+ void setDeadlineReminderActionHandler(DeadlineReminderActionHandler? handler);
+
+ void setDeadlineReminderDeliveryHandler(
+ DeadlineReminderDeliveryHandler? handler,
+ );
+
+ Future showDeadlineReminder(DeadlineReminderPayload payload);
+
+ Future synchronizeScheduledDeadlineReminders(
+ List reminders,
+ );
+}
+
abstract interface class WindowBridge {
void setExpandRequestHandler(ExpandRequestHandler? handler);
@@ -36,16 +133,10 @@ abstract interface class WindowBridge {
Future setPreferredTheme(String themePreference);
Future setAlwaysOnTop(bool alwaysOnTop);
-
- Future configureBorderlessSecondaryWindow(
- int viewId, {
- bool positionAdjacentToMainWindow = false,
- });
-
- Future revealBorderlessSecondaryWindow(int viewId);
}
-class MethodChannelWindowBridge implements WindowBridge {
+class MethodChannelWindowBridge
+ implements WindowBridge, DeadlineReminderBridge {
MethodChannelWindowBridge([
this._channel = const MethodChannel('floatick/window'),
]) {
@@ -55,9 +146,40 @@ class MethodChannelWindowBridge implements WindowBridge {
final MethodChannel _channel;
ExpandRequestHandler? _expandRequestHandler;
CollapseRequestHandler? _collapseRequestHandler;
+ DeadlineReminderActionHandler? _deadlineReminderActionHandler;
+ DeadlineReminderDeliveryHandler? _deadlineReminderDeliveryHandler;
WindowExpansionAnchor? _pendingExpansionAnchor;
bool _pendingCollapseRequest = false;
+ @override
+ void setDeadlineReminderActionHandler(
+ DeadlineReminderActionHandler? handler,
+ ) {
+ _deadlineReminderActionHandler = handler;
+ }
+
+ @override
+ void setDeadlineReminderDeliveryHandler(
+ DeadlineReminderDeliveryHandler? handler,
+ ) {
+ _deadlineReminderDeliveryHandler = handler;
+ }
+
+ @override
+ Future showDeadlineReminder(DeadlineReminderPayload payload) {
+ return _channel.invokeMethod('showDeadlineReminder', payload.toMap());
+ }
+
+ @override
+ Future synchronizeScheduledDeadlineReminders(
+ List reminders,
+ ) {
+ return _channel.invokeMethod(
+ 'synchronizeScheduledDeadlineReminders',
+ reminders.map((reminder) => reminder.toMap()).toList(growable: false),
+ );
+ }
+
@override
void setExpandRequestHandler(ExpandRequestHandler? handler) {
_expandRequestHandler = handler;
@@ -120,28 +242,6 @@ class MethodChannelWindowBridge implements WindowBridge {
return _channel.invokeMethod('setAlwaysOnTop', alwaysOnTop);
}
- @override
- Future configureBorderlessSecondaryWindow(
- int viewId, {
- bool positionAdjacentToMainWindow = false,
- }) {
- return _channel.invokeMethod(
- 'configureBorderlessSecondaryWindow',
- {
- 'viewId': viewId,
- 'positionAdjacentToMainWindow': positionAdjacentToMainWindow,
- },
- );
- }
-
- @override
- Future revealBorderlessSecondaryWindow(int viewId) {
- return _channel.invokeMethod(
- 'revealBorderlessSecondaryWindow',
- viewId,
- );
- }
-
Future _handleNativeMethod(MethodCall call) async {
switch (call.method) {
case 'requestExpand':
@@ -163,6 +263,61 @@ class MethodChannelWindowBridge implements WindowBridge {
handler();
}
return;
+ case 'deadlineReminderAction':
+ final arguments = call.arguments;
+ if (arguments is! Map) {
+ throw const FormatException(
+ 'Deadline reminder action must be a map.',
+ );
+ }
+ final todoId = arguments['todoId'];
+ final kindName = arguments['action'];
+ if (todoId is! String || kindName is! String) {
+ throw const FormatException(
+ 'Deadline reminder action is missing required fields.',
+ );
+ }
+ final kind = DeadlineReminderActionKind.values.firstWhere(
+ (value) => value.name == kindName,
+ orElse: () => throw FormatException(
+ 'Unknown deadline reminder action: $kindName',
+ ),
+ );
+ _deadlineReminderActionHandler?.call(
+ DeadlineReminderAction(kind: kind, todoId: todoId),
+ );
+ return;
+ case 'deadlineReminderDelivered':
+ final arguments = call.arguments;
+ if (arguments is! Map) {
+ throw const FormatException(
+ 'Deadline reminder delivery must be a map.',
+ );
+ }
+ final notificationId = arguments['notificationId'];
+ final todoId = arguments['todoId'];
+ final kindName = arguments['deliveryKind'];
+ if (notificationId is! String ||
+ todoId is! String ||
+ kindName is! String) {
+ throw const FormatException(
+ 'Deadline reminder delivery is missing required fields.',
+ );
+ }
+ final kind = DeadlineReminderDeliveryKind.values.firstWhere(
+ (value) => value.name == kindName,
+ orElse: () => throw FormatException(
+ 'Unknown deadline reminder delivery kind: $kindName',
+ ),
+ );
+ _deadlineReminderDeliveryHandler?.call(
+ DeadlineReminderDelivery(
+ notificationId: notificationId,
+ todoId: todoId,
+ kind: kind,
+ ),
+ );
+ return;
default:
throw MissingPluginException(
'Unsupported native method: ${call.method}',
diff --git a/lib/core/ui/floatick_editor_components.dart b/lib/core/ui/floatick_editor_components.dart
index 73d3865..a89565d 100644
--- a/lib/core/ui/floatick_editor_components.dart
+++ b/lib/core/ui/floatick_editor_components.dart
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import '../../l10n/l10n.dart';
-import 'floatick_hover_motion.dart';
const _editorDrawerRadius = Radius.circular(22);
@@ -243,7 +242,7 @@ class FloatickDocumentEditor extends StatelessWidget {
final hasFocus =
titleFocusNode.hasFocus || contentFocusNode.hasFocus;
final borderColor = hasFocus
- ? theme.colorScheme.primary
+ ? theme.colorScheme.primary.withValues(alpha: 0.38)
: theme.colorScheme.onSurface.withValues(
alpha: theme.brightness == Brightness.dark ? 0.08 : 0.06,
);
@@ -257,10 +256,7 @@ class FloatickDocumentEditor extends StatelessWidget {
decoration: BoxDecoration(
color: inputDecorationTheme.fillColor,
borderRadius: BorderRadius.circular(12),
- border: Border.all(
- color: borderColor,
- width: hasFocus ? 1.5 : 1,
- ),
+ border: Border.all(color: borderColor, width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -359,13 +355,12 @@ class FloatickEditorModeSwitch extends StatelessWidget {
@override
Widget build(BuildContext context) {
- final theme = Theme.of(context);
return Container(
key: controlKey,
- height: 30,
- padding: const EdgeInsets.all(2),
+ height: 28,
+ padding: EdgeInsets.zero,
decoration: BoxDecoration(
- color: theme.colorScheme.onSurface.withValues(alpha: 0.055),
+ color: Colors.transparent,
borderRadius: BorderRadius.circular(9),
),
child: Row(
@@ -441,32 +436,28 @@ class _EditorModeButton extends StatelessWidget {
return Semantics(
button: true,
selected: selected,
- child: FloatickHoverMotion(
- hoverScale: FloatickMotion.controlHoverScale,
- pressedScale: FloatickMotion.controlPressedScale,
- child: InkWell(
- onTap: onPressed,
- borderRadius: BorderRadius.circular(7),
- child: AnimatedContainer(
- duration: MediaQuery.disableAnimationsOf(context)
- ? Duration.zero
- : const Duration(milliseconds: 140),
- alignment: Alignment.center,
- padding: const EdgeInsets.symmetric(horizontal: 9),
- decoration: BoxDecoration(
+ child: InkWell(
+ onTap: onPressed,
+ borderRadius: BorderRadius.circular(7),
+ child: AnimatedContainer(
+ duration: MediaQuery.disableAnimationsOf(context)
+ ? Duration.zero
+ : const Duration(milliseconds: 130),
+ alignment: Alignment.center,
+ padding: const EdgeInsets.symmetric(horizontal: 9),
+ decoration: BoxDecoration(
+ color: selected
+ ? theme.colorScheme.primary.withValues(alpha: 0.08)
+ : Colors.transparent,
+ borderRadius: BorderRadius.circular(7),
+ ),
+ child: Text(
+ label,
+ style: theme.textTheme.labelSmall?.copyWith(
color: selected
- ? theme.colorScheme.surface.withValues(alpha: 0.92)
- : Colors.transparent,
- borderRadius: BorderRadius.circular(7),
- ),
- child: Text(
- label,
- style: theme.textTheme.labelSmall?.copyWith(
- color: selected
- ? theme.colorScheme.onSurface
- : theme.colorScheme.onSurface.withValues(alpha: 0.52),
- fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
- ),
+ ? theme.colorScheme.primary
+ : theme.colorScheme.onSurface.withValues(alpha: 0.52),
+ fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
),
),
),
diff --git a/lib/features/notes/presentation/note_editor_drawer.dart b/lib/features/notes/presentation/note_editor_drawer.dart
index 0f274f4..383768c 100644
--- a/lib/features/notes/presentation/note_editor_drawer.dart
+++ b/lib/features/notes/presentation/note_editor_drawer.dart
@@ -339,6 +339,9 @@ class NoteEditorDrawerState extends State {
onPressed: _isSaving
? null
: () => unawaited(_closeAfterFlush()),
+ style: TextButton.styleFrom(
+ foregroundColor: onSurface.withValues(alpha: 0.68),
+ ),
child: Text(context.l10n.finishNoteAction),
),
],
diff --git a/lib/features/notes/presentation/note_panel_content.dart b/lib/features/notes/presentation/note_panel_content.dart
index 8c6796a..284106c 100644
--- a/lib/features/notes/presentation/note_panel_content.dart
+++ b/lib/features/notes/presentation/note_panel_content.dart
@@ -44,6 +44,14 @@ class NotePanelContent extends StatelessWidget {
final regular = archived
? items
: items.where((item) => !item.isPinned).toList(growable: false);
+ final datedRegularRows = _buildDatedNoteRows(
+ context: context,
+ items: regular,
+ archived: archived,
+ controller: controller,
+ availableTags: availableTags,
+ onOpen: onOpen,
+ );
return ListView(
key: const Key('note-list'),
padding: const EdgeInsets.fromLTRB(14, 10, 14, 18),
@@ -64,30 +72,74 @@ class NotePanelContent extends StatelessWidget {
),
const SizedBox(height: 7),
],
- if (regular.isNotEmpty) ...[
- _SectionLabel(
- icon: archived
- ? Icons.inventory_2_outlined
- : Icons.schedule_rounded,
- label: archived
- ? context.l10n.archiveScopeLabel
- : context.l10n.recentNotesLabel,
- ),
- ...regular.map(
- (item) => _NoteListRow(
- item: item,
- archived: archived,
- controller: controller,
- availableTags: availableTags,
- onOpen: () => onOpen(item.id),
- ),
- ),
- ],
+ ...datedRegularRows,
],
);
}
}
+List _buildDatedNoteRows({
+ required BuildContext context,
+ required List items,
+ required bool archived,
+ required NoteViewModel controller,
+ required List availableTags,
+ required ValueChanged onOpen,
+}) {
+ final children = [];
+ DateTime? previousDay;
+ for (final item in items) {
+ final relevantDate = _relevantNoteDate(item, archived: archived).toLocal();
+ final day = DateTime(
+ relevantDate.year,
+ relevantDate.month,
+ relevantDate.day,
+ );
+ if (day != previousDay) {
+ children.add(
+ _DateSectionLabel(day: day, label: _formatNoteDay(context, day)),
+ );
+ previousDay = day;
+ }
+ children.add(
+ _NoteListRow(
+ item: item,
+ archived: archived,
+ controller: controller,
+ availableTags: availableTags,
+ onOpen: () => onOpen(item.id),
+ ),
+ );
+ }
+ return children;
+}
+
+class _DateSectionLabel extends StatelessWidget {
+ const _DateSectionLabel({required this.day, required this.label});
+
+ final DateTime day;
+ final String label;
+
+ @override
+ Widget build(BuildContext context) {
+ final color = Theme.of(
+ context,
+ ).colorScheme.onSurface.withValues(alpha: 0.58);
+ return Padding(
+ key: ValueKey('note-date-${day.year}-${day.month}-${day.day}'),
+ padding: const EdgeInsets.fromLTRB(8, 13, 8, 7),
+ child: Text(
+ label,
+ style: TextStyle(
+ color: color,
+ fontSize: 11.5,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ );
+ }
+}
+
class _SectionLabel extends StatelessWidget {
const _SectionLabel({required this.icon, required this.label});
@@ -227,7 +279,13 @@ class _NoteListRowState extends State<_NoteListRow> {
),
const SizedBox(width: 8),
Text(
- _formatUpdatedTime(context, widget.item.updatedAt),
+ _formatNoteTime(
+ context,
+ _relevantNoteDate(
+ widget.item,
+ archived: widget.archived,
+ ),
+ ),
style: theme.textTheme.labelSmall?.copyWith(
color: onSurface.withValues(alpha: 0.36),
),
@@ -407,16 +465,26 @@ String _previewFor(String content) {
.trim();
}
-String _formatUpdatedTime(BuildContext context, DateTime value) {
- final local = value.toLocal();
+DateTime _relevantNoteDate(NoteItem item, {required bool archived}) {
+ return archived ? (item.archivedAt ?? item.updatedAt) : item.updatedAt;
+}
+
+String _formatNoteDay(BuildContext context, DateTime day) {
final now = DateTime.now();
- if (local.year == now.year &&
- local.month == now.month &&
- local.day == now.day) {
- return MaterialLocalizations.of(context).formatTimeOfDay(
- TimeOfDay.fromDateTime(local),
- alwaysUse24HourFormat: true,
- );
+ final today = DateTime(now.year, now.month, now.day);
+ final difference = today.difference(day).inDays;
+ if (difference == 0) {
+ return context.l10n.todayLabel;
+ }
+ if (difference == 1) {
+ return context.l10n.yesterdayLabel;
}
- return '${local.month}/${local.day}';
+ return MaterialLocalizations.of(context).formatFullDate(day);
+}
+
+String _formatNoteTime(BuildContext context, DateTime value) {
+ final local = value.toLocal();
+ return MaterialLocalizations.of(
+ context,
+ ).formatTimeOfDay(TimeOfDay.fromDateTime(local), alwaysUse24HourFormat: true);
}
diff --git a/lib/features/sticky_boards/data/sticky_board_repository.dart b/lib/features/sticky_boards/data/sticky_board_repository.dart
deleted file mode 100644
index 2560495..0000000
--- a/lib/features/sticky_boards/data/sticky_board_repository.dart
+++ /dev/null
@@ -1,92 +0,0 @@
-import 'dart:convert';
-import 'dart:io';
-
-import '../../../core/storage/storage_failure.dart';
-import '../../todos/data/todo_repository.dart';
-import '../domain/sticky_board_workspace.dart';
-
-abstract interface class StickyBoardRepository {
- String get storagePath;
-
- Future load();
-
- Future save(StickyBoardWorkspace workspace);
-}
-
-class LocalStickyBoardRepository implements StickyBoardRepository {
- LocalStickyBoardRepository({Directory? rootDirectory})
- : rootDirectory = rootDirectory ?? _defaultRootDirectory();
-
- static const fileName = 'sticky_boards.json';
-
- final Directory rootDirectory;
-
- File get _storageFile => File('${rootDirectory.path}/$fileName');
-
- @override
- String get storagePath => _storageFile.path;
-
- @override
- Future load() async {
- try {
- await rootDirectory.create(recursive: true);
- if (!await _storageFile.exists()) {
- return StickyBoardWorkspace.empty();
- }
-
- final decoded = jsonDecode(await _storageFile.readAsString());
- if (decoded is! Map) {
- throw const FormatException(
- 'Sticky board storage root must be a JSON object.',
- );
- }
- return StickyBoardWorkspace.fromJson(Map.from(decoded));
- } on FormatException catch (error) {
- throw StorageFailure(
- kind: StorageFailureKind.invalidData,
- path: storagePath,
- cause: error,
- );
- } on FileSystemException catch (error) {
- throw StorageFailure(
- kind: StorageFailureKind.read,
- path: storagePath,
- cause: error,
- );
- }
- }
-
- @override
- Future save(StickyBoardWorkspace workspace) async {
- final temporaryFile = File(
- '${_storageFile.path}.tmp-$pid-${DateTime.now().microsecondsSinceEpoch}',
- );
- try {
- await rootDirectory.create(recursive: true);
- final encoded = const JsonEncoder.withIndent(
- ' ',
- ).convert(workspace.toJson());
- await temporaryFile.writeAsString('$encoded\n', flush: true);
- await temporaryFile.rename(_storageFile.path);
- } on FileSystemException catch (error) {
- if (await temporaryFile.exists()) {
- await temporaryFile.delete();
- }
- throw StorageFailure(
- kind: StorageFailureKind.write,
- path: storagePath,
- cause: error,
- );
- }
- }
-
- static Directory _defaultRootDirectory() {
- final homeDirectory = Platform.environment['HOME'];
- if (homeDirectory == null || homeDirectory.trim().isEmpty) {
- throw const StorageFailure(
- kind: StorageFailureKind.homeDirectoryUnavailable,
- );
- }
- return Directory('$homeDirectory/${LocalTodoRepository.directoryName}');
- }
-}
diff --git a/lib/features/sticky_boards/domain/sticky_board.dart b/lib/features/sticky_boards/domain/sticky_board.dart
deleted file mode 100644
index ea2c0d3..0000000
--- a/lib/features/sticky_boards/domain/sticky_board.dart
+++ /dev/null
@@ -1,193 +0,0 @@
-import 'package:characters/characters.dart';
-
-class StickyBoardWindowFrame {
- const StickyBoardWindowFrame({
- required this.left,
- required this.top,
- required this.width,
- required this.height,
- });
-
- final double left;
- final double top;
- final double width;
- final double height;
-
- factory StickyBoardWindowFrame.fromJson(Map json) {
- return StickyBoardWindowFrame(
- left: _requiredFiniteDouble(json, 'left'),
- top: _requiredFiniteDouble(json, 'top'),
- width: _requiredPositiveDouble(json, 'width'),
- height: _requiredPositiveDouble(json, 'height'),
- );
- }
-
- Map toJson() {
- return {
- 'left': left,
- 'top': top,
- 'width': width,
- 'height': height,
- };
- }
-
- static double _requiredFiniteDouble(Map json, String key) {
- final value = json[key];
- if (value is! num || !value.toDouble().isFinite) {
- throw FormatException(
- 'Sticky board window frame "$key" must be a finite number.',
- );
- }
- return value.toDouble();
- }
-
- static double _requiredPositiveDouble(Map json, String key) {
- final value = _requiredFiniteDouble(json, key);
- if (value <= 0) {
- throw FormatException(
- 'Sticky board window frame "$key" must be positive.',
- );
- }
- return value;
- }
-
- @override
- bool operator ==(Object other) {
- return other is StickyBoardWindowFrame &&
- other.left == left &&
- other.top == top &&
- other.width == width &&
- other.height == height;
- }
-
- @override
- int get hashCode => Object.hash(left, top, width, height);
-}
-
-class StickyBoard {
- const StickyBoard({
- required this.id,
- required this.name,
- required this.colorValue,
- required this.createdAt,
- this.isPinned = false,
- this.windowFrame,
- });
-
- static const int maxNameLength = 32;
-
- final String id;
- final String name;
- final int colorValue;
- final DateTime createdAt;
- final bool isPinned;
- final StickyBoardWindowFrame? windowFrame;
-
- StickyBoard copyWith({
- String? name,
- int? colorValue,
- bool? isPinned,
- StickyBoardWindowFrame? windowFrame,
- }) {
- return StickyBoard(
- id: id,
- name: name ?? this.name,
- colorValue: colorValue ?? this.colorValue,
- createdAt: createdAt,
- isPinned: isPinned ?? this.isPinned,
- windowFrame: windowFrame ?? this.windowFrame,
- );
- }
-
- factory StickyBoard.fromJson(Map json) {
- final name = _requiredString(json, 'name');
- if (name.characters.length > maxNameLength) {
- throw const FormatException(
- 'Sticky board field "name" must contain at most 32 characters.',
- );
- }
-
- final colorValue = json['colorValue'];
- if (colorValue is! int ||
- colorValue.isNegative ||
- colorValue > 0xFFFFFFFF) {
- throw const FormatException(
- 'Sticky board field "colorValue" must be a 32-bit ARGB integer.',
- );
- }
-
- final isPinned = json['isPinned'];
- if (isPinned is! bool) {
- throw const FormatException(
- 'Sticky board field "isPinned" must be a Boolean.',
- );
- }
-
- final rawWindowFrame = json['windowFrame'];
- if (rawWindowFrame != null && rawWindowFrame is! Map) {
- throw const FormatException(
- 'Sticky board field "windowFrame" must be a JSON object.',
- );
- }
-
- return StickyBoard(
- id: _requiredString(json, 'id'),
- name: name,
- colorValue: colorValue,
- createdAt: _requiredDate(json, 'createdAt'),
- isPinned: isPinned,
- windowFrame: rawWindowFrame == null
- ? null
- : StickyBoardWindowFrame.fromJson(
- Map.from(rawWindowFrame),
- ),
- );
- }
-
- Map toJson() {
- return {
- 'id': id,
- 'name': name,
- 'colorValue': colorValue,
- 'createdAt': createdAt.toUtc().toIso8601String(),
- 'isPinned': isPinned,
- if (windowFrame != null) 'windowFrame': windowFrame!.toJson(),
- };
- }
-
- static String _requiredString(Map json, String key) {
- final value = json[key];
- if (value is! String || value.trim().isEmpty) {
- throw FormatException(
- 'Sticky board field "$key" must be a non-empty string.',
- );
- }
- return value.trim();
- }
-
- static DateTime _requiredDate(Map json, String key) {
- final value = json[key];
- if (value is! String) {
- throw FormatException(
- 'Sticky board field "$key" must be an ISO-8601 string.',
- );
- }
- return DateTime.parse(value);
- }
-
- @override
- bool operator ==(Object other) {
- return other is StickyBoard &&
- other.id == id &&
- other.name == name &&
- other.colorValue == colorValue &&
- other.createdAt == createdAt &&
- other.isPinned == isPinned &&
- other.windowFrame == windowFrame;
- }
-
- @override
- int get hashCode {
- return Object.hash(id, name, colorValue, createdAt, isPinned, windowFrame);
- }
-}
diff --git a/lib/features/sticky_boards/domain/sticky_board_workspace.dart b/lib/features/sticky_boards/domain/sticky_board_workspace.dart
deleted file mode 100644
index 5da26cb..0000000
--- a/lib/features/sticky_boards/domain/sticky_board_workspace.dart
+++ /dev/null
@@ -1,104 +0,0 @@
-import 'sticky_board.dart';
-
-class StickyBoardWorkspace {
- StickyBoardWorkspace({
- required Iterable boards,
- required Map> boardTodoIds,
- }) : boards = List.unmodifiable(boards),
- boardTodoIds = Map>.unmodifiable(
- boardTodoIds.map(
- (boardId, todoIds) =>
- MapEntry(boardId, List.unmodifiable(todoIds.toSet())),
- ),
- );
-
- factory StickyBoardWorkspace.empty() {
- return StickyBoardWorkspace(
- boards: const [],
- boardTodoIds: const >{},
- );
- }
-
- final List boards;
- final Map> boardTodoIds;
-
- List todoIdsForBoard(String boardId) {
- return boardTodoIds[boardId] ?? const [];
- }
-
- factory StickyBoardWorkspace.fromJson(Map json) {
- if (json['version'] != 1) {
- throw const FormatException(
- 'Sticky board workspace field "version" must equal 1.',
- );
- }
-
- final rawBoards = json['boards'];
- if (rawBoards is! List) {
- throw const FormatException(
- 'Sticky board workspace field "boards" must be a JSON array.',
- );
- }
- final boards = rawBoards
- .map((entry) {
- if (entry is! Map) {
- throw const FormatException(
- 'Each sticky board must be a JSON object.',
- );
- }
- return StickyBoard.fromJson(Map.from(entry));
- })
- .toList(growable: false);
- if (boards.map((board) => board.id).toSet().length != boards.length) {
- throw const FormatException('Sticky board ids must be unique.');
- }
- if (boards.map((board) => board.name.toLowerCase()).toSet().length !=
- boards.length) {
- throw const FormatException(
- 'Sticky board names must be unique regardless of letter case.',
- );
- }
- final knownBoardIds = boards.map((board) => board.id).toSet();
-
- final rawBoardTodoIds = json['boardTodoIds'];
- if (rawBoardTodoIds is! Map) {
- throw const FormatException(
- 'Sticky board workspace field "boardTodoIds" must be a JSON object.',
- );
- }
- final boardTodoIds = >{};
- for (final entry in rawBoardTodoIds.entries) {
- if (entry.key is! String ||
- !knownBoardIds.contains(entry.key) ||
- entry.value is! List) {
- throw const FormatException(
- 'Each sticky board relation must reference an existing board.',
- );
- }
- final todoIds = [];
- for (final todoId in entry.value as List) {
- if (todoId is! String || todoId.trim().isEmpty) {
- throw const FormatException(
- 'Sticky board todo ids must be non-empty strings.',
- );
- }
- todoIds.add(todoId);
- }
- boardTodoIds[entry.key as String] = todoIds;
- }
-
- return StickyBoardWorkspace(boards: boards, boardTodoIds: boardTodoIds);
- }
-
- Map toJson() {
- final sortedRelations = boardTodoIds.entries.toList()
- ..sort((left, right) => left.key.compareTo(right.key));
- return {
- 'version': 1,
- 'boards': boards.map((board) => board.toJson()).toList(growable: false),
- 'boardTodoIds': {
- for (final entry in sortedRelations) entry.key: entry.value,
- },
- };
- }
-}
diff --git a/lib/features/sticky_boards/presentation/pinned_sticky_board_window.dart b/lib/features/sticky_boards/presentation/pinned_sticky_board_window.dart
deleted file mode 100644
index 5b0d297..0000000
--- a/lib/features/sticky_boards/presentation/pinned_sticky_board_window.dart
+++ /dev/null
@@ -1,337 +0,0 @@
-import 'dart:async';
-
-import 'package:flutter/material.dart';
-import 'package:multiview_desktop/multiview_desktop.dart';
-
-import '../../../app/theme/floatick_theme.dart';
-import '../../../core/ui/floatick_hover_motion.dart';
-import '../../../l10n/l10n.dart';
-import '../../todos/domain/todo_item.dart';
-import '../../todos/presentation/todo_view_model.dart';
-import '../domain/sticky_board.dart';
-import 'sticky_board_frame_save_scheduler.dart';
-import 'sticky_board_palette.dart';
-import 'sticky_board_view_model.dart';
-import 'sticky_board_window_coordinator.dart';
-import 'widgets/sticky_board_read_only_todo_row.dart';
-import 'widgets/sticky_board_todo_details.dart';
-
-class PinnedStickyBoardWindow extends StatefulWidget {
- const PinnedStickyBoardWindow({
- required this.boardId,
- required this.viewId,
- required this.boardController,
- required this.todoController,
- required this.coordinator,
- super.key,
- });
-
- final String boardId;
- final int viewId;
- final StickyBoardViewModel boardController;
- final TodoViewModel todoController;
- final StickyBoardWindowCoordinator coordinator;
-
- @override
- State createState() =>
- _PinnedStickyBoardWindowState();
-}
-
-class _PinnedStickyBoardWindowState extends State
- with WindowListener {
- final StickyBoardFrameSaveScheduler _frameSaveScheduler =
- StickyBoardFrameSaveScheduler();
- bool _isUnpinning = false;
- String? _detailsTodoId;
-
- @override
- void initState() {
- super.initState();
- widget.boardController.addListener(_handleModelChanged);
- widget.todoController.addListener(_handleModelChanged);
- widget.coordinator.registerWindow(
- boardId: widget.boardId,
- viewId: widget.viewId,
- );
- WidgetsBinding.instance.addPostFrameCallback((_) {
- if (!mounted) {
- return;
- }
- unawaited(MultiViewDesktop.of(context).setPreventClose(true));
- });
- }
-
- @override
- void dispose() {
- _frameSaveScheduler.cancel();
- widget.boardController.removeListener(_handleModelChanged);
- widget.todoController.removeListener(_handleModelChanged);
- widget.coordinator.forgetWindow(
- boardId: widget.boardId,
- viewId: widget.viewId,
- );
- super.dispose();
- }
-
- void _handleModelChanged() {
- if (!mounted) {
- return;
- }
- final detailsTodoId = _detailsTodoId;
- if (detailsTodoId != null) {
- final item = widget.todoController.itemById(detailsTodoId);
- final belongsToBoard = widget.boardController
- .todoIdsForBoard(widget.boardId)
- .contains(detailsTodoId);
- if (item == null || item.isArchived || !belongsToBoard) {
- _detailsTodoId = null;
- }
- }
- setState(() {});
- }
-
- @override
- void onWindowClose() {
- if (!_isUnpinning) {
- _frameSaveScheduler.cancel();
- unawaited(_persistBoundsAndUnpin());
- }
- }
-
- @override
- void onWindowMoved() {
- _scheduleBoundsSave();
- }
-
- @override
- void onWindowResized() {
- _scheduleBoundsSave();
- }
-
- void _scheduleBoundsSave() {
- _frameSaveScheduler.schedule(() => unawaited(_persistBounds()));
- }
-
- Future _persistBounds({bool allowClosing = false}) async {
- if (!mounted || (_isUnpinning && !allowClosing)) {
- return;
- }
- try {
- final bounds = await MultiViewDesktop.of(context).getBounds();
- await widget.coordinator.saveWindowFrame(
- boardId: widget.boardId,
- bounds: bounds,
- );
- } on Object catch (error, stackTrace) {
- debugPrint(
- 'Floatick could not save sticky board ${widget.boardId} bounds: '
- '$error',
- );
- debugPrintStack(stackTrace: stackTrace);
- }
- }
-
- Future _unpin() async {
- if (_isUnpinning) {
- return;
- }
- _frameSaveScheduler.cancel();
- _isUnpinning = true;
- try {
- await widget.coordinator.unpin(widget.boardId);
- } finally {
- _isUnpinning = false;
- }
- }
-
- Future _persistBoundsAndUnpin() async {
- if (_isUnpinning) {
- return;
- }
- _isUnpinning = true;
- try {
- await _persistBounds(allowClosing: true);
- await widget.coordinator.unpin(widget.boardId);
- } finally {
- _isUnpinning = false;
- }
- }
-
- void _showTodoDetails(String todoId) {
- setState(() => _detailsTodoId = todoId);
- }
-
- void _closeTodoDetails() {
- setState(() => _detailsTodoId = null);
- }
-
- @override
- Widget build(BuildContext context) {
- final board = widget.boardController.boardById(widget.boardId);
- if (board == null) {
- WidgetsBinding.instance.addPostFrameCallback((_) {
- if (mounted) {
- unawaited(_unpin());
- }
- });
- return const SizedBox.shrink();
- }
- final theme = Theme.of(context);
- final isDark = theme.brightness == Brightness.dark;
- final boardColor = StickyBoardPalette.color(board.colorValue);
- final surfaceColor = StickyBoardPalette.surfaceColor(
- value: board.colorValue,
- baseColor: isDark
- ? FloatickColors.darkSurface
- : FloatickColors.lightSurface,
- brightness: theme.brightness,
- );
-
- final detailsItem = _detailsTodoId == null
- ? null
- : widget.todoController.itemById(_detailsTodoId!);
-
- const borderRadius = BorderRadius.all(Radius.circular(22));
- return Material(
- type: MaterialType.transparency,
- child: ClipRRect(
- borderRadius: borderRadius,
- clipBehavior: Clip.antiAlias,
- child: DecoratedBox(
- decoration: BoxDecoration(
- color: surfaceColor,
- borderRadius: borderRadius,
- border: Border.all(
- color: boardColor.withValues(alpha: isDark ? 0.44 : 0.34),
- ),
- ),
- child: Column(
- children: [
- _PinnedHeader(board: board, onUnpin: () => unawaited(_unpin())),
- Divider(
- height: 1,
- color: Theme.of(
- context,
- ).colorScheme.onSurface.withValues(alpha: 0.08),
- ),
- Expanded(
- child: AnimatedSwitcher(
- duration: MediaQuery.disableAnimationsOf(context)
- ? Duration.zero
- : const Duration(milliseconds: 150),
- switchInCurve: Curves.easeOut,
- switchOutCurve: Curves.easeIn,
- child: detailsItem == null
- ? _buildTodoList(board)
- : StickyBoardTodoDetails(
- key: ValueKey(
- 'sticky-board-details-${detailsItem.id}',
- ),
- item: detailsItem,
- tags: widget.todoController.tags
- .where(
- (tag) => widget.todoController
- .tagIdsForTodo(detailsItem.id)
- .contains(tag.id),
- )
- .toList(growable: false),
- onBack: _closeTodoDetails,
- ),
- ),
- ),
- ],
- ),
- ),
- ),
- );
- }
-
- Widget _buildTodoList(StickyBoard board) {
- final items = widget.boardController
- .todoIdsForBoard(board.id)
- .map(widget.todoController.itemById)
- .whereType()
- .where((item) => !item.isArchived)
- .toList(growable: false);
- if (items.isEmpty) {
- return Center(
- child: Padding(
- padding: const EdgeInsets.all(24),
- child: Text(
- key: const Key('pinned-sticky-board-empty'),
- context.l10n.emptyPinnedStickyBoardMessage,
- textAlign: TextAlign.center,
- style: Theme.of(context).textTheme.bodyMedium?.copyWith(
- color: Theme.of(
- context,
- ).colorScheme.onSurface.withValues(alpha: 0.52),
- ),
- ),
- ),
- );
- }
- return ListView.builder(
- padding: const EdgeInsets.fromLTRB(10, 8, 10, 8),
- itemCount: items.length,
- itemBuilder: (context, index) {
- final item = items[index];
- return StickyBoardReadOnlyTodoRow(
- key: ValueKey('pinned-sticky-board-todo-${item.id}'),
- item: item,
- onToggleCompletion: () =>
- unawaited(widget.todoController.toggleCompletion(item.id)),
- onOpenDetails: () => _showTodoDetails(item.id),
- );
- },
- );
- }
-}
-
-class _PinnedHeader extends StatelessWidget {
- const _PinnedHeader({required this.board, required this.onUnpin});
-
- final StickyBoard board;
- final VoidCallback onUnpin;
-
- @override
- Widget build(BuildContext context) {
- return DragToMoveArea(
- child: Padding(
- padding: const EdgeInsets.fromLTRB(14, 9, 8, 9),
- child: Row(
- children: [
- Expanded(
- child: Text(
- board.name,
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: Theme.of(
- context,
- ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
- ),
- ),
- FloatickHoverMotion(
- hoverScale: FloatickMotion.emphasisHoverScale,
- pressedScale: FloatickMotion.emphasisPressedScale,
- hoverTurns: FloatickMotion.emphasisHoverTurns,
- child: IconButton(
- key: const Key('pinned-sticky-board-unpin'),
- tooltip: context.l10n.unpinStickyBoardTooltip,
- onPressed: onUnpin,
- style: const ButtonStyle(
- foregroundBuilder:
- FloatickMotion.passthroughForegroundBuilder,
- ),
- icon: Icon(
- Icons.push_pin_rounded,
- size: 17,
- color: Theme.of(context).colorScheme.primary,
- ),
- ),
- ),
- ],
- ),
- ),
- );
- }
-}
diff --git a/lib/features/sticky_boards/presentation/sticky_board_drawers.dart b/lib/features/sticky_boards/presentation/sticky_board_drawers.dart
deleted file mode 100644
index 4d04fb7..0000000
--- a/lib/features/sticky_boards/presentation/sticky_board_drawers.dart
+++ /dev/null
@@ -1,1340 +0,0 @@
-import 'dart:async';
-
-import 'package:flutter/material.dart';
-import 'package:flutter/services.dart';
-
-import '../../../core/ui/floatick_hover_motion.dart';
-import '../../../l10n/l10n.dart';
-import '../../todos/domain/todo_item.dart';
-import '../../todos/presentation/todo_view_model.dart';
-import '../domain/sticky_board.dart';
-import 'sticky_board_palette.dart';
-import 'sticky_board_view_model.dart';
-import 'widgets/sticky_board_management_todo_row.dart';
-import 'widgets/sticky_board_todo_details.dart';
-
-const BorderRadius _selectionRowRadius = BorderRadius.all(Radius.circular(11));
-
-class StickyBoardManagementDrawer extends StatefulWidget {
- const StickyBoardManagementDrawer({
- required this.controller,
- required this.todoController,
- required this.isOpen,
- required this.borderOnLeft,
- required this.onClose,
- required this.onOpenBoard,
- required this.onTogglePin,
- required this.onDeleteBoard,
- required this.closeFocusNode,
- super.key,
- });
-
- final StickyBoardViewModel controller;
- final TodoViewModel todoController;
- final bool isOpen;
- final bool borderOnLeft;
- final VoidCallback onClose;
- final ValueChanged onOpenBoard;
- final ValueChanged onTogglePin;
- final ValueChanged onDeleteBoard;
- final FocusNode closeFocusNode;
-
- @override
- State createState() =>
- _StickyBoardManagementDrawerState();
-}
-
-class _StickyBoardManagementDrawerState
- extends State {
- final _queryController = TextEditingController();
- final _queryFocusNode = FocusNode();
-
- String? _editingBoardId;
- String? _validationMessage;
- int _selectedColorValue = StickyBoardPalette.teal;
- bool _isSaving = false;
-
- @override
- void initState() {
- super.initState();
- if (widget.isOpen) {
- _requestQueryFocus();
- }
- }
-
- @override
- void didUpdateWidget(covariant StickyBoardManagementDrawer oldWidget) {
- super.didUpdateWidget(oldWidget);
- if (!oldWidget.isOpen && widget.isOpen) {
- _requestQueryFocus();
- }
- }
-
- @override
- void dispose() {
- _queryController.dispose();
- _queryFocusNode.dispose();
- super.dispose();
- }
-
- void _requestQueryFocus() {
- WidgetsBinding.instance.addPostFrameCallback((_) {
- if (mounted && widget.isOpen) {
- _queryFocusNode.requestFocus();
- }
- });
- }
-
- Future _submit() async {
- if (_isSaving) {
- return;
- }
- final name = _queryController.text.trim();
- if (name.isEmpty) {
- setState(
- () => _validationMessage = context.l10n.stickyBoardNameRequiredMessage,
- );
- return;
- }
-
- setState(() {
- _isSaving = true;
- _validationMessage = null;
- });
- final result = _editingBoardId == null
- ? await widget.controller.createBoard(
- name: name,
- colorValue: _selectedColorValue,
- )
- : await widget.controller.updateBoard(
- id: _editingBoardId!,
- name: name,
- colorValue: _selectedColorValue,
- );
- if (!mounted) {
- return;
- }
-
- setState(() {
- _isSaving = false;
- if (result == StickyBoardMutationResult.success) {
- _editingBoardId = null;
- _queryController.clear();
- _selectedColorValue = StickyBoardPalette.teal;
- } else {
- _validationMessage = _messageForResult(result);
- }
- });
- if (result == StickyBoardMutationResult.success) {
- _queryFocusNode.requestFocus();
- }
- }
-
- void _beginEditing(StickyBoard board) {
- setState(() {
- _editingBoardId = board.id;
- _validationMessage = null;
- _selectedColorValue = board.colorValue;
- _queryController.text = board.name;
- _queryController.selection = TextSelection(
- baseOffset: 0,
- extentOffset: board.name.length,
- );
- });
- _queryFocusNode.requestFocus();
- }
-
- void _cancelEditing() {
- setState(() {
- _editingBoardId = null;
- _validationMessage = null;
- _queryController.clear();
- _selectedColorValue = StickyBoardPalette.teal;
- });
- _queryFocusNode.requestFocus();
- }
-
- Future _requestDeleteConfirmation(StickyBoard board) async {
- final confirmed = await showDialog(
- context: context,
- builder: (dialogContext) {
- final theme = Theme.of(dialogContext);
- return AlertDialog(
- key: ValueKey('sticky-board-delete-confirmation-${board.id}'),
- title: Text(dialogContext.l10n.deleteStickyBoardTitle),
- content: Text(dialogContext.l10n.deleteStickyBoardMessage),
- actions: [
- TextButton(
- key: ValueKey('cancel-delete-sticky-board-${board.id}'),
- onPressed: () => Navigator.of(dialogContext).pop(false),
- child: Text(dialogContext.l10n.cancelAction),
- ),
- TextButton(
- key: ValueKey('confirm-delete-sticky-board-${board.id}'),
- onPressed: () => Navigator.of(dialogContext).pop(true),
- style: TextButton.styleFrom(
- foregroundColor: theme.colorScheme.error,
- ),
- child: Text(dialogContext.l10n.confirmAction),
- ),
- ],
- );
- },
- );
- if (!mounted || confirmed != true) {
- return;
- }
- if (_editingBoardId == board.id) {
- setState(() {
- _editingBoardId = null;
- _queryController.clear();
- _selectedColorValue = StickyBoardPalette.teal;
- });
- }
- widget.onDeleteBoard(board.id);
- }
-
- String _messageForResult(StickyBoardMutationResult result) {
- return switch (result) {
- StickyBoardMutationResult.emptyName =>
- context.l10n.stickyBoardNameRequiredMessage,
- StickyBoardMutationResult.nameTooLong =>
- context.l10n.stickyBoardNameTooLongMessage(StickyBoard.maxNameLength),
- StickyBoardMutationResult.duplicateName =>
- context.l10n.duplicateStickyBoardNameMessage,
- StickyBoardMutationResult.notFound =>
- context.l10n.stickyBoardNotFoundMessage,
- StickyBoardMutationResult.invalidColor =>
- context.l10n.invalidTagColorMessage,
- StickyBoardMutationResult.storageFailure =>
- context.l10n.stickyBoardStorageFailureMessage,
- StickyBoardMutationResult.success => '',
- };
- }
-
- @override
- Widget build(BuildContext context) {
- final theme = Theme.of(context);
- final query = _queryController.text.trim().toLowerCase();
- return _StickyBoardDrawerSurface(
- key: const Key('sticky-board-management-drawer'),
- borderOnLeft: widget.borderOnLeft,
- child: AnimatedBuilder(
- animation: widget.controller,
- builder: (context, _) {
- final filteredBoards = widget.controller.boards
- .where(
- (board) =>
- query.isEmpty || board.name.toLowerCase().contains(query),
- )
- .toList(growable: false);
- return Column(
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- _DrawerHeader(
- title: context.l10n.stickyBoardsTitle,
- closeTooltip: context.l10n.closeStickyBoardsTooltip,
- onClose: widget.onClose,
- closeFocusNode: widget.closeFocusNode,
- ),
- const _DrawerDivider(),
- Padding(
- padding: const EdgeInsets.fromLTRB(15, 14, 15, 10),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- TextField(
- key: const Key('sticky-board-search-create-field'),
- controller: _queryController,
- focusNode: _queryFocusNode,
- enabled: !_isSaving,
- textInputAction: TextInputAction.done,
- inputFormatters: [
- LengthLimitingTextInputFormatter(
- StickyBoard.maxNameLength,
- ),
- ],
- onChanged: (_) {
- setState(() => _validationMessage = null);
- },
- onSubmitted: (_) => unawaited(_submit()),
- decoration: InputDecoration(
- hintText: context.l10n.searchOrCreateStickyBoardHint,
- prefixIcon: const Icon(Icons.search_rounded, size: 18),
- suffixIcon: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- if (_editingBoardId != null)
- IconButton(
- tooltip: context.l10n.cancelEditTooltip,
- onPressed: _isSaving ? null : _cancelEditing,
- icon: const Icon(Icons.close_rounded, size: 16),
- ),
- IconButton(
- key: const Key('submit-sticky-board'),
- tooltip: context.l10n.createStickyBoardTooltip,
- onPressed: _isSaving
- ? null
- : () => unawaited(_submit()),
- icon: _isSaving
- ? const SizedBox.square(
- dimension: 14,
- child: CircularProgressIndicator(
- strokeWidth: 1.8,
- ),
- )
- : Icon(
- _editingBoardId == null
- ? Icons.add_rounded
- : Icons.check_rounded,
- size: 18,
- ),
- ),
- ],
- ),
- ),
- ),
- const SizedBox(height: 11),
- Wrap(
- key: const Key('sticky-board-color-palette'),
- spacing: 9,
- runSpacing: 8,
- children: [
- for (final colorValue in StickyBoardPalette.values)
- _ColorButton(
- colorValue: colorValue,
- selected: _selectedColorValue == colorValue,
- enabled: !_isSaving,
- onPressed: () {
- setState(() => _selectedColorValue = colorValue);
- },
- ),
- ],
- ),
- if (_validationMessage != null) ...[
- const SizedBox(height: 9),
- Semantics(
- liveRegion: true,
- child: Text(
- _validationMessage!,
- key: const Key('sticky-board-validation-message'),
- style: theme.textTheme.bodySmall?.copyWith(
- color: theme.colorScheme.error,
- ),
- ),
- ),
- ],
- ],
- ),
- ),
- const _DrawerDivider(),
- Expanded(
- child: _buildBoardList(
- context: context,
- boards: filteredBoards,
- hasQuery: query.isNotEmpty,
- ),
- ),
- Padding(
- padding: const EdgeInsets.fromLTRB(16, 8, 16, 14),
- child: Row(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Icon(
- Icons.info_outline_rounded,
- size: 15,
- color: theme.colorScheme.onSurface.withValues(
- alpha: 0.38,
- ),
- ),
- const SizedBox(width: 7),
- Expanded(
- child: Text(
- context.l10n.stickyBoardDeleteKeepsTodosHint,
- style: theme.textTheme.labelSmall?.copyWith(
- color: theme.colorScheme.onSurface.withValues(
- alpha: 0.40,
- ),
- ),
- ),
- ),
- ],
- ),
- ),
- ],
- );
- },
- ),
- );
- }
-
- Widget _buildBoardList({
- required BuildContext context,
- required List boards,
- required bool hasQuery,
- }) {
- if (boards.isEmpty) {
- return _EmptyBoards(hasQuery: hasQuery);
- }
- return LayoutBuilder(
- builder: (context, constraints) {
- final columnCount = constraints.maxWidth >= 360 ? 2 : 1;
- return GridView.builder(
- key: const Key('sticky-board-thumbnail-grid'),
- padding: const EdgeInsets.fromLTRB(10, 10, 10, 12),
- gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
- crossAxisCount: columnCount,
- crossAxisSpacing: 10,
- mainAxisSpacing: 10,
- mainAxisExtent: 150,
- ),
- itemCount: boards.length,
- itemBuilder: (context, index) {
- final board = boards[index];
- final previewItems = widget.controller
- .todoIdsForBoard(board.id)
- .map(widget.todoController.itemById)
- .whereType()
- .take(2)
- .toList(growable: false);
- return _ManagedBoardCard(
- key: ValueKey('sticky-board-${board.id}'),
- board: board,
- previewItems: previewItems,
- todoCount: widget.controller.todoCountForBoard(board.id),
- isEditing: _editingBoardId == board.id,
- onOpen: () => widget.onOpenBoard(board.id),
- onEdit: () => _beginEditing(board),
- onTogglePin: () => widget.onTogglePin(board.id),
- onDelete: () => unawaited(_requestDeleteConfirmation(board)),
- );
- },
- );
- },
- );
- }
-}
-
-class StickyBoardDetailDrawer extends StatefulWidget {
- const StickyBoardDetailDrawer({
- required this.board,
- required this.todoController,
- required this.boardController,
- required this.borderOnLeft,
- required this.onBack,
- required this.onClose,
- required this.onTogglePin,
- required this.onAddExisting,
- required this.onCreateTodo,
- required this.closeFocusNode,
- super.key,
- });
-
- final StickyBoard board;
- final TodoViewModel todoController;
- final StickyBoardViewModel boardController;
- final bool borderOnLeft;
- final VoidCallback onBack;
- final VoidCallback onClose;
- final VoidCallback onTogglePin;
- final VoidCallback onAddExisting;
- final VoidCallback onCreateTodo;
- final FocusNode closeFocusNode;
-
- @override
- State createState() =>
- _StickyBoardDetailDrawerState();
-}
-
-class _StickyBoardDetailDrawerState extends State {
- String? _detailsTodoId;
-
- void _openDetails(String todoId) {
- setState(() => _detailsTodoId = todoId);
- }
-
- void _closeDetails() {
- setState(() => _detailsTodoId = null);
- }
-
- @override
- Widget build(BuildContext context) {
- final boardTodoIds = widget.boardController.todoIdsForBoard(
- widget.board.id,
- );
- final items = boardTodoIds
- .map(widget.todoController.itemById)
- .whereType()
- .where((item) => !item.isArchived)
- .toList(growable: false);
- final detailsCandidate = _detailsTodoId == null
- ? null
- : widget.todoController.itemById(_detailsTodoId!);
- final detailsItem =
- detailsCandidate != null &&
- !detailsCandidate.isArchived &&
- boardTodoIds.contains(detailsCandidate.id)
- ? detailsCandidate
- : null;
-
- return _StickyBoardDrawerSurface(
- key: const Key('sticky-board-detail-drawer'),
- borderOnLeft: widget.borderOnLeft,
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- Padding(
- padding: const EdgeInsets.fromLTRB(7, 10, 9, 10),
- child: Row(
- children: [
- IconButton(
- key: const Key('sticky-board-back'),
- tooltip: context.l10n.backToStickyBoardsTooltip,
- onPressed: widget.onBack,
- icon: const Icon(Icons.arrow_back_rounded, size: 18),
- ),
- Container(
- width: 10,
- height: 10,
- decoration: BoxDecoration(
- color: Color(widget.board.colorValue),
- shape: BoxShape.circle,
- ),
- ),
- const SizedBox(width: 9),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- widget.board.name,
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: Theme.of(context).textTheme.titleSmall?.copyWith(
- fontWeight: FontWeight.w600,
- ),
- ),
- Text(
- context.l10n.stickyBoardTodoCount(items.length),
- style: Theme.of(context).textTheme.labelSmall?.copyWith(
- color: Theme.of(
- context,
- ).colorScheme.onSurface.withValues(alpha: 0.42),
- ),
- ),
- ],
- ),
- ),
- FloatickHoverMotion(
- hoverScale: FloatickMotion.emphasisHoverScale,
- pressedScale: FloatickMotion.emphasisPressedScale,
- hoverTurns: FloatickMotion.emphasisHoverTurns,
- child: IconButton(
- key: const Key('sticky-board-pin'),
- tooltip: widget.board.isPinned
- ? context.l10n.unpinStickyBoardTooltip
- : context.l10n.pinStickyBoardTooltip,
- onPressed: widget.onTogglePin,
- style: const ButtonStyle(
- foregroundBuilder:
- FloatickMotion.passthroughForegroundBuilder,
- ),
- icon: Icon(
- widget.board.isPinned
- ? Icons.push_pin_rounded
- : Icons.push_pin_outlined,
- size: 18,
- color: widget.board.isPinned
- ? Theme.of(context).colorScheme.primary
- : null,
- ),
- ),
- ),
- IconButton(
- focusNode: widget.closeFocusNode,
- tooltip: context.l10n.closeStickyBoardsTooltip,
- onPressed: widget.onClose,
- icon: const Icon(Icons.close_rounded, size: 18),
- ),
- ],
- ),
- ),
- const _DrawerDivider(),
- Expanded(
- child: AnimatedSwitcher(
- duration: MediaQuery.disableAnimationsOf(context)
- ? Duration.zero
- : const Duration(milliseconds: 150),
- switchInCurve: Curves.easeOut,
- switchOutCurve: Curves.easeIn,
- child: detailsItem == null
- ? _buildBoardMembers(items)
- : StickyBoardTodoDetails(
- key: ValueKey(
- 'sticky-board-managed-details-${detailsItem.id}',
- ),
- item: detailsItem,
- tags: widget.todoController.tags
- .where(
- (tag) => widget.todoController
- .tagIdsForTodo(detailsItem.id)
- .contains(tag.id),
- )
- .toList(growable: false),
- onBack: _closeDetails,
- ),
- ),
- ),
- ],
- ),
- );
- }
-
- Widget _buildBoardMembers(List items) {
- return Column(
- key: ValueKey('sticky-board-members-${widget.board.id}'),
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- Padding(
- padding: const EdgeInsets.fromLTRB(13, 12, 13, 8),
- child: Row(
- children: [
- Expanded(
- child: OutlinedButton.icon(
- key: const Key('sticky-board-add-existing'),
- onPressed: widget.onAddExisting,
- icon: const Icon(Icons.playlist_add_rounded, size: 17),
- label: Text(context.l10n.addExistingTodoAction),
- ),
- ),
- const SizedBox(width: 8),
- Expanded(
- child: FilledButton.tonalIcon(
- key: const Key('sticky-board-new-todo'),
- onPressed: widget.onCreateTodo,
- icon: const Icon(Icons.add_rounded, size: 17),
- label: Text(context.l10n.newTodoInStickyBoardAction),
- ),
- ),
- ],
- ),
- ),
- Expanded(
- child: items.isEmpty
- ? _EmptyBoardTodos(onCreateTodo: widget.onCreateTodo)
- : ListView.builder(
- padding: const EdgeInsets.fromLTRB(10, 4, 10, 14),
- itemCount: items.length,
- itemBuilder: (context, index) {
- final item = items[index];
- return StickyBoardManagementTodoRow(
- key: ValueKey('sticky-board-todo-${item.id}'),
- item: item,
- tags: widget.todoController.tags,
- assignedTagIds: widget.todoController.tagIdsForTodo(
- item.id,
- ),
- onOpenDetails: () => _openDetails(item.id),
- onRemove: () => unawaited(
- widget.boardController.removeTodo(
- boardId: widget.board.id,
- todoId: item.id,
- ),
- ),
- );
- },
- ),
- ),
- ],
- );
- }
-}
-
-class StickyBoardTodoPickerDrawer extends StatefulWidget {
- const StickyBoardTodoPickerDrawer({
- required this.board,
- required this.todoController,
- required this.boardController,
- required this.borderOnLeft,
- required this.onBack,
- required this.onClose,
- required this.closeFocusNode,
- super.key,
- });
-
- final StickyBoard board;
- final TodoViewModel todoController;
- final StickyBoardViewModel boardController;
- final bool borderOnLeft;
- final VoidCallback onBack;
- final VoidCallback onClose;
- final FocusNode closeFocusNode;
-
- @override
- State createState() =>
- _StickyBoardTodoPickerDrawerState();
-}
-
-class _StickyBoardTodoPickerDrawerState
- extends State {
- final _searchController = TextEditingController();
-
- @override
- void dispose() {
- _searchController.dispose();
- super.dispose();
- }
-
- @override
- Widget build(BuildContext context) {
- final theme = Theme.of(context);
- final items = widget.todoController.itemsForView(
- archived: false,
- query: _searchController.text,
- );
- return _StickyBoardDrawerSurface(
- key: const Key('sticky-board-todo-picker-drawer'),
- borderOnLeft: widget.borderOnLeft,
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- Padding(
- padding: const EdgeInsets.fromLTRB(7, 10, 9, 10),
- child: Row(
- children: [
- IconButton(
- tooltip: context.l10n.backToStickyBoardsTooltip,
- onPressed: widget.onBack,
- icon: const Icon(Icons.arrow_back_rounded, size: 18),
- ),
- Expanded(
- child: Text(
- context.l10n.addExistingTodoTitle,
- style: Theme.of(context).textTheme.titleSmall?.copyWith(
- fontWeight: FontWeight.w600,
- ),
- ),
- ),
- IconButton(
- focusNode: widget.closeFocusNode,
- tooltip: context.l10n.closeStickyBoardsTooltip,
- onPressed: widget.onClose,
- icon: const Icon(Icons.close_rounded, size: 18),
- ),
- ],
- ),
- ),
- const _DrawerDivider(),
- Padding(
- padding: const EdgeInsets.all(13),
- child: TextField(
- key: const Key('sticky-board-todo-search'),
- controller: _searchController,
- onChanged: (_) => setState(() {}),
- decoration: InputDecoration(
- hintText: context.l10n.searchTodosToAddHint,
- prefixIcon: const Icon(Icons.search_rounded, size: 18),
- ),
- ),
- ),
- Expanded(
- child: items.isEmpty
- ? Center(
- child: Padding(
- padding: const EdgeInsets.all(24),
- child: Text(
- context.l10n.noTodosAvailableForBoardMessage,
- textAlign: TextAlign.center,
- style: Theme.of(context).textTheme.bodySmall?.copyWith(
- color: Theme.of(
- context,
- ).colorScheme.onSurface.withValues(alpha: 0.46),
- ),
- ),
- ),
- )
- : ListView.builder(
- padding: const EdgeInsets.fromLTRB(10, 0, 10, 14),
- itemCount: items.length,
- itemBuilder: (context, index) {
- final item = items[index];
- final selected = widget.boardController.containsTodo(
- boardId: widget.board.id,
- todoId: item.id,
- );
- return Padding(
- padding: const EdgeInsets.only(bottom: 2),
- child: Material(
- type: MaterialType.transparency,
- shape: const RoundedRectangleBorder(
- borderRadius: _selectionRowRadius,
- ),
- clipBehavior: Clip.antiAlias,
- child: CheckboxListTile(
- key: ValueKey(
- 'sticky-board-picker-${item.id}',
- ),
- value: selected,
- onChanged: (value) {
- unawaited(
- widget.boardController.setTodoMembership(
- boardId: widget.board.id,
- todoId: item.id,
- selected: value ?? false,
- ),
- );
- },
- dense: true,
- controlAffinity: ListTileControlAffinity.leading,
- contentPadding: const EdgeInsets.symmetric(
- horizontal: 4,
- ),
- shape: const RoundedRectangleBorder(
- borderRadius: _selectionRowRadius,
- ),
- hoverColor: theme.colorScheme.onSurface.withValues(
- alpha: 0.045,
- ),
- title: Text(
- item.title,
- maxLines: 2,
- overflow: TextOverflow.ellipsis,
- style: theme.textTheme.bodyMedium,
- ),
- ),
- ),
- );
- },
- ),
- ),
- ],
- ),
- );
- }
-}
-
-class _StickyBoardDrawerSurface extends StatelessWidget {
- const _StickyBoardDrawerSurface({
- required this.borderOnLeft,
- required this.child,
- super.key,
- });
-
- final bool borderOnLeft;
- final Widget child;
-
- @override
- Widget build(BuildContext context) {
- final isDark = Theme.of(context).brightness == Brightness.dark;
- final borderSide = BorderSide(
- color: isDark
- ? Colors.white.withValues(alpha: 0.10)
- : Colors.black.withValues(alpha: 0.07),
- );
- return DecoratedBox(
- decoration: BoxDecoration(
- color: isDark ? const Color(0xFF202A2E) : const Color(0xFFF9FBFA),
- border: Border(
- left: borderOnLeft ? borderSide : BorderSide.none,
- right: borderOnLeft ? BorderSide.none : borderSide,
- ),
- ),
- child: child,
- );
- }
-}
-
-class _DrawerHeader extends StatelessWidget {
- const _DrawerHeader({
- required this.title,
- required this.closeTooltip,
- required this.onClose,
- required this.closeFocusNode,
- });
-
- final String title;
- final String closeTooltip;
- final VoidCallback onClose;
- final FocusNode closeFocusNode;
-
- @override
- Widget build(BuildContext context) {
- return Padding(
- padding: const EdgeInsets.fromLTRB(17, 10, 9, 10),
- child: Row(
- children: [
- Expanded(
- child: Text(
- title,
- style: Theme.of(
- context,
- ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600),
- ),
- ),
- IconButton(
- focusNode: closeFocusNode,
- tooltip: closeTooltip,
- onPressed: onClose,
- icon: const Icon(Icons.close_rounded, size: 18),
- ),
- ],
- ),
- );
- }
-}
-
-class _DrawerDivider extends StatelessWidget {
- const _DrawerDivider();
-
- @override
- Widget build(BuildContext context) {
- final isDark = Theme.of(context).brightness == Brightness.dark;
- return Divider(
- height: 1,
- thickness: 1,
- color: isDark
- ? Colors.white.withValues(alpha: 0.08)
- : Colors.black.withValues(alpha: 0.06),
- );
- }
-}
-
-class _ColorButton extends StatelessWidget {
- const _ColorButton({
- required this.colorValue,
- required this.selected,
- required this.enabled,
- required this.onPressed,
- });
-
- final int colorValue;
- final bool selected;
- final bool enabled;
- final VoidCallback onPressed;
-
- @override
- Widget build(BuildContext context) {
- final color = StickyBoardPalette.color(colorValue);
- return Semantics(
- button: true,
- selected: selected,
- label: context.l10n.tagColorSemanticsLabel,
- child: FloatickHoverMotion(
- enabled: enabled,
- hoverScale: FloatickMotion.swatchHoverScale,
- pressedScale: FloatickMotion.swatchPressedScale,
- child: GestureDetector(
- onTap: enabled ? onPressed : null,
- child: AnimatedContainer(
- duration: MediaQuery.disableAnimationsOf(context)
- ? Duration.zero
- : const Duration(milliseconds: 150),
- width: 23,
- height: 23,
- decoration: BoxDecoration(
- color: color,
- shape: BoxShape.circle,
- border: Border.all(
- color: selected
- ? Theme.of(context).colorScheme.onSurface
- : Colors.transparent,
- width: 2,
- ),
- ),
- child: selected
- ? const Icon(Icons.check_rounded, size: 13, color: Colors.white)
- : null,
- ),
- ),
- ),
- );
- }
-}
-
-class _ManagedBoardCard extends StatefulWidget {
- const _ManagedBoardCard({
- required this.board,
- required this.previewItems,
- required this.todoCount,
- required this.isEditing,
- required this.onOpen,
- required this.onEdit,
- required this.onTogglePin,
- required this.onDelete,
- super.key,
- });
-
- final StickyBoard board;
- final List previewItems;
- final int todoCount;
- final bool isEditing;
- final VoidCallback onOpen;
- final VoidCallback onEdit;
- final VoidCallback onTogglePin;
- final VoidCallback onDelete;
-
- @override
- State<_ManagedBoardCard> createState() => _ManagedBoardCardState();
-}
-
-class _ManagedBoardCardState extends State<_ManagedBoardCard> {
- bool _hovered = false;
-
- @override
- Widget build(BuildContext context) {
- final theme = Theme.of(context);
- final boardColor = Color(widget.board.colorValue);
- final showActions = _hovered || widget.isEditing;
- final reduceMotion = MediaQuery.disableAnimationsOf(context);
- final cardBackground = StickyBoardPalette.surfaceColor(
- value: widget.board.colorValue,
- baseColor: theme.colorScheme.surfaceContainerHighest,
- brightness: theme.brightness,
- hovered: _hovered,
- );
- return Semantics(
- button: true,
- label: widget.board.name,
- child: MouseRegion(
- cursor: SystemMouseCursors.click,
- onEnter: (_) => setState(() => _hovered = true),
- onExit: (_) => setState(() => _hovered = false),
- child: AnimatedContainer(
- key: ValueKey('sticky-board-thumbnail-${widget.board.id}'),
- duration: reduceMotion
- ? Duration.zero
- : const Duration(milliseconds: 160),
- curve: Curves.easeOutCubic,
- decoration: BoxDecoration(
- color: cardBackground,
- borderRadius: BorderRadius.circular(14),
- border: Border.all(
- color: widget.isEditing
- ? theme.colorScheme.primary
- : _hovered
- ? boardColor.withValues(alpha: 0.58)
- : theme.colorScheme.onSurface.withValues(alpha: 0.10),
- ),
- ),
- child: Material(
- color: Colors.transparent,
- borderRadius: BorderRadius.circular(14),
- clipBehavior: Clip.antiAlias,
- child: InkWell(
- onTap: widget.onOpen,
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- Padding(
- padding: const EdgeInsets.fromLTRB(11, 7, 5, 4),
- child: Row(
- children: [
- Expanded(
- child: Text(
- widget.board.name,
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: theme.textTheme.bodyMedium?.copyWith(
- fontWeight: FontWeight.w700,
- ),
- ),
- ),
- FloatickHoverMotion(
- hoverScale: FloatickMotion.emphasisHoverScale,
- pressedScale: FloatickMotion.emphasisPressedScale,
- hoverTurns: FloatickMotion.emphasisHoverTurns,
- child: IconButton(
- key: ValueKey(
- 'toggle-sticky-board-pin-${widget.board.id}',
- ),
- tooltip: widget.board.isPinned
- ? context.l10n.unpinStickyBoardTooltip
- : context.l10n.pinStickyBoardTooltip,
- onPressed: widget.onTogglePin,
- constraints: const BoxConstraints.tightFor(
- width: 30,
- height: 30,
- ),
- padding: const EdgeInsets.all(6),
- style: const ButtonStyle(
- foregroundBuilder:
- FloatickMotion.passthroughForegroundBuilder,
- ),
- icon: Icon(
- widget.board.isPinned
- ? Icons.push_pin_rounded
- : Icons.push_pin_outlined,
- size: 15,
- color: widget.board.isPinned
- ? theme.colorScheme.primary
- : null,
- ),
- ),
- ),
- ],
- ),
- ),
- Divider(
- height: 1,
- thickness: 1,
- color: theme.colorScheme.onSurface.withValues(alpha: 0.07),
- ),
- Expanded(
- child: Padding(
- padding: const EdgeInsets.fromLTRB(10, 6, 10, 2),
- child: widget.previewItems.isEmpty
- ? const _EmptyBoardPreview()
- : Column(
- children: [
- for (final item in widget.previewItems)
- _BoardPreviewTodoLine(item: item),
- ],
- ),
- ),
- ),
- Padding(
- padding: const EdgeInsets.fromLTRB(10, 0, 5, 5),
- child: Row(
- children: [
- Icon(
- Icons.view_agenda_outlined,
- size: 12,
- color: theme.colorScheme.onSurface.withValues(
- alpha: 0.38,
- ),
- ),
- const SizedBox(width: 5),
- Text(
- context.l10n.stickyBoardTodoCount(widget.todoCount),
- style: theme.textTheme.labelSmall?.copyWith(
- color: theme.colorScheme.onSurface.withValues(
- alpha: 0.46,
- ),
- ),
- ),
- const Spacer(),
- AnimatedOpacity(
- duration: reduceMotion
- ? Duration.zero
- : const Duration(milliseconds: 140),
- opacity: showActions ? 1 : 0,
- child: IgnorePointer(
- ignoring: !showActions,
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- IconButton(
- tooltip:
- context.l10n.renameStickyBoardTooltip,
- onPressed: widget.onEdit,
- constraints: const BoxConstraints.tightFor(
- width: 28,
- height: 28,
- ),
- padding: const EdgeInsets.all(6),
- icon: const Icon(
- Icons.edit_outlined,
- size: 14,
- ),
- ),
- IconButton(
- key: ValueKey(
- 'delete-sticky-board-${widget.board.id}',
- ),
- tooltip:
- context.l10n.deleteStickyBoardTooltip,
- onPressed: widget.onDelete,
- constraints: const BoxConstraints.tightFor(
- width: 28,
- height: 28,
- ),
- padding: const EdgeInsets.all(6),
- icon: const Icon(
- Icons.delete_outline_rounded,
- size: 14,
- ),
- ),
- ],
- ),
- ),
- ),
- ],
- ),
- ),
- ],
- ),
- ),
- ),
- ),
- ),
- );
- }
-}
-
-class _BoardPreviewTodoLine extends StatelessWidget {
- const _BoardPreviewTodoLine({required this.item});
-
- final TodoItem item;
-
- @override
- Widget build(BuildContext context) {
- final theme = Theme.of(context);
- final mutedColor = theme.colorScheme.onSurface.withValues(alpha: 0.42);
- return SizedBox(
- height: 24,
- child: Row(
- children: [
- Container(
- width: 11,
- height: 11,
- decoration: BoxDecoration(
- color: item.isCompleted
- ? theme.colorScheme.primary.withValues(alpha: 0.78)
- : Colors.transparent,
- borderRadius: BorderRadius.circular(3),
- border: Border.all(
- color: item.isCompleted
- ? theme.colorScheme.primary
- : mutedColor,
- width: 1.2,
- ),
- ),
- child: item.isCompleted
- ? Icon(
- Icons.check_rounded,
- size: 8,
- color: theme.colorScheme.onPrimary,
- )
- : null,
- ),
- const SizedBox(width: 7),
- Expanded(
- child: Text(
- item.title,
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: theme.textTheme.labelSmall?.copyWith(
- color: theme.colorScheme.onSurface.withValues(
- alpha: item.isCompleted ? 0.40 : 0.66,
- ),
- decoration: item.isCompleted
- ? TextDecoration.lineThrough
- : null,
- ),
- ),
- ),
- ],
- ),
- );
- }
-}
-
-class _EmptyBoardPreview extends StatelessWidget {
- const _EmptyBoardPreview();
-
- @override
- Widget build(BuildContext context) {
- final color = Theme.of(
- context,
- ).colorScheme.onSurface.withValues(alpha: 0.12);
- return Column(
- children: [
- for (final widthFactor in [0.86, 0.64])
- SizedBox(
- height: 24,
- child: Row(
- children: [
- Container(
- width: 11,
- height: 11,
- decoration: BoxDecoration(
- borderRadius: BorderRadius.circular(3),
- border: Border.all(color: color, width: 1.2),
- ),
- ),
- const SizedBox(width: 7),
- Expanded(
- child: Align(
- alignment: Alignment.centerLeft,
- child: FractionallySizedBox(
- widthFactor: widthFactor,
- child: Container(
- height: 5,
- decoration: BoxDecoration(
- color: color,
- borderRadius: BorderRadius.circular(3),
- ),
- ),
- ),
- ),
- ),
- ],
- ),
- ),
- ],
- );
- }
-}
-
-class _EmptyBoards extends StatelessWidget {
- const _EmptyBoards({required this.hasQuery});
-
- final bool hasQuery;
-
- @override
- Widget build(BuildContext context) {
- final theme = Theme.of(context);
- return Center(
- child: Padding(
- padding: const EdgeInsets.all(28),
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- Icon(
- Icons.sticky_note_2_outlined,
- size: 30,
- color: theme.colorScheme.primary,
- ),
- const SizedBox(height: 11),
- Text(
- hasQuery
- ? context.l10n.noMatchingStickyBoardsMessage
- : context.l10n.emptyStickyBoardsTitle,
- textAlign: TextAlign.center,
- style: theme.textTheme.bodyMedium?.copyWith(
- fontWeight: FontWeight.w600,
- ),
- ),
- if (!hasQuery) ...[
- const SizedBox(height: 5),
- Text(
- context.l10n.emptyStickyBoardsMessage,
- textAlign: TextAlign.center,
- style: theme.textTheme.bodySmall?.copyWith(
- color: theme.colorScheme.onSurface.withValues(alpha: 0.44),
- ),
- ),
- ],
- ],
- ),
- ),
- );
- }
-}
-
-class _EmptyBoardTodos extends StatelessWidget {
- const _EmptyBoardTodos({required this.onCreateTodo});
-
- final VoidCallback onCreateTodo;
-
- @override
- Widget build(BuildContext context) {
- return Center(
- child: TextButton.icon(
- onPressed: onCreateTodo,
- icon: const Icon(Icons.add_rounded, size: 17),
- label: Text(context.l10n.newTodoInStickyBoardAction),
- ),
- );
- }
-}
diff --git a/lib/features/sticky_boards/presentation/sticky_board_frame_save_scheduler.dart b/lib/features/sticky_boards/presentation/sticky_board_frame_save_scheduler.dart
deleted file mode 100644
index 08b359b..0000000
--- a/lib/features/sticky_boards/presentation/sticky_board_frame_save_scheduler.dart
+++ /dev/null
@@ -1,25 +0,0 @@
-import 'dart:async';
-
-import 'package:flutter/foundation.dart';
-
-class StickyBoardFrameSaveScheduler {
- StickyBoardFrameSaveScheduler({
- this.delay = const Duration(milliseconds: 200),
- });
-
- final Duration delay;
- Timer? _timer;
-
- void schedule(VoidCallback save) {
- _timer?.cancel();
- _timer = Timer(delay, () {
- _timer = null;
- save();
- });
- }
-
- void cancel() {
- _timer?.cancel();
- _timer = null;
- }
-}
diff --git a/lib/features/sticky_boards/presentation/sticky_board_palette.dart b/lib/features/sticky_boards/presentation/sticky_board_palette.dart
deleted file mode 100644
index c9d525d..0000000
--- a/lib/features/sticky_boards/presentation/sticky_board_palette.dart
+++ /dev/null
@@ -1,36 +0,0 @@
-import 'package:flutter/material.dart';
-
-abstract final class StickyBoardPalette {
- static const int teal = 0xFF20B8A8;
- static const int blue = 0xFF4C8FF5;
- static const int indigo = 0xFF6F73E8;
- static const int purple = 0xFFA46BE0;
- static const int pink = 0xFFE36F9F;
- static const int orange = 0xFFF18A45;
- static const int yellow = 0xFFE0B83F;
- static const int red = 0xFFE15F5F;
-
- static const List values = [
- teal,
- blue,
- indigo,
- purple,
- pink,
- orange,
- yellow,
- red,
- ];
-
- static Color color(int value) => Color(value);
-
- static Color surfaceColor({
- required int value,
- required Color baseColor,
- required Brightness brightness,
- bool hovered = false,
- }) {
- final isDark = brightness == Brightness.dark;
- final alpha = isDark ? (hovered ? 0.34 : 0.27) : (hovered ? 0.25 : 0.20);
- return Color.alphaBlend(color(value).withValues(alpha: alpha), baseColor);
- }
-}
diff --git a/lib/features/sticky_boards/presentation/sticky_board_view_model.dart b/lib/features/sticky_boards/presentation/sticky_board_view_model.dart
deleted file mode 100644
index 2877230..0000000
--- a/lib/features/sticky_boards/presentation/sticky_board_view_model.dart
+++ /dev/null
@@ -1,389 +0,0 @@
-import 'dart:math';
-
-import 'package:characters/characters.dart';
-import 'package:flutter/foundation.dart';
-
-import '../../../core/storage/storage_failure.dart';
-import '../data/sticky_board_repository.dart';
-import '../domain/sticky_board.dart';
-import '../domain/sticky_board_workspace.dart';
-
-typedef StickyBoardClock = DateTime Function();
-typedef StickyBoardIdGenerator = String Function();
-
-enum StickyBoardMutationResult {
- success,
- emptyName,
- nameTooLong,
- duplicateName,
- notFound,
- invalidColor,
- storageFailure,
-}
-
-class StickyBoardViewModel extends ChangeNotifier {
- StickyBoardViewModel({
- required StickyBoardRepository repository,
- StickyBoardClock? clock,
- StickyBoardIdGenerator? idGenerator,
- }) : _storage = repository,
- _clock = clock ?? DateTime.now,
- _idGenerator = idGenerator ?? _generateUuidV4;
-
- final StickyBoardRepository _storage;
- final StickyBoardClock _clock;
- final StickyBoardIdGenerator _idGenerator;
-
- StickyBoardWorkspace _workspace = StickyBoardWorkspace.empty();
- StorageFailure? _error;
- bool _isLoading = false;
- Future _mutationQueue = Future.value();
-
- List get boards => _workspace.boards;
- StorageFailure? get error => _error;
- bool get isLoading => _isLoading;
-
- StickyBoard? boardById(String id) {
- for (final board in _workspace.boards) {
- if (board.id == id) {
- return board;
- }
- }
- return null;
- }
-
- List todoIdsForBoard(String boardId) {
- return _workspace.todoIdsForBoard(boardId);
- }
-
- int todoCountForBoard(String boardId) {
- return todoIdsForBoard(boardId).length;
- }
-
- bool containsTodo({required String boardId, required String todoId}) {
- return todoIdsForBoard(boardId).contains(todoId);
- }
-
- Future load() async {
- _isLoading = true;
- _error = null;
- notifyListeners();
- try {
- _workspace = await _storage.load();
- } on StorageFailure catch (error) {
- _error = error;
- } finally {
- _isLoading = false;
- notifyListeners();
- }
- }
-
- Future createBoard({
- required String name,
- required int colorValue,
- }) {
- return _enqueueMutation(() async {
- final validation = _validateName(name);
- if (validation != StickyBoardMutationResult.success) {
- return validation;
- }
- if (!_isValidColorValue(colorValue)) {
- return StickyBoardMutationResult.invalidColor;
- }
-
- final normalizedName = name.trim();
- if (_workspace.boards.any(
- (board) => _sameName(board.name, normalizedName),
- )) {
- return StickyBoardMutationResult.duplicateName;
- }
-
- final boardId = _idGenerator();
- if (_workspace.boards.any((board) => board.id == boardId)) {
- return StickyBoardMutationResult.storageFailure;
- }
- final updatedWorkspace = StickyBoardWorkspace(
- boards: [
- ..._workspace.boards,
- StickyBoard(
- id: boardId,
- name: normalizedName,
- colorValue: colorValue,
- createdAt: _clock().toUtc(),
- ),
- ],
- boardTodoIds: _workspace.boardTodoIds,
- );
- return await _save(updatedWorkspace)
- ? StickyBoardMutationResult.success
- : StickyBoardMutationResult.storageFailure;
- });
- }
-
- Future updateBoard({
- required String id,
- required String name,
- required int colorValue,
- }) {
- return _enqueueMutation(() async {
- final validation = _validateName(name);
- if (validation != StickyBoardMutationResult.success) {
- return validation;
- }
- if (!_isValidColorValue(colorValue)) {
- return StickyBoardMutationResult.invalidColor;
- }
-
- final existingIndex = _workspace.boards.indexWhere(
- (board) => board.id == id,
- );
- if (existingIndex == -1) {
- return StickyBoardMutationResult.notFound;
- }
- final normalizedName = name.trim();
- if (_workspace.boards.any(
- (board) => board.id != id && _sameName(board.name, normalizedName),
- )) {
- return StickyBoardMutationResult.duplicateName;
- }
-
- final existingBoard = _workspace.boards[existingIndex];
- if (existingBoard.name == normalizedName &&
- existingBoard.colorValue == colorValue) {
- return StickyBoardMutationResult.success;
- }
- final updatedBoards = List.of(_workspace.boards);
- updatedBoards[existingIndex] = existingBoard.copyWith(
- name: normalizedName,
- colorValue: colorValue,
- );
- return await _save(
- StickyBoardWorkspace(
- boards: updatedBoards,
- boardTodoIds: _workspace.boardTodoIds,
- ),
- )
- ? StickyBoardMutationResult.success
- : StickyBoardMutationResult.storageFailure;
- });
- }
-
- Future deleteBoard(String id) {
- return _enqueueMutation(() async {
- if (boardById(id) == null) {
- return StickyBoardMutationResult.notFound;
- }
- final updatedRelations = >{
- ..._workspace.boardTodoIds,
- }..remove(id);
- return await _save(
- StickyBoardWorkspace(
- boards: _workspace.boards.where((board) => board.id != id),
- boardTodoIds: updatedRelations,
- ),
- )
- ? StickyBoardMutationResult.success
- : StickyBoardMutationResult.storageFailure;
- });
- }
-
- Future setPinned(String boardId, bool isPinned) {
- return _enqueueMutation(() async {
- final existingIndex = _workspace.boards.indexWhere(
- (board) => board.id == boardId,
- );
- if (existingIndex == -1) {
- return false;
- }
- final existingBoard = _workspace.boards[existingIndex];
- if (existingBoard.isPinned == isPinned) {
- return true;
- }
- final updatedBoards = List.of(_workspace.boards);
- updatedBoards[existingIndex] = existingBoard.copyWith(isPinned: isPinned);
- return _save(
- StickyBoardWorkspace(
- boards: updatedBoards,
- boardTodoIds: _workspace.boardTodoIds,
- ),
- );
- });
- }
-
- Future saveWindowFrame({
- required String boardId,
- required StickyBoardWindowFrame frame,
- }) {
- return _enqueueMutation(() async {
- final existingIndex = _workspace.boards.indexWhere(
- (board) => board.id == boardId,
- );
- if (existingIndex == -1) {
- return false;
- }
- final existingBoard = _workspace.boards[existingIndex];
- if (existingBoard.windowFrame == frame) {
- return true;
- }
- final updatedBoards = List.of(_workspace.boards);
- updatedBoards[existingIndex] = existingBoard.copyWith(windowFrame: frame);
- return _save(
- StickyBoardWorkspace(
- boards: updatedBoards,
- boardTodoIds: _workspace.boardTodoIds,
- ),
- );
- });
- }
-
- Future addTodo({required String boardId, required String todoId}) {
- return _enqueueMutation(() async {
- if (boardById(boardId) == null || todoId.trim().isEmpty) {
- return false;
- }
- final currentTodoIds = todoIdsForBoard(boardId);
- if (currentTodoIds.contains(todoId)) {
- return true;
- }
- return _save(
- StickyBoardWorkspace(
- boards: _workspace.boards,
- boardTodoIds: >{
- ..._workspace.boardTodoIds,
- boardId: [...currentTodoIds, todoId],
- },
- ),
- );
- });
- }
-
- Future removeTodo({required String boardId, required String todoId}) {
- return _enqueueMutation(() async {
- if (boardById(boardId) == null) {
- return false;
- }
- final currentTodoIds = todoIdsForBoard(boardId);
- if (!currentTodoIds.contains(todoId)) {
- return true;
- }
- final updatedRelations = >{
- ..._workspace.boardTodoIds,
- };
- final remainingTodoIds = currentTodoIds
- .where((id) => id != todoId)
- .toList(growable: false);
- if (remainingTodoIds.isEmpty) {
- updatedRelations.remove(boardId);
- } else {
- updatedRelations[boardId] = remainingTodoIds;
- }
- return _save(
- StickyBoardWorkspace(
- boards: _workspace.boards,
- boardTodoIds: updatedRelations,
- ),
- );
- });
- }
-
- Future removeTodoFromAllBoards(String todoId) {
- return _enqueueMutation(() async {
- var changed = false;
- final updatedRelations = >{};
- for (final entry in _workspace.boardTodoIds.entries) {
- final remainingTodoIds = entry.value
- .where((id) => id != todoId)
- .toList(growable: false);
- changed = changed || remainingTodoIds.length != entry.value.length;
- if (remainingTodoIds.isNotEmpty) {
- updatedRelations[entry.key] = remainingTodoIds;
- }
- }
- if (!changed) {
- return true;
- }
- return _save(
- StickyBoardWorkspace(
- boards: _workspace.boards,
- boardTodoIds: updatedRelations,
- ),
- );
- });
- }
-
- Future setTodoMembership({
- required String boardId,
- required String todoId,
- required bool selected,
- }) {
- if (selected) {
- return addTodo(boardId: boardId, todoId: todoId);
- }
- return removeTodo(boardId: boardId, todoId: todoId);
- }
-
- void dismissError() {
- if (_error == null) {
- return;
- }
- _error = null;
- notifyListeners();
- }
-
- Future _enqueueMutation(Future Function() mutation) {
- final operation = _mutationQueue.then((_) => mutation());
- _mutationQueue = operation.then(
- (_) {},
- onError: (Object _, StackTrace _) {},
- );
- return operation;
- }
-
- Future _save(StickyBoardWorkspace workspace) async {
- try {
- await _storage.save(workspace);
- _workspace = workspace;
- _error = null;
- notifyListeners();
- return true;
- } on StorageFailure catch (error) {
- _error = error;
- notifyListeners();
- return false;
- }
- }
-
- static StickyBoardMutationResult _validateName(String name) {
- final normalizedName = name.trim();
- if (normalizedName.isEmpty) {
- return StickyBoardMutationResult.emptyName;
- }
- if (normalizedName.characters.length > StickyBoard.maxNameLength) {
- return StickyBoardMutationResult.nameTooLong;
- }
- return StickyBoardMutationResult.success;
- }
-
- static bool _sameName(String left, String right) {
- return left.toLowerCase() == right.toLowerCase();
- }
-
- static bool _isValidColorValue(int value) {
- return !value.isNegative && value <= 0xFFFFFFFF;
- }
-
- static String _generateUuidV4() {
- final random = Random.secure();
- final bytes = List.generate(16, (_) => random.nextInt(256));
- bytes[6] = (bytes[6] & 0x0f) | 0x40;
- bytes[8] = (bytes[8] & 0x3f) | 0x80;
- final hex = bytes
- .map((byte) => byte.toRadixString(16).padLeft(2, '0'))
- .join();
- return '${hex.substring(0, 8)}-'
- '${hex.substring(8, 12)}-'
- '${hex.substring(12, 16)}-'
- '${hex.substring(16, 20)}-'
- '${hex.substring(20)}';
- }
-}
diff --git a/lib/features/sticky_boards/presentation/sticky_board_window_coordinator.dart b/lib/features/sticky_boards/presentation/sticky_board_window_coordinator.dart
deleted file mode 100644
index ef83e16..0000000
--- a/lib/features/sticky_boards/presentation/sticky_board_window_coordinator.dart
+++ /dev/null
@@ -1,304 +0,0 @@
-import 'package:flutter/material.dart';
-import 'package:multiview_desktop/multiview_desktop.dart';
-
-import '../../../core/platform/window_bridge.dart';
-import '../../todos/presentation/todo_view_model.dart';
-import '../domain/sticky_board.dart';
-import 'pinned_sticky_board_window.dart';
-import 'sticky_board_view_model.dart';
-
-enum StickyBoardMainWindowDestination { board, todoDetails, todoEdit }
-
-class StickyBoardMainWindowRequest {
- const StickyBoardMainWindowRequest({
- required this.boardId,
- this.destination = StickyBoardMainWindowDestination.board,
- this.todoId,
- }) : assert(
- destination == StickyBoardMainWindowDestination.board ||
- todoId != null,
- );
-
- final String boardId;
- final StickyBoardMainWindowDestination destination;
- final String? todoId;
-}
-
-typedef StickyBoardMainWindowRequestHandler =
- void Function(StickyBoardMainWindowRequest request);
-typedef StickyBoardWindowLauncher = Future Function(String boardId);
-typedef StickyBoardWindowHider = Future Function(String boardId);
-
-class StickyBoardWindowCoordinator {
- StickyBoardWindowCoordinator({
- required StickyBoardViewModel boardController,
- required TodoViewModel todoController,
- required this.windowBridge,
- this.windowLauncher,
- this.windowHider,
- }) : _boards = boardController,
- _todos = todoController;
-
- static const Size defaultWindowSize = Size(380, 460);
- static const Size minimumWindowSize = Size(320, 300);
- static const Size maximumWindowSize = Size(560, 760);
-
- final StickyBoardViewModel _boards;
- final TodoViewModel _todos;
- final WindowBridge windowBridge;
- final StickyBoardWindowLauncher? windowLauncher;
- final StickyBoardWindowHider? windowHider;
- final Map _windowIdsByBoardId = {};
- final Map> _boardWindowOperations =
- >{};
- final Set _restoredPinnedBoardIds = {};
-
- StickyBoardMainWindowRequestHandler? _mainWindowRequest;
- bool _didRestorePinnedBoards = false;
- Future? _restorePinnedBoardsOperation;
-
- void setMainWindowRequestHandler(
- StickyBoardMainWindowRequestHandler? handler,
- ) {
- _mainWindowRequest = handler;
- }
-
- void requestMainWindow(StickyBoardMainWindowRequest request) {
- _mainWindowRequest?.call(request);
- }
-
- Future restorePinnedBoards() {
- if (_didRestorePinnedBoards) {
- return Future.value();
- }
- return _restorePinnedBoardsOperation ??= _restorePinnedBoards()
- .whenComplete(() => _restorePinnedBoardsOperation = null);
- }
-
- Future _restorePinnedBoards() async {
- final pinnedBoardIds = _boards.boards
- .where((board) => board.isPinned)
- .map((board) => board.id)
- .toSet();
- _restoredPinnedBoardIds.removeWhere(
- (boardId) => !pinnedBoardIds.contains(boardId),
- );
- var hadFailure = false;
- for (final boardId in pinnedBoardIds.where(
- (boardId) => !_restoredPinnedBoardIds.contains(boardId),
- )) {
- try {
- await _openWindow(boardId);
- _restoredPinnedBoardIds.add(boardId);
- } on Object catch (error, stackTrace) {
- await _hideRegisteredWindowBestEffort(boardId);
- hadFailure = true;
- debugPrint('Floatick could not restore sticky board $boardId: $error');
- debugPrintStack(stackTrace: stackTrace);
- }
- }
- _didRestorePinnedBoards =
- !hadFailure && _restoredPinnedBoardIds.containsAll(pinnedBoardIds);
- }
-
- Future togglePin(String boardId) {
- return _enqueueBoardWindowOperation(boardId, () async {
- final board = _boards.boardById(boardId);
- if (board == null) {
- return;
- }
- if (board.isPinned) {
- await _unpin(boardId);
- } else {
- await _pin(boardId);
- }
- });
- }
-
- Future pin(String boardId) {
- return _enqueueBoardWindowOperation(boardId, () => _pin(boardId));
- }
-
- Future unpin(String boardId) {
- return _enqueueBoardWindowOperation(boardId, () => _unpin(boardId));
- }
-
- Future _pin(String boardId) async {
- final board = _boards.boardById(boardId);
- if (board == null) {
- return;
- }
- try {
- await _openWindow(boardId, positionAdjacentToMainWindow: true);
- if (!board.isPinned && !await _boards.setPinned(boardId, true)) {
- await _hideRegisteredWindowBestEffort(boardId);
- return;
- }
- _restoredPinnedBoardIds.add(boardId);
- } on Object catch (error, stackTrace) {
- await _hideRegisteredWindowBestEffort(boardId);
- debugPrint('Floatick could not pin sticky board $boardId: $error');
- debugPrintStack(stackTrace: stackTrace);
- }
- }
-
- Future _unpin(String boardId) async {
- _restoredPinnedBoardIds.remove(boardId);
- _didRestorePinnedBoards = false;
- if (!await _boards.setPinned(boardId, false)) {
- return;
- }
- try {
- await _hideRegisteredWindow(boardId);
- } on Object catch (error, stackTrace) {
- final restored = await _boards.setPinned(boardId, true);
- if (restored) {
- _restoredPinnedBoardIds.add(boardId);
- } else {
- debugPrint(
- 'Floatick could not restore the pin state for sticky board $boardId.',
- );
- }
- debugPrint('Floatick could not unpin sticky board $boardId: $error');
- debugPrintStack(stackTrace: stackTrace);
- }
- }
-
- Future deleteBoard(String boardId) async {
- _restoredPinnedBoardIds.remove(boardId);
- _didRestorePinnedBoards = false;
- final result = await _boards.deleteBoard(boardId);
- if (result == StickyBoardMutationResult.success) {
- await _hideRegisteredWindowBestEffort(boardId);
- }
- return result;
- }
-
- void registerWindow({required String boardId, required int viewId}) {
- _windowIdsByBoardId[boardId] = viewId;
- }
-
- void forgetWindow({required String boardId, required int viewId}) {
- if (_windowIdsByBoardId[boardId] == viewId) {
- _windowIdsByBoardId.remove(boardId);
- }
- }
-
- Future saveWindowFrame({
- required String boardId,
- required Rect bounds,
- }) {
- return _boards
- .saveWindowFrame(
- boardId: boardId,
- frame: StickyBoardWindowFrame(
- left: bounds.left,
- top: bounds.top,
- width: bounds.width,
- height: bounds.height,
- ),
- )
- .then((_) {});
- }
-
- Future _openWindow(
- String boardId, {
- bool positionAdjacentToMainWindow = false,
- }) async {
- final launcher = windowLauncher;
- if (launcher != null) {
- await launcher(boardId);
- return;
- }
- final existingViewId = _windowIdsByBoardId[boardId];
- if (existingViewId != null) {
- await MultiViewDesktop.fromId(existingViewId).show();
- await MultiViewDesktop.fromId(existingViewId).focus();
- return;
- }
- final board = _boards.boardById(boardId);
- if (board == null) {
- return;
- }
- final frame = board.windowFrame;
- final shouldPositionAdjacent =
- positionAdjacentToMainWindow || frame == null;
- final viewId = await openWindow(
- (context, id) => PinnedStickyBoardWindow(
- boardId: boardId,
- viewId: id,
- boardController: _boards,
- todoController: _todos,
- coordinator: this,
- ),
- options: WindowOptions(
- size: frame == null
- ? defaultWindowSize
- : Size(frame.width, frame.height),
- minimumSize: minimumWindowSize,
- maximumSize: maximumWindowSize,
- alignment: Alignment.center,
- backgroundColor: Colors.transparent,
- titleBarStyle: TitleBarStyle.hidden,
- windowButtonVisibility: false,
- title: board.name,
- alwaysOnTop: true,
- ),
- );
- _windowIdsByBoardId[boardId] = viewId;
- final window = MultiViewDesktop.fromId(viewId);
- await window.setHasShadow(false);
- await windowBridge.configureBorderlessSecondaryWindow(
- viewId,
- positionAdjacentToMainWindow: shouldPositionAdjacent,
- );
- await window.setVisibleOnAllWorkspaces(true, visibleOnFullScreen: true);
- if (frame != null && !shouldPositionAdjacent) {
- await window.setPosition(Offset(frame.left, frame.top));
- }
- // The native window remains fully transparent while it is configured and
- // positioned. Waiting for Flutter's first completed frame prevents the
- // default AppKit window surface from flashing before the board is ready.
- await WidgetsBinding.instance.endOfFrame;
- await windowBridge.revealBorderlessSecondaryWindow(viewId);
- }
-
- Future _enqueueBoardWindowOperation(
- String boardId,
- Future Function() operation,
- ) {
- final previousOperation =
- _boardWindowOperations[boardId] ?? Future.value();
- final nextOperation = previousOperation.then(
- (_) => operation(),
- onError: (Object _, StackTrace _) => operation(),
- );
- _boardWindowOperations[boardId] = nextOperation;
- return nextOperation.whenComplete(() {
- if (identical(_boardWindowOperations[boardId], nextOperation)) {
- _boardWindowOperations.remove(boardId);
- }
- });
- }
-
- Future _hideRegisteredWindow(String boardId) async {
- final hider = windowHider;
- if (hider != null) {
- await hider(boardId);
- return;
- }
- final viewId = _windowIdsByBoardId[boardId];
- if (viewId != null) {
- await MultiViewDesktop.fromId(viewId).hide();
- }
- }
-
- Future _hideRegisteredWindowBestEffort(String boardId) async {
- try {
- await _hideRegisteredWindow(boardId);
- } on Object catch (error, stackTrace) {
- debugPrint('Floatick could not hide sticky board $boardId: $error');
- debugPrintStack(stackTrace: stackTrace);
- }
- }
-}
diff --git a/lib/features/sticky_boards/presentation/widgets/sticky_board_management_todo_row.dart b/lib/features/sticky_boards/presentation/widgets/sticky_board_management_todo_row.dart
deleted file mode 100644
index 0199987..0000000
--- a/lib/features/sticky_boards/presentation/widgets/sticky_board_management_todo_row.dart
+++ /dev/null
@@ -1,239 +0,0 @@
-import 'package:flutter/material.dart';
-
-import '../../../../l10n/l10n.dart';
-import '../../../todos/domain/todo_item.dart';
-import '../../../todos/domain/todo_tag.dart';
-import '../../../todos/presentation/widgets/floatick_tag_chip.dart';
-
-class StickyBoardManagementTodoRow extends StatefulWidget {
- const StickyBoardManagementTodoRow({
- required this.item,
- required this.tags,
- required this.assignedTagIds,
- required this.onOpenDetails,
- required this.onRemove,
- super.key,
- });
-
- final TodoItem item;
- final List tags;
- final List assignedTagIds;
- final VoidCallback onOpenDetails;
- final VoidCallback onRemove;
-
- @override
- State createState() =>
- _StickyBoardManagementTodoRowState();
-}
-
-class _StickyBoardManagementTodoRowState
- extends State {
- final FocusNode _rowFocusNode = FocusNode();
-
- bool _isHovered = false;
- bool _hasFocus = false;
-
- @override
- void dispose() {
- _rowFocusNode.dispose();
- super.dispose();
- }
-
- @override
- Widget build(BuildContext context) {
- final item = widget.item;
- final theme = Theme.of(context);
- final onSurface = theme.colorScheme.onSurface;
- final reduceMotion = MediaQuery.disableAnimationsOf(context);
- final showRemoveAction = _isHovered || _hasFocus;
- final assignedIds = widget.assignedTagIds.toSet();
- final assignedTags = widget.tags
- .where((tag) => assignedIds.contains(tag.id))
- .toList(growable: false);
-
- return Focus(
- focusNode: _rowFocusNode,
- onFocusChange: (hasFocus) {
- if (_hasFocus != hasFocus) {
- setState(() => _hasFocus = hasFocus);
- }
- },
- child: Semantics(
- container: true,
- label: item.title,
- value: item.isCompleted
- ? context.l10n.completedStatus
- : context.l10n.incompleteStatus,
- child: MouseRegion(
- onEnter: (_) => setState(() => _isHovered = true),
- onExit: (_) => setState(() => _isHovered = false),
- child: AnimatedContainer(
- duration: reduceMotion
- ? Duration.zero
- : const Duration(milliseconds: 150),
- margin: const EdgeInsets.symmetric(vertical: 2),
- padding: const EdgeInsets.fromLTRB(4, 6, 5, 6),
- decoration: BoxDecoration(
- color: _isHovered
- ? (theme.brightness == Brightness.dark
- ? Colors.white.withValues(alpha: 0.055)
- : Colors.black.withValues(alpha: 0.035))
- : Colors.transparent,
- borderRadius: BorderRadius.circular(11),
- ),
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- Row(
- crossAxisAlignment: CrossAxisAlignment.center,
- children: [
- Padding(
- padding: const EdgeInsets.all(4),
- child: Container(
- key: ValueKey(
- 'sticky-board-managed-completion-status-${item.id}',
- ),
- width: 21,
- height: 21,
- decoration: BoxDecoration(
- color: item.isCompleted
- ? theme.colorScheme.primary
- : Colors.transparent,
- borderRadius: BorderRadius.circular(7),
- border: Border.all(
- color: item.isCompleted
- ? theme.colorScheme.primary
- : onSurface.withValues(alpha: 0.28),
- width: 1.4,
- ),
- ),
- child: item.isCompleted
- ? const Icon(
- Icons.check_rounded,
- size: 15,
- color: Colors.white,
- )
- : null,
- ),
- ),
- const SizedBox(width: 7),
- Expanded(
- child: MouseRegion(
- cursor: SystemMouseCursors.click,
- child: GestureDetector(
- key: ValueKey(
- 'sticky-board-managed-open-details-${item.id}',
- ),
- behavior: HitTestBehavior.opaque,
- onDoubleTap: widget.onOpenDetails,
- child: SizedBox(
- height: 30,
- child: Align(
- alignment: Alignment.centerLeft,
- child: Text(
- item.title,
- key: ValueKey(
- 'sticky-board-managed-title-${item.id}',
- ),
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: theme.textTheme.bodyMedium?.copyWith(
- color: onSurface.withValues(
- alpha: item.isCompleted ? 0.45 : 0.91,
- ),
- fontWeight: FontWeight.w500,
- decoration: item.isCompleted
- ? TextDecoration.lineThrough
- : null,
- decorationColor: onSurface.withValues(
- alpha: 0.42,
- ),
- ),
- ),
- ),
- ),
- ),
- ),
- ),
- const SizedBox(width: 3),
- SizedBox.square(
- dimension: 30,
- child: AnimatedOpacity(
- duration: reduceMotion
- ? Duration.zero
- : const Duration(milliseconds: 140),
- opacity: showRemoveAction ? 1 : 0,
- child: IgnorePointer(
- ignoring: !showRemoveAction,
- child: ExcludeFocus(
- excluding: !showRemoveAction,
- child: IconButton(
- key: ValueKey(
- 'remove-from-board-${item.id}',
- ),
- tooltip:
- context.l10n.removeFromStickyBoardTooltip,
- onPressed: widget.onRemove,
- padding: EdgeInsets.zero,
- icon: const Icon(
- Icons.remove_circle_outline_rounded,
- size: 16,
- ),
- ),
- ),
- ),
- ),
- ),
- ],
- ),
- const SizedBox(height: 3),
- Row(
- key: ValueKey(
- 'sticky-board-managed-metadata-${item.id}',
- ),
- crossAxisAlignment: CrossAxisAlignment.center,
- children: [
- const SizedBox(width: 36),
- Expanded(
- child: Wrap(
- spacing: 4,
- runSpacing: 3,
- children: [
- for (final tag in assignedTags)
- FloatickTagChip(
- key: ValueKey(
- 'sticky-board-managed-tag-${item.id}-${tag.id}',
- ),
- tag: tag,
- compact: true,
- ),
- ],
- ),
- ),
- const SizedBox(width: 7),
- Text(
- _formatTime(context, item.createdAt),
- key: ValueKey(
- 'sticky-board-managed-time-${item.id}',
- ),
- style: theme.textTheme.labelSmall?.copyWith(
- color: onSurface.withValues(alpha: 0.35),
- ),
- ),
- ],
- ),
- ],
- ),
- ),
- ),
- ),
- );
- }
-}
-
-String _formatTime(BuildContext context, DateTime date) {
- return MaterialLocalizations.of(context).formatTimeOfDay(
- TimeOfDay.fromDateTime(date.toLocal()),
- alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context),
- );
-}
diff --git a/lib/features/sticky_boards/presentation/widgets/sticky_board_read_only_todo_row.dart b/lib/features/sticky_boards/presentation/widgets/sticky_board_read_only_todo_row.dart
deleted file mode 100644
index 5a7688c..0000000
--- a/lib/features/sticky_boards/presentation/widgets/sticky_board_read_only_todo_row.dart
+++ /dev/null
@@ -1,148 +0,0 @@
-import 'package:flutter/material.dart';
-
-import '../../../../l10n/l10n.dart';
-import '../../../todos/domain/todo_item.dart';
-
-class StickyBoardReadOnlyTodoRow extends StatefulWidget {
- const StickyBoardReadOnlyTodoRow({
- required this.item,
- required this.onToggleCompletion,
- required this.onOpenDetails,
- super.key,
- });
-
- final TodoItem item;
- final VoidCallback onToggleCompletion;
- final VoidCallback onOpenDetails;
-
- @override
- State createState() =>
- _StickyBoardReadOnlyTodoRowState();
-}
-
-class _StickyBoardReadOnlyTodoRowState
- extends State {
- bool _isHovered = false;
-
- @override
- Widget build(BuildContext context) {
- final item = widget.item;
- final theme = Theme.of(context);
- final onSurface = theme.colorScheme.onSurface;
- final reduceMotion = MediaQuery.disableAnimationsOf(context);
-
- return Semantics(
- container: true,
- label: item.title,
- value: item.isCompleted
- ? context.l10n.completedStatus
- : context.l10n.incompleteStatus,
- child: MouseRegion(
- cursor: SystemMouseCursors.click,
- onEnter: (_) => setState(() => _isHovered = true),
- onExit: (_) => setState(() => _isHovered = false),
- child: AnimatedContainer(
- duration: reduceMotion
- ? Duration.zero
- : const Duration(milliseconds: 150),
- margin: const EdgeInsets.symmetric(vertical: 2),
- padding: const EdgeInsets.fromLTRB(4, 5, 8, 5),
- decoration: BoxDecoration(
- color: _isHovered
- ? (theme.brightness == Brightness.dark
- ? Colors.white.withValues(alpha: 0.055)
- : Colors.black.withValues(alpha: 0.035))
- : Colors.transparent,
- borderRadius: BorderRadius.circular(11),
- ),
- child: Row(
- crossAxisAlignment: CrossAxisAlignment.center,
- children: [
- Semantics(
- button: true,
- checked: item.isCompleted,
- label: item.title,
- value: item.isCompleted
- ? context.l10n.completedStatus
- : context.l10n.incompleteStatus,
- child: MouseRegion(
- cursor: SystemMouseCursors.click,
- child: GestureDetector(
- key: ValueKey(
- 'sticky-board-completion-toggle-${item.id}',
- ),
- behavior: HitTestBehavior.opaque,
- onTap: widget.onToggleCompletion,
- child: Padding(
- padding: const EdgeInsets.all(4),
- child: Container(
- key: ValueKey(
- 'sticky-board-completion-status-${item.id}',
- ),
- width: 21,
- height: 21,
- decoration: BoxDecoration(
- color: item.isCompleted
- ? theme.colorScheme.primary
- : Colors.transparent,
- borderRadius: BorderRadius.circular(7),
- border: Border.all(
- color: item.isCompleted
- ? theme.colorScheme.primary
- : onSurface.withValues(alpha: 0.28),
- width: 1.4,
- ),
- ),
- child: item.isCompleted
- ? const Icon(
- Icons.check_rounded,
- size: 15,
- color: Colors.white,
- )
- : null,
- ),
- ),
- ),
- ),
- ),
- const SizedBox(width: 7),
- Expanded(
- child: GestureDetector(
- key: ValueKey(
- 'sticky-board-open-details-region-${item.id}',
- ),
- behavior: HitTestBehavior.opaque,
- onDoubleTap: widget.onOpenDetails,
- child: ConstrainedBox(
- constraints: const BoxConstraints(minHeight: 29),
- child: Align(
- alignment: Alignment.centerLeft,
- child: Text(
- item.title,
- key: ValueKey(
- 'sticky-board-todo-title-${item.id}',
- ),
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: theme.textTheme.bodyMedium?.copyWith(
- color: onSurface.withValues(
- alpha: item.isCompleted ? 0.45 : 0.91,
- ),
- fontWeight: FontWeight.w500,
- decoration: item.isCompleted
- ? TextDecoration.lineThrough
- : null,
- decorationColor: onSurface.withValues(alpha: 0.42),
- ),
- ),
- ),
- ),
- ),
- ),
- ],
- ),
- ),
- ),
- );
- }
-}
diff --git a/lib/features/sticky_boards/presentation/widgets/sticky_board_todo_details.dart b/lib/features/sticky_boards/presentation/widgets/sticky_board_todo_details.dart
deleted file mode 100644
index 57e83d1..0000000
--- a/lib/features/sticky_boards/presentation/widgets/sticky_board_todo_details.dart
+++ /dev/null
@@ -1,164 +0,0 @@
-import 'package:flutter/material.dart';
-
-import '../../../../l10n/l10n.dart';
-import '../../../todos/domain/todo_item.dart';
-import '../../../todos/domain/todo_tag.dart';
-import '../../../todos/presentation/todo_clipboard_controller.dart';
-import '../../../todos/presentation/widgets/floatick_tag_chip.dart';
-import '../../../todos/presentation/widgets/todo_copy_button.dart';
-import '../../../../core/ui/floatick_markdown.dart';
-
-class StickyBoardTodoDetails extends StatefulWidget {
- const StickyBoardTodoDetails({
- required this.item,
- required this.tags,
- required this.onBack,
- super.key,
- });
-
- final TodoItem item;
- final List tags;
- final VoidCallback onBack;
-
- @override
- State createState() => _StickyBoardTodoDetailsState();
-}
-
-class _StickyBoardTodoDetailsState extends State {
- final _copyController = TodoClipboardController();
-
- @override
- void didUpdateWidget(covariant StickyBoardTodoDetails oldWidget) {
- super.didUpdateWidget(oldWidget);
- if (oldWidget.item.id != widget.item.id ||
- oldWidget.item.title != widget.item.title ||
- oldWidget.item.content != widget.item.content) {
- _copyController.reset();
- }
- }
-
- @override
- void dispose() {
- _copyController.dispose();
- super.dispose();
- }
-
- @override
- Widget build(BuildContext context) {
- final theme = Theme.of(context);
- final onSurface = theme.colorScheme.onSurface;
- final item = widget.item;
-
- return Column(
- key: const Key('sticky-board-todo-details'),
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- Padding(
- padding: const EdgeInsets.fromLTRB(8, 7, 8, 6),
- child: Row(
- children: [
- IconButton(
- key: const Key('sticky-board-details-back'),
- tooltip: MaterialLocalizations.of(context).backButtonTooltip,
- onPressed: widget.onBack,
- icon: const Icon(Icons.arrow_back_rounded, size: 18),
- ),
- const SizedBox(width: 2),
- Expanded(
- child: Text(
- context.l10n.todoDetailsDrawerTitle,
- style: theme.textTheme.titleSmall?.copyWith(
- fontWeight: FontWeight.w600,
- ),
- ),
- ),
- TodoCopyButton(
- key: const Key('sticky-board-details-copy'),
- item: item,
- controller: _copyController,
- dimension: 40,
- iconSize: 18,
- ),
- ],
- ),
- ),
- Divider(height: 1, color: onSurface.withValues(alpha: 0.08)),
- Padding(
- padding: const EdgeInsets.fromLTRB(18, 16, 18, 2),
- child: Text(
- item.title,
- key: const Key('sticky-board-details-title'),
- style: theme.textTheme.titleMedium?.copyWith(
- fontWeight: FontWeight.w600,
- ),
- ),
- ),
- if (widget.tags.isNotEmpty)
- Padding(
- padding: const EdgeInsets.fromLTRB(18, 10, 18, 2),
- child: Wrap(
- key: const Key('sticky-board-details-tags'),
- spacing: 6,
- runSpacing: 5,
- children: [
- for (final tag in widget.tags)
- FloatickTagChip(
- key: ValueKey('sticky-board-details-tag-${tag.id}'),
- tag: tag,
- ),
- ],
- ),
- ),
- const SizedBox(height: 8),
- Expanded(
- child: item.content.trim().isEmpty
- ? _EmptyStickyBoardTodoContent(onSurface: onSurface)
- : FloatickMarkdownContent(
- key: const Key('sticky-board-details-markdown'),
- content: item.content,
- ),
- ),
- ],
- );
- }
-}
-
-class _EmptyStickyBoardTodoContent extends StatelessWidget {
- const _EmptyStickyBoardTodoContent({required this.onSurface});
-
- final Color onSurface;
-
- @override
- Widget build(BuildContext context) {
- return Center(
- child: Padding(
- padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- Icon(
- Icons.notes_rounded,
- size: 28,
- color: onSurface.withValues(alpha: 0.28),
- ),
- const SizedBox(height: 9),
- Text(
- context.l10n.noTodoContentTitle,
- style: Theme.of(
- context,
- ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
- ),
- const SizedBox(height: 4),
- Text(
- context.l10n.noTodoContentMessage,
- textAlign: TextAlign.center,
- style: Theme.of(context).textTheme.bodySmall?.copyWith(
- color: onSurface.withValues(alpha: 0.48),
- ),
- ),
- ],
- ),
- ),
- );
- }
-}
diff --git a/lib/features/todos/domain/todo_item.dart b/lib/features/todos/domain/todo_item.dart
index a359cd9..f181fd9 100644
--- a/lib/features/todos/domain/todo_item.dart
+++ b/lib/features/todos/domain/todo_item.dart
@@ -4,17 +4,32 @@ class TodoItem {
required this.title,
required this.createdAt,
this.content = '',
+ this.startedAt,
this.completedAt,
this.archivedAt,
+ this.dueAt,
+ this.reminderAt,
+ this.notifyAtDeadline = true,
+ this.deadlineNotifiedAt,
+ this.reminderNotifiedAt,
+ this.snoozedUntil,
});
final String id;
final String title;
final String content;
final DateTime createdAt;
+ final DateTime? startedAt;
final DateTime? completedAt;
final DateTime? archivedAt;
+ final DateTime? dueAt;
+ final DateTime? reminderAt;
+ final bool notifyAtDeadline;
+ final DateTime? deadlineNotifiedAt;
+ final DateTime? reminderNotifiedAt;
+ final DateTime? snoozedUntil;
+ bool get isDoing => startedAt != null && !isCompleted && !isArchived;
bool get isCompleted => completedAt != null;
bool get isArchived => archivedAt != null;
@@ -24,8 +39,15 @@ class TodoItem {
title: value,
content: content,
createdAt: createdAt,
+ startedAt: startedAt,
completedAt: completedAt,
archivedAt: archivedAt,
+ dueAt: dueAt,
+ reminderAt: reminderAt,
+ notifyAtDeadline: notifyAtDeadline,
+ deadlineNotifiedAt: deadlineNotifiedAt,
+ reminderNotifiedAt: reminderNotifiedAt,
+ snoozedUntil: snoozedUntil,
);
}
@@ -35,8 +57,33 @@ class TodoItem {
title: title,
content: content,
createdAt: createdAt,
+ startedAt: startedAt,
completedAt: completedAt,
archivedAt: archivedAt,
+ dueAt: dueAt,
+ reminderAt: reminderAt,
+ notifyAtDeadline: notifyAtDeadline,
+ deadlineNotifiedAt: deadlineNotifiedAt,
+ reminderNotifiedAt: reminderNotifiedAt,
+ snoozedUntil: snoozedUntil,
+ );
+ }
+
+ TodoItem withStartedAt(DateTime? value) {
+ return TodoItem(
+ id: id,
+ title: title,
+ content: content,
+ createdAt: createdAt,
+ startedAt: value,
+ completedAt: completedAt,
+ archivedAt: archivedAt,
+ dueAt: dueAt,
+ reminderAt: reminderAt,
+ notifyAtDeadline: notifyAtDeadline,
+ deadlineNotifiedAt: deadlineNotifiedAt,
+ reminderNotifiedAt: reminderNotifiedAt,
+ snoozedUntil: snoozedUntil,
);
}
@@ -46,8 +93,15 @@ class TodoItem {
title: title,
content: content,
createdAt: createdAt,
+ startedAt: startedAt,
completedAt: value,
archivedAt: archivedAt,
+ dueAt: dueAt,
+ reminderAt: reminderAt,
+ notifyAtDeadline: notifyAtDeadline,
+ deadlineNotifiedAt: deadlineNotifiedAt,
+ reminderNotifiedAt: reminderNotifiedAt,
+ snoozedUntil: snoozedUntil,
);
}
@@ -57,8 +111,77 @@ class TodoItem {
title: title,
content: content,
createdAt: createdAt,
+ startedAt: startedAt,
completedAt: completedAt,
archivedAt: value,
+ dueAt: dueAt,
+ reminderAt: reminderAt,
+ notifyAtDeadline: notifyAtDeadline,
+ deadlineNotifiedAt: deadlineNotifiedAt,
+ reminderNotifiedAt: reminderNotifiedAt,
+ snoozedUntil: snoozedUntil,
+ );
+ }
+
+ TodoItem withSchedule(TodoScheduleDraft schedule) {
+ final normalized = schedule.normalized();
+ final scheduleChanged =
+ dueAt != normalized.dueAt ||
+ reminderAt != normalized.reminderAt ||
+ notifyAtDeadline != normalized.notifyAtDeadline;
+ if (!scheduleChanged) {
+ return this;
+ }
+ return TodoItem(
+ id: id,
+ title: title,
+ content: content,
+ createdAt: createdAt,
+ startedAt: startedAt,
+ completedAt: completedAt,
+ archivedAt: archivedAt,
+ dueAt: normalized.dueAt,
+ reminderAt: normalized.reminderAt,
+ notifyAtDeadline: normalized.notifyAtDeadline,
+ );
+ }
+
+ TodoItem withNotificationDelivery({
+ DateTime? deadlineDeliveredAt,
+ DateTime? reminderDeliveredAt,
+ }) {
+ return TodoItem(
+ id: id,
+ title: title,
+ content: content,
+ createdAt: createdAt,
+ startedAt: startedAt,
+ completedAt: completedAt,
+ archivedAt: archivedAt,
+ dueAt: dueAt,
+ reminderAt: reminderAt,
+ notifyAtDeadline: notifyAtDeadline,
+ deadlineNotifiedAt: deadlineDeliveredAt ?? deadlineNotifiedAt,
+ reminderNotifiedAt: reminderDeliveredAt ?? reminderNotifiedAt,
+ snoozedUntil: snoozedUntil,
+ );
+ }
+
+ TodoItem withSnoozedUntil(DateTime? value) {
+ return TodoItem(
+ id: id,
+ title: title,
+ content: content,
+ createdAt: createdAt,
+ startedAt: startedAt,
+ completedAt: completedAt,
+ archivedAt: archivedAt,
+ dueAt: dueAt,
+ reminderAt: reminderAt,
+ notifyAtDeadline: notifyAtDeadline,
+ deadlineNotifiedAt: deadlineNotifiedAt,
+ reminderNotifiedAt: reminderNotifiedAt,
+ snoozedUntil: value,
);
}
@@ -68,8 +191,15 @@ class TodoItem {
title: _requiredString(json, 'title'),
content: _optionalString(json, 'content'),
createdAt: _requiredDate(json, 'createdAt'),
+ startedAt: _optionalDate(json, 'startedAt'),
completedAt: _optionalDate(json, 'completedAt'),
archivedAt: _optionalDate(json, 'archivedAt'),
+ dueAt: _optionalDate(json, 'dueAt'),
+ reminderAt: _optionalDate(json, 'reminderAt'),
+ notifyAtDeadline: _optionalBool(json, 'notifyAtDeadline') ?? true,
+ deadlineNotifiedAt: _optionalDate(json, 'deadlineNotifiedAt'),
+ reminderNotifiedAt: _optionalDate(json, 'reminderNotifiedAt'),
+ snoozedUntil: _optionalDate(json, 'snoozedUntil'),
);
}
@@ -79,10 +209,21 @@ class TodoItem {
'title': title,
if (content.isNotEmpty) 'content': content,
'createdAt': createdAt.toUtc().toIso8601String(),
+ if (startedAt != null) 'startedAt': startedAt!.toUtc().toIso8601String(),
if (completedAt != null)
'completedAt': completedAt!.toUtc().toIso8601String(),
if (archivedAt != null)
'archivedAt': archivedAt!.toUtc().toIso8601String(),
+ if (dueAt != null) 'dueAt': dueAt!.toUtc().toIso8601String(),
+ if (reminderAt != null)
+ 'reminderAt': reminderAt!.toUtc().toIso8601String(),
+ if (!notifyAtDeadline) 'notifyAtDeadline': false,
+ if (deadlineNotifiedAt != null)
+ 'deadlineNotifiedAt': deadlineNotifiedAt!.toUtc().toIso8601String(),
+ if (reminderNotifiedAt != null)
+ 'reminderNotifiedAt': reminderNotifiedAt!.toUtc().toIso8601String(),
+ if (snoozedUntil != null)
+ 'snoozedUntil': snoozedUntil!.toUtc().toIso8601String(),
};
}
@@ -124,6 +265,17 @@ class TodoItem {
return DateTime.parse(value);
}
+ static bool? _optionalBool(Map json, String key) {
+ final value = json[key];
+ if (value == null) {
+ return null;
+ }
+ if (value is! bool) {
+ throw FormatException('Todo field "$key" must be a boolean.');
+ }
+ return value;
+ }
+
@override
bool operator ==(Object other) {
return other is TodoItem &&
@@ -131,12 +283,78 @@ class TodoItem {
other.title == title &&
other.content == content &&
other.createdAt == createdAt &&
+ other.startedAt == startedAt &&
other.completedAt == completedAt &&
- other.archivedAt == archivedAt;
+ other.archivedAt == archivedAt &&
+ other.dueAt == dueAt &&
+ other.reminderAt == reminderAt &&
+ other.notifyAtDeadline == notifyAtDeadline &&
+ other.deadlineNotifiedAt == deadlineNotifiedAt &&
+ other.reminderNotifiedAt == reminderNotifiedAt &&
+ other.snoozedUntil == snoozedUntil;
}
@override
int get hashCode {
- return Object.hash(id, title, content, createdAt, completedAt, archivedAt);
+ return Object.hash(
+ id,
+ title,
+ content,
+ createdAt,
+ startedAt,
+ completedAt,
+ archivedAt,
+ dueAt,
+ reminderAt,
+ notifyAtDeadline,
+ deadlineNotifiedAt,
+ reminderNotifiedAt,
+ snoozedUntil,
+ );
+ }
+}
+
+class TodoScheduleDraft {
+ const TodoScheduleDraft({
+ this.dueAt,
+ this.reminderAt,
+ this.notifyAtDeadline = true,
+ });
+
+ final DateTime? dueAt;
+ final DateTime? reminderAt;
+ final bool notifyAtDeadline;
+
+ bool get hasDeadline => dueAt != null;
+
+ TodoScheduleDraft normalized() {
+ final normalizedDueAt = dueAt?.toUtc();
+ if (normalizedDueAt == null) {
+ return const TodoScheduleDraft();
+ }
+ return TodoScheduleDraft(
+ dueAt: normalizedDueAt,
+ reminderAt: reminderAt?.toUtc(),
+ notifyAtDeadline: notifyAtDeadline,
+ );
}
+
+ factory TodoScheduleDraft.fromItem(TodoItem? item) {
+ return TodoScheduleDraft(
+ dueAt: item?.dueAt,
+ reminderAt: item?.reminderAt,
+ notifyAtDeadline: item?.notifyAtDeadline ?? true,
+ );
+ }
+
+ @override
+ bool operator ==(Object other) {
+ return other is TodoScheduleDraft &&
+ other.dueAt == dueAt &&
+ other.reminderAt == reminderAt &&
+ other.notifyAtDeadline == notifyAtDeadline;
+ }
+
+ @override
+ int get hashCode => Object.hash(dueAt, reminderAt, notifyAtDeadline);
}
diff --git a/lib/features/todos/presentation/deadline_reminder_scheduler.dart b/lib/features/todos/presentation/deadline_reminder_scheduler.dart
new file mode 100644
index 0000000..09206a1
--- /dev/null
+++ b/lib/features/todos/presentation/deadline_reminder_scheduler.dart
@@ -0,0 +1,345 @@
+import 'dart:async';
+
+import 'package:flutter/widgets.dart';
+
+import '../../../core/platform/window_bridge.dart';
+import '../domain/todo_item.dart';
+import 'todo_view_model.dart';
+
+typedef DeadlineReminderClock = DateTime Function();
+typedef OpenTodoFromReminder = void Function(String todoId);
+
+enum _ReminderEventKind { snoozed, deadline, advance }
+
+class DeadlineReminderScheduler with WidgetsBindingObserver {
+ DeadlineReminderScheduler({
+ required TodoViewModel todoViewModel,
+ required DeadlineReminderBridge bridge,
+ required OpenTodoFromReminder onOpenTodo,
+ DeadlineReminderClock? clock,
+ }) : this._(todoViewModel, bridge, onOpenTodo, clock ?? DateTime.now);
+
+ DeadlineReminderScheduler._(
+ this._todoViewModel,
+ this._bridge,
+ this._onOpenTodo,
+ this._clock,
+ ) {
+ WidgetsBinding.instance.addObserver(this);
+ _todoViewModel.addListener(_scheduleNext);
+ _bridge.setDeadlineReminderActionHandler(_handleAction);
+ _bridge.setDeadlineReminderDeliveryHandler(_handleDelivery);
+ _scheduleNext();
+ }
+
+ static const Duration _maximumTimerDuration = Duration(days: 20);
+
+ final TodoViewModel _todoViewModel;
+ final DeadlineReminderBridge _bridge;
+ final OpenTodoFromReminder _onOpenTodo;
+ final DeadlineReminderClock _clock;
+
+ Timer? _timer;
+ bool _isDisposed = false;
+ bool _isDispatching = false;
+ DateTime? _retryAfter;
+
+ void dispose() {
+ if (_isDisposed) {
+ return;
+ }
+ _isDisposed = true;
+ _timer?.cancel();
+ WidgetsBinding.instance.removeObserver(this);
+ _todoViewModel.removeListener(_scheduleNext);
+ _bridge.setDeadlineReminderActionHandler(null);
+ _bridge.setDeadlineReminderDeliveryHandler(null);
+ }
+
+ void refresh() {
+ _scheduleNext();
+ }
+
+ @override
+ void didChangeAppLifecycleState(AppLifecycleState state) {
+ if (state == AppLifecycleState.resumed) {
+ refresh();
+ }
+ }
+
+ void _scheduleNext() {
+ if (_isDisposed || _isDispatching) {
+ return;
+ }
+ _timer?.cancel();
+ final now = _clock().toUtc();
+ final events = _pendingEvents(now);
+ _synchronizeNativeSchedule(events, now);
+ final retryAfter = _retryAfter;
+ if (retryAfter != null && now.isBefore(retryAfter)) {
+ _timer = Timer(retryAfter.difference(now), _scheduleNext);
+ return;
+ }
+ if (events.isEmpty) {
+ return;
+ }
+ final event = events.first;
+ final delay = event.at.difference(now);
+ if (delay <= Duration.zero) {
+ scheduleMicrotask(() => unawaited(_dispatch(event)));
+ return;
+ }
+ final timerDelay = delay > _maximumTimerDuration
+ ? _maximumTimerDuration
+ : delay;
+ _timer = Timer(timerDelay, _scheduleNext);
+ }
+
+ List<_ReminderEvent> _pendingEvents(DateTime now) {
+ final events = <_ReminderEvent>[];
+ for (final item in _todoViewModel.items) {
+ if (item.isCompleted || item.isArchived || item.dueAt == null) {
+ continue;
+ }
+ if (item.snoozedUntil case final snoozedUntil?) {
+ events.add(
+ _ReminderEvent(
+ item: item,
+ kind: _ReminderEventKind.snoozed,
+ at: snoozedUntil,
+ ),
+ );
+ }
+ final dueAt = item.dueAt!;
+ if (item.notifyAtDeadline && item.deadlineNotifiedAt == null) {
+ events.add(
+ _ReminderEvent(
+ item: item,
+ kind: _ReminderEventKind.deadline,
+ at: dueAt,
+ ),
+ );
+ }
+ final reminderAt = item.reminderAt;
+ final reminderIsStillRelevant =
+ reminderAt != null &&
+ item.reminderNotifiedAt == null &&
+ (!item.notifyAtDeadline ||
+ reminderAt.isAfter(dueAt) ||
+ now.isBefore(dueAt));
+ if (reminderIsStillRelevant) {
+ events.add(
+ _ReminderEvent(
+ item: item,
+ kind: _ReminderEventKind.advance,
+ at: reminderAt,
+ ),
+ );
+ }
+ }
+ events.sort((left, right) {
+ final timeComparison = left.at.compareTo(right.at);
+ if (timeComparison != 0) {
+ return timeComparison;
+ }
+ return left.kind.index.compareTo(right.kind.index);
+ });
+ return events;
+ }
+
+ void _synchronizeNativeSchedule(List<_ReminderEvent> events, DateTime now) {
+ final reminders = events
+ .map(
+ (event) => ScheduledDeadlineReminder(
+ notificationId: event.notificationId,
+ triggerAt: event.at,
+ deliveryKind: event.deliveryKind,
+ payload: _payloadFor(event, now),
+ ),
+ )
+ .toList(growable: false);
+ unawaited(
+ _bridge.synchronizeScheduledDeadlineReminders(reminders).catchError((
+ Object error,
+ StackTrace stackTrace,
+ ) {
+ debugPrint(
+ 'Floatick could not synchronize native deadline reminders: '
+ '$error',
+ );
+ debugPrintStack(stackTrace: stackTrace);
+ }),
+ );
+ }
+
+ DeadlineReminderPayload _payloadFor(_ReminderEvent event, DateTime now) {
+ final dueAt = event.item.dueAt!;
+ final tags = _todoViewModel.tagsForTodo(event.item.id);
+ return DeadlineReminderPayload(
+ notificationId: event.notificationId,
+ todoId: event.item.id,
+ title: event.item.title,
+ dueLabel: _formatDueLabel(dueAt.toLocal()),
+ isOverdue: now.isAfter(dueAt),
+ isAdvanceReminder:
+ event.kind == _ReminderEventKind.advance ||
+ (event.kind == _ReminderEventKind.snoozed && now.isBefore(dueAt)),
+ tagLabel: tags.isEmpty ? null : tags.first.name,
+ );
+ }
+
+ Future _dispatch(_ReminderEvent event) async {
+ if (_isDisposed || _isDispatching) {
+ return;
+ }
+ final currentItem = _todoViewModel.itemById(event.item.id);
+ if (currentItem == null ||
+ currentItem.isCompleted ||
+ currentItem.isArchived) {
+ _scheduleNext();
+ return;
+ }
+ _isDispatching = true;
+ try {
+ final dueAt = currentItem.dueAt;
+ if (dueAt == null) {
+ return;
+ }
+ final now = _clock().toUtc();
+ final currentEvent = _ReminderEvent(
+ item: currentItem,
+ kind: event.kind,
+ at: event.at,
+ );
+ final isStillPending = _pendingEvents(
+ now,
+ ).any((candidate) => candidate.notificationId == event.notificationId);
+ if (!isStillPending) {
+ return;
+ }
+ await _bridge.showDeadlineReminder(_payloadFor(currentEvent, now));
+ _retryAfter = null;
+ switch (event.kind) {
+ case _ReminderEventKind.snoozed:
+ await _todoViewModel.clearSnoozedNotification(currentItem.id);
+ break;
+ case _ReminderEventKind.deadline:
+ await _todoViewModel.markDeadlineNotificationDelivered(
+ currentItem.id,
+ );
+ if (currentItem.reminderAt != null &&
+ !currentItem.reminderAt!.isAfter(dueAt) &&
+ currentItem.reminderNotifiedAt == null) {
+ await _todoViewModel.markReminderNotificationDelivered(
+ currentItem.id,
+ );
+ }
+ break;
+ case _ReminderEventKind.advance:
+ await _todoViewModel.markReminderNotificationDelivered(
+ currentItem.id,
+ );
+ break;
+ }
+ } on Object catch (error, stackTrace) {
+ _retryAfter = _clock().toUtc().add(const Duration(minutes: 1));
+ debugPrint('Floatick could not show a deadline reminder: $error');
+ debugPrintStack(stackTrace: stackTrace);
+ } finally {
+ _isDispatching = false;
+ _scheduleNext();
+ }
+ }
+
+ void _handleAction(DeadlineReminderAction action) {
+ switch (action.kind) {
+ case DeadlineReminderActionKind.open:
+ _onOpenTodo(action.todoId);
+ break;
+ case DeadlineReminderActionKind.dismiss:
+ break;
+ }
+ }
+
+ void _handleDelivery(DeadlineReminderDelivery delivery) {
+ unawaited(
+ _recordNativeDelivery(delivery).catchError((
+ Object error,
+ StackTrace stackTrace,
+ ) {
+ debugPrint(
+ 'Floatick could not persist a native deadline reminder: $error',
+ );
+ debugPrintStack(stackTrace: stackTrace);
+ }),
+ );
+ }
+
+ Future _recordNativeDelivery(DeadlineReminderDelivery delivery) async {
+ if (_isDisposed) {
+ return;
+ }
+ final item = _todoViewModel.itemById(delivery.todoId);
+ if (item == null || item.isCompleted || item.isArchived) {
+ return;
+ }
+ final now = _clock().toUtc();
+ final isStillPending = _pendingEvents(
+ now,
+ ).any((event) => event.notificationId == delivery.notificationId);
+ if (!isStillPending) {
+ return;
+ }
+ switch (delivery.kind) {
+ case DeadlineReminderDeliveryKind.snoozed:
+ if (item.snoozedUntil != null) {
+ await _todoViewModel.clearSnoozedNotification(item.id);
+ }
+ break;
+ case DeadlineReminderDeliveryKind.deadline:
+ if (item.deadlineNotifiedAt == null) {
+ await _todoViewModel.markDeadlineNotificationDelivered(item.id);
+ }
+ final dueAt = item.dueAt;
+ if (dueAt != null &&
+ item.reminderAt != null &&
+ !item.reminderAt!.isAfter(dueAt) &&
+ item.reminderNotifiedAt == null) {
+ await _todoViewModel.markReminderNotificationDelivered(item.id);
+ }
+ break;
+ case DeadlineReminderDeliveryKind.advance:
+ if (item.reminderNotifiedAt == null) {
+ await _todoViewModel.markReminderNotificationDelivered(item.id);
+ }
+ break;
+ }
+ }
+}
+
+String _formatDueLabel(DateTime dueAt) {
+ final hour = dueAt.hour.toString().padLeft(2, '0');
+ final minute = dueAt.minute.toString().padLeft(2, '0');
+ return '${dueAt.month}/${dueAt.day} · $hour:$minute';
+}
+
+class _ReminderEvent {
+ const _ReminderEvent({
+ required this.item,
+ required this.kind,
+ required this.at,
+ });
+
+ final TodoItem item;
+ final _ReminderEventKind kind;
+ final DateTime at;
+
+ String get notificationId =>
+ '${item.id}:${kind.name}:${at.toUtc().microsecondsSinceEpoch}';
+
+ DeadlineReminderDeliveryKind get deliveryKind => switch (kind) {
+ _ReminderEventKind.snoozed => DeadlineReminderDeliveryKind.snoozed,
+ _ReminderEventKind.deadline => DeadlineReminderDeliveryKind.deadline,
+ _ReminderEventKind.advance => DeadlineReminderDeliveryKind.advance,
+ };
+}
diff --git a/lib/features/todos/presentation/todo_deadline_picker.dart b/lib/features/todos/presentation/todo_deadline_picker.dart
new file mode 100644
index 0000000..14030ae
--- /dev/null
+++ b/lib/features/todos/presentation/todo_deadline_picker.dart
@@ -0,0 +1,880 @@
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:intl/intl.dart';
+
+import '../../../core/ui/floatick_editor_components.dart';
+import '../../../core/ui/floatick_modal_bottom_sheet.dart';
+import '../domain/todo_item.dart';
+
+const double _deadlinePickerHeight = 590;
+
+Future showTodoDeadlinePicker({
+ required BuildContext context,
+ required TodoScheduleDraft initialSchedule,
+}) {
+ return showFloatickModalBottomSheet