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 workspace for macOS with an editor, tags, Sticky Boards, and settings

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 悬浮工作区,展示编辑器、标签、便利板与设置

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( + context: context, + builder: (sheetContext) => SizedBox( + height: _deadlinePickerHeight, + child: TodoDeadlinePicker( + initialSchedule: initialSchedule, + onClose: () => Navigator.of(sheetContext).pop(), + onSave: (schedule) => Navigator.of(sheetContext).pop(schedule), + ), + ), + ); +} + +enum _ReminderChoice { + atDeadline, + tenMinutesBefore, + oneHourBefore, + oneDayBefore, + none, + custom, +} + +class TodoDeadlinePicker extends StatefulWidget { + const TodoDeadlinePicker({ + required this.initialSchedule, + required this.onClose, + required this.onSave, + super.key, + }); + + final TodoScheduleDraft initialSchedule; + final VoidCallback onClose; + final ValueChanged onSave; + + @override + State createState() => _TodoDeadlinePickerState(); +} + +class _TodoDeadlinePickerState extends State { + static const _selectableReminderChoices = <_ReminderChoice>[ + _ReminderChoice.atDeadline, + _ReminderChoice.tenMinutesBefore, + _ReminderChoice.oneHourBefore, + _ReminderChoice.oneDayBefore, + _ReminderChoice.none, + ]; + + final _closeFocusNode = FocusNode(); + late DateTime _dueAt; + late DateTime? _reminderAt; + late bool _notifyAtDeadline; + + @override + void initState() { + super.initState(); + final localDueAt = widget.initialSchedule.dueAt?.toLocal(); + final now = DateTime.now(); + _dueAt = + localDueAt ?? + DateTime(now.year, now.month, now.day, now.hour, now.minute); + _reminderAt = widget.initialSchedule.reminderAt?.toLocal(); + _notifyAtDeadline = widget.initialSchedule.hasDeadline + ? widget.initialSchedule.notifyAtDeadline + : true; + } + + @override + void dispose() { + _closeFocusNode.dispose(); + super.dispose(); + } + + void _setSelectedDate(DateTime date) { + setState(() { + _replaceDueAt( + DateTime(date.year, date.month, date.day, _dueAt.hour, _dueAt.minute), + ); + }); + } + + void _setSelectedTime(TimeOfDay time) { + setState(() { + _replaceDueAt( + DateTime(_dueAt.year, _dueAt.month, _dueAt.day, time.hour, time.minute), + ); + }); + } + + void _replaceDueAt(DateTime value) { + final reminderOffset = _reminderAt?.difference(_dueAt); + _dueAt = value; + if (reminderOffset != null) { + _reminderAt = value.add(reminderOffset); + } + } + + void _selectReminder(_ReminderChoice choice) { + setState(() { + switch (choice) { + case _ReminderChoice.atDeadline: + _notifyAtDeadline = true; + _reminderAt = null; + break; + case _ReminderChoice.tenMinutesBefore: + _notifyAtDeadline = false; + _reminderAt = _dueAt.subtract(const Duration(minutes: 10)); + break; + case _ReminderChoice.oneHourBefore: + _notifyAtDeadline = false; + _reminderAt = _dueAt.subtract(const Duration(hours: 1)); + break; + case _ReminderChoice.oneDayBefore: + _notifyAtDeadline = false; + _reminderAt = _dueAt.subtract(const Duration(days: 1)); + break; + case _ReminderChoice.none: + _notifyAtDeadline = false; + _reminderAt = null; + break; + case _ReminderChoice.custom: + break; + } + }); + } + + _ReminderChoice get _reminderChoice { + final reminderAt = _reminderAt; + if (reminderAt == null) { + return _notifyAtDeadline + ? _ReminderChoice.atDeadline + : _ReminderChoice.none; + } + if (_notifyAtDeadline) { + return _ReminderChoice.custom; + } + final offset = _dueAt.difference(reminderAt); + if (offset == const Duration(minutes: 10)) { + return _ReminderChoice.tenMinutesBefore; + } + if (offset == const Duration(hours: 1)) { + return _ReminderChoice.oneHourBefore; + } + if (offset == const Duration(days: 1)) { + return _ReminderChoice.oneDayBefore; + } + return _ReminderChoice.custom; + } + + String _reminderLabel(String locale) { + final reminderAt = _reminderAt; + if (_reminderChoice != _ReminderChoice.custom) { + return _copy.reminderChoiceName(_reminderChoice); + } + if (reminderAt == null) { + return _copy.atDeadline; + } + final customTime = DateFormat.MMMd(locale).add_Hm().format(reminderAt); + return _notifyAtDeadline ? _copy.customAndDeadline(customTime) : customTime; + } + + _DeadlineCopy get _copy => _DeadlineCopy.of(context); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final locale = Localizations.localeOf(context).toLanguageTag(); + final hasExistingDeadline = widget.initialSchedule.hasDeadline; + return FloatickEditorDrawerSurface( + key: const Key('todo-deadline-picker'), + title: hasExistingDeadline ? _copy.editDeadline : _copy.addDeadline, + closeTooltip: _copy.close, + onClose: widget.onClose, + closeFocusNode: _closeFocusNode, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: Padding( + key: const Key('deadline-picker-content'), + padding: const EdgeInsets.fromLTRB(20, 6, 20, 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: Theme( + data: theme.copyWith( + datePickerTheme: DatePickerThemeData( + backgroundColor: Colors.transparent, + headerBackgroundColor: Colors.transparent, + surfaceTintColor: Colors.transparent, + todayBorder: BorderSide( + color: theme.colorScheme.primary.withValues( + alpha: 0.55, + ), + ), + ), + ), + child: CalendarDatePicker( + key: const Key('todo-deadline-calendar'), + initialDate: _dueAt, + firstDate: DateTime.now().subtract( + const Duration(days: 3650), + ), + lastDate: DateTime.now().add( + const Duration(days: 3650), + ), + onDateChanged: _setSelectedDate, + ), + ), + ), + const SizedBox(height: 6), + _SectionLabel(text: _copy.deadline), + const SizedBox(height: 5), + _SelectionRow( + key: const Key('todo-deadline-time-field'), + icon: Icons.calendar_today_outlined, + label: DateFormat.yMMMd(locale).format(_dueAt), + trailing: _InlineTimeEditor( + initialTime: TimeOfDay.fromDateTime(_dueAt), + copy: _copy, + onChanged: _setSelectedTime, + ), + showChevron: false, + ), + const SizedBox(height: 10), + _SectionLabel(text: _copy.reminder), + const SizedBox(height: 5), + PopupMenuButton<_ReminderChoice>( + key: const Key('todo-reminder-picker'), + tooltip: _copy.reminder, + position: PopupMenuPosition.under, + initialValue: _reminderChoice == _ReminderChoice.custom + ? null + : _reminderChoice, + onSelected: _selectReminder, + itemBuilder: (context) => >[ + for (final choice in _selectableReminderChoices) + PopupMenuItem<_ReminderChoice>( + value: choice, + child: Row( + children: [ + SizedBox( + width: 22, + child: choice == _reminderChoice + ? Icon( + Icons.check_rounded, + size: 17, + color: theme.colorScheme.primary, + ) + : null, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + _copy.reminderChoiceName(choice), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ], + child: _SelectionRow( + icon: Icons.notifications_none_rounded, + label: _reminderLabel(locale), + ), + ), + ], + ), + ), + ), + FloatickEditorFooter( + key: const Key('deadline-picker-footer'), + child: Row( + children: [ + if (hasExistingDeadline) + TextButton( + key: const Key('clear-todo-deadline'), + onPressed: () => widget.onSave(const TodoScheduleDraft()), + child: Text(_copy.clear), + ), + const Spacer(), + TextButton( + onPressed: widget.onClose, + child: Text(_copy.cancel), + ), + const SizedBox(width: 8), + FilledButton.icon( + key: const Key('save-todo-deadline'), + onPressed: () => widget.onSave( + TodoScheduleDraft( + dueAt: _dueAt, + reminderAt: _reminderAt, + notifyAtDeadline: _notifyAtDeadline, + ), + ), + icon: const Icon(Icons.check_rounded, size: 17), + label: Text( + hasExistingDeadline ? _copy.save : _copy.setDeadline, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _InlineTimeEditor extends StatefulWidget { + const _InlineTimeEditor({ + required this.initialTime, + required this.copy, + required this.onChanged, + }); + + final TimeOfDay initialTime; + final _DeadlineCopy copy; + final ValueChanged onChanged; + + @override + State<_InlineTimeEditor> createState() => _InlineTimeEditorState(); +} + +class _InlineTimeEditorState extends State<_InlineTimeEditor> { + final _hourFocusNode = FocusNode(); + final _minuteFocusNode = FocusNode(); + late final TextEditingController _hourController; + late final TextEditingController _minuteController; + late int _hour; + late int _minute; + + @override + void initState() { + super.initState(); + _hour = widget.initialTime.hour; + _minute = widget.initialTime.minute; + _hourController = TextEditingController(text: _twoDigits(_hour)); + _minuteController = TextEditingController(text: _twoDigits(_minute)); + _hourFocusNode.addListener(_normalizeHourOnBlur); + _minuteFocusNode.addListener(_normalizeMinuteOnBlur); + } + + @override + void didUpdateWidget(covariant _InlineTimeEditor oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.initialTime == oldWidget.initialTime || + widget.initialTime == TimeOfDay(hour: _hour, minute: _minute)) { + return; + } + _hour = widget.initialTime.hour; + _minute = widget.initialTime.minute; + if (!_hourFocusNode.hasFocus) { + _syncHourController(); + } + if (!_minuteFocusNode.hasFocus) { + _syncMinuteController(); + } + } + + @override + void dispose() { + _hourFocusNode + ..removeListener(_normalizeHourOnBlur) + ..dispose(); + _minuteFocusNode + ..removeListener(_normalizeMinuteOnBlur) + ..dispose(); + _hourController.dispose(); + _minuteController.dispose(); + super.dispose(); + } + + static String _twoDigits(int value) => value.toString().padLeft(2, '0'); + + void _normalizeHourOnBlur() { + if (!_hourFocusNode.hasFocus) { + _syncHourController(); + } + } + + void _normalizeMinuteOnBlur() { + if (!_minuteFocusNode.hasFocus) { + _syncMinuteController(); + } + } + + void _syncHourController() { + _hourController.value = TextEditingValue( + text: _twoDigits(_hour), + selection: const TextSelection.collapsed(offset: 2), + ); + } + + void _syncMinuteController() { + _minuteController.value = TextEditingValue( + text: _twoDigits(_minute), + selection: const TextSelection.collapsed(offset: 2), + ); + } + + void _readValidHour(String value) { + final parsed = int.tryParse(value); + if (parsed != null && parsed >= 0 && parsed <= 23) { + _hour = parsed; + _emitTime(); + } + } + + void _readValidMinute(String value) { + final parsed = int.tryParse(value); + if (parsed != null && parsed >= 0 && parsed <= 59) { + _minute = parsed; + _emitTime(); + } + } + + void _emitTime() { + widget.onChanged(TimeOfDay(hour: _hour, minute: _minute)); + } + + void _stepHour(int amount) { + _readValidHour(_hourController.text); + setState(() { + _hour = (_hour + amount) % 24; + _syncHourController(); + }); + _emitTime(); + } + + void _stepMinute(int amount) { + _readValidHour(_hourController.text); + _readValidMinute(_minuteController.text); + final adjusted = DateTime(2000, 1, 1, _hour, _minute + amount); + setState(() { + _hour = adjusted.hour; + _minute = adjusted.minute; + _syncHourController(); + _syncMinuteController(); + }); + _emitTime(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SizedBox( + key: const Key('todo-inline-time-editor'), + width: 132, + height: 34, + child: Row( + children: [ + Expanded( + child: _TimeSpinnerField( + key: const Key('todo-time-hour-spinner'), + inputKey: const Key('todo-time-hour-input'), + incrementKey: const Key('todo-time-hour-increment'), + decrementKey: const Key('todo-time-hour-decrement'), + label: widget.copy.hour, + controller: _hourController, + focusNode: _hourFocusNode, + textInputAction: TextInputAction.next, + onChanged: _readValidHour, + onIncrement: () => _stepHour(1), + onDecrement: () => _stepHour(-1), + incrementTooltip: widget.copy.increaseHour, + decrementTooltip: widget.copy.decreaseHour, + onSubmitted: (_) => _minuteFocusNode.requestFocus(), + ), + ), + SizedBox( + key: const Key('todo-time-separator'), + width: 16, + child: Text( + ':', + textAlign: TextAlign.center, + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.68), + fontWeight: FontWeight.w600, + ), + ), + ), + Expanded( + child: _TimeSpinnerField( + key: const Key('todo-time-minute-spinner'), + inputKey: const Key('todo-time-minute-input'), + incrementKey: const Key('todo-time-minute-increment'), + decrementKey: const Key('todo-time-minute-decrement'), + label: widget.copy.minute, + controller: _minuteController, + focusNode: _minuteFocusNode, + textInputAction: TextInputAction.done, + onChanged: _readValidMinute, + onIncrement: () => _stepMinute(1), + onDecrement: () => _stepMinute(-1), + incrementTooltip: widget.copy.increaseMinute, + decrementTooltip: widget.copy.decreaseMinute, + onSubmitted: (_) => _minuteFocusNode.unfocus(), + ), + ), + ], + ), + ); + } +} + +class _TimeSpinnerField extends StatefulWidget { + const _TimeSpinnerField({ + required this.inputKey, + required this.incrementKey, + required this.decrementKey, + required this.label, + required this.controller, + required this.focusNode, + required this.textInputAction, + required this.onChanged, + required this.onIncrement, + required this.onDecrement, + required this.incrementTooltip, + required this.decrementTooltip, + required this.onSubmitted, + super.key, + }); + + final Key inputKey; + final Key incrementKey; + final Key decrementKey; + final String label; + final TextEditingController controller; + final FocusNode focusNode; + final TextInputAction textInputAction; + final ValueChanged onChanged; + final VoidCallback onIncrement; + final VoidCallback onDecrement; + final String incrementTooltip; + final String decrementTooltip; + final ValueChanged onSubmitted; + + @override + State<_TimeSpinnerField> createState() => _TimeSpinnerFieldState(); +} + +class _TimeSpinnerFieldState extends State<_TimeSpinnerField> { + static const double _scrollThreshold = 8; + double _scrollAccumulator = 0; + bool _isHovered = false; + + void _handlePointerSignal(PointerSignalEvent event) { + if (event is! PointerScrollEvent) { + return; + } + _scrollAccumulator += event.scrollDelta.dy; + if (_scrollAccumulator <= -_scrollThreshold) { + _scrollAccumulator = 0; + widget.onIncrement(); + } else if (_scrollAccumulator >= _scrollThreshold) { + _scrollAccumulator = 0; + widget.onDecrement(); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Semantics( + label: widget.label, + textField: true, + child: MouseRegion( + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), + child: Listener( + onPointerSignal: _handlePointerSignal, + child: ListenableBuilder( + listenable: widget.focusNode, + builder: (context, child) { + final baseColor = + theme.inputDecorationTheme.fillColor ?? + theme.colorScheme.onSurface.withValues(alpha: 0.045); + return AnimatedContainer( + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 140), + curve: Curves.easeOut, + decoration: BoxDecoration( + color: _isHovered && !widget.focusNode.hasFocus + ? Color.alphaBlend( + theme.colorScheme.onSurface.withValues(alpha: 0.025), + baseColor, + ) + : baseColor, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: widget.focusNode.hasFocus + ? theme.colorScheme.primary.withValues(alpha: 0.72) + : theme.colorScheme.onSurface.withValues( + alpha: _isHovered ? 0.20 : 0.12, + ), + width: widget.focusNode.hasFocus ? 1.25 : 1, + ), + ), + clipBehavior: Clip.antiAlias, + child: child, + ); + }, + child: Row( + children: [ + Expanded( + child: CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.arrowUp): + widget.onIncrement, + const SingleActivator(LogicalKeyboardKey.arrowDown): + widget.onDecrement, + }, + child: TextFormField( + key: widget.inputKey, + controller: widget.controller, + focusNode: widget.focusNode, + keyboardType: TextInputType.number, + textInputAction: widget.textInputAction, + textAlign: TextAlign.center, + textAlignVertical: TextAlignVertical.center, + maxLength: 2, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + onChanged: widget.onChanged, + onFieldSubmitted: widget.onSubmitted, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + fontFeatures: const [ + FontFeature.tabularFigures(), + ], + ), + decoration: const InputDecoration( + isDense: true, + filled: false, + fillColor: Colors.transparent, + hoverColor: Colors.transparent, + counterText: '', + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: EdgeInsets.symmetric(horizontal: 3), + ), + ), + ), + ), + Container( + width: 18, + decoration: BoxDecoration( + border: Border( + left: BorderSide( + color: theme.colorScheme.onSurface.withValues( + alpha: 0.10, + ), + ), + ), + ), + child: Column( + children: [ + Expanded( + child: _TimeStepButton( + key: widget.incrementKey, + icon: Icons.keyboard_arrow_up_rounded, + tooltip: widget.incrementTooltip, + onPressed: widget.onIncrement, + ), + ), + Divider( + height: 1, + thickness: 1, + color: theme.colorScheme.onSurface.withValues( + alpha: 0.10, + ), + ), + Expanded( + child: _TimeStepButton( + key: widget.decrementKey, + icon: Icons.keyboard_arrow_down_rounded, + tooltip: widget.decrementTooltip, + onPressed: widget.onDecrement, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _TimeStepButton extends StatelessWidget { + const _TimeStepButton({ + required this.icon, + required this.tooltip, + required this.onPressed, + super.key, + }); + + final IconData icon; + final String tooltip; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return IconButton( + tooltip: tooltip, + onPressed: onPressed, + padding: EdgeInsets.zero, + constraints: const BoxConstraints.expand(), + style: ButtonStyle( + shape: const WidgetStatePropertyAll( + RoundedRectangleBorder(), + ), + backgroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) { + return Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.14); + } + if (states.contains(WidgetState.hovered)) { + return Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.055); + } + return Colors.transparent; + }), + ), + icon: Icon(icon, size: 12), + ); + } +} + +class _SectionLabel extends StatelessWidget { + const _SectionLabel({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + return Text( + text, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.68), + fontWeight: FontWeight.w600, + ), + ); + } +} + +class _SelectionRow extends StatelessWidget { + const _SelectionRow({ + required this.icon, + required this.label, + this.trailing, + this.showChevron = true, + super.key, + }); + + final IconData icon; + final String label; + final Widget? trailing; + final bool showChevron; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Material( + color: theme.colorScheme.onSurface.withValues(alpha: 0.035), + borderRadius: BorderRadius.circular(11), + child: Container( + height: 48, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(11), + border: Border.all( + color: theme.colorScheme.onSurface.withValues(alpha: 0.10), + ), + ), + child: Row( + children: [ + Icon(icon, size: 18), + const SizedBox(width: 10), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + if (trailing case final trailing?) ...[ + const SizedBox(width: 10), + trailing, + ], + if (showChevron) ...[ + const SizedBox(width: 8), + Icon( + Icons.chevron_right_rounded, + size: 19, + color: theme.colorScheme.onSurface.withValues(alpha: 0.58), + ), + ], + ], + ), + ), + ); + } +} + +class _DeadlineCopy { + const _DeadlineCopy(this.isChinese); + + factory _DeadlineCopy.of(BuildContext context) { + return _DeadlineCopy( + Localizations.localeOf( + context, + ).languageCode.toLowerCase().startsWith('zh'), + ); + } + + final bool isChinese; + + String get addDeadline => isChinese ? '添加截止时间' : 'Add deadline'; + String get editDeadline => isChinese ? '编辑截止时间' : 'Edit deadline'; + String get deadline => isChinese ? '截止时间' : 'Deadline'; + String get reminder => isChinese ? '提醒' : 'Reminder'; + String get atDeadline => isChinese ? '截止时' : 'At deadline'; + String get clear => isChinese ? '清除' : 'Clear'; + String get cancel => isChinese ? '取消' : 'Cancel'; + String get save => isChinese ? '保存' : 'Save'; + String get setDeadline => isChinese ? '设置截止时间' : 'Set deadline'; + String get close => isChinese ? '关闭' : 'Close'; + String get hour => isChinese ? '小时' : 'Hour'; + String get minute => isChinese ? '分钟' : 'Minute'; + String get increaseHour => isChinese ? '增加一小时' : 'Increase hour'; + String get decreaseHour => isChinese ? '减少一小时' : 'Decrease hour'; + String get increaseMinute => isChinese ? '增加一分钟' : 'Increase minute'; + String get decreaseMinute => isChinese ? '减少一分钟' : 'Decrease minute'; + + String reminderChoiceName(_ReminderChoice choice) { + return switch (choice) { + _ReminderChoice.atDeadline => atDeadline, + _ReminderChoice.tenMinutesBefore => + isChinese ? '提前 10 分钟' : '10 minutes before', + _ReminderChoice.oneHourBefore => isChinese ? '提前 1 小时' : '1 hour before', + _ReminderChoice.oneDayBefore => isChinese ? '提前 1 天' : '1 day before', + _ReminderChoice.none => isChinese ? '不提醒' : 'No reminder', + _ReminderChoice.custom => isChinese ? '自定义提醒' : 'Custom reminder', + }; + } + + String customAndDeadline(String customTime) { + return isChinese ? '$customTime,并在截止时再次提醒' : '$customTime and at deadline'; + } +} diff --git a/lib/features/todos/presentation/todo_editor_drawer.dart b/lib/features/todos/presentation/todo_editor_drawer.dart index e347cdd..8ff1d53 100644 --- a/lib/features/todos/presentation/todo_editor_drawer.dart +++ b/lib/features/todos/presentation/todo_editor_drawer.dart @@ -10,6 +10,7 @@ import '../../../l10n/l10n.dart'; import '../domain/todo_item.dart'; import '../domain/todo_tag.dart'; import 'todo_clipboard_controller.dart'; +import 'todo_deadline_picker.dart'; import 'widgets/editor_tag_selector.dart'; import 'widgets/floatick_tag_chip.dart'; import 'widgets/todo_copy_button.dart'; @@ -44,7 +45,12 @@ class TodoEditorDrawer extends StatefulWidget { final VoidCallback onClose; final VoidCallback onEdit; final VoidCallback onOpenTagAssignment; - final Future Function(String title, String content, List tagIds) + final Future Function( + String title, + String content, + List tagIds, + TodoScheduleDraft schedule, + ) onSave; final VoidCallback onSaved; final FocusNode closeFocusNode; @@ -64,6 +70,7 @@ class _TodoEditorDrawerState extends State { bool _showPreview = false; bool _isSaving = false; bool _saveFailed = false; + TodoScheduleDraft _schedule = const TodoScheduleDraft(); bool get _isEditing => widget.mode == TodoEditorDrawerMode.create || @@ -85,7 +92,10 @@ class _TodoEditorDrawerState extends State { oldWidget.mode != widget.mode || oldWidget.item?.id != widget.item?.id || oldWidget.item?.title != widget.item?.title || - oldWidget.item?.content != widget.item?.content; + oldWidget.item?.content != widget.item?.content || + oldWidget.item?.dueAt != widget.item?.dueAt || + oldWidget.item?.reminderAt != widget.item?.reminderAt || + oldWidget.item?.notifyAtDeadline != widget.item?.notifyAtDeadline; final didOpen = !oldWidget.isOpen && widget.isOpen; if (changedContext) { _copyController.reset(); @@ -117,6 +127,7 @@ class _TodoEditorDrawerState extends State { void _syncControllers() { _titleController.text = widget.item?.title ?? ''; _contentController.text = widget.item?.content ?? ''; + _schedule = TodoScheduleDraft.fromItem(widget.item); } void _requestInitialFocus() { @@ -153,7 +164,23 @@ class _TodoEditorDrawerState extends State { !setEquals( widget.originalAssignedTagIds.toSet(), widget.assignedTagIds.toSet(), - )); + ) || + TodoScheduleDraft.fromItem(item).normalized() != + _schedule.normalized()); + } + + Future _openDeadlinePicker() async { + final schedule = await showTodoDeadlinePicker( + context: context, + initialSchedule: _schedule, + ); + if (!mounted || schedule == null) { + return; + } + setState(() { + _schedule = schedule.normalized(); + _saveFailed = false; + }); } Future _submit() async { @@ -173,6 +200,7 @@ class _TodoEditorDrawerState extends State { .where((tag) => widget.assignedTagIds.contains(tag.id)) .map((tag) => tag.id) .toList(growable: false), + _schedule, ); if (!mounted) { return; @@ -249,6 +277,7 @@ class _TodoEditorDrawerState extends State { isSaving: _isSaving, saveFailed: _saveFailed, canSave: _canSave, + schedule: _schedule, mode: widget.mode, onChanged: () { setState(() { @@ -259,6 +288,7 @@ class _TodoEditorDrawerState extends State { setState(() => _showPreview = showPreview); }, onOpenTagAssignment: widget.onOpenTagAssignment, + onOpenDeadline: () => unawaited(_openDeadlinePicker()), onSubmit: () => unawaited(_submit()), onCancel: widget.onClose, ), @@ -280,10 +310,12 @@ class _TodoEditor extends StatelessWidget { required this.isSaving, required this.saveFailed, required this.canSave, + required this.schedule, required this.mode, required this.onChanged, required this.onPreviewChanged, required this.onOpenTagAssignment, + required this.onOpenDeadline, required this.onSubmit, required this.onCancel, super.key, @@ -300,10 +332,12 @@ class _TodoEditor extends StatelessWidget { final bool isSaving; final bool saveFailed; final bool canSave; + final TodoScheduleDraft schedule; final TodoEditorDrawerMode mode; final VoidCallback onChanged; final ValueChanged onPreviewChanged; final VoidCallback onOpenTagAssignment; + final VoidCallback onOpenDeadline; final VoidCallback onSubmit; final VoidCallback onCancel; @@ -361,13 +395,27 @@ class _TodoEditor extends StatelessWidget { contentHint: context.l10n.todoContentFieldHint, titleSemanticsLabel: context.l10n.todoTitleLabel, contentSemanticsLabel: context.l10n.todoContentLabel, - toolbarLeading: EditorTagSelector( - availableTags: availableTags, - selectedTagIds: selectedTagIds, - enabled: !isSaving, - buttonKey: const Key('todo-editor-tag-button'), - tagKeyPrefix: 'todo-editor-tag', - onPressed: onOpenTagAssignment, + toolbarLeading: Row( + children: [ + Expanded( + child: EditorTagSelector( + availableTags: availableTags, + selectedTagIds: selectedTagIds, + enabled: !isSaving, + buttonKey: const Key( + 'todo-editor-tag-button', + ), + tagKeyPrefix: 'todo-editor-tag', + onPressed: onOpenTagAssignment, + ), + ), + const SizedBox(width: 6), + _DeadlineToolbarButton( + schedule: schedule, + enabled: !isSaving, + onPressed: onOpenDeadline, + ), + ], ), enabled: !isSaving, showPreview: showPreview, @@ -479,6 +527,10 @@ class _TodoDetails extends StatelessWidget { ], ), ], + if (item.dueAt case final dueAt?) ...[ + const SizedBox(height: 10), + _TodoDetailsDeadline(item: item, dueAt: dueAt), + ], const SizedBox(height: 14), Expanded( child: item.content.trim().isEmpty @@ -494,6 +546,73 @@ class _TodoDetails extends StatelessWidget { } } +class _DeadlineToolbarButton extends StatelessWidget { + const _DeadlineToolbarButton({ + required this.schedule, + required this.enabled, + required this.onPressed, + }); + + final TodoScheduleDraft schedule; + final bool enabled; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final dueAt = schedule.dueAt?.toLocal(); + final isChinese = Localizations.localeOf(context).languageCode == 'zh'; + return IconButton( + key: const Key('todo-editor-deadline-button'), + onPressed: enabled ? onPressed : null, + tooltip: dueAt == null + ? (isChinese ? '设置截止时间' : 'Set deadline') + : (isChinese ? '修改截止时间' : 'Change deadline'), + style: IconButton.styleFrom( + minimumSize: const Size.square(30), + maximumSize: const Size.square(30), + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + foregroundColor: dueAt == null ? null : theme.colorScheme.primary, + ), + icon: Icon( + dueAt == null ? Icons.event_outlined : Icons.event_rounded, + size: 17, + ), + ); + } +} + +class _TodoDetailsDeadline extends StatelessWidget { + const _TodoDetailsDeadline({required this.item, required this.dueAt}); + + final TodoItem item; + final DateTime dueAt; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final localDueAt = dueAt.toLocal(); + final isOverdue = !item.isCompleted && DateTime.now().isAfter(localDueAt); + final color = isOverdue + ? theme.colorScheme.error + : theme.colorScheme.primary; + final localizations = MaterialLocalizations.of(context); + return Row( + key: const Key('todo-details-deadline'), + children: [ + Icon(Icons.event_outlined, size: 16, color: color), + const SizedBox(width: 7), + Text( + '${localizations.formatMediumDate(localDueAt)} · ' + '${localizations.formatTimeOfDay(TimeOfDay.fromDateTime(localDueAt), alwaysUse24HourFormat: true)}', + style: theme.textTheme.bodySmall?.copyWith(color: color), + ), + ], + ); + } +} + class _EmptyTodoContent extends StatelessWidget { const _EmptyTodoContent({required this.canEdit}); diff --git a/lib/features/todos/presentation/todo_panel.dart b/lib/features/todos/presentation/todo_panel.dart index c7907f7..7b0f107 100644 --- a/lib/features/todos/presentation/todo_panel.dart +++ b/lib/features/todos/presentation/todo_panel.dart @@ -15,13 +15,11 @@ import '../../notes/presentation/note_panel_content.dart'; import '../../notes/presentation/note_view_model.dart'; import '../../settings/presentation/settings_drawer.dart'; import '../../settings/presentation/settings_view_model.dart'; -import '../../sticky_boards/presentation/sticky_board_drawers.dart'; -import '../../sticky_boards/presentation/sticky_board_view_model.dart'; -import '../../sticky_boards/presentation/sticky_board_window_coordinator.dart'; import '../../updates/presentation/update_view_model.dart'; import '../domain/todo_item.dart'; import 'tag_filter_drawer.dart'; import 'tag_management_drawer.dart'; +import 'todo_deadline_picker.dart'; import 'todo_editor_drawer.dart'; import 'todo_view_model.dart'; import 'widgets/tag_menus.dart'; @@ -29,9 +27,7 @@ import 'widgets/todo_list_row.dart'; const double _settingsDrawerWidth = 268; const double _tagDrawerWidth = 292; -const double _stickyBoardDrawerWidth = 336; -const double _todoDrawerHeight = 520; -const double _noteDrawerHeight = 590; +const double _editorDrawerHeight = 590; const Duration _drawerSlideDuration = Duration(milliseconds: 220); const Duration _drawerScrimDuration = Duration(milliseconds: 160); const Duration _scrollHoverResumeDelay = Duration(milliseconds: 120); @@ -47,9 +43,6 @@ enum _TodoPanelDrawerMode { tagFilter, tagAssignment, tagManagement, - stickyBoardManagement, - stickyBoardDetail, - stickyBoardTodoPicker, createTodo, todoDetails, editTodo, @@ -57,13 +50,7 @@ enum _TodoPanelDrawerMode { editNote, } -enum _TodoPanelDrawerFamily { - settings, - tags, - stickyBoards, - todoEditor, - noteEditor, -} +enum _TodoPanelDrawerFamily { settings, tags, todoEditor, noteEditor } class TodoPanel extends StatefulWidget { const TodoPanel({ @@ -71,13 +58,11 @@ class TodoPanel extends StatefulWidget { this.noteController, required this.settingsController, required this.updateController, - required this.stickyBoardController, - required this.stickyBoardWindowCoordinator, required this.windowBridge, required this.expansionAnchor, - required this.stickyBoardRequest, - required this.stickyBoardRequestSerial, required this.onCollapse, + this.requestedTodoId, + this.requestedTodoSerial = 0, super.key, }); @@ -85,13 +70,11 @@ class TodoPanel extends StatefulWidget { final NoteViewModel? noteController; final SettingsViewModel settingsController; final UpdateViewModel updateController; - final StickyBoardViewModel stickyBoardController; - final StickyBoardWindowCoordinator stickyBoardWindowCoordinator; final WindowBridge windowBridge; final WindowExpansionAnchor expansionAnchor; - final StickyBoardMainWindowRequest? stickyBoardRequest; - final int stickyBoardRequestSerial; final VoidCallback onCollapse; + final String? requestedTodoId; + final int requestedTodoSerial; @override State createState() => _TodoPanelState(); @@ -105,7 +88,6 @@ class _TodoPanelState extends State { final _tagFilterCloseFocusNode = FocusNode(); final _tagAssignmentCloseFocusNode = FocusNode(); final _tagManagementCloseFocusNode = FocusNode(); - final _stickyBoardCloseFocusNode = FocusNode(); final _todoDrawerCloseFocusNode = FocusNode(); final _noteDrawerCloseFocusNode = FocusNode(); GlobalKey _noteEditorKey = @@ -113,6 +95,7 @@ class _TodoPanelState extends State { _PanelContentKind _contentKind = _PanelContentKind.todos; TodoListScope _scope = TodoListScope.active; + TodoProgressFilter _progressFilter = TodoProgressFilter.all; bool _noteArchived = false; String _query = ''; final Set _selectedTagIds = {}; @@ -123,31 +106,47 @@ class _TodoPanelState extends State { _TodoPanelDrawerMode _lastTodoDrawerMode = _TodoPanelDrawerMode.createTodo; _TodoPanelDrawerMode? _tagManagementReturnMode; _TodoPanelDrawerMode? _tagAssignmentReturnMode; - _TodoPanelDrawerMode? _todoDrawerReturnMode; Set _todoEditorTagIds = {}; Set _noteEditorTagIds = {}; final Set<_TodoPanelDrawerFamily> _mountedDrawerFamilies = <_TodoPanelDrawerFamily>{}; - String? _selectedStickyBoardId; - String? _todoCreationBoardId; - String? _pendingCreatedTodoId; int _todoEditorSession = 0; String? _selectedNoteId; - int _lastHandledStickyBoardRequestSerial = -1; int _drawerRequestSerial = 0; @override void initState() { super.initState(); - _handleRequestedStickyBoard(); + if (widget.requestedTodoId != null) { + _scheduleRequestedTodoOpen(); + } } @override void didUpdateWidget(covariant TodoPanel oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.stickyBoardRequestSerial != widget.stickyBoardRequestSerial) { - _handleRequestedStickyBoard(); + if (oldWidget.requestedTodoSerial == widget.requestedTodoSerial || + widget.requestedTodoId == null) { + return; } + _scheduleRequestedTodoOpen(); + } + + void _scheduleRequestedTodoOpen() { + WidgetsBinding.instance.addPostFrameCallback((_) { + final todoId = widget.requestedTodoId; + if (!mounted || + todoId == null || + widget.controller.itemById(todoId) == null) { + return; + } + setState(() { + _contentKind = _PanelContentKind.todos; + _scope = TodoListScope.active; + _progressFilter = TodoProgressFilter.all; + }); + _openTodoDetails(todoId); + }); } @override @@ -159,7 +158,6 @@ class _TodoPanelState extends State { _tagFilterCloseFocusNode.dispose(); _tagAssignmentCloseFocusNode.dispose(); _tagManagementCloseFocusNode.dispose(); - _stickyBoardCloseFocusNode.dispose(); _todoDrawerCloseFocusNode.dispose(); _noteDrawerCloseFocusNode.dispose(); super.dispose(); @@ -174,129 +172,22 @@ class _TodoPanelState extends State { _scope = _scope == TodoListScope.active ? TodoListScope.archived : TodoListScope.active; - }); - } - - void _handleRequestedStickyBoard() { - if (_lastHandledStickyBoardRequestSerial == - widget.stickyBoardRequestSerial) { - return; - } - _lastHandledStickyBoardRequestSerial = widget.stickyBoardRequestSerial; - final request = widget.stickyBoardRequest; - if (request == null || - widget.stickyBoardController.boardById(request.boardId) == null) { - return; - } - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) { - return; + if (_scope == TodoListScope.archived) { + _progressFilter = TodoProgressFilter.all; } - _openRequestedStickyBoard(request); }); } - void _openRequestedStickyBoard(StickyBoardMainWindowRequest request) { - final boardId = request.boardId; - final todoId = request.todoId; - if (widget.stickyBoardController.boardById(boardId) == null) { + void _toggleDoingFilter() { + if (_contentKind != _PanelContentKind.todos || + _scope != TodoListScope.active) { return; } - if (todoId != null && widget.controller.itemById(todoId) == null) { - return; - } - - _selectedStickyBoardId = boardId; - if (_contentKind != _PanelContentKind.todos) { - setState(() => _contentKind = _PanelContentKind.todos); - } - if (!_mountedDrawerFamilies.contains(_TodoPanelDrawerFamily.stickyBoards)) { - setState( - () => _mountedDrawerFamilies.add(_TodoPanelDrawerFamily.stickyBoards), - ); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _performStickyBoardRequest(request); - } - }); - return; - } - _performStickyBoardRequest(request); - } - - void _performStickyBoardRequest(StickyBoardMainWindowRequest request) { - final todoId = request.todoId; - switch (request.destination) { - case StickyBoardMainWindowDestination.board: - _openStickyBoard(request.boardId); - case StickyBoardMainWindowDestination.todoDetails: - if (todoId == null) { - return; - } - _todoDrawerReturnMode = _TodoPanelDrawerMode.stickyBoardDetail; - _showTodoDrawer( - _TodoPanelDrawerMode.todoDetails, - todoId: todoId, - initialTagIds: widget.controller.tagIdsForTodo(todoId), - ); - case StickyBoardMainWindowDestination.todoEdit: - if (todoId == null) { - return; - } - _todoDrawerReturnMode = _TodoPanelDrawerMode.stickyBoardDetail; - _showTodoDrawer( - _TodoPanelDrawerMode.editTodo, - todoId: todoId, - initialTagIds: widget.controller.tagIdsForTodo(todoId), - ); - } - } - - void _openStickyBoards() { - _selectedStickyBoardId = null; - _showDrawer(_TodoPanelDrawerMode.stickyBoardManagement); - } - - void _openStickyBoard(String boardId) { - if (widget.stickyBoardController.boardById(boardId) == null) { - return; - } - if (_drawerMode == _TodoPanelDrawerMode.stickyBoardDetail) { - setState(() => _selectedStickyBoardId = boardId); - return; - } - _selectedStickyBoardId = boardId; - _showDrawer(_TodoPanelDrawerMode.stickyBoardDetail); - } - - void _openStickyBoardTodoPicker() { - if (_selectedStickyBoardId == null) { - return; - } - _showDrawer(_TodoPanelDrawerMode.stickyBoardTodoPicker); - } - - void _backToStickyBoardManagement() { - _showDrawer(_TodoPanelDrawerMode.stickyBoardManagement); - } - - void _backToStickyBoardDetail() { - if (_selectedStickyBoardId == null) { - _backToStickyBoardManagement(); - return; - } - _showDrawer(_TodoPanelDrawerMode.stickyBoardDetail); - } - - void _toggleStickyBoardPin(String boardId) { - unawaited(widget.stickyBoardWindowCoordinator.togglePin(boardId)); - } - - void _deleteStickyBoard(String boardId) { - if (_selectedStickyBoardId == boardId) { - _selectedStickyBoardId = null; - } - unawaited(widget.stickyBoardWindowCoordinator.deleteBoard(boardId)); + setState(() { + _progressFilter = _progressFilter == TodoProgressFilter.doing + ? TodoProgressFilter.all + : TodoProgressFilter.doing; + }); } void _openTagFilter() { @@ -311,6 +202,9 @@ class _TodoPanelState extends State { setState(() { _contentKind = kind; _query = ''; + if (kind == _PanelContentKind.notes) { + _progressFilter = TodoProgressFilter.all; + } }); } @@ -387,12 +281,7 @@ class _TodoPanelState extends State { _showDrawer(_TodoPanelDrawerMode.tagManagement); } - void _openTodoCreate({String? stickyBoardId}) { - _todoCreationBoardId = stickyBoardId; - _pendingCreatedTodoId = null; - _todoDrawerReturnMode = stickyBoardId == null - ? null - : _TodoPanelDrawerMode.stickyBoardDetail; + void _openTodoCreate() { _showTodoDrawer( _TodoPanelDrawerMode.createTodo, initialTagIds: const [], @@ -401,9 +290,6 @@ class _TodoPanelState extends State { } void _openTodoDetails(String todoId) { - if (_drawerMode == _TodoPanelDrawerMode.stickyBoardDetail) { - _todoDrawerReturnMode = _TodoPanelDrawerMode.stickyBoardDetail; - } _showTodoDrawer( _TodoPanelDrawerMode.todoDetails, todoId: todoId, @@ -412,10 +298,6 @@ class _TodoPanelState extends State { } void _openTodoEdit(String todoId) { - if (_drawerMode == _TodoPanelDrawerMode.stickyBoardDetail || - _todoDrawerReturnMode == _TodoPanelDrawerMode.stickyBoardDetail) { - _todoDrawerReturnMode = _TodoPanelDrawerMode.stickyBoardDetail; - } _showTodoDrawer( _TodoPanelDrawerMode.editTodo, todoId: todoId, @@ -424,32 +306,8 @@ class _TodoPanelState extends State { } Future _deleteArchivedTodoPermanently(String todoId) async { - final boardIds = widget.stickyBoardController.boards - .where( - (board) => widget.stickyBoardController - .todoIdsForBoard(board.id) - .contains(todoId), - ) - .map((board) => board.id) - .toList(growable: false); - final removedFromBoards = await widget.stickyBoardController - .removeTodoFromAllBoards(todoId); - if (!removedFromBoards) { - return; - } final deleted = await widget.controller.deletePermanently(todoId); - if (!deleted) { - if (widget.controller.itemById(todoId) != null) { - for (final boardId in boardIds) { - await widget.stickyBoardController.addTodo( - boardId: boardId, - todoId: todoId, - ); - } - } - return; - } - if (mounted && _selectedTodoId == todoId) { + if (deleted && mounted && _selectedTodoId == todoId) { _closeActiveDrawer(); } } @@ -458,53 +316,15 @@ class _TodoPanelState extends State { required String title, required String content, required Iterable tagIds, + required TodoScheduleDraft schedule, }) async { - final boardId = _todoCreationBoardId; - var todoId = _pendingCreatedTodoId; - if (todoId == null) { - final todoIdsBeforeSave = widget.controller.items - .map((item) => item.id) - .toSet(); - final item = await widget.controller.create( - title, - content: content, - tagIds: tagIds, - ); - if (item == null) { - final partiallySavedItems = widget.controller.items - .where((item) => !todoIdsBeforeSave.contains(item.id)) - .toList(growable: false); - if (partiallySavedItems.length == 1) { - _pendingCreatedTodoId = partiallySavedItems.single.id; - } - return false; - } - todoId = item.id; - _pendingCreatedTodoId = todoId; - } else { - final updated = await widget.controller.updateDetails( - id: todoId, - title: title, - content: content, - tagIds: tagIds, - ); - if (!updated) { - return false; - } - } - - if (boardId == null) { - _pendingCreatedTodoId = null; - return true; - } - final linked = await widget.stickyBoardController.addTodo( - boardId: boardId, - todoId: todoId, + final item = await widget.controller.create( + title, + content: content, + tagIds: tagIds, + schedule: schedule, ); - if (linked) { - _pendingCreatedTodoId = null; - } - return linked; + return item != null; } void _openTagAssignmentFromEditor() { @@ -548,7 +368,6 @@ class _TodoPanelState extends State { _tagFilterCloseFocusNode.unfocus(); _tagAssignmentCloseFocusNode.unfocus(); _tagManagementCloseFocusNode.unfocus(); - _stickyBoardCloseFocusNode.unfocus(); _todoDrawerCloseFocusNode.unfocus(); _noteDrawerCloseFocusNode.unfocus(); } @@ -602,10 +421,6 @@ class _TodoPanelState extends State { _TodoPanelDrawerMode.settings => _settingsCloseFocusNode, _TodoPanelDrawerMode.tagFilter => _tagFilterCloseFocusNode, _TodoPanelDrawerMode.tagAssignment => _tagAssignmentCloseFocusNode, - _TodoPanelDrawerMode.stickyBoardManagement || - _TodoPanelDrawerMode.stickyBoardDetail || - _TodoPanelDrawerMode.stickyBoardTodoPicker => - _stickyBoardCloseFocusNode, _TodoPanelDrawerMode.none || _TodoPanelDrawerMode.tagManagement || _TodoPanelDrawerMode.createTodo || @@ -701,9 +516,6 @@ class _TodoPanelState extends State { final returnMode = switch (closedMode) { _TodoPanelDrawerMode.tagManagement => _tagManagementReturnMode, _TodoPanelDrawerMode.tagAssignment => _tagAssignmentReturnMode, - _TodoPanelDrawerMode.createTodo || - _TodoPanelDrawerMode.todoDetails || - _TodoPanelDrawerMode.editTodo => _todoDrawerReturnMode, _ => null, }; _unfocusDrawerControls(); @@ -715,11 +527,6 @@ class _TodoPanelState extends State { if (closedMode == _TodoPanelDrawerMode.tagAssignment) { _tagAssignmentReturnMode = null; } - if (_isTodoDrawerMode(closedMode)) { - _todoDrawerReturnMode = null; - _todoCreationBoardId = null; - _pendingCreatedTodoId = null; - } if (_isNoteDrawerMode(closedMode)) { _selectedNoteId = null; _noteEditorTagIds = {}; @@ -791,10 +598,6 @@ class _TodoPanelState extends State { _TodoPanelDrawerMode.tagFilter || _TodoPanelDrawerMode.tagAssignment || _TodoPanelDrawerMode.tagManagement => _TodoPanelDrawerFamily.tags, - _TodoPanelDrawerMode.stickyBoardManagement || - _TodoPanelDrawerMode.stickyBoardDetail || - _TodoPanelDrawerMode.stickyBoardTodoPicker => - _TodoPanelDrawerFamily.stickyBoards, _TodoPanelDrawerMode.createTodo || _TodoPanelDrawerMode.todoDetails || _TodoPanelDrawerMode.editTodo => _TodoPanelDrawerFamily.todoEditor, @@ -819,16 +622,6 @@ class _TodoPanelState extends State { _drawerMode == _TodoPanelDrawerMode.tagManagement; final isTagDrawerOpen = isTagFilterOpen || isTagAssignmentOpen || isTagManagementOpen; - final isStickyBoardManagementOpen = - _drawerMode == _TodoPanelDrawerMode.stickyBoardManagement; - final isStickyBoardDetailOpen = - _drawerMode == _TodoPanelDrawerMode.stickyBoardDetail; - final isStickyBoardTodoPickerOpen = - _drawerMode == _TodoPanelDrawerMode.stickyBoardTodoPicker; - final isStickyBoardDrawerOpen = - isStickyBoardManagementOpen || - isStickyBoardDetailOpen || - isStickyBoardTodoPickerOpen; final isTodoDrawerOpen = _drawerMode == _TodoPanelDrawerMode.createTodo || _drawerMode == _TodoPanelDrawerMode.todoDetails || @@ -842,9 +635,6 @@ class _TodoPanelState extends State { final hasTagDrawer = _mountedDrawerFamilies.contains( _TodoPanelDrawerFamily.tags, ); - final hasStickyBoardDrawer = _mountedDrawerFamilies.contains( - _TodoPanelDrawerFamily.stickyBoards, - ); final hasTodoDrawer = _mountedDrawerFamilies.contains( _TodoPanelDrawerFamily.todoEditor, ); @@ -866,11 +656,6 @@ class _TodoPanelState extends State { tagAssignmentOwner == _TodoPanelDrawerMode.editNote); final isTodoDrawerVisible = isTodoDrawerOpen || isTodoTagContext; final isNoteDrawerVisible = isNoteDrawerOpen || isNoteTagContext; - final isStickyBoardContextVisible = - (_todoDrawerReturnMode == _TodoPanelDrawerMode.stickyBoardDetail && - (isTodoDrawerOpen || isTodoTagContext)); - final isStickyBoardDrawerVisible = - isStickyBoardDrawerOpen || isStickyBoardContextVisible; final visibleTagDrawerMode = isTagDrawerOpen ? _drawerMode : _lastTagDrawerMode; @@ -934,10 +719,11 @@ class _TodoPanelState extends State { ), _NewTodoIntent: CallbackAction<_NewTodoIntent>( onInvoke: (_) { - if (_contentKind == _PanelContentKind.notes) { - _openNoteCreate(); - } else { - _openTodoCreate(); + switch (_contentKind) { + case _PanelContentKind.notes: + _openNoteCreate(); + case _PanelContentKind.todos: + _openTodoCreate(); } return null; }, @@ -966,7 +752,7 @@ class _TodoPanelState extends State { ), border: Border.all( color: isDark - ? Colors.white.withValues(alpha: 0.12) + ? Colors.white.withValues(alpha: 0.09) : Colors.white.withValues(alpha: 0.86), ), ), @@ -980,12 +766,11 @@ class _TodoPanelState extends State { ExcludeFocus( excluding: isDrawerOpen, child: AnimatedBuilder( - animation: widget.noteController == null - ? widget.controller - : Listenable.merge([ - widget.controller, - widget.noteController!, - ]), + animation: Listenable.merge([ + widget.controller, + if (widget.noteController != null) + widget.noteController!, + ]), builder: (context, _) { final selectedTags = widget.controller.tags .where( @@ -1002,8 +787,7 @@ class _TodoPanelState extends State { activeCount: widget.controller.activeCount, archivedCount: widget.controller.archivedCount, - notesSelected: - _contentKind == _PanelContentKind.notes, + contentKind: _contentKind, noteArchived: _noteArchived, noteActiveCount: widget.noteController?.activeCount ?? 0, @@ -1012,23 +796,22 @@ class _TodoPanelState extends State { 0, onToggleArchive: _toggleArchiveScope, onToggleNoteArchive: _toggleNoteArchive, - onOpenStickyBoards: _openStickyBoards, onOpenSettings: _openSettings, onCollapse: widget.onCollapse, ), - if (widget.noteController != null) - Padding( - padding: const EdgeInsets.fromLTRB( - 20, - 0, - 20, - 12, - ), - child: _ContentSwitcher( - selected: _contentKind, - onSelected: _selectContentKind, - ), + Padding( + padding: const EdgeInsets.fromLTRB( + 20, + 0, + 20, + 12, + ), + child: _ContentSwitcher( + selected: _contentKind, + showNotes: widget.noteController != null, + onSelected: _selectContentKind, ), + ), Padding( padding: const EdgeInsets.fromLTRB( 20, @@ -1051,29 +834,29 @@ class _TodoPanelState extends State { ); }, decoration: InputDecoration( - hintText: - _contentKind == - _PanelContentKind - .notes - ? (_noteArchived - ? context - .l10n - .searchNoteArchiveHint - : context - .l10n - .searchNotesHint) - : (_scope == - TodoListScope - .active - ? context - .l10n - .searchTodosHint - : context - .l10n - .searchArchiveHint), + hintText: switch (_contentKind) { + _PanelContentKind.notes => + _noteArchived + ? context + .l10n + .searchNoteArchiveHint + : context + .l10n + .searchNotesHint, + _PanelContentKind.todos => + _scope == + TodoListScope + .active + ? context + .l10n + .searchTodosHint + : context + .l10n + .searchArchiveHint, + }, prefixIcon: const Icon( Icons.search_rounded, - size: 19, + size: 17, ), suffixIcon: _query.isEmpty ? null @@ -1099,6 +882,18 @@ class _TodoPanelState extends State { ), ), const SizedBox(width: 9), + if (_contentKind == + _PanelContentKind.todos && + _scope == + TodoListScope.active) ...[ + _DoingFilterButton( + selected: + _progressFilter == + TodoProgressFilter.doing, + onPressed: _toggleDoingFilter, + ), + const SizedBox(width: 9), + ], TagFilterButton( selectedCount: effectiveSelectedTagIds @@ -1117,40 +912,76 @@ class _TodoPanelState extends State { const SizedBox(width: 9), SizedBox( height: 42, - child: FilledButton.tonalIcon( + width: 42, + child: IconButton( key: Key( - _contentKind == - _PanelContentKind - .notes - ? 'add-note-button' - : 'add-todo-button', + switch (_contentKind) { + _PanelContentKind.notes => + 'add-note-button', + _PanelContentKind.todos => + 'add-todo-button', + }, ), onPressed: - _contentKind == - _PanelContentKind - .notes - ? _openNoteCreate - : _openTodoCreate, - style: FilledButton.styleFrom( - padding: - const EdgeInsets.symmetric( - horizontal: 12, - ), - ), - icon: const Icon( - Icons.add_rounded, - size: 18, - ), - label: Text( - _contentKind == - _PanelContentKind - .notes - ? context + switch (_contentKind) { + _PanelContentKind + .notes => + _openNoteCreate, + _PanelContentKind + .todos => + _openTodoCreate, + }, + tooltip: + switch (_contentKind) { + _PanelContentKind + .notes => + context .l10n - .newNoteAction - : context + .newNoteAction, + _PanelContentKind + .todos => + context .l10n .newTodoAction, + }, + style: IconButton.styleFrom( + foregroundColor: Theme.of( + context, + ).colorScheme.primary, + backgroundColor: + Theme.of(context) + .colorScheme + .primary + .withValues( + alpha: isDark + ? 0.025 + : 0.04, + ), + side: BorderSide( + color: Theme.of(context) + .colorScheme + .primary + .withValues( + alpha: isDark + ? 0.42 + : 0.32, + ), + ), + minimumSize: + const Size.square(42), + maximumSize: + const Size.square(42), + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular( + 12, + ), + ), + ), + icon: const Icon( + Icons.add_rounded, + size: 17, ), ), ), @@ -1179,24 +1010,6 @@ class _TodoPanelState extends State { onDismiss: widget.noteController!.dismissError, ), - if (_contentKind == _PanelContentKind.todos) - AnimatedBuilder( - animation: widget.stickyBoardController, - builder: (context, _) { - final error = - widget.stickyBoardController.error; - if (error == null) { - return const SizedBox.shrink(); - } - return _ErrorBanner( - message: context.l10n - .messageForStorageFailure(error), - onDismiss: widget - .stickyBoardController - .dismissError, - ); - }, - ), Divider( height: 1, thickness: 1, @@ -1205,32 +1018,32 @@ class _TodoPanelState extends State { : Colors.black.withValues(alpha: 0.055), ), Expanded( - child: - _contentKind == _PanelContentKind.notes - ? NotePanelContent( - controller: widget.noteController!, - archived: _noteArchived, - query: _query, - availableTags: - widget.controller.tags, - selectedTagIds: - effectiveSelectedTagIds, - onOpen: _openNote, - ) - : _TodoList( - controller: widget.controller, - scope: _scope, - query: _query, - selectedTagIds: - effectiveSelectedTagIds, - onClearTagFilters: _clearTagFilters, - onOpenTagManagement: - _openTagManagement, - onOpenDetails: _openTodoDetails, - onEditTodo: _openTodoEdit, - onDeleteTodo: - _deleteArchivedTodoPermanently, - ), + child: switch (_contentKind) { + _PanelContentKind.notes => + NotePanelContent( + controller: widget.noteController!, + archived: _noteArchived, + query: _query, + availableTags: widget.controller.tags, + selectedTagIds: + effectiveSelectedTagIds, + onOpen: _openNote, + ), + _PanelContentKind.todos => _TodoList( + controller: widget.controller, + scope: _scope, + query: _query, + progressFilter: _progressFilter, + selectedTagIds: effectiveSelectedTagIds, + onClearDoingFilter: _toggleDoingFilter, + onClearTagFilters: _clearTagFilters, + onOpenTagManagement: _openTagManagement, + onOpenDetails: _openTodoDetails, + onEditTodo: _openTodoEdit, + onDeleteTodo: + _deleteArchivedTodoPermanently, + ), + }, ), ], ); @@ -1299,104 +1112,12 @@ class _TodoPanelState extends State { ), ), ), - if (hasStickyBoardDrawer) - Positioned( - top: 0, - left: tagDrawerOnLeft ? 0 : null, - right: tagDrawerOnLeft ? null : 0, - bottom: 0, - width: _stickyBoardDrawerWidth, - child: IgnorePointer( - key: const Key('sticky-board-drawer-pointer'), - ignoring: !isStickyBoardDrawerOpen, - child: ExcludeSemantics( - excluding: !isStickyBoardDrawerOpen, - child: AnimatedSlide( - key: const Key('sticky-board-drawer-slide'), - duration: reduceMotion - ? Duration.zero - : _drawerSlideDuration, - curve: Curves.easeOutCubic, - offset: isStickyBoardDrawerVisible - ? Offset.zero - : Offset(tagDrawerOnLeft ? -1 : 1, 0), - child: FocusTraversalGroup( - child: AnimatedBuilder( - animation: Listenable.merge([ - widget.stickyBoardController, - widget.controller, - ]), - builder: (context, _) { - final board = - _selectedStickyBoardId == null - ? null - : widget.stickyBoardController - .boardById( - _selectedStickyBoardId!, - ); - if (isStickyBoardTodoPickerOpen && - board != null) { - return StickyBoardTodoPickerDrawer( - board: board, - todoController: widget.controller, - boardController: - widget.stickyBoardController, - borderOnLeft: !tagDrawerOnLeft, - onBack: _backToStickyBoardDetail, - onClose: _closeActiveDrawer, - closeFocusNode: - _stickyBoardCloseFocusNode, - ); - } - if ((isStickyBoardDetailOpen || - isStickyBoardContextVisible) && - board != null) { - return StickyBoardDetailDrawer( - board: board, - todoController: widget.controller, - boardController: - widget.stickyBoardController, - borderOnLeft: !tagDrawerOnLeft, - onBack: - _backToStickyBoardManagement, - onClose: _closeActiveDrawer, - onTogglePin: () => - _toggleStickyBoardPin(board.id), - onAddExisting: - _openStickyBoardTodoPicker, - onCreateTodo: () => _openTodoCreate( - stickyBoardId: board.id, - ), - closeFocusNode: - _stickyBoardCloseFocusNode, - ); - } - return StickyBoardManagementDrawer( - controller: - widget.stickyBoardController, - todoController: widget.controller, - isOpen: isStickyBoardManagementOpen, - borderOnLeft: !tagDrawerOnLeft, - onClose: _closeActiveDrawer, - onOpenBoard: _openStickyBoard, - onTogglePin: _toggleStickyBoardPin, - onDeleteBoard: _deleteStickyBoard, - closeFocusNode: - _stickyBoardCloseFocusNode, - ); - }, - ), - ), - ), - ), - ), - ), if (hasTodoDrawer) Positioned( left: 0, right: 0, bottom: 0, - height: _todoDrawerHeight, + height: _editorDrawerHeight, child: IgnorePointer( key: const Key('todo-drawer-pointer'), ignoring: !isTodoDrawerOpen, @@ -1434,27 +1155,32 @@ class _TodoPanelState extends State { }, onOpenTagAssignment: _openTagAssignmentFromEditor, - onSave: (title, content, tagIds) { - if (todoEditorMode == - TodoEditorDrawerMode.create) { - return _saveCreatedTodo( - title: title, - content: content, - tagIds: tagIds, - ); - } - final todoId = selectedTodo?.id; - if (todoId == null) { - return Future.value(false); - } - return widget.controller - .updateDetails( - id: todoId, - title: title, - content: content, - tagIds: tagIds, - ); - }, + onSave: + (title, content, tagIds, schedule) { + if (todoEditorMode == + TodoEditorDrawerMode.create) { + return _saveCreatedTodo( + title: title, + content: content, + tagIds: tagIds, + schedule: schedule, + ); + } + final todoId = selectedTodo?.id; + if (todoId == null) { + return Future.value( + false, + ); + } + return widget.controller + .updateDetails( + id: todoId, + title: title, + content: content, + tagIds: tagIds, + schedule: schedule, + ); + }, onSaved: () { if (_drawerMode == _TodoPanelDrawerMode.createTodo) { @@ -1483,7 +1209,7 @@ class _TodoPanelState extends State { left: 0, right: 0, bottom: 0, - height: _noteDrawerHeight, + height: _editorDrawerHeight, child: IgnorePointer( key: const Key('note-drawer-pointer'), ignoring: !isNoteDrawerOpen, @@ -1677,23 +1403,20 @@ class _TodoPanelState extends State { } class _ContentSwitcher extends StatelessWidget { - const _ContentSwitcher({required this.selected, required this.onSelected}); + const _ContentSwitcher({ + required this.selected, + required this.showNotes, + required this.onSelected, + }); final _PanelContentKind selected; + final bool showNotes; final ValueChanged<_PanelContentKind> onSelected; @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final onSurface = theme.colorScheme.onSurface; - return Container( + return SizedBox( height: 40, - padding: const EdgeInsets.all(3), - decoration: BoxDecoration( - color: onSurface.withValues(alpha: 0.035), - borderRadius: BorderRadius.circular(11), - border: Border.all(color: onSurface.withValues(alpha: 0.07)), - ), child: Row( children: [ _ContentSwitcherButton( @@ -1702,20 +1425,74 @@ class _ContentSwitcher extends StatelessWidget { selected: selected == _PanelContentKind.todos, onPressed: () => onSelected(_PanelContentKind.todos), ), - const SizedBox(width: 3), - _ContentSwitcherButton( - key: const Key('note-tab'), - label: context.l10n.notesTabLabel, - selected: selected == _PanelContentKind.notes, - onPressed: () => onSelected(_PanelContentKind.notes), - ), + if (showNotes) ...[ + const SizedBox(width: 3), + _ContentSwitcherButton( + key: const Key('note-tab'), + label: context.l10n.notesTabLabel, + selected: selected == _PanelContentKind.notes, + onPressed: () => onSelected(_PanelContentKind.notes), + ), + ], ], ), ); } } -class _ContentSwitcherButton extends StatelessWidget { +class _DoingFilterButton extends StatelessWidget { + const _DoingFilterButton({required this.selected, required this.onPressed}); + + final bool selected; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final primary = theme.colorScheme.primary; + return Semantics( + selected: selected, + child: SizedBox.square( + dimension: 42, + child: IconButton( + key: const Key('doing-filter-button'), + tooltip: selected + ? context.l10n.clearDoingFilterTooltip + : context.l10n.filterDoingTooltip, + isSelected: selected, + onPressed: onPressed, + style: IconButton.styleFrom( + minimumSize: const Size.square(42), + maximumSize: const Size.square(42), + padding: EdgeInsets.zero, + foregroundColor: selected + ? primary + : theme.colorScheme.onSurface.withValues(alpha: 0.62), + backgroundColor: selected + ? primary.withValues(alpha: isDark ? 0.13 : 0.10) + : isDark + ? theme.inputDecorationTheme.fillColor + : const Color(0xFFF0F4F2), + side: BorderSide( + color: selected + ? primary.withValues(alpha: isDark ? 0.46 : 0.36) + : isDark + ? theme.colorScheme.onSurface.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.045), + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + icon: const Icon(Icons.timelapse_rounded, size: 17), + ), + ), + ); + } +} + +class _ContentSwitcherButton extends StatefulWidget { const _ContentSwitcherButton({ required this.label, required this.selected, @@ -1727,30 +1504,61 @@ class _ContentSwitcherButton extends StatelessWidget { final bool selected; final VoidCallback onPressed; + @override + State<_ContentSwitcherButton> createState() => _ContentSwitcherButtonState(); +} + +class _ContentSwitcherButtonState extends State<_ContentSwitcherButton> { + bool _isHovered = false; + @override Widget build(BuildContext context) { final theme = Theme.of(context); + final reduceMotion = MediaQuery.disableAnimationsOf(context); + final hoverColor = widget.selected + ? theme.colorScheme.primary.withValues(alpha: 0.055) + : theme.colorScheme.onSurface.withValues( + alpha: theme.brightness == Brightness.dark ? 0.055 : 0.035, + ); return Expanded( child: Semantics( button: true, - selected: selected, + selected: widget.selected, child: Material( - color: selected - ? theme.colorScheme.primary.withValues(alpha: 0.13) - : Colors.transparent, + color: Colors.transparent, borderRadius: BorderRadius.circular(8), - child: InkWell( - onTap: onPressed, - borderRadius: BorderRadius.circular(8), - hoverColor: theme.colorScheme.primary.withValues(alpha: 0.06), - child: Center( - child: Text( - label, - style: theme.textTheme.labelLarge?.copyWith( - color: selected - ? theme.colorScheme.primary - : theme.colorScheme.onSurface.withValues(alpha: 0.62), - fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), + child: AnimatedContainer( + key: const Key('content-tab-hover-surface'), + duration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 150), + curve: Curves.easeOut, + decoration: BoxDecoration( + color: _isHovered ? hoverColor : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: InkWell( + onTap: widget.onPressed, + borderRadius: BorderRadius.circular(8), + hoverColor: Colors.transparent, + highlightColor: Colors.transparent, + splashFactory: NoSplash.splashFactory, + child: Center( + child: Text( + widget.label, + style: theme.textTheme.labelLarge?.copyWith( + color: widget.selected + ? theme.colorScheme.primary + : theme.colorScheme.onSurface.withValues(alpha: 0.62), + fontWeight: widget.selected + ? FontWeight.w600 + : FontWeight.w500, + ), + ), ), ), ), @@ -1766,13 +1574,12 @@ class _PanelHeader extends StatelessWidget { required this.scope, required this.activeCount, required this.archivedCount, - required this.notesSelected, + required this.contentKind, required this.noteArchived, required this.noteActiveCount, required this.noteArchivedCount, required this.onToggleArchive, required this.onToggleNoteArchive, - required this.onOpenStickyBoards, required this.onOpenSettings, required this.onCollapse, }); @@ -1780,13 +1587,12 @@ class _PanelHeader extends StatelessWidget { final TodoListScope scope; final int activeCount; final int archivedCount; - final bool notesSelected; + final _PanelContentKind contentKind; final bool noteArchived; final int noteActiveCount; final int noteArchivedCount; final VoidCallback onToggleArchive; final VoidCallback onToggleNoteArchive; - final VoidCallback onOpenStickyBoards; final VoidCallback onOpenSettings; final VoidCallback onCollapse; @@ -1794,18 +1600,22 @@ class _PanelHeader extends StatelessWidget { Widget build(BuildContext context) { final onSurface = Theme.of(context).colorScheme.onSurface; final localizations = context.l10n; - final archiveSelected = notesSelected - ? noteArchived - : scope == TodoListScope.archived; - final statusText = notesSelected - ? noteArchived - ? '${localizations.archiveScopeLabel} · $noteArchivedCount' - : localizations.noteCountLabel(noteActiveCount) - : scope == TodoListScope.archived - ? '${localizations.archiveScopeLabel} · $archivedCount' - : activeCount == 0 - ? localizations.allClearToday - : localizations.activeTodoCount(activeCount); + final archiveSelected = switch (contentKind) { + _PanelContentKind.notes => noteArchived, + _PanelContentKind.todos => scope == TodoListScope.archived, + }; + final statusText = switch (contentKind) { + _PanelContentKind.notes => + noteArchived + ? '${localizations.archiveScopeLabel} · $noteArchivedCount' + : localizations.noteCountLabel(noteActiveCount), + _PanelContentKind.todos => + scope == TodoListScope.archived + ? '${localizations.archiveScopeLabel} · $archivedCount' + : activeCount == 0 + ? localizations.allClearToday + : localizations.activeTodoCount(activeCount), + }; return Padding( padding: const EdgeInsets.fromLTRB(20, 18, 12, 16), child: Row( @@ -1816,8 +1626,9 @@ class _PanelHeader extends StatelessWidget { child: Text( statusText, style: TextStyle( - color: onSurface.withValues(alpha: 0.53), - fontSize: 12, + color: onSurface.withValues(alpha: 0.62), + fontSize: 12.5, + fontWeight: FontWeight.w500, ), ), ), @@ -1828,7 +1639,9 @@ class _PanelHeader extends StatelessWidget { tooltip: archiveSelected ? localizations.activeScopeLabel : localizations.archiveScopeLabel, - onPressed: notesSelected ? onToggleNoteArchive : onToggleArchive, + onPressed: contentKind == _PanelContentKind.notes + ? onToggleNoteArchive + : onToggleArchive, color: archiveSelected ? Theme.of(context).colorScheme.primary : null, @@ -1836,28 +1649,21 @@ class _PanelHeader extends StatelessWidget { archiveSelected ? Icons.archive_rounded : Icons.archive_outlined, - size: 19, + size: 17, ), ), ), - if (!notesSelected) - IconButton( - key: const Key('sticky-boards-button'), - tooltip: localizations.stickyBoardsTooltip, - onPressed: onOpenStickyBoards, - icon: const Icon(Icons.sticky_note_2_outlined, size: 19), - ), IconButton( key: const Key('settings-button'), tooltip: localizations.settingsTooltip, onPressed: onOpenSettings, - icon: const Icon(Icons.settings_outlined, size: 19), + icon: const Icon(Icons.settings_outlined, size: 17), ), IconButton( key: const Key('collapse-button'), tooltip: localizations.collapseTooltip, onPressed: onCollapse, - icon: const Icon(Icons.unfold_less_rounded, size: 20), + icon: const Icon(Icons.keyboard_arrow_up_rounded, size: 18), ), ], ), @@ -1922,7 +1728,9 @@ class _TodoList extends StatefulWidget { required this.controller, required this.scope, required this.query, + required this.progressFilter, required this.selectedTagIds, + required this.onClearDoingFilter, required this.onClearTagFilters, required this.onOpenTagManagement, required this.onOpenDetails, @@ -1933,7 +1741,9 @@ class _TodoList extends StatefulWidget { final TodoViewModel controller; final TodoListScope scope; final String query; + final TodoProgressFilter progressFilter; final Set selectedTagIds; + final VoidCallback onClearDoingFilter; final VoidCallback onClearTagFilters; final VoidCallback onOpenTagManagement; final ValueChanged onOpenDetails; @@ -1964,9 +1774,17 @@ class _TodoListState extends State<_TodoList> { final entries = _buildEntries(context); if (entries.isEmpty) { + final hasSearchOrTagFilters = + widget.query.isNotEmpty || widget.selectedTagIds.isNotEmpty; return _EmptyList( scope: widget.scope, - hasQuery: widget.query.isNotEmpty || widget.selectedTagIds.isNotEmpty, + hasQuery: hasSearchOrTagFilters, + doingOnly: + widget.progressFilter == TodoProgressFilter.doing && + widget.scope == TodoListScope.active, + onClearDoingFilter: hasSearchOrTagFilters + ? null + : widget.onClearDoingFilter, onClearTagFilters: widget.selectedTagIds.isEmpty ? null : widget.onClearTagFilters, @@ -1990,6 +1808,7 @@ class _TodoListState extends State<_TodoList> { archived: archived, query: widget.query, selectedTagIds: widget.selectedTagIds, + progressFilter: archived ? TodoProgressFilter.all : widget.progressFilter, ); final locale = Localizations.localeOf(context); final now = DateTime.now(); @@ -2055,6 +1874,17 @@ class _ScrollableTodoEntriesState extends State<_ScrollableTodoEntries> { Timer? _hoverResumeTimer; bool _isScrolling = false; + Future _editDeadline(TodoItem item) async { + final schedule = await showTodoDeadlinePicker( + context: context, + initialSchedule: TodoScheduleDraft.fromItem(item), + ); + if (!mounted || schedule == null) { + return; + } + await widget.controller.updateSchedule(id: item.id, schedule: schedule); + } + @override void dispose() { _hoverResumeTimer?.cancel(); @@ -2109,8 +1939,15 @@ class _ScrollableTodoEntriesState extends State<_ScrollableTodoEntries> { hoverEnabled: !_isScrolling, onToggle: () => unawaited(widget.controller.toggleCompletion(entry.item.id)), + onToggleDoing: archived + ? null + : () => + unawaited(widget.controller.toggleDoing(entry.item.id)), onOpenDetails: () => widget.onOpenDetails(entry.item.id), onEdit: archived ? null : () => widget.onEditTodo(entry.item.id), + onSetDeadline: archived + ? null + : () => unawaited(_editDeadline(entry.item)), onArchive: () => unawaited(widget.controller.archive(entry.item.id)), onRestore: () => @@ -2160,7 +1997,7 @@ class _DateDivider extends StatelessWidget { Widget build(BuildContext context) { final color = Theme.of( context, - ).colorScheme.onSurface.withValues(alpha: 0.48); + ).colorScheme.onSurface.withValues(alpha: 0.58); return Padding( padding: const EdgeInsets.fromLTRB(8, 13, 8, 7), child: Row( @@ -2173,10 +2010,6 @@ class _DateDivider extends StatelessWidget { fontWeight: FontWeight.w600, ), ), - const SizedBox(width: 10), - Expanded( - child: Divider(height: 1, color: color.withValues(alpha: 0.2)), - ), ], ), ); @@ -2187,17 +2020,22 @@ class _EmptyList extends StatelessWidget { const _EmptyList({ required this.scope, required this.hasQuery, + required this.doingOnly, + this.onClearDoingFilter, this.onClearTagFilters, }); final TodoListScope scope; final bool hasQuery; + final bool doingOnly; + final VoidCallback? onClearDoingFilter; final VoidCallback? onClearTagFilters; @override Widget build(BuildContext context) { final onSurface = Theme.of(context).colorScheme.onSurface; final isArchive = scope == TodoListScope.archived; + final showDoingEmptyState = doingOnly && !hasQuery; final localizations = context.l10n; return Center( child: Padding( @@ -2215,7 +2053,9 @@ class _EmptyList extends StatelessWidget { borderRadius: BorderRadius.circular(19), ), child: Icon( - hasQuery + showDoingEmptyState + ? Icons.timelapse_rounded + : hasQuery ? Icons.search_off_rounded : (isArchive ? Icons.inventory_2_outlined @@ -2226,7 +2066,9 @@ class _EmptyList extends StatelessWidget { ), const SizedBox(height: 15), Text( - hasQuery + showDoingEmptyState + ? localizations.emptyDoingTodosTitle + : hasQuery ? localizations.noSearchResultsTitle : (isArchive ? localizations.emptyArchiveTitle @@ -2240,7 +2082,9 @@ class _EmptyList extends StatelessWidget { ), const SizedBox(height: 5), Text( - hasQuery + showDoingEmptyState + ? localizations.emptyDoingTodosMessage + : hasQuery ? localizations.noSearchResultsMessage : (isArchive ? localizations.emptyArchiveMessage @@ -2268,6 +2112,23 @@ class _EmptyList extends StatelessWidget { child: Text(localizations.clearTagFilterTooltip), ), ], + if (onClearDoingFilter != null && showDoingEmptyState) ...[ + const SizedBox(height: 10), + TextButton( + key: const Key('clear-doing-filter'), + onPressed: onClearDoingFilter, + style: TextButton.styleFrom( + minimumSize: const Size(0, 30), + padding: const EdgeInsets.symmetric(horizontal: 10), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + textStyle: const TextStyle( + fontSize: 11.5, + fontWeight: FontWeight.w600, + ), + ), + child: Text(localizations.clearDoingFilterTooltip), + ), + ], ], ), ), diff --git a/lib/features/todos/presentation/todo_view_model.dart b/lib/features/todos/presentation/todo_view_model.dart index 1bb9cb1..a2f877f 100644 --- a/lib/features/todos/presentation/todo_view_model.dart +++ b/lib/features/todos/presentation/todo_view_model.dart @@ -26,6 +26,8 @@ enum TagMutationResult { storageFailure, } +enum TodoProgressFilter { all, doing } + class TodoViewModel extends ChangeNotifier { TodoViewModel({ required TodoRepository todoRepository, @@ -66,6 +68,7 @@ class TodoViewModel extends ChangeNotifier { bool? _cachedViewArchived; String? _cachedViewQuery; Set _cachedViewTagIds = const {}; + TodoProgressFilter? _cachedViewProgressFilter; List? _cachedViewItems; TagWorkspace _tagWorkspace = TagWorkspace.empty(); Map _tagUsageCounts = const {}; @@ -118,12 +121,14 @@ class TodoViewModel extends ChangeNotifier { required bool archived, required String query, Set selectedTagIds = const {}, + TodoProgressFilter progressFilter = TodoProgressFilter.all, }) { final normalizedQuery = query.trim().toLowerCase(); if (_cachedViewItems != null && _cachedViewArchived == archived && _cachedViewQuery == normalizedQuery && - setEquals(_cachedViewTagIds, selectedTagIds)) { + setEquals(_cachedViewTagIds, selectedTagIds) && + _cachedViewProgressFilter == progressFilter) { return _cachedViewItems!; } @@ -140,11 +145,16 @@ class TodoViewModel extends ChangeNotifier { (_normalizedTagNamesByTodoId[item.id] ?? const []).any( (name) => name.contains(normalizedQuery), ); - return matchesTag && matchesQuery; + final matchesProgress = switch (progressFilter) { + TodoProgressFilter.all => true, + TodoProgressFilter.doing => item.isDoing, + }; + return matchesTag && matchesQuery && matchesProgress; }).toList(); _cachedViewArchived = archived; _cachedViewQuery = normalizedQuery; _cachedViewTagIds = Set.unmodifiable(selectedTagIds); + _cachedViewProgressFilter = progressFilter; _cachedViewItems = List.unmodifiable(visibleItems); return _cachedViewItems!; } @@ -182,14 +192,22 @@ class TodoViewModel extends ChangeNotifier { String title, { String content = '', Iterable tagIds = const [], + TodoScheduleDraft schedule = const TodoScheduleDraft(), }) async { - return await create(title, content: content, tagIds: tagIds) != null; + return await create( + title, + content: content, + tagIds: tagIds, + schedule: schedule, + ) != + null; } Future create( String title, { String content = '', Iterable tagIds = const [], + TodoScheduleDraft schedule = const TodoScheduleDraft(), }) { final normalizedTitle = title.trim(); if (normalizedTitle.isEmpty && content.trim().isEmpty) { @@ -213,7 +231,7 @@ class TodoViewModel extends ChangeNotifier { title: resolvedTitle, content: content, createdAt: _clock().toUtc(), - ); + ).withSchedule(schedule); final updatedItems = [..._items, createdItem]; final updatedWorkspace = _workspaceWithTodoTags( todoId: todoId, @@ -238,6 +256,24 @@ class TodoViewModel extends ChangeNotifier { }); } + Future complete(String id) { + return _updateItem(id, (item) { + if (item.isArchived || item.isCompleted) { + return item; + } + return item.withCompletedAt(_clock().toUtc()); + }); + } + + Future toggleDoing(String id) { + return _updateItem(id, (item) { + if (item.isArchived || item.isCompleted) { + return item; + } + return item.withStartedAt(item.isDoing ? null : _clock().toUtc()); + }); + } + Future rename(String id, String title) async { final normalizedTitle = title.trim(); if (normalizedTitle.isEmpty) { @@ -265,6 +301,7 @@ class TodoViewModel extends ChangeNotifier { required String title, required String content, Iterable? tagIds, + TodoScheduleDraft? schedule, }) { final normalizedTitle = title.trim(); if (normalizedTitle.isEmpty && content.trim().isEmpty) { @@ -292,7 +329,10 @@ class TodoViewModel extends ChangeNotifier { final todoChanged = existingItem.title != resolvedTitle || - existingItem.content != content; + existingItem.content != content || + (schedule != null && + TodoScheduleDraft.fromItem(existingItem) != + schedule.normalized()); final tagsChanged = !listEquals(tagIdsForTodo(id), normalizedTagIds); if (!todoChanged && !tagsChanged) { return true; @@ -300,10 +340,14 @@ class TodoViewModel extends ChangeNotifier { final updatedItems = List.of(_items); if (todoChanged) { - updatedItems[existingIndex] = existingItem.withDetails( + var updatedItem = existingItem.withDetails( title: resolvedTitle, content: content, ); + if (schedule != null) { + updatedItem = updatedItem.withSchedule(schedule); + } + updatedItems[existingIndex] = updatedItem; } return _commitTodoAndTags( updatedItems: updatedItems, @@ -317,6 +361,19 @@ class TodoViewModel extends ChangeNotifier { }); } + Future updateSchedule({ + required String id, + required TodoScheduleDraft schedule, + }) { + final normalizedSchedule = schedule.normalized(); + return _updateItem(id, (item) { + if (item.isArchived) { + return item; + } + return item.withSchedule(normalizedSchedule); + }); + } + Future archive(String id) { return _updateItem(id, (item) => item.withArchivedAt(_clock().toUtc())); } @@ -325,6 +382,38 @@ class TodoViewModel extends ChangeNotifier { return _updateItem(id, (item) => item.withArchivedAt(null)); } + Future markDeadlineNotificationDelivered(String id) { + final deliveredAt = _clock().toUtc(); + return _updateItem( + id, + (item) => item.withNotificationDelivery(deadlineDeliveredAt: deliveredAt), + ); + } + + Future markReminderNotificationDelivered(String id) { + final deliveredAt = _clock().toUtc(); + return _updateItem( + id, + (item) => item.withNotificationDelivery(reminderDeliveredAt: deliveredAt), + ); + } + + Future snoozeNotification(String id, Duration duration) { + if (duration <= Duration.zero) { + return Future.value(); + } + return _updateItem( + id, + (item) => item.isArchived || item.isCompleted || item.dueAt == null + ? item + : item.withSnoozedUntil(_clock().toUtc().add(duration)), + ); + } + + Future clearSnoozedNotification(String id) { + return _updateItem(id, (item) => item.withSnoozedUntil(null)); + } + Future deletePermanently(String id) { return _enqueueTodoAndTagMutation(() async { final existingItem = itemById(id); @@ -727,6 +816,7 @@ class TodoViewModel extends ChangeNotifier { _cachedViewArchived = null; _cachedViewQuery = null; _cachedViewTagIds = const {}; + _cachedViewProgressFilter = null; _cachedViewItems = null; } diff --git a/lib/features/todos/presentation/widgets/floatick_tag_chip.dart b/lib/features/todos/presentation/widgets/floatick_tag_chip.dart index b59e5c9..1535370 100644 --- a/lib/features/todos/presentation/widgets/floatick_tag_chip.dart +++ b/lib/features/todos/presentation/widgets/floatick_tag_chip.dart @@ -59,7 +59,7 @@ class FloatickTagChip extends StatelessWidget { Colors.black.withValues(alpha: 0.22), color, ), - fontSize: compact ? 8.75 : 10.5, + fontSize: compact ? 9.25 : 10.5, fontWeight: FontWeight.w600, height: 1, ), diff --git a/lib/features/todos/presentation/widgets/tag_menus.dart b/lib/features/todos/presentation/widgets/tag_menus.dart index 77763b6..3e59008 100644 --- a/lib/features/todos/presentation/widgets/tag_menus.dart +++ b/lib/features/todos/presentation/widgets/tag_menus.dart @@ -35,12 +35,12 @@ class TagFilterButton extends StatelessWidget { maximumSize: const Size.square(_tagFilterButtonDimension), padding: EdgeInsets.zero, backgroundColor: isDark - ? Colors.white.withValues(alpha: 0.055) + ? theme.inputDecorationTheme.fillColor : const Color(0xFFF0F4F2), foregroundColor: theme.colorScheme.onSurface.withValues(alpha: 0.62), side: BorderSide( color: isDark - ? Colors.white.withValues(alpha: 0.06) + ? theme.colorScheme.onSurface.withValues(alpha: 0.08) : Colors.black.withValues(alpha: 0.045), ), shape: RoundedRectangleBorder( @@ -51,7 +51,7 @@ class TagFilterButton extends StatelessWidget { icon: Stack( clipBehavior: Clip.none, children: [ - const Icon(Icons.sell_outlined, size: 18), + const Icon(Icons.local_offer_outlined, size: 16), if (selectedCount > 0) Positioned( top: -7, @@ -89,8 +89,8 @@ class TagFilterButton extends StatelessWidget { clipBehavior: Clip.none, children: [ Icon( - Icons.sell_rounded, - size: 18, + Icons.local_offer_rounded, + size: 16, color: theme.colorScheme.primary, ), if (selectedCount > 0) diff --git a/lib/features/todos/presentation/widgets/todo_actions_bottom_sheet.dart b/lib/features/todos/presentation/widgets/todo_actions_bottom_sheet.dart index 9557f0d..054434e 100644 --- a/lib/features/todos/presentation/widgets/todo_actions_bottom_sheet.dart +++ b/lib/features/todos/presentation/widgets/todo_actions_bottom_sheet.dart @@ -10,7 +10,6 @@ enum TodoActionsSheetAction { edit, assignTags, archive, - removeFromStickyBoard, restore, deletePermanently, } @@ -20,7 +19,6 @@ Future showTodoActionsBottomSheet({ required String todoId, required bool archivedScope, required bool showArchiveAction, - required bool showRemoveFromStickyBoard, }) { return showFloatickModalBottomSheet( context: context, @@ -29,7 +27,6 @@ Future showTodoActionsBottomSheet({ todoId: todoId, archivedScope: archivedScope, showArchiveAction: showArchiveAction, - showRemoveFromStickyBoard: showRemoveFromStickyBoard, ); }, ); @@ -40,13 +37,11 @@ class _TodoActionsBottomSheet extends StatelessWidget { required this.todoId, required this.archivedScope, required this.showArchiveAction, - required this.showRemoveFromStickyBoard, }); final String todoId; final bool archivedScope; final bool showArchiveAction; - final bool showRemoveFromStickyBoard; @override Widget build(BuildContext context) { @@ -192,14 +187,6 @@ class _TodoActionsBottomSheet extends StatelessWidget { label: localizations.archiveTooltip, onTap: () => _select(context, TodoActionsSheetAction.archive), ), - if (showRemoveFromStickyBoard) - _TodoActionRow( - key: ValueKey('todo-action-remove-$todoId'), - icon: Icons.remove_circle_outline_rounded, - label: localizations.removeFromStickyBoardTooltip, - onTap: () => - _select(context, TodoActionsSheetAction.removeFromStickyBoard), - ), ] else ...[ _TodoActionRow( key: ValueKey('todo-action-restore-$todoId'), diff --git a/lib/features/todos/presentation/widgets/todo_copy_button.dart b/lib/features/todos/presentation/widgets/todo_copy_button.dart index 6e87ef1..8723c61 100644 --- a/lib/features/todos/presentation/widgets/todo_copy_button.dart +++ b/lib/features/todos/presentation/widgets/todo_copy_button.dart @@ -42,7 +42,7 @@ class TodoCopyButton extends StatelessWidget { final (tooltip, icon, color) = switch (status) { TodoCopyStatus.idle => ( context.l10n.copyTodoAsMarkdownTooltip, - Icons.content_copy_rounded, + Icons.content_copy_outlined, null, ), TodoCopyStatus.copied => ( diff --git a/lib/features/todos/presentation/widgets/todo_list_row.dart b/lib/features/todos/presentation/widgets/todo_list_row.dart index dd06c5a..5df7f47 100644 --- a/lib/features/todos/presentation/widgets/todo_list_row.dart +++ b/lib/features/todos/presentation/widgets/todo_list_row.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../../../core/ui/floatick_hover_motion.dart'; import '../../../../l10n/l10n.dart'; @@ -17,6 +18,7 @@ class TodoListRow extends StatefulWidget { required this.item, required this.archivedScope, required this.onToggle, + required this.onToggleDoing, required this.onOpenDetails, required this.onEdit, required this.onArchive, @@ -26,7 +28,7 @@ class TodoListRow extends StatefulWidget { this.onToggleTag, this.onOpenTagManagement, this.onOpenTagAssignment, - this.onRemoveFromStickyBoard, + this.onSetDeadline, this.onDeletePermanently, this.showArchiveAction = true, this.compact = false, @@ -37,6 +39,7 @@ class TodoListRow extends StatefulWidget { onOpenTagAssignment != null || (onToggleTag != null && onOpenTagManagement != null), ), + assert(archivedScope || onToggleDoing != null), assert(!archivedScope || onEdit == null), assert(archivedScope || onEdit != null), assert(archivedScope || onDeletePermanently == null), @@ -45,6 +48,7 @@ class TodoListRow extends StatefulWidget { final TodoItem item; final bool archivedScope; final VoidCallback onToggle; + final VoidCallback? onToggleDoing; final VoidCallback onOpenDetails; final VoidCallback? onEdit; final VoidCallback onArchive; @@ -54,7 +58,7 @@ class TodoListRow extends StatefulWidget { final Future Function(String tagId)? onToggleTag; final VoidCallback? onOpenTagManagement; final VoidCallback? onOpenTagAssignment; - final VoidCallback? onRemoveFromStickyBoard; + final VoidCallback? onSetDeadline; final VoidCallback? onDeletePermanently; final bool showArchiveAction; final bool compact; @@ -153,7 +157,6 @@ class _TodoListRowState extends State { todoId: widget.item.id, archivedScope: widget.archivedScope, showArchiveAction: widget.showArchiveAction, - showRemoveFromStickyBoard: widget.onRemoveFromStickyBoard != null, ); if (!mounted || action == null) { return; @@ -175,9 +178,6 @@ class _TodoListRowState extends State { case TodoActionsSheetAction.archive: widget.onArchive(); return; - case TodoActionsSheetAction.removeFromStickyBoard: - widget.onRemoveFromStickyBoard?.call(); - return; case TodoActionsSheetAction.restore: widget.onRestore(); return; @@ -196,6 +196,16 @@ class _TodoListRowState extends State { final onSurface = theme.colorScheme.onSurface; final reduceMotion = MediaQuery.disableAnimationsOf(context); final showContextActions = (widget.hoverEnabled && _isHovered) || _hasFocus; + final isDoing = item.isDoing; + final rowColor = isDoing + ? theme.colorScheme.primary.withValues( + alpha: isDark ? (_isHovered ? 0.11 : 0.08) : 0.07, + ) + : _isHovered + ? (isDark + ? Colors.white.withValues(alpha: 0.055) + : Colors.black.withValues(alpha: 0.035)) + : Colors.transparent; return GestureDetector( behavior: HitTestBehavior.translucent, @@ -212,11 +222,14 @@ class _TodoListRowState extends State { label: item.title, value: item.isCompleted ? localizations.completedStatus + : isDoing + ? localizations.doingStatus : localizations.incompleteStatus, child: MouseRegion( onEnter: widget.hoverEnabled ? (_) => _setHovered(true) : null, onExit: (_) => _setHovered(false), child: AnimatedContainer( + key: ValueKey('todo-row-surface-${widget.item.id}'), duration: reduceMotion || !widget.hoverEnabled ? Duration.zero : const Duration(milliseconds: 150), @@ -228,11 +241,7 @@ class _TodoListRowState extends State { widget.compact ? 6 : 8, ), decoration: BoxDecoration( - color: _isHovered - ? (isDark - ? Colors.white.withValues(alpha: 0.055) - : Colors.black.withValues(alpha: 0.035)) - : Colors.transparent, + color: rowColor, borderRadius: BorderRadius.circular(11), ), child: Column( @@ -327,6 +336,9 @@ class _TodoListRowState extends State { alpha: item.isCompleted ? 0.45 : 0.91, ), fontSize: widget.compact ? 12.5 : 13.5, + fontWeight: item.isCompleted + ? FontWeight.w400 + : FontWeight.w500, height: 1.3, decoration: item.isCompleted ? TextDecoration.lineThrough @@ -343,10 +355,54 @@ class _TodoListRowState extends State { ), const SizedBox(width: 3), SizedBox( - width: 60, + width: widget.archivedScope + ? 60 + : widget.onSetDeadline == null + ? 90 + : 120, child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ + if (!widget.archivedScope) + _HoverAction( + key: ValueKey( + 'toggle-doing-todo-${widget.item.id}', + ), + visible: + !item.isCompleted && showContextActions, + semanticLabel: isDoing + ? localizations.stopDoingTooltip + : localizations.startDoingTooltip, + onPressed: widget.onToggleDoing!, + icon: isDoing + ? Icons.pause_rounded + : Symbols.play_arrow, + foregroundColor: isDoing + ? theme.colorScheme.primary + : null, + ), + if (!widget.archivedScope && + widget.onSetDeadline != null) + _HoverAction( + key: ValueKey( + 'deadline-todo-${widget.item.id}', + ), + visible: + !item.isCompleted && showContextActions, + semanticLabel: _deadlineActionTooltip( + context, + hasDeadline: item.dueAt != null, + ), + tooltip: _deadlineActionTooltip( + context, + hasDeadline: item.dueAt != null, + ), + onPressed: widget.onSetDeadline!, + icon: Symbols.alarm, + foregroundColor: item.dueAt == null + ? null + : theme.colorScheme.primary, + ), TodoCopyButton( key: ValueKey( 'copy-todo-${widget.item.id}', @@ -360,6 +416,8 @@ class _TodoListRowState extends State { 'more-todo-${widget.item.id}', ), visible: showContextActions, + semanticLabel: + localizations.moreTodoActionsTooltip, tooltip: localizations.moreTodoActionsTooltip, onPressed: () => unawaited(_showActions()), icon: Icons.more_horiz_rounded, @@ -385,10 +443,15 @@ class _TodoListRowState extends State { assignedTagIds: widget.assignedTagIds, ) : _ExternalTagAssignment( + item: item, todoId: item.id, tags: widget.tags, assignedTagIds: widget.assignedTagIds, onPressed: _openTagAssignment, + onSetDeadline: item.isCompleted + ? null + : widget.onSetDeadline, + showDoingStatus: isDoing, ), ), const SizedBox(width: 7), @@ -401,8 +464,9 @@ class _TodoListRowState extends State { ), key: ValueKey('todo-time-${widget.item.id}'), style: TextStyle( - color: onSurface.withValues(alpha: 0.35), + color: onSurface.withValues(alpha: 0.45), fontSize: 10.5, + fontWeight: FontWeight.w400, ), ), ], @@ -419,16 +483,22 @@ class _TodoListRowState extends State { class _ExternalTagAssignment extends StatelessWidget { const _ExternalTagAssignment({ + required this.item, required this.todoId, required this.tags, required this.assignedTagIds, required this.onPressed, + required this.onSetDeadline, + required this.showDoingStatus, }); + final TodoItem item; final String todoId; final List tags; final List assignedTagIds; final VoidCallback onPressed; + final VoidCallback? onSetDeadline; + final bool showDoingStatus; @override Widget build(BuildContext context) { @@ -437,34 +507,167 @@ class _ExternalTagAssignment extends StatelessWidget { .where((tag) => assignedIds.contains(tag.id)) .toList(growable: false); - return Wrap( - spacing: 4, - runSpacing: 3, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - for (final tag in assignedTags) - FloatickTagChip( - key: ValueKey('todo-tag-$todoId-${tag.id}'), - tag: tag, - compact: true, + return SizedBox( + height: 20, + child: ClipRect( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + if (showDoingStatus) ...[ + _DoingStatusChip(todoId: todoId), + const SizedBox(width: 4), + ], + if (item.dueAt != null) ...[ + _DeadlineStatusChip(item: item, onPressed: onSetDeadline), + const SizedBox(width: 4), + ], + for (final tag in assignedTags) ...[ + FloatickTagChip( + key: ValueKey('todo-tag-$todoId-${tag.id}'), + tag: tag, + compact: true, + ), + const SizedBox(width: 4), + ], + SizedBox.square( + dimension: 20, + child: IconButton( + key: ValueKey('assign-tags-$todoId'), + tooltip: context.l10n.assignTagsTooltip, + onPressed: onPressed, + padding: EdgeInsets.zero, + icon: Icon( + assignedTags.isEmpty + ? Icons.local_offer_outlined + : Icons.local_offer_rounded, + size: 12, + color: assignedTags.isEmpty + ? null + : Theme.of(context).colorScheme.primary, + ), + ), + ), + ], ), - SizedBox.square( - dimension: 20, - child: IconButton( - key: ValueKey('assign-tags-$todoId'), - tooltip: context.l10n.assignTagsTooltip, - onPressed: onPressed, - padding: EdgeInsets.zero, - icon: Icon( - assignedTags.isEmpty ? Icons.sell_outlined : Icons.sell_rounded, - size: 13, - color: assignedTags.isEmpty - ? null - : Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} + +class _DeadlineStatusChip extends StatelessWidget { + const _DeadlineStatusChip({required this.item, required this.onPressed}); + + final TodoItem item; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final dueAt = item.dueAt!.toLocal(); + final now = DateTime.now(); + final theme = Theme.of(context); + final isOverdue = !item.isCompleted && now.isAfter(dueAt); + final isSoon = + !isOverdue && dueAt.difference(now) <= const Duration(hours: 24); + final color = item.isCompleted + ? theme.colorScheme.onSurface.withValues(alpha: 0.45) + : isOverdue + ? theme.colorScheme.error + : isSoon + ? const Color(0xFFD79A2B) + : theme.colorScheme.primary; + final isChinese = Localizations.localeOf(context).languageCode == 'zh'; + final label = isOverdue + ? (isChinese ? '已逾期' : 'Overdue') + : _isSameDay(now, dueAt) + ? '${isChinese ? '今天' : 'Today'} ${_formatTime(context, dueAt)}' + : MaterialLocalizations.of(context).formatShortDate(dueAt); + final chip = Container( + key: ValueKey('deadline-status-${item.id}'), + height: 17, + padding: const EdgeInsets.symmetric(horizontal: 6), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: color.withValues(alpha: 0.24)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.schedule_rounded, size: 9.5, color: color), + const SizedBox(width: 3), + Text( + label, + style: TextStyle( + color: color, + fontSize: 9.25, + fontWeight: FontWeight.w600, + height: 1, ), ), + ], + ), + ); + final handlePressed = onPressed; + if (handlePressed == null) { + return chip; + } + return Tooltip( + message: _deadlineActionTooltip(context, hasDeadline: true), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: handlePressed, + child: chip, ), - ], + ), + ); + } +} + +class _DoingStatusChip extends StatelessWidget { + const _DoingStatusChip({required this.todoId}); + + final String todoId; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final color = theme.colorScheme.primary; + final isDark = theme.brightness == Brightness.dark; + return Container( + key: ValueKey('doing-status-$todoId'), + height: 17, + padding: const EdgeInsets.symmetric(horizontal: 6), + decoration: BoxDecoration( + color: color.withValues(alpha: isDark ? 0.16 : 0.10), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: color.withValues(alpha: isDark ? 0.28 : 0.22), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 4, + height: 4, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 4), + Text( + context.l10n.doingStatus, + style: TextStyle( + color: color, + fontSize: 9.25, + fontWeight: FontWeight.w600, + height: 1, + ), + ), + ], + ), ); } } @@ -504,16 +707,20 @@ class _ReadOnlyTodoTags extends StatelessWidget { class _HoverAction extends StatelessWidget { const _HoverAction({ required this.visible, - required this.tooltip, + required this.semanticLabel, required this.onPressed, required this.icon, + this.foregroundColor, + this.tooltip, super.key, }); final bool visible; - final String tooltip; + final String semanticLabel; final VoidCallback onPressed; final IconData icon; + final Color? foregroundColor; + final String? tooltip; @override Widget build(BuildContext context) { @@ -529,12 +736,22 @@ class _HoverAction extends StatelessWidget { ignoring: !visible, child: ExcludeFocus( excluding: !visible, - child: IconButton( - tooltip: tooltip, - onPressed: onPressed, - padding: EdgeInsets.zero, - icon: Icon(icon, size: 17), - ), + child: tooltip == null + ? Semantics( + button: true, + label: semanticLabel, + child: IconButton( + onPressed: onPressed, + padding: EdgeInsets.zero, + icon: Icon(icon, size: 17, color: foregroundColor), + ), + ) + : IconButton( + tooltip: tooltip, + onPressed: onPressed, + padding: EdgeInsets.zero, + icon: Icon(icon, size: 17, color: foregroundColor), + ), ), ), ), @@ -549,3 +766,20 @@ String _formatTime(BuildContext context, DateTime date) { alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context), ); } + +bool _isSameDay(DateTime left, DateTime right) { + return left.year == right.year && + left.month == right.month && + left.day == right.day; +} + +String _deadlineActionTooltip( + BuildContext context, { + required bool hasDeadline, +}) { + final isChinese = Localizations.localeOf(context).languageCode == 'zh'; + if (hasDeadline) { + return isChinese ? '编辑截止时间' : 'Edit deadline'; + } + return isChinese ? '设置截止时间' : 'Set deadline'; +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index fd88382..cc08a5f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -106,51 +106,6 @@ } }, "settingsTooltip": "Settings", - "stickyBoardsTitle": "Sticky Boards", - "stickyBoardsTooltip": "Open Sticky Boards", - "closeStickyBoardsTooltip": "Close Sticky Boards", - "backToStickyBoardsTooltip": "Back to Sticky Boards", - "searchOrCreateStickyBoardHint": "Search or create a sticky board", - "createStickyBoardTooltip": "Create sticky board", - "stickyBoardNameRequiredMessage": "Enter a sticky board name.", - "stickyBoardNameTooLongMessage": "Use no more than {maxLength} characters.", - "@stickyBoardNameTooLongMessage": { - "placeholders": { - "maxLength": { - "type": "int" - } - } - }, - "duplicateStickyBoardNameMessage": "A sticky board with this name already exists.", - "stickyBoardNotFoundMessage": "This sticky board no longer exists.", - "stickyBoardStorageFailureMessage": "Couldn't save sticky board changes.", - "stickyBoardTodoCount": "{count, plural, =1{1 todo} other{{count} todos}}", - "@stickyBoardTodoCount": { - "placeholders": { - "count": { - "type": "int" - } - } - }, - "emptyStickyBoardsTitle": "Create your first sticky board", - "emptyStickyBoardsMessage": "Group related todos without moving or duplicating them.", - "noMatchingStickyBoardsMessage": "No matching sticky boards. Press Return to create this one.", - "renameStickyBoardTooltip": "Rename sticky board", - "deleteStickyBoardTooltip": "Delete sticky board", - "deleteStickyBoardTitle": "Delete this sticky board?", - "deleteStickyBoardMessage": "Its todos will stay safely in All Todos.", - "keepStickyBoardAction": "Keep sticky board", - "pinStickyBoardTooltip": "Pin to desktop", - "unpinStickyBoardTooltip": "Unpin from desktop", - "stickyBoardPinnedLabel": "Pinned", - "addExistingTodoAction": "Add existing", - "addExistingTodoTitle": "Add existing todos", - "searchTodosToAddHint": "Search todos to add", - "noTodosAvailableForBoardMessage": "No todos are available to add.", - "emptyPinnedStickyBoardMessage": "No todos on this board.", - "newTodoInStickyBoardAction": "New todo", - "removeFromStickyBoardTooltip": "Remove from sticky board", - "stickyBoardDeleteKeepsTodosHint": "Deleting a sticky board never deletes its todos.", "collapseTooltip": "Collapse (Esc)", "activeScopeLabel": "Todos", "archiveScopeLabel": "Archive", @@ -185,6 +140,13 @@ "dismissErrorTooltip": "Dismiss error", "completedStatus": "Completed", "incompleteStatus": "Incomplete", + "doingStatus": "Doing", + "filterDoingTooltip": "Show doing todos", + "clearDoingFilterTooltip": "Show all todos", + "emptyDoingTodosTitle": "Nothing in progress", + "emptyDoingTodosMessage": "Start a todo when you're ready to focus", + "startDoingTooltip": "Start doing", + "stopDoingTooltip": "Return to todo", "markIncompleteTooltip": "Mark incomplete", "markCompleteTooltip": "Mark complete", "editTooltip": "Edit", @@ -230,7 +192,6 @@ "noteEmptyDraftHint": "Blank notes are not saved", "finishNoteAction": "Done", "pinnedNotesLabel": "Pinned", - "recentNotesLabel": "Recently edited", "noteWithoutContent": "No content yet", "pinNoteTooltip": "Pin note", "unpinNoteTooltip": "Unpin note", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 6b2c3e7..19f88cb 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -524,192 +524,6 @@ abstract class AppLocalizations { /// **'Settings'** String get settingsTooltip; - /// No description provided for @stickyBoardsTitle. - /// - /// In en, this message translates to: - /// **'Sticky Boards'** - String get stickyBoardsTitle; - - /// No description provided for @stickyBoardsTooltip. - /// - /// In en, this message translates to: - /// **'Open Sticky Boards'** - String get stickyBoardsTooltip; - - /// No description provided for @closeStickyBoardsTooltip. - /// - /// In en, this message translates to: - /// **'Close Sticky Boards'** - String get closeStickyBoardsTooltip; - - /// No description provided for @backToStickyBoardsTooltip. - /// - /// In en, this message translates to: - /// **'Back to Sticky Boards'** - String get backToStickyBoardsTooltip; - - /// No description provided for @searchOrCreateStickyBoardHint. - /// - /// In en, this message translates to: - /// **'Search or create a sticky board'** - String get searchOrCreateStickyBoardHint; - - /// No description provided for @createStickyBoardTooltip. - /// - /// In en, this message translates to: - /// **'Create sticky board'** - String get createStickyBoardTooltip; - - /// No description provided for @stickyBoardNameRequiredMessage. - /// - /// In en, this message translates to: - /// **'Enter a sticky board name.'** - String get stickyBoardNameRequiredMessage; - - /// No description provided for @stickyBoardNameTooLongMessage. - /// - /// In en, this message translates to: - /// **'Use no more than {maxLength} characters.'** - String stickyBoardNameTooLongMessage(int maxLength); - - /// No description provided for @duplicateStickyBoardNameMessage. - /// - /// In en, this message translates to: - /// **'A sticky board with this name already exists.'** - String get duplicateStickyBoardNameMessage; - - /// No description provided for @stickyBoardNotFoundMessage. - /// - /// In en, this message translates to: - /// **'This sticky board no longer exists.'** - String get stickyBoardNotFoundMessage; - - /// No description provided for @stickyBoardStorageFailureMessage. - /// - /// In en, this message translates to: - /// **'Couldn\'t save sticky board changes.'** - String get stickyBoardStorageFailureMessage; - - /// No description provided for @stickyBoardTodoCount. - /// - /// In en, this message translates to: - /// **'{count, plural, =1{1 todo} other{{count} todos}}'** - String stickyBoardTodoCount(int count); - - /// No description provided for @emptyStickyBoardsTitle. - /// - /// In en, this message translates to: - /// **'Create your first sticky board'** - String get emptyStickyBoardsTitle; - - /// No description provided for @emptyStickyBoardsMessage. - /// - /// In en, this message translates to: - /// **'Group related todos without moving or duplicating them.'** - String get emptyStickyBoardsMessage; - - /// No description provided for @noMatchingStickyBoardsMessage. - /// - /// In en, this message translates to: - /// **'No matching sticky boards. Press Return to create this one.'** - String get noMatchingStickyBoardsMessage; - - /// No description provided for @renameStickyBoardTooltip. - /// - /// In en, this message translates to: - /// **'Rename sticky board'** - String get renameStickyBoardTooltip; - - /// No description provided for @deleteStickyBoardTooltip. - /// - /// In en, this message translates to: - /// **'Delete sticky board'** - String get deleteStickyBoardTooltip; - - /// No description provided for @deleteStickyBoardTitle. - /// - /// In en, this message translates to: - /// **'Delete this sticky board?'** - String get deleteStickyBoardTitle; - - /// No description provided for @deleteStickyBoardMessage. - /// - /// In en, this message translates to: - /// **'Its todos will stay safely in All Todos.'** - String get deleteStickyBoardMessage; - - /// No description provided for @keepStickyBoardAction. - /// - /// In en, this message translates to: - /// **'Keep sticky board'** - String get keepStickyBoardAction; - - /// No description provided for @pinStickyBoardTooltip. - /// - /// In en, this message translates to: - /// **'Pin to desktop'** - String get pinStickyBoardTooltip; - - /// No description provided for @unpinStickyBoardTooltip. - /// - /// In en, this message translates to: - /// **'Unpin from desktop'** - String get unpinStickyBoardTooltip; - - /// No description provided for @stickyBoardPinnedLabel. - /// - /// In en, this message translates to: - /// **'Pinned'** - String get stickyBoardPinnedLabel; - - /// No description provided for @addExistingTodoAction. - /// - /// In en, this message translates to: - /// **'Add existing'** - String get addExistingTodoAction; - - /// No description provided for @addExistingTodoTitle. - /// - /// In en, this message translates to: - /// **'Add existing todos'** - String get addExistingTodoTitle; - - /// No description provided for @searchTodosToAddHint. - /// - /// In en, this message translates to: - /// **'Search todos to add'** - String get searchTodosToAddHint; - - /// No description provided for @noTodosAvailableForBoardMessage. - /// - /// In en, this message translates to: - /// **'No todos are available to add.'** - String get noTodosAvailableForBoardMessage; - - /// No description provided for @emptyPinnedStickyBoardMessage. - /// - /// In en, this message translates to: - /// **'No todos on this board.'** - String get emptyPinnedStickyBoardMessage; - - /// No description provided for @newTodoInStickyBoardAction. - /// - /// In en, this message translates to: - /// **'New todo'** - String get newTodoInStickyBoardAction; - - /// No description provided for @removeFromStickyBoardTooltip. - /// - /// In en, this message translates to: - /// **'Remove from sticky board'** - String get removeFromStickyBoardTooltip; - - /// No description provided for @stickyBoardDeleteKeepsTodosHint. - /// - /// In en, this message translates to: - /// **'Deleting a sticky board never deletes its todos.'** - String get stickyBoardDeleteKeepsTodosHint; - /// No description provided for @collapseTooltip. /// /// In en, this message translates to: @@ -914,6 +728,48 @@ abstract class AppLocalizations { /// **'Incomplete'** String get incompleteStatus; + /// No description provided for @doingStatus. + /// + /// In en, this message translates to: + /// **'Doing'** + String get doingStatus; + + /// No description provided for @filterDoingTooltip. + /// + /// In en, this message translates to: + /// **'Show doing todos'** + String get filterDoingTooltip; + + /// No description provided for @clearDoingFilterTooltip. + /// + /// In en, this message translates to: + /// **'Show all todos'** + String get clearDoingFilterTooltip; + + /// No description provided for @emptyDoingTodosTitle. + /// + /// In en, this message translates to: + /// **'Nothing in progress'** + String get emptyDoingTodosTitle; + + /// No description provided for @emptyDoingTodosMessage. + /// + /// In en, this message translates to: + /// **'Start a todo when you\'re ready to focus'** + String get emptyDoingTodosMessage; + + /// No description provided for @startDoingTooltip. + /// + /// In en, this message translates to: + /// **'Start doing'** + String get startDoingTooltip; + + /// No description provided for @stopDoingTooltip. + /// + /// In en, this message translates to: + /// **'Return to todo'** + String get stopDoingTooltip; + /// No description provided for @markIncompleteTooltip. /// /// In en, this message translates to: @@ -1142,12 +998,6 @@ abstract class AppLocalizations { /// **'Pinned'** String get pinnedNotesLabel; - /// No description provided for @recentNotesLabel. - /// - /// In en, this message translates to: - /// **'Recently edited'** - String get recentNotesLabel; - /// No description provided for @noteWithoutContent. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 45c1a98..df50576 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -250,117 +250,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settingsTooltip => 'Settings'; - @override - String get stickyBoardsTitle => 'Sticky Boards'; - - @override - String get stickyBoardsTooltip => 'Open Sticky Boards'; - - @override - String get closeStickyBoardsTooltip => 'Close Sticky Boards'; - - @override - String get backToStickyBoardsTooltip => 'Back to Sticky Boards'; - - @override - String get searchOrCreateStickyBoardHint => 'Search or create a sticky board'; - - @override - String get createStickyBoardTooltip => 'Create sticky board'; - - @override - String get stickyBoardNameRequiredMessage => 'Enter a sticky board name.'; - - @override - String stickyBoardNameTooLongMessage(int maxLength) { - return 'Use no more than $maxLength characters.'; - } - - @override - String get duplicateStickyBoardNameMessage => - 'A sticky board with this name already exists.'; - - @override - String get stickyBoardNotFoundMessage => - 'This sticky board no longer exists.'; - - @override - String get stickyBoardStorageFailureMessage => - 'Couldn\'t save sticky board changes.'; - - @override - String stickyBoardTodoCount(int count) { - String _temp0 = intl.Intl.pluralLogic( - count, - locale: localeName, - other: '$count todos', - one: '1 todo', - ); - return '$_temp0'; - } - - @override - String get emptyStickyBoardsTitle => 'Create your first sticky board'; - - @override - String get emptyStickyBoardsMessage => - 'Group related todos without moving or duplicating them.'; - - @override - String get noMatchingStickyBoardsMessage => - 'No matching sticky boards. Press Return to create this one.'; - - @override - String get renameStickyBoardTooltip => 'Rename sticky board'; - - @override - String get deleteStickyBoardTooltip => 'Delete sticky board'; - - @override - String get deleteStickyBoardTitle => 'Delete this sticky board?'; - - @override - String get deleteStickyBoardMessage => - 'Its todos will stay safely in All Todos.'; - - @override - String get keepStickyBoardAction => 'Keep sticky board'; - - @override - String get pinStickyBoardTooltip => 'Pin to desktop'; - - @override - String get unpinStickyBoardTooltip => 'Unpin from desktop'; - - @override - String get stickyBoardPinnedLabel => 'Pinned'; - - @override - String get addExistingTodoAction => 'Add existing'; - - @override - String get addExistingTodoTitle => 'Add existing todos'; - - @override - String get searchTodosToAddHint => 'Search todos to add'; - - @override - String get noTodosAvailableForBoardMessage => - 'No todos are available to add.'; - - @override - String get emptyPinnedStickyBoardMessage => 'No todos on this board.'; - - @override - String get newTodoInStickyBoardAction => 'New todo'; - - @override - String get removeFromStickyBoardTooltip => 'Remove from sticky board'; - - @override - String get stickyBoardDeleteKeepsTodosHint => - 'Deleting a sticky board never deletes its todos.'; - @override String get collapseTooltip => 'Collapse (Esc)'; @@ -464,6 +353,28 @@ class AppLocalizationsEn extends AppLocalizations { @override String get incompleteStatus => 'Incomplete'; + @override + String get doingStatus => 'Doing'; + + @override + String get filterDoingTooltip => 'Show doing todos'; + + @override + String get clearDoingFilterTooltip => 'Show all todos'; + + @override + String get emptyDoingTodosTitle => 'Nothing in progress'; + + @override + String get emptyDoingTodosMessage => + 'Start a todo when you\'re ready to focus'; + + @override + String get startDoingTooltip => 'Start doing'; + + @override + String get stopDoingTooltip => 'Return to todo'; + @override String get markIncompleteTooltip => 'Mark incomplete'; @@ -586,9 +497,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get pinnedNotesLabel => 'Pinned'; - @override - String get recentNotesLabel => 'Recently edited'; - @override String get noteWithoutContent => 'No content yet'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 3c6b96e..4424ab1 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -236,103 +236,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settingsTooltip => '设置'; - @override - String get stickyBoardsTitle => '便利板'; - - @override - String get stickyBoardsTooltip => '打开便利板'; - - @override - String get closeStickyBoardsTooltip => '关闭便利板'; - - @override - String get backToStickyBoardsTooltip => '返回便利板'; - - @override - String get searchOrCreateStickyBoardHint => '搜索或创建便利板'; - - @override - String get createStickyBoardTooltip => '创建便利板'; - - @override - String get stickyBoardNameRequiredMessage => '请输入便利板名称。'; - - @override - String stickyBoardNameTooLongMessage(int maxLength) { - return '名称最多 $maxLength 个字符。'; - } - - @override - String get duplicateStickyBoardNameMessage => '已经存在同名便利板。'; - - @override - String get stickyBoardNotFoundMessage => '这个便利板已不存在。'; - - @override - String get stickyBoardStorageFailureMessage => '无法保存便利板修改。'; - - @override - String stickyBoardTodoCount(int count) { - return '$count 项待办'; - } - - @override - String get emptyStickyBoardsTitle => '创建第一个便利板'; - - @override - String get emptyStickyBoardsMessage => '无需移动或复制待办,也能把相关事项放在一起。'; - - @override - String get noMatchingStickyBoardsMessage => '没有匹配的便利板,按 Return 可直接创建。'; - - @override - String get renameStickyBoardTooltip => '重命名便利板'; - - @override - String get deleteStickyBoardTooltip => '删除便利板'; - - @override - String get deleteStickyBoardTitle => '删除这个便利板?'; - - @override - String get deleteStickyBoardMessage => '其中的待办仍会安全保留在全部待办中。'; - - @override - String get keepStickyBoardAction => '保留便利板'; - - @override - String get pinStickyBoardTooltip => '固定到桌面'; - - @override - String get unpinStickyBoardTooltip => '取消桌面固定'; - - @override - String get stickyBoardPinnedLabel => '已固定'; - - @override - String get addExistingTodoAction => '添加现有待办'; - - @override - String get addExistingTodoTitle => '添加现有待办'; - - @override - String get searchTodosToAddHint => '搜索可添加的待办'; - - @override - String get noTodosAvailableForBoardMessage => '暂无可添加的待办。'; - - @override - String get emptyPinnedStickyBoardMessage => '这个便利板还没有待办。'; - - @override - String get newTodoInStickyBoardAction => '新建待办'; - - @override - String get removeFromStickyBoardTooltip => '从便利板移除'; - - @override - String get stickyBoardDeleteKeepsTodosHint => '删除便利板不会删除其中的待办。'; - @override String get collapseTooltip => '收起(Esc)'; @@ -435,6 +338,27 @@ class AppLocalizationsZh extends AppLocalizations { @override String get incompleteStatus => '未完成'; + @override + String get doingStatus => '进行中'; + + @override + String get filterDoingTooltip => '只看进行中的待办'; + + @override + String get clearDoingFilterTooltip => '显示全部待办'; + + @override + String get emptyDoingTodosTitle => '暂无进行中的待办'; + + @override + String get emptyDoingTodosMessage => '开始一项待办,进入专注状态'; + + @override + String get startDoingTooltip => '开始进行'; + + @override + String get stopDoingTooltip => '退回待办'; + @override String get markIncompleteTooltip => '标记为未完成'; @@ -551,9 +475,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get pinnedNotesLabel => '置顶'; - @override - String get recentNotesLabel => '最近编辑'; - @override String get noteWithoutContent => '暂无正文'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 346af04..cac9782 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -71,37 +71,6 @@ "allClearToday": "今天已经清空", "activeTodoCount": "{count, plural, other{{count} 项待完成}}", "settingsTooltip": "设置", - "stickyBoardsTitle": "便利板", - "stickyBoardsTooltip": "打开便利板", - "closeStickyBoardsTooltip": "关闭便利板", - "backToStickyBoardsTooltip": "返回便利板", - "searchOrCreateStickyBoardHint": "搜索或创建便利板", - "createStickyBoardTooltip": "创建便利板", - "stickyBoardNameRequiredMessage": "请输入便利板名称。", - "stickyBoardNameTooLongMessage": "名称最多 {maxLength} 个字符。", - "duplicateStickyBoardNameMessage": "已经存在同名便利板。", - "stickyBoardNotFoundMessage": "这个便利板已不存在。", - "stickyBoardStorageFailureMessage": "无法保存便利板修改。", - "stickyBoardTodoCount": "{count} 项待办", - "emptyStickyBoardsTitle": "创建第一个便利板", - "emptyStickyBoardsMessage": "无需移动或复制待办,也能把相关事项放在一起。", - "noMatchingStickyBoardsMessage": "没有匹配的便利板,按 Return 可直接创建。", - "renameStickyBoardTooltip": "重命名便利板", - "deleteStickyBoardTooltip": "删除便利板", - "deleteStickyBoardTitle": "删除这个便利板?", - "deleteStickyBoardMessage": "其中的待办仍会安全保留在全部待办中。", - "keepStickyBoardAction": "保留便利板", - "pinStickyBoardTooltip": "固定到桌面", - "unpinStickyBoardTooltip": "取消桌面固定", - "stickyBoardPinnedLabel": "已固定", - "addExistingTodoAction": "添加现有待办", - "addExistingTodoTitle": "添加现有待办", - "searchTodosToAddHint": "搜索可添加的待办", - "noTodosAvailableForBoardMessage": "暂无可添加的待办。", - "emptyPinnedStickyBoardMessage": "这个便利板还没有待办。", - "newTodoInStickyBoardAction": "新建待办", - "removeFromStickyBoardTooltip": "从便利板移除", - "stickyBoardDeleteKeepsTodosHint": "删除便利板不会删除其中的待办。", "collapseTooltip": "收起(Esc)", "activeScopeLabel": "待办", "archiveScopeLabel": "归档", @@ -136,6 +105,13 @@ "dismissErrorTooltip": "关闭错误提示", "completedStatus": "已完成", "incompleteStatus": "未完成", + "doingStatus": "进行中", + "filterDoingTooltip": "只看进行中的待办", + "clearDoingFilterTooltip": "显示全部待办", + "emptyDoingTodosTitle": "暂无进行中的待办", + "emptyDoingTodosMessage": "开始一项待办,进入专注状态", + "startDoingTooltip": "开始进行", + "stopDoingTooltip": "退回待办", "markIncompleteTooltip": "标记为未完成", "markCompleteTooltip": "标记为已完成", "editTooltip": "编辑", @@ -181,7 +157,6 @@ "noteEmptyDraftHint": "空白笔记不会保存", "finishNoteAction": "完成", "pinnedNotesLabel": "置顶", - "recentNotesLabel": "最近编辑", "noteWithoutContent": "暂无正文", "pinNoteTooltip": "置顶笔记", "unpinNoteTooltip": "取消置顶", diff --git a/lib/main.dart b/lib/main.dart index 17b20a1..2666518 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,6 @@ import 'dart:ui'; import 'package:flutter/widgets.dart'; -import 'package:multiview_desktop/multiview_desktop.dart'; import 'app/floatick_app.dart'; import 'core/platform/window_bridge.dart'; @@ -10,9 +9,6 @@ import 'features/notes/presentation/note_view_model.dart'; import 'features/settings/data/login_item_repository.dart'; import 'features/settings/data/settings_repository.dart'; import 'features/settings/presentation/settings_view_model.dart'; -import 'features/sticky_boards/data/sticky_board_repository.dart'; -import 'features/sticky_boards/presentation/sticky_board_view_model.dart'; -import 'features/sticky_boards/presentation/sticky_board_window_coordinator.dart'; import 'features/todos/data/first_run_workspace_seeder.dart'; import 'features/todos/data/tag_repository.dart'; import 'features/todos/data/todo_repository.dart'; @@ -50,31 +46,21 @@ Future main() async { final updateController = UpdateViewModel( updateRepository: MethodChannelUpdateRepository(), ); - final stickyBoardController = StickyBoardViewModel( - repository: LocalStickyBoardRepository(), - ); await Future.wait(>[ controller.load(), noteController.load(), settingsController.load(), updateController.load(), - stickyBoardController.load(), ]); - final stickyBoardWindowCoordinator = StickyBoardWindowCoordinator( - boardController: stickyBoardController, - todoController: controller, - windowBridge: windowBridge, - ); - runMultiApp( - home: (context, viewId) => FloatickApp( + runApp( + FloatickApp( controller: controller, noteController: noteController, settingsController: settingsController, updateController: updateController, - stickyBoardController: stickyBoardController, - stickyBoardWindowCoordinator: stickyBoardWindowCoordinator, windowBridge: windowBridge, + deadlineReminderBridge: windowBridge, ), ); } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index fcff8df..cccf817 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,8 +5,6 @@ import FlutterMacOS import Foundation -import multiview_desktop func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - MultiviewDesktopPlugin.register(with: registry.registrar(forPlugin: "MultiviewDesktopPlugin")) } diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift index 9565f87..ff6e7bb 100644 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -1,6 +1,5 @@ import Cocoa import FlutterMacOS -import multiview_desktop @main class AppDelegate: FlutterAppDelegate { @@ -21,28 +20,12 @@ class AppDelegate: FlutterAppDelegate { { return mainWindow.handleApplicationReopen() } - if MultiviewDesktopPlugin.applicationShouldHandleReopen( - sender, - hasVisibleWindows: flag - ) { - return true - } return super.applicationShouldHandleReopen( sender, hasVisibleWindows: flag ) } - override func applicationShouldTerminate( - _ sender: NSApplication - ) -> NSApplication.TerminateReply { - return MultiviewDesktopPlugin.applicationShouldTerminate(sender) - } - - override func applicationDockMenu(_ sender: NSApplication) -> NSMenu? { - return MultiviewDesktopPlugin.applicationDockMenu(sender) - } - override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { return true } diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index 308eb05..480b2da 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -1,6 +1,5 @@ import Cocoa import FlutterMacOS -import multiview_desktop final class MainFlutterWindow: NSWindow { private enum Layout { @@ -54,9 +53,8 @@ final class MainFlutterWindow: NSWindow { private var appliedAlwaysOnTop: Bool? private var preferredAppearance = PreferredAppearance.system private var isCollapseRequestPending = false - private var secondaryWindowKeyObserver: NSObjectProtocol? - private let configuredSecondaryWindows = NSHashTable.weakObjects() - private let initialSecondaryWindows = NSHashTable.weakObjects() + private var deadlineReminderController: DeadlineReminderPanelController? + private var deadlineReminderNativeScheduler: DeadlineReminderNativeScheduler? override var canBecomeKey: Bool { true } override var canBecomeMain: Bool { true } @@ -119,7 +117,7 @@ final class MainFlutterWindow: NSWindow { project: nil, allowHeadlessExecution: true ) - MultiviewDesktopPlugin.prepareEngine(engine, window: self) + engine.run(withEntrypoint: nil) let flutterViewController = FlutterViewController( engine: engine, nibName: nil, @@ -138,7 +136,6 @@ final class MainFlutterWindow: NSWindow { configureWindowChannel(for: flutterViewController) configureUpdateService(for: flutterViewController) configureLoginItemService(for: flutterViewController) - observeInitialSecondaryWindowPresentation() let origin = restoredCollapsedOrigin() ?? defaultCollapsedOrigin() collapsedOrigin = clampedOrigin( @@ -300,64 +297,48 @@ final class MainFlutterWindow: NSWindow { } self.setAlwaysOnTop(alwaysOnTop) result(nil) - case "configureBorderlessSecondaryWindow": + case "showDeadlineReminder": guard let arguments = call.arguments as? [String: Any], - let viewIdentifier = (arguments["viewId"] as? NSNumber)?.int64Value, - let positionAdjacentToMainWindow = - arguments["positionAdjacentToMainWindow"] as? Bool + let payload = DeadlineReminderPayload(arguments: arguments) else { result( FlutterError( code: "invalid_argument", - message: - "configureBorderlessSecondaryWindow expects a view ID and positioning preference.", + message: "showDeadlineReminder expects a valid reminder payload.", details: nil ) ) return } - guard self.configureBorderlessSecondaryWindow( - viewIdentifier: viewIdentifier, - positionAdjacentToMainWindow: positionAdjacentToMainWindow - ) else { - result( - FlutterError( - code: "window_unavailable", - message: "The secondary Flutter window could not be found.", - details: viewIdentifier - ) - ) - return - } + self.ensureDeadlineReminderController().enqueue(payload) result(nil) - case "revealBorderlessSecondaryWindow": - guard - let viewIdentifier = (call.arguments as? NSNumber)?.int64Value - else { + case "synchronizeScheduledDeadlineReminders": + guard let rawEntries = call.arguments as? [[String: Any]] else { result( FlutterError( code: "invalid_argument", message: - "revealBorderlessSecondaryWindow expects a view ID.", + "synchronizeScheduledDeadlineReminders expects a list.", details: nil ) ) return } - guard self.revealBorderlessSecondaryWindow( - viewIdentifier: viewIdentifier - ) else { + let entries = rawEntries.compactMap( + ScheduledDeadlineReminderEntry.init(arguments:) + ) + guard entries.count == rawEntries.count else { result( FlutterError( - code: "window_unavailable", - message: - "The configured secondary Flutter window could not be found.", - details: viewIdentifier + code: "invalid_argument", + message: "A scheduled deadline reminder is invalid.", + details: nil ) ) return } + self.ensureDeadlineReminderNativeScheduler().replace(with: entries) result(nil) default: result(FlutterMethodNotImplemented) @@ -366,208 +347,59 @@ final class MainFlutterWindow: NSWindow { windowChannel = channel } - private func configureBorderlessSecondaryWindow( - viewIdentifier: Int64, - positionAdjacentToMainWindow: Bool - ) -> Bool { - guard - let targetWindow = NSApp.windows.first(where: { window in - guard - window !== self, - let controller = self.flutterViewController(in: window) - else { - return false - } - return controller.viewIdentifier == viewIdentifier - }), - let flutterViewController = flutterViewController(in: targetWindow) - else { - return false + private func ensureDeadlineReminderController() + -> DeadlineReminderPanelController + { + if let deadlineReminderController { + return deadlineReminderController } - - targetWindow.alphaValue = 0 - configureTransparentRoundedWindow( - targetWindow, - flutterViewController: flutterViewController - ) - let existingFrame = targetWindow.frame - targetWindow.styleMask = [.borderless, .resizable] - targetWindow.setFrame(existingFrame, display: false) - targetWindow.preservesContentDuringLiveResize = true - targetWindow.contentView?.layerContentsRedrawPolicy = .onSetNeedsDisplay - targetWindow.contentView?.layerContentsPlacement = .scaleAxesIndependently - targetWindow.appearance = preferredAppearance.nativeAppearance - if positionAdjacentToMainWindow { - positionSecondaryWindowAdjacentToMainWindow(targetWindow) - } - if isExpanded { - DispatchQueue.main.async { [weak self] in - guard let self, self.isExpanded else { - return + let controller = DeadlineReminderPanelController( + appearance: preferredAppearance.nativeAppearance, + collapsedIconFrameProvider: { [weak self] in + self?.collapsedIconPanel?.frame + }, + visibleFrameProvider: { [weak self] in + guard let self else { + return nil } - self.activateAndFocusFlutterContent() + return self.collapsedIconPanel?.screen?.visibleFrame + ?? self.screen?.visibleFrame + }, + onAction: { [weak self] action in + self?.windowChannel?.invokeMethod( + "deadlineReminderAction", + arguments: action + ) } - } - configuredSecondaryWindows.add(targetWindow) - return true + ) + deadlineReminderController = controller + return controller } - private func revealBorderlessSecondaryWindow( - viewIdentifier: Int64 - ) -> Bool { - guard - let targetWindow = NSApp.windows.first(where: { window in - guard - window !== self, - let controller = self.flutterViewController(in: window) - else { - return false - } - return controller.viewIdentifier == viewIdentifier - }), - configuredSecondaryWindows.contains(targetWindow) - else { - return false + private func ensureDeadlineReminderNativeScheduler() + -> DeadlineReminderNativeScheduler + { + if let deadlineReminderNativeScheduler { + return deadlineReminderNativeScheduler } - - targetWindow.displayIfNeeded() - targetWindow.alphaValue = 1 - targetWindow.orderFrontRegardless() - return true - } - - private func observeInitialSecondaryWindowPresentation() { - secondaryWindowKeyObserver = NotificationCenter.default.addObserver( - forName: NSWindow.didBecomeKeyNotification, - object: nil, - queue: .main - ) { [weak self] notification in - guard - let self, - let targetWindow = notification.object as? NSWindow, - targetWindow !== self, - !self.configuredSecondaryWindows.contains(targetWindow), - let flutterViewController = self.flutterViewController( - in: targetWindow - ) - else { - return - } - - // multiview_desktop orders a new NSWindow on screen before Dart can - // apply its WindowOptions. Keep that initial native surface invisible; - // the coordinator reveals it only after configuration, positioning and - // Flutter's first completed frame. - self.initialSecondaryWindows.add(targetWindow) - targetWindow.alphaValue = 0 - self.configureTransparentRoundedWindow( - targetWindow, - flutterViewController: flutterViewController - ) - DispatchQueue.main.async { [weak self, weak targetWindow] in - guard let self, let targetWindow else { + let scheduler = DeadlineReminderNativeScheduler( + onDue: { [weak self] entry in + guard let self else { return } - defer { - self.initialSecondaryWindows.remove(targetWindow) - } - guard self.isExpanded else { - return - } - // Creating a pinned board briefly makes its hidden native window key. - // Restore the main window so that this programmatic handoff is not - // mistaken for an outside click. - self.activateAndFocusFlutterContent() + self.ensureDeadlineReminderController().enqueue(entry.payload) + self.windowChannel?.invokeMethod( + "deadlineReminderDelivered", + arguments: [ + "notificationId": entry.notificationId, + "todoId": entry.payload.todoId, + "deliveryKind": entry.deliveryKind, + ] + ) } - } - } - - private func configureTransparentRoundedWindow( - _ targetWindow: NSWindow, - flutterViewController: FlutterViewController - ) { - targetWindow.backgroundColor = .clear - targetWindow.isOpaque = false - targetWindow.hasShadow = false - targetWindow.invalidateShadow() - targetWindow.contentView?.wantsLayer = true - targetWindow.contentView?.layer?.backgroundColor = NSColor.clear.cgColor - targetWindow.contentView?.layer?.isOpaque = false - configureRoundedFlutterSurface( - in: flutterViewController, - cornerRadius: 22 - ) - } - - private func positionSecondaryWindowAdjacentToMainWindow( - _ targetWindow: NSWindow - ) { - let mainFrame = frame - let targetSize = targetWindow.frame.size - let targetScreen = screen( - containing: NSPoint(x: mainFrame.midX, y: mainFrame.midY) - ) - let visibleFrame = targetScreen.visibleFrame.insetBy( - dx: Layout.screenPadding, - dy: Layout.screenPadding - ) - let gap: CGFloat = 12 - let rightOriginX = mainFrame.maxX + gap - let leftOriginX = mainFrame.minX - targetSize.width - gap - let fitsOnRight = rightOriginX + targetSize.width <= visibleFrame.maxX - let fitsOnLeft = leftOriginX >= visibleFrame.minX - - let originX: CGFloat - if fitsOnRight && !fitsOnLeft { - originX = rightOriginX - } else if fitsOnLeft && !fitsOnRight { - originX = leftOriginX - } else if visibleFrame.maxX - mainFrame.maxX >= - mainFrame.minX - visibleFrame.minX - { - originX = rightOriginX - } else { - originX = leftOriginX - } - - let centeredOriginY = mainFrame.midY - targetSize.height / 2 - let maximumX = max( - visibleFrame.minX, - visibleFrame.maxX - targetSize.width - ) - let maximumY = max( - visibleFrame.minY, - visibleFrame.maxY - targetSize.height ) - targetWindow.setFrameOrigin( - NSPoint( - x: min(max(originX, visibleFrame.minX), maximumX), - y: min(max(centeredOriginY, visibleFrame.minY), maximumY) - ) - ) - } - - private func flutterViewController( - in window: NSWindow - ) -> FlutterViewController? { - return flutterViewController(in: window.contentViewController) - } - - private func flutterViewController( - in controller: NSViewController? - ) -> FlutterViewController? { - guard let controller else { - return nil - } - if let flutterViewController = controller as? FlutterViewController { - return flutterViewController - } - for child in controller.children { - if let flutterViewController = flutterViewController(in: child) { - return flutterViewController - } - } - return nil + deadlineReminderNativeScheduler = scheduler + return scheduler } private func configureRoundedFlutterSurface( @@ -611,12 +443,7 @@ final class MainFlutterWindow: NSWindow { let nativeAppearance = preference.nativeAppearance appearance = nativeAppearance collapsedIconPanel?.appearance = nativeAppearance - for window in NSApp.windows where window !== self { - guard flutterViewController(in: window) != nil else { - continue - } - window.appearance = nativeAppearance - } + deadlineReminderController?.setAppearance(nativeAppearance) } private func configureUpdateService( @@ -698,6 +525,7 @@ final class MainFlutterWindow: NSWindow { self.collapsedIconPanel?.setFrameOrigin(origin) self.collapsedOrigin = origin self.pendingExpansionAnchor = nil + self.deadlineReminderController?.updateCollapsedAnchor() } overlay.onDragEnded = { [weak self] in guard let self else { @@ -707,18 +535,13 @@ final class MainFlutterWindow: NSWindow { self.collapsedOrigin = iconOrigin } self.persistCollapsedOrigin() + self.deadlineReminderController?.updateCollapsedAnchor() } iconView.addSubview(overlay) collapsedDragOverlay = overlay } private func requestCollapseIfNeeded() { - if - let keyWindow = NSApp.keyWindow, - initialSecondaryWindows.contains(keyWindow) - { - return - } guard isExpanded, !isKeyWindow, @@ -775,6 +598,7 @@ final class MainFlutterWindow: NSWindow { NSRect(origin: collapsedOrigin, size: Layout.collapsedSize), display: false ) + deadlineReminderController?.updateCollapsedAnchor() collapsedIconPanel?.alphaValue = animated ? 0 : 1 collapsedIconPanel?.orderFrontRegardless() transitionWindows( @@ -804,6 +628,7 @@ final class MainFlutterWindow: NSWindow { NSRect(origin: collapsedOrigin, size: Layout.collapsedSize), display: false ) + deadlineReminderController?.updateCollapsedAnchor() collapsedIconPanel?.alphaValue = 1 collapsedIconPanel?.orderFrontRegardless() } @@ -1342,6 +1167,779 @@ final class FloatingTodoIconView: NSView { } } +enum DeadlineReminderLayout { + static let cardSize = NSSize(width: 368, height: 132) + static let pillSize = NSSize(width: 112, height: 32) + static let screenInset: CGFloat = 16 + static let slideOffset: CGFloat = 16 + static let pillSpacing: CGFloat = 8 + static let pillScreenInset: CGFloat = 8 + + static func pillFrame( + anchoredTo iconFrame: NSRect, + within visibleFrame: NSRect + ) -> NSRect { + let minimumX = visibleFrame.minX + pillScreenInset + let maximumX = max( + minimumX, + visibleFrame.maxX - pillSize.width - pillScreenInset + ) + let centeredX = iconFrame.midX - pillSize.width / 2 + let originX = min(max(centeredX, minimumX), maximumX) + + let minimumY = visibleFrame.minY + pillScreenInset + let maximumY = max( + minimumY, + visibleFrame.maxY - pillSize.height - pillScreenInset + ) + let aboveY = iconFrame.maxY + pillSpacing + let belowY = iconFrame.minY - pillSize.height - pillSpacing + let preferredY = aboveY <= maximumY ? aboveY : belowY + let originY = min(max(preferredY, minimumY), maximumY) + + return NSRect( + origin: NSPoint(x: originX, y: originY), + size: pillSize + ) + } + + static func cardFrame(size: NSSize, within visibleFrame: NSRect) -> NSRect { + let originX = max( + visibleFrame.minX, + visibleFrame.maxX - size.width - screenInset + ) + let originY = max( + visibleFrame.minY, + visibleFrame.maxY - size.height - screenInset + ) + return NSRect(origin: NSPoint(x: originX, y: originY), size: size) + } +} + +struct DeadlineReminderPayload { + let notificationId: String + let todoId: String + let title: String + let dueLabel: String + let isOverdue: Bool + let isAdvanceReminder: Bool + let tagLabel: String? + + init?(arguments: [String: Any]) { + guard + let notificationId = arguments["notificationId"] as? String, + !notificationId.isEmpty, + let todoId = arguments["todoId"] as? String, + !todoId.isEmpty, + let title = arguments["title"] as? String, + !title.isEmpty, + let dueLabel = arguments["dueLabel"] as? String, + let isOverdue = arguments["isOverdue"] as? Bool, + let isAdvanceReminder = arguments["isAdvanceReminder"] as? Bool + else { + return nil + } + self.notificationId = notificationId + self.todoId = todoId + self.title = title + self.dueLabel = dueLabel + self.isOverdue = isOverdue + self.isAdvanceReminder = isAdvanceReminder + self.tagLabel = arguments["tagLabel"] as? String + } +} + +struct ScheduledDeadlineReminderEntry { + private static let deliveryKinds = Set(["snoozed", "deadline", "advance"]) + + let notificationId: String + let triggerAt: Date + let deliveryKind: String + let payload: DeadlineReminderPayload + + init?(arguments: [String: Any]) { + guard + let notificationId = arguments["notificationId"] as? String, + !notificationId.isEmpty, + let triggerAtMilliseconds = arguments["triggerAtMilliseconds"] + as? NSNumber, + triggerAtMilliseconds.doubleValue.isFinite, + let deliveryKind = arguments["deliveryKind"] as? String, + Self.deliveryKinds.contains(deliveryKind), + let payloadArguments = arguments["payload"] as? [String: Any], + let payload = DeadlineReminderPayload(arguments: payloadArguments), + payload.notificationId == notificationId + else { + return nil + } + self.notificationId = notificationId + self.triggerAt = Date( + timeIntervalSince1970: triggerAtMilliseconds.doubleValue / 1_000 + ) + self.deliveryKind = deliveryKind + self.payload = payload + } +} + +final class DeadlineReminderNativeScheduler { + typealias NowProvider = () -> Date + typealias DueHandler = (ScheduledDeadlineReminderEntry) -> Void + + private let nowProvider: NowProvider + private let onDue: DueHandler + private let timerQueue = DispatchQueue( + label: "io.github.lucaslushuo.floatick.deadline-reminder-timer", + qos: .userInitiated + ) + private var entries: [ScheduledDeadlineReminderEntry] = [] + private var deliveredNotificationIds = Set() + private var timer: DispatchSourceTimer? + + init( + nowProvider: @escaping NowProvider = Date.init, + onDue: @escaping DueHandler + ) { + self.nowProvider = nowProvider + self.onDue = onDue + } + + deinit { + timer?.cancel() + } + + func replace(with entries: [ScheduledDeadlineReminderEntry]) { + let scheduledIds = Set(entries.map(\.notificationId)) + deliveredNotificationIds.formIntersection(scheduledIds) + self.entries = entries + .filter { !deliveredNotificationIds.contains($0.notificationId) } + .sorted(by: Self.isOrderedBefore) + processDueReminders() + } + + func processDueReminders() { + timer?.cancel() + timer = nil + + let now = nowProvider() + let firstPendingIndex = entries.firstIndex { $0.triggerAt > now } + ?? entries.endIndex + let dueEntries = Array(entries[.. 0 else { + DispatchQueue.main.async { [weak self] in + self?.processDueReminders() + } + return + } + let leewayMilliseconds = min( + 500, + max(50, Int(interval * 10)) + ) + let timer = DispatchSource.makeTimerSource(queue: timerQueue) + timer.schedule( + deadline: .now() + interval, + leeway: .milliseconds(leewayMilliseconds) + ) + timer.setEventHandler { [weak self] in + DispatchQueue.main.async { + self?.processDueReminders() + } + } + self.timer = timer + timer.resume() + } + + private static func isOrderedBefore( + _ left: ScheduledDeadlineReminderEntry, + _ right: ScheduledDeadlineReminderEntry + ) -> Bool { + if left.triggerAt == right.triggerAt { + return left.notificationId < right.notificationId + } + return left.triggerAt < right.triggerAt + } +} + +private final class DeadlineReminderPanelController { + private let panel: NSPanel + private let collapsedIconFrameProvider: () -> NSRect? + private let visibleFrameProvider: () -> NSRect? + private let onAction: ([String: Any]) -> Void + private var queue: [DeadlineReminderPayload] = [] + private var currentPayload: DeadlineReminderPayload? + private var collapseTimer: Timer? + private var isShowingPill = false + + init( + appearance: NSAppearance?, + collapsedIconFrameProvider: @escaping () -> NSRect?, + visibleFrameProvider: @escaping () -> NSRect?, + onAction: @escaping ([String: Any]) -> Void + ) { + panel = NSPanel( + contentRect: NSRect(origin: .zero, size: DeadlineReminderLayout.cardSize), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + self.collapsedIconFrameProvider = collapsedIconFrameProvider + self.visibleFrameProvider = visibleFrameProvider + self.onAction = onAction + panel.backgroundColor = .clear + panel.isOpaque = false + panel.hasShadow = true + panel.hidesOnDeactivate = false + panel.isReleasedWhenClosed = false + panel.level = .statusBar + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + panel.animationBehavior = .none + panel.appearance = appearance + } + + func setAppearance(_ appearance: NSAppearance?) { + panel.appearance = appearance + } + + func updateCollapsedAnchor() { + guard isShowingPill else { + return + } + panel.setFrame(collapsedPillFrame(), display: true) + } + + func enqueue(_ payload: DeadlineReminderPayload) { + if currentPayload?.notificationId == payload.notificationId || + queue.contains(where: { + $0.notificationId == payload.notificationId + }) + { + return + } + queue.append(payload) + showNextIfNeeded() + } + + private func showNextIfNeeded() { + guard currentPayload == nil, !queue.isEmpty else { + return + } + let payload = queue.removeFirst() + currentPayload = payload + showCard(payload, animated: true) + } + + private func showCard( + _ payload: DeadlineReminderPayload, + animated: Bool + ) { + collapseTimer?.invalidate() + isShowingPill = false + let cardView = DeadlineReminderCardView(payload: payload) + cardView.onOpen = { [weak self] in self?.finish(action: "open") } + cardView.onDismiss = { [weak self] in self?.finish(action: "dismiss") } + panel.contentView = cardView + let targetFrame = frame(size: DeadlineReminderLayout.cardSize) + if animated { + let startFrame = targetFrame.offsetBy( + dx: DeadlineReminderLayout.slideOffset, + dy: 0 + ) + panel.setFrame(startFrame, display: false) + panel.alphaValue = 0 + panel.orderFrontRegardless() + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.18 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + panel.animator().setFrame(targetFrame, display: true) + panel.animator().alphaValue = 1 + } + } else { + panel.setFrame(targetFrame, display: true) + panel.alphaValue = 1 + panel.orderFrontRegardless() + } + collapseTimer = Timer.scheduledTimer( + withTimeInterval: 12, + repeats: false + ) { [weak self] _ in + self?.collapseToPill() + } + } + + private func collapseToPill() { + guard currentPayload != nil else { + return + } + collapseTimer?.invalidate() + let count = queue.count + 1 + let pill = DeadlineReminderPillView(count: count) + pill.onOpen = { [weak self] in + guard let self, let payload = self.currentPayload else { + return + } + self.showCard(payload, animated: false) + } + panel.contentView = pill + isShowingPill = true + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.16 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + panel.animator().setFrame( + collapsedPillFrame(), + display: true + ) + } + } + + private func finish(action: String) { + guard let payload = currentPayload else { + return + } + collapseTimer?.invalidate() + isShowingPill = false + let arguments: [String: Any] = [ + "action": action, + "todoId": payload.todoId, + ] + onAction(arguments) + currentPayload = nil + NSAnimationContext.runAnimationGroup( + { context in + context.duration = 0.12 + panel.animator().alphaValue = 0 + }, + completionHandler: { [weak self] in + guard let self else { + return + } + self.panel.orderOut(nil) + self.panel.alphaValue = 1 + self.showNextIfNeeded() + } + ) + } + + private func frame(size: NSSize) -> NSRect { + let mouseLocation = NSEvent.mouseLocation + let mouseScreenVisibleFrame = NSScreen.screens.first { + NSMouseInRect(mouseLocation, $0.frame, false) + }?.visibleFrame + let visibleFrame = usableVisibleFrame(mouseScreenVisibleFrame) + ?? usableVisibleFrame(visibleFrameProvider()) + ?? usableVisibleFrame(NSScreen.main?.visibleFrame) + ?? usableVisibleFrame(NSScreen.screens.first?.visibleFrame) + ?? NSRect(origin: .zero, size: size) + return DeadlineReminderLayout.cardFrame( + size: size, + within: visibleFrame + ) + } + + private func collapsedPillFrame() -> NSRect { + guard let iconFrame = collapsedIconFrameProvider() else { + return frame(size: DeadlineReminderLayout.pillSize) + } + let iconCenter = NSPoint(x: iconFrame.midX, y: iconFrame.midY) + let iconScreenVisibleFrame = NSScreen.screens.first { + $0.frame.contains(iconCenter) + }?.visibleFrame + let visibleFrame = usableVisibleFrame(iconScreenVisibleFrame) + ?? usableVisibleFrame(visibleFrameProvider()) + ?? usableVisibleFrame(NSScreen.main?.visibleFrame) + ?? NSRect(origin: iconFrame.origin, size: DeadlineReminderLayout.pillSize) + return DeadlineReminderLayout.pillFrame( + anchoredTo: iconFrame, + within: visibleFrame + ) + } + + private func usableVisibleFrame(_ frame: NSRect?) -> NSRect? { + guard let frame, frame.width > 0, frame.height > 0 else { + return nil + } + return frame + } +} + +final class DeadlineReminderCardView: NSView { + private enum ActionIdentifier { + static let open = NSUserInterfaceItemIdentifier("deadline-reminder-open") + } + + var onOpen: (() -> Void)? + var onDismiss: (() -> Void)? + + init(payload: DeadlineReminderPayload) { + super.init( + frame: NSRect(origin: .zero, size: DeadlineReminderLayout.cardSize) + ) + wantsLayer = true + layer?.backgroundColor = NSColor( + calibratedRed: 25 / 255, + green: 33 / 255, + blue: 36 / 255, + alpha: 0.98 + ).cgColor + layer?.cornerRadius = 11 + layer?.borderWidth = 1 + layer?.borderColor = NSColor.white.withAlphaComponent(0.11).cgColor + layer?.masksToBounds = true + + let accentColor = NSColor( + calibratedRed: 34 / 255, + green: 184 / 255, + blue: 167 / 255, + alpha: 1 + ) + let accentRail = NSView() + accentRail.translatesAutoresizingMaskIntoConstraints = false + accentRail.wantsLayer = true + accentRail.layer?.backgroundColor = accentColor.cgColor + + let statusIcon = NSImageView() + statusIcon.translatesAutoresizingMaskIntoConstraints = false + statusIcon.image = Self.symbolImage( + named: "alarm", + accessibilityDescription: nil, + pointSize: 13, + weight: .regular + ) + statusIcon.imageScaling = .scaleProportionallyDown + statusIcon.contentTintColor = accentColor + statusIcon.setAccessibilityElement(false) + + let nowLabel = makeLabel(NativeCopy.now, size: 9.5, weight: .regular) + nowLabel.textColor = NSColor.white.withAlphaComponent(0.43) + let closeButton = iconButton( + symbolName: "xmark", + fallbackTitle: "×", + accessibilityLabel: NativeCopy.dismiss, + action: #selector(closePressed) + ) + + let titleLabel = makeLabel(payload.title, size: 13.5, weight: .semibold) + titleLabel.lineBreakMode = .byTruncatingTail + titleLabel.maximumNumberOfLines = 1 + + let duePrefix = payload.isOverdue ? NativeCopy.overdue : NativeCopy.due + var metadata = "\(duePrefix) \(payload.dueLabel)" + if let tagLabel = payload.tagLabel, !tagLabel.isEmpty { + metadata += " · \(tagLabel)" + } + let metadataLabel = makeLabel(metadata, size: 10, weight: .regular) + metadataLabel.textColor = payload.isOverdue + ? NSColor(calibratedRed: 241 / 255, green: 120 / 255, blue: 66 / 255, alpha: 1) + : NSColor.white.withAlphaComponent(0.48) + + let openButton = DeadlineReminderActionButton( + symbolName: "arrow.up.right", + fallbackTitle: "↗", + accessibilityLabel: NativeCopy.open, + identifier: ActionIdentifier.open, + target: self, + action: #selector(openPressed) + ) + + [accentRail, statusIcon, nowLabel, closeButton, titleLabel, + metadataLabel, openButton].forEach(addSubview) + + NSLayoutConstraint.activate([ + accentRail.leadingAnchor.constraint(equalTo: leadingAnchor), + accentRail.topAnchor.constraint(equalTo: topAnchor), + accentRail.bottomAnchor.constraint(equalTo: bottomAnchor), + accentRail.widthAnchor.constraint(equalToConstant: 3), + + statusIcon.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14), + statusIcon.topAnchor.constraint(equalTo: topAnchor, constant: 12), + statusIcon.widthAnchor.constraint(equalToConstant: 18), + statusIcon.heightAnchor.constraint(equalToConstant: 18), + + nowLabel.leadingAnchor.constraint(equalTo: statusIcon.trailingAnchor, constant: 7), + nowLabel.centerYAnchor.constraint(equalTo: statusIcon.centerYAnchor), + nowLabel.trailingAnchor.constraint( + lessThanOrEqualTo: closeButton.leadingAnchor, + constant: -8 + ), + closeButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), + closeButton.centerYAnchor.constraint(equalTo: statusIcon.centerYAnchor), + closeButton.widthAnchor.constraint(equalToConstant: 24), + closeButton.heightAnchor.constraint(equalToConstant: 24), + + titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14), + titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14), + titleLabel.topAnchor.constraint(equalTo: statusIcon.bottomAnchor, constant: 8), + metadataLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), + metadataLabel.trailingAnchor.constraint(lessThanOrEqualTo: titleLabel.trailingAnchor), + metadataLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 3), + + openButton.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), + openButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -10), + openButton.widthAnchor.constraint(equalToConstant: 30), + openButton.heightAnchor.constraint(equalToConstant: 30), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("DeadlineReminderCardView is created programmatically.") + } + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + + @objc private func openPressed() { onOpen?() } + @objc private func closePressed() { onDismiss?() } + + private func makeLabel( + _ text: String, + size: CGFloat, + weight: NSFont.Weight + ) -> NSTextField { + let label = NSTextField(labelWithString: text) + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .systemFont(ofSize: size, weight: weight) + label.textColor = NSColor.white.withAlphaComponent(0.92) + return label + } + + private func iconButton( + symbolName: String, + fallbackTitle: String, + accessibilityLabel: String, + action: Selector + ) -> NSButton { + let button = NSButton(title: "", target: self, action: action) + button.translatesAutoresizingMaskIntoConstraints = false + button.isBordered = false + button.contentTintColor = NSColor.white.withAlphaComponent(0.58) + if let image = Self.symbolImage( + named: symbolName, + accessibilityDescription: accessibilityLabel, + pointSize: 12, + weight: .medium + ) { + button.image = image + button.imagePosition = .imageOnly + button.imageScaling = .scaleProportionallyDown + } else { + button.title = fallbackTitle + button.font = .systemFont(ofSize: 17, weight: .regular) + } + button.toolTip = accessibilityLabel + button.setAccessibilityLabel(accessibilityLabel) + return button + } + + private static func symbolImage( + named symbolName: String, + accessibilityDescription: String?, + pointSize: CGFloat, + weight: NSFont.Weight + ) -> NSImage? { + guard #available(macOS 11.0, *) else { + return nil + } + let configuration = NSImage.SymbolConfiguration( + pointSize: pointSize, + weight: weight + ) + return NSImage( + systemSymbolName: symbolName, + accessibilityDescription: accessibilityDescription + )?.withSymbolConfiguration(configuration) + } +} + +private final class DeadlineReminderActionButton: NSButton { + private var pointerTrackingArea: NSTrackingArea? + + init( + symbolName: String, + fallbackTitle: String, + accessibilityLabel: String, + identifier: NSUserInterfaceItemIdentifier, + target: AnyObject?, + action: Selector + ) { + super.init(frame: .zero) + self.identifier = identifier + self.target = target + self.action = action + translatesAutoresizingMaskIntoConstraints = false + isBordered = false + wantsLayer = true + layer?.cornerRadius = 6 + layer?.borderWidth = 0 + if #available(macOS 11.0, *), + let image = NSImage( + systemSymbolName: symbolName, + accessibilityDescription: accessibilityLabel + )?.withSymbolConfiguration( + NSImage.SymbolConfiguration(pointSize: 16, weight: .semibold) + ) + { + self.image = image + imagePosition = .imageOnly + imageScaling = .scaleProportionallyDown + title = "" + } else { + title = fallbackTitle + font = .systemFont(ofSize: 17, weight: .semibold) + } + toolTip = accessibilityLabel + setAccessibilityLabel(accessibilityLabel) + applyAppearance(isHovered: false) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("DeadlineReminderActionButton is created programmatically.") + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let pointerTrackingArea { + removeTrackingArea(pointerTrackingArea) + } + let trackingArea = NSTrackingArea( + rect: bounds, + options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], + owner: self, + userInfo: nil + ) + addTrackingArea(trackingArea) + pointerTrackingArea = trackingArea + } + + override func mouseEntered(with event: NSEvent) { + applyAppearance(isHovered: true) + } + + override func mouseExited(with event: NSEvent) { + applyAppearance(isHovered: false) + } + + override func resetCursorRects() { + addCursorRect(bounds, cursor: .pointingHand) + } + + private func applyAppearance(isHovered: Bool) { + let iconColor = NSColor( + calibratedRed: 34 / 255, + green: 184 / 255, + blue: 167 / 255, + alpha: isHovered ? 1 : 0.92 + ) + layer?.backgroundColor = iconColor.withAlphaComponent( + isHovered ? 0.16 : 0 + ).cgColor + contentTintColor = iconColor + layer?.borderWidth = 0 + if image == nil { + attributedTitle = NSAttributedString( + string: title, + attributes: [ + .font: NSFont.systemFont(ofSize: 17, weight: .semibold), + .foregroundColor: iconColor, + ] + ) + } + } +} + +private final class DeadlineReminderPillView: NSView { + var onOpen: (() -> Void)? + + init(count: Int) { + super.init( + frame: NSRect(origin: .zero, size: DeadlineReminderLayout.pillSize) + ) + wantsLayer = true + layer?.backgroundColor = NSColor( + calibratedRed: 29 / 255, + green: 37 / 255, + blue: 41 / 255, + alpha: 0.98 + ).cgColor + layer?.cornerRadius = 9 + layer?.borderWidth = 1 + layer?.borderColor = NSColor.white.withAlphaComponent(0.10).cgColor + let dot = NSView() + dot.translatesAutoresizingMaskIntoConstraints = false + dot.wantsLayer = true + dot.layer?.cornerRadius = 2.5 + dot.layer?.backgroundColor = NSColor( + calibratedRed: 34 / 255, + green: 184 / 255, + blue: 167 / 255, + alpha: 1 + ).cgColor + let labelText = NativeCopy.overdueCount(count) + let label = NSTextField(labelWithString: labelText) + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .systemFont(ofSize: 10.5, weight: .semibold) + label.textColor = NSColor( + calibratedRed: 34 / 255, + green: 184 / 255, + blue: 167 / 255, + alpha: 1 + ) + let stack = NSStackView(views: [dot, label]) + stack.translatesAutoresizingMaskIntoConstraints = false + stack.orientation = .horizontal + stack.alignment = .centerY + stack.spacing = 6 + addSubview(stack) + setAccessibilityElement(true) + setAccessibilityRole(.button) + setAccessibilityLabel(labelText) + NSLayoutConstraint.activate([ + dot.widthAnchor.constraint(equalToConstant: 5), + dot.heightAnchor.constraint(equalToConstant: 5), + stack.centerXAnchor.constraint(equalTo: centerXAnchor), + stack.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("DeadlineReminderPillView is created programmatically.") + } + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + + override func mouseDown(with event: NSEvent) { + onOpen?() + } + + override func resetCursorRects() { + addCursorRect(bounds, cursor: .pointingHand) + } + + override func accessibilityPerformPress() -> Bool { + onOpen?() + return true + } +} + private enum NativeCopy { static var preferredLanguageCode: String? @@ -1362,6 +1960,19 @@ private enum NativeCopy { static var quitFloatick: String { usesChinese ? "退出 Floatick" : "Quit Floatick" } + + static var now: String { usesChinese ? "刚刚" : "now" } + static var due: String { usesChinese ? "截止" : "Due" } + static var overdue: String { usesChinese ? "逾期" : "Overdue" } + static var open: String { usesChinese ? "打开" : "Open" } + static var dismiss: String { usesChinese ? "关闭" : "Dismiss" } + + static func overdueCount(_ count: Int) -> String { + if usesChinese { + return "\(count) 个提醒" + } + return count == 1 ? "1 reminder" : "\(count) reminders" + } } final class CollapsedDragOverlayView: NSView { diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift index 417887a..c5d7e36 100644 --- a/macos/RunnerTests/RunnerTests.swift +++ b/macos/RunnerTests/RunnerTests.swift @@ -36,6 +36,201 @@ class RunnerTests: XCTestCase { XCTAssertEqual(origin.y, 74) } + func testReminderPillAnchorsAboveFloatingIcon() { + let iconFrame = NSRect(x: 1_100, y: 80, width: 72, height: 72) + let visibleFrame = NSRect(x: 0, y: 0, width: 1_440, height: 900) + + let pillFrame = DeadlineReminderLayout.pillFrame( + anchoredTo: iconFrame, + within: visibleFrame + ) + + XCTAssertEqual(pillFrame.midX, iconFrame.midX) + XCTAssertEqual( + pillFrame.minY, + iconFrame.maxY + DeadlineReminderLayout.pillSpacing + ) + } + + func testReminderPillFallsBelowIconAtTopScreenEdge() { + let iconFrame = NSRect(x: 1_340, y: 828, width: 72, height: 72) + let visibleFrame = NSRect(x: 0, y: 0, width: 1_440, height: 900) + + let pillFrame = DeadlineReminderLayout.pillFrame( + anchoredTo: iconFrame, + within: visibleFrame + ) + + XCTAssertEqual( + pillFrame.minY, + iconFrame.minY + - DeadlineReminderLayout.pillSize.height + - DeadlineReminderLayout.pillSpacing + ) + XCTAssertLessThanOrEqual( + pillFrame.maxX, + visibleFrame.maxX - DeadlineReminderLayout.pillScreenInset + ) + } + + func testDeadlineReminderCardUsesTopRightOfVisibleFrame() { + let visibleFrame = NSRect(x: -1_440, y: 24, width: 1_440, height: 876) + + let cardFrame = DeadlineReminderLayout.cardFrame( + size: DeadlineReminderLayout.cardSize, + within: visibleFrame + ) + + XCTAssertEqual( + cardFrame.maxX, + visibleFrame.maxX - DeadlineReminderLayout.screenInset + ) + XCTAssertEqual( + cardFrame.maxY, + visibleFrame.maxY - DeadlineReminderLayout.screenInset + ) + } + + func testDeadlineReminderCardCanBeConstructedWithActionButtons() { + guard + let payload = DeadlineReminderPayload( + arguments: [ + "notificationId": "todo-1:deadline:card", + "todoId": "todo-1", + "title": "Test reminder", + "dueLabel": "Due now", + "isOverdue": true, + "isAdvanceReminder": false, + ] + ) + else { + XCTFail("Expected a valid deadline reminder payload.") + return + } + + let card = DeadlineReminderCardView(payload: payload) + + XCTAssertEqual(card.frame.size, DeadlineReminderLayout.cardSize) + } + + func testDeadlineReminderCardUsesOnlyBorderlessOpenAction() { + guard + let payload = DeadlineReminderPayload( + arguments: [ + "notificationId": "todo-1:deadline:icon-actions", + "todoId": "todo-1", + "title": "Test reminder", + "dueLabel": "Due now", + "isOverdue": false, + "isAdvanceReminder": false, + ] + ) + else { + XCTFail("Expected a valid deadline reminder payload.") + return + } + let card = DeadlineReminderCardView(payload: payload) + XCTAssertNil(button(with: "deadline-reminder-complete", in: card)) + XCTAssertNil(button(with: "deadline-reminder-snooze", in: card)) + guard let openButton = button(with: "deadline-reminder-open", in: card) + else { + XCTFail("Expected the open action button.") + return + } + XCTAssertEqual(openButton.title, "") + XCTAssertNotNil(openButton.image) + XCTAssertFalse(openButton.isBordered) + XCTAssertEqual(openButton.layer?.borderWidth, 0) + XCTAssertFalse((openButton.toolTip ?? "").isEmpty) + XCTAssertFalse((openButton.accessibilityLabel() ?? "").isEmpty) + } + + func testNativeReminderSchedulerDeliversWhileFlutterWindowIsInactive() { + var now = Date(timeIntervalSince1970: 1_786_329_600) + guard + let entry = ScheduledDeadlineReminderEntry( + arguments: scheduledReminderArguments( + notificationId: "todo-1:deadline:1786329660000000", + payloadNotificationId: "todo-1:deadline:1786329660000000", + triggerAt: now.addingTimeInterval(60) + ) + ) + else { + XCTFail("Expected a valid scheduled reminder entry.") + return + } + var deliveredNotificationIds: [String] = [] + let scheduler = DeadlineReminderNativeScheduler( + nowProvider: { now }, + onDue: { deliveredNotificationIds.append($0.notificationId) } + ) + + scheduler.replace(with: [entry]) + XCTAssertTrue(deliveredNotificationIds.isEmpty) + + now.addTimeInterval(61) + scheduler.processDueReminders() + + XCTAssertEqual( + deliveredNotificationIds, + ["todo-1:deadline:1786329660000000"] + ) + + scheduler.replace(with: [entry]) + scheduler.processDueReminders() + XCTAssertEqual(deliveredNotificationIds.count, 1) + scheduler.cancel() + } + + func testNativeReminderTimerFiresWhileWindowRemainsHidden() { + let hiddenWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 200, height: 120), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + hiddenWindow.orderOut(nil) + let notificationId = "todo-1:deadline:hidden-window" + guard + let entry = ScheduledDeadlineReminderEntry( + arguments: scheduledReminderArguments( + notificationId: notificationId, + payloadNotificationId: notificationId, + triggerAt: Date().addingTimeInterval(0.15) + ) + ) + else { + XCTFail("Expected a valid scheduled reminder entry.") + return + } + let deliveryExpectation = expectation( + description: "The native timer fires while the window stays hidden." + ) + let scheduler = DeadlineReminderNativeScheduler { deliveredEntry in + XCTAssertEqual(deliveredEntry.notificationId, notificationId) + XCTAssertFalse(hiddenWindow.isVisible) + deliveryExpectation.fulfill() + } + + scheduler.replace(with: [entry]) + + wait(for: [deliveryExpectation], timeout: 1) + XCTAssertFalse(hiddenWindow.isVisible) + scheduler.cancel() + } + + func testScheduledReminderRejectsMismatchedNotificationId() { + let entry = ScheduledDeadlineReminderEntry( + arguments: scheduledReminderArguments( + notificationId: "outer-id", + payloadNotificationId: "payload-id", + triggerAt: Date(timeIntervalSince1970: 1_786_329_660) + ) + ) + + XCTAssertNil(entry) + } + func testFloatingIconAttentionAnimationIncludesScaleAndGlow() { let iconView = FloatingTodoIconView( frame: NSRect(x: 0, y: 0, width: 72, height: 72), @@ -106,4 +301,41 @@ class RunnerTests: XCTestCase { ) XCTAssertEqual(Set(glowKeyPaths ?? []), Set(["opacity"])) } + + private func scheduledReminderArguments( + notificationId: String, + payloadNotificationId: String, + triggerAt: Date + ) -> [String: Any] { + [ + "notificationId": notificationId, + "triggerAtMilliseconds": NSNumber( + value: triggerAt.timeIntervalSince1970 * 1_000 + ), + "deliveryKind": "deadline", + "payload": [ + "notificationId": payloadNotificationId, + "todoId": "todo-1", + "title": "Test reminder", + "dueLabel": "Due now", + "isOverdue": false, + "isAdvanceReminder": false, + ], + ] + } + + private func button(with identifier: String, in view: NSView) -> NSButton? { + if + let button = view as? NSButton, + button.identifier?.rawValue == identifier + { + return button + } + for subview in view.subviews { + if let button = button(with: identifier, in: subview) { + return button + } + } + return nil + } } diff --git a/pubspec.lock b/pubspec.lock index 3632dbf..6c417fa 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -175,6 +175,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.13.0" + material_symbols_icons: + dependency: "direct main" + description: + name: material_symbols_icons + sha256: bd513edc2bc9b034108d5518c48cf5cebbb67d0653728cc452368cb27ca80bb0 + url: "https://pub.dev" + source: hosted + version: "4.2960.0" meta: dependency: transitive description: @@ -183,14 +191,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.0" - multiview_desktop: - dependency: "direct main" - description: - name: multiview_desktop - sha256: "131170caef6bd646b4d547363ca68b145f6c7ab2a4db4579d14ddf280dfd710e" - url: "https://pub.dev" - source: hosted - version: "1.2.0" path: dependency: transitive description: @@ -302,4 +302,4 @@ packages: version: "3.1.0" sdks: dart: ">=3.12.2 <4.0.0" - flutter: ">=3.38.2" + flutter: ">=3.27.1" diff --git a/pubspec.yaml b/pubspec.yaml index 9fcb110..7529e8e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: floatick description: A focused, local-first floating todo list for macOS. publish_to: 'none' -version: 0.3.3+10 +version: 0.3.4+11 environment: sdk: ^3.12.2 @@ -14,7 +14,7 @@ dependencies: sdk: flutter flutter_markdown_plus: ^1.0.12 intl: any - multiview_desktop: ^1.2.0 + material_symbols_icons: ^4.2960.0 dev_dependencies: integration_test: diff --git a/test/app/floatick_app_test.dart b/test/app/floatick_app_test.dart index 615a132..881a78b 100644 --- a/test/app/floatick_app_test.dart +++ b/test/app/floatick_app_test.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:floatick/app/floatick_app.dart'; import 'package:floatick/core/platform/window_bridge.dart'; -import 'package:floatick/core/storage/storage_failure.dart'; import 'package:floatick/features/notes/data/note_repository.dart'; import 'package:floatick/features/notes/domain/note_item.dart'; import 'package:floatick/features/notes/presentation/note_view_model.dart'; @@ -11,18 +10,10 @@ 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.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'; import 'package:floatick/features/todos/domain/todo_item.dart'; -import 'package:floatick/features/todos/domain/todo_tag.dart'; -import 'package:floatick/features/todos/presentation/todo_editor_drawer.dart'; -import 'package:floatick/features/todos/presentation/todo_panel.dart'; import 'package:floatick/features/todos/presentation/todo_view_model.dart'; import 'package:floatick/features/updates/data/update_repository.dart'; import 'package:floatick/features/updates/domain/update_settings_snapshot.dart'; @@ -66,19 +57,10 @@ void main() { final updateController = UpdateViewModel( updateRepository: updateRepository, ); - final stickyBoardController = StickyBoardViewModel( - repository: _WidgetTestStickyBoardRepository(), - ); - final stickyBoardWindowCoordinator = StickyBoardWindowCoordinator( - boardController: stickyBoardController, - todoController: controller, - windowBridge: windowBridge, - ); await controller.load(); await noteController.load(); await settingsController.load(); await updateController.load(); - await stickyBoardController.load(); await tester.pumpWidget( FloatickApp( @@ -86,8 +68,6 @@ void main() { noteController: noteController, settingsController: settingsController, updateController: updateController, - stickyBoardController: stickyBoardController, - stickyBoardWindowCoordinator: stickyBoardWindowCoordinator, windowBridge: windowBridge, locale: const Locale('zh'), ), @@ -141,6 +121,14 @@ void main() { expect(find.text('今天已经清空'), findsOneWidget); expect(find.byKey(const ValueKey('panel-brand-mark')), findsOneWidget); expect(find.byKey(const Key('search-field')), findsOneWidget); + expect(find.byKey(const Key('doing-filter-button')), findsOneWidget); + expect( + find.descendant( + of: find.byKey(const Key('doing-filter-button')), + matching: find.byIcon(Icons.timelapse_rounded), + ), + findsOneWidget, + ); expect(find.byKey(const Key('tag-filter-button')), findsOneWidget); expect(find.byKey(const Key('add-todo-button')), findsOneWidget); expect(find.byKey(const Key('archive-scope-button')), findsOneWidget); @@ -151,28 +139,76 @@ void main() { await tester.pumpAndSettle(); expect(find.text('归档 · 0'), findsOneWidget); expect(find.text('搜索归档'), findsOneWidget); + expect(find.byKey(const Key('doing-filter-button')), findsNothing); expect(find.byKey(const Key('add-todo-button')), findsNothing); await tester.tap(find.byKey(const Key('archive-scope-button'))); await tester.pumpAndSettle(); expect(find.text('今天已经清空'), findsOneWidget); expect(find.text('搜索待办'), findsOneWidget); + expect(find.byKey(const Key('doing-filter-button')), findsOneWidget); expect(find.byKey(const Key('add-todo-button')), findsOneWidget); + await tester.tap(find.byKey(const Key('doing-filter-button'))); + await tester.pumpAndSettle(); + expect( + tester + .widget(find.byKey(const Key('doing-filter-button'))) + .isSelected, + isTrue, + ); + expect( + find.descendant( + of: find.byKey(const Key('doing-filter-button')), + matching: find.byIcon(Icons.timelapse_rounded), + ), + findsOneWidget, + ); + expect(find.text('暂无进行中的待办'), findsOneWidget); + await tester.tap(find.byKey(const Key('clear-doing-filter'))); + await tester.pumpAndSettle(); + expect( + tester + .widget(find.byKey(const Key('doing-filter-button'))) + .isSelected, + isFalse, + ); + final searchRect = tester.getRect(find.byKey(const Key('search-field'))); + final doingFilterRect = tester.getRect( + find.byKey(const Key('doing-filter-button')), + ); final tagFilterRect = tester.getRect( find.byKey(const Key('tag-filter-button')), ); final newTodoRect = tester.getRect( find.byKey(const Key('add-todo-button')), ); - expect(tagFilterRect.left, greaterThan(searchRect.right)); + expect(doingFilterRect.left, greaterThan(searchRect.right)); + expect(tagFilterRect.left, greaterThan(doingFilterRect.right)); expect(newTodoRect.left, greaterThan(tagFilterRect.right)); + expect( + (doingFilterRect.center.dy - searchRect.center.dy).abs(), + lessThan(1), + ); expect((tagFilterRect.center.dy - searchRect.center.dy).abs(), lessThan(1)); expect((newTodoRect.center.dy - searchRect.center.dy).abs(), lessThan(1)); expect(tagFilterRect.size, const Size.square(42)); - expect(newTodoRect.height, 42); - expect(find.text('新建'), findsOneWidget); + expect(doingFilterRect.size, const Size.square(42)); + expect(newTodoRect.size, const Size.square(42)); + expect( + tester + .widget(find.byKey(const Key('add-todo-button'))) + .tooltip, + '新建', + ); + expect( + find.descendant( + of: find.byKey(const Key('add-todo-button')), + matching: find.byIcon(Icons.add_rounded), + ), + findsOneWidget, + ); final panelSurface = tester.widget( find.byKey(const Key('todo-panel-surface')), ); @@ -180,15 +216,65 @@ void main() { expect(panelDecoration.boxShadow, isNull); expect(find.byKey(const Key('settings-drawer-slide')), findsNothing); expect(find.byKey(const Key('tag-drawer-slide')), findsNothing); - expect(find.byKey(const Key('sticky-board-drawer-slide')), findsNothing); expect(find.byKey(const Key('todo-drawer-slide')), findsNothing); expect(find.byKey(const Key('todo-context-scrim')), findsNothing); expect(find.byKey(const Key('todo-tab')), findsOneWidget); expect(find.byKey(const Key('note-tab')), findsOneWidget); + final todoTabInkWell = tester.widget( + find.descendant( + of: find.byKey(const Key('todo-tab')), + matching: find.byType(InkWell), + ), + ); + expect(todoTabInkWell.highlightColor, Colors.transparent); + expect(todoTabInkWell.splashFactory, NoSplash.splashFactory); + final todoTabSizeBeforeHover = tester.getSize( + find.byKey(const Key('todo-tab')), + ); + final todoTabHoverSurface = find.descendant( + of: find.byKey(const Key('todo-tab')), + matching: find.byKey(const Key('content-tab-hover-surface')), + ); + expect( + (tester.widget(todoTabHoverSurface).decoration + as BoxDecoration) + .color, + Colors.transparent, + ); + final tabHoverMouse = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await tabHoverMouse.addPointer(location: Offset.zero); + await tabHoverMouse.moveTo( + tester.getCenter(find.byKey(const Key('todo-tab'))), + ); + await tester.pump(const Duration(milliseconds: 160)); + expect( + (tester.widget(todoTabHoverSurface).decoration + as BoxDecoration) + .color, + isNot(Colors.transparent), + ); + expect( + tester.getSize(find.byKey(const Key('todo-tab'))), + todoTabSizeBeforeHover, + ); + await tabHoverMouse.moveTo(Offset.zero); + await tester.pump(const Duration(milliseconds: 160)); + await tabHoverMouse.removePointer(); + expect( + find.byKey(const Key('selected-content-tab-indicator')), + findsNothing, + ); await tester.tap(find.byKey(const Key('note-tab'))); await tester.pumpAndSettle(); + expect( + find.byKey(const Key('selected-content-tab-indicator')), + findsNothing, + ); expect(find.text('搜索笔记'), findsOneWidget); + expect(find.byKey(const Key('doing-filter-button')), findsNothing); expect(find.byKey(const Key('tag-filter-button')), findsOneWidget); expect(find.byKey(const Key('add-note-button')), findsOneWidget); expect(find.text('写下第一条笔记'), findsOneWidget); @@ -213,6 +299,7 @@ void main() { await tester.tap(find.byKey(const Key('todo-tab'))); await tester.pumpAndSettle(); expect(find.text('搜索待办'), findsOneWidget); + expect(find.byKey(const Key('doing-filter-button')), findsOneWidget); await tester.tap(find.byKey(const Key('settings-button'))); await tester.pumpAndSettle(); @@ -448,6 +535,15 @@ void main() { tester.getCenter(find.text('Design the floating icon').hitTestable()), ); await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('deadline-todo-new-todo'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('todo-deadline-picker')), findsOneWidget); + expect(find.byKey(const Key('deadline-shortcut-row')), findsNothing); + await tester.tap(find.byKey(const Key('save-todo-deadline'))); + await tester.pumpAndSettle(); + expect(repository.savedItems.single.dueAt, isNotNull); + expect(find.byKey(const Key('deadline-status-new-todo')), findsOneWidget); + await tester.tap(find.byKey(const Key('more-todo-new-todo'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('todo-action-view-new-todo'))); @@ -578,27 +674,16 @@ void main() { final updateController = UpdateViewModel( updateRepository: _WidgetTestUpdateRepository(), ); - final stickyBoardController = StickyBoardViewModel( - repository: _WidgetTestStickyBoardRepository(), - ); final windowBridge = _WidgetTestWindowBridge(); - final stickyBoardWindowCoordinator = StickyBoardWindowCoordinator( - boardController: stickyBoardController, - todoController: controller, - windowBridge: windowBridge, - ); await controller.load(); await settingsController.load(); await updateController.load(); - await stickyBoardController.load(); await tester.pumpWidget( FloatickApp( controller: controller, settingsController: settingsController, updateController: updateController, - stickyBoardController: stickyBoardController, - stickyBoardWindowCoordinator: stickyBoardWindowCoordinator, windowBridge: windowBridge, locale: const Locale('en'), ), @@ -909,549 +994,6 @@ void main() { ); }); - testWidgets('sticky boards create virtual groups and reuse the todo editor', ( - WidgetTester tester, - ) async { - tester.view.physicalSize = const Size(440, 700); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - final todoRepository = _WidgetTestRepository() - ..savedItems = [ - TodoItem( - id: 'existing-todo', - title: 'Review the launch checklist', - createdAt: DateTime.utc(2026, 7, 26, 9), - ), - TodoItem( - id: 'content-only-todo', - title: 'Plan the next iteration', - content: 'Private launch phrase', - createdAt: DateTime.utc(2026, 7, 26, 8), - ), - ]; - final tagRepository = _WidgetTestTagRepository() - ..savedWorkspace = TagWorkspace( - tags: [ - TodoTag( - id: 'tag-focus', - name: 'Focus', - colorValue: 0xFF4C8FF5, - createdAt: DateTime.utc(2026, 7, 26, 7), - ), - ], - assignments: const >{ - 'existing-todo': ['tag-focus'], - }, - ); - var todoSequence = 0; - final todoController = TodoViewModel( - todoRepository: todoRepository, - tagRepository: tagRepository, - idGenerator: () => 'created-todo-${++todoSequence}', - ); - final stickyBoardRepository = _WidgetTestStickyBoardRepository(); - final stickyBoardController = StickyBoardViewModel( - repository: stickyBoardRepository, - idGenerator: () => 'board-launch', - ); - final settingsController = SettingsViewModel( - settingsRepository: _WidgetTestSettingsRepository(), - loginItemRepository: _WidgetTestLoginItemRepository(), - ); - final updateController = UpdateViewModel( - updateRepository: _WidgetTestUpdateRepository(), - ); - final windowBridge = _WidgetTestWindowBridge(); - final stickyBoardWindowCoordinator = StickyBoardWindowCoordinator( - boardController: stickyBoardController, - todoController: todoController, - windowBridge: windowBridge, - windowLauncher: (_) async {}, - ); - await Future.wait(>[ - todoController.load(), - stickyBoardController.load(), - settingsController.load(), - updateController.load(), - ]); - - await tester.pumpWidget( - FloatickApp( - controller: todoController, - settingsController: settingsController, - updateController: updateController, - stickyBoardController: stickyBoardController, - stickyBoardWindowCoordinator: stickyBoardWindowCoordinator, - windowBridge: windowBridge, - locale: const Locale('en'), - ), - ); - windowBridge.expandRequestHandler?.call(WindowExpansionAnchor.topRight); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('sticky-boards-button')), findsOneWidget); - expect( - find.descendant( - of: find.byKey(const Key('sticky-boards-button')), - matching: find.byIcon(Icons.sticky_note_2_outlined), - ), - findsOneWidget, - ); - await tester.tap(find.byKey(const Key('sticky-boards-button'))); - await tester.pumpAndSettle(); - expect( - find.byKey(const Key('sticky-board-management-drawer')), - findsOneWidget, - ); - - 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-grid')), - findsOneWidget, - ); - expect(find.byKey(const Key('sticky-board-board-launch')), findsOneWidget); - expect( - find.byKey(const Key('sticky-board-thumbnail-board-launch')), - findsOneWidget, - ); - final pinButton = find.byKey( - const Key('toggle-sticky-board-pin-board-launch'), - ); - expect(pinButton, findsOneWidget); - await tester.tap(pinButton); - await tester.pumpAndSettle(); - expect(stickyBoardController.boardById('board-launch')?.isPinned, isTrue); - expect( - find.descendant( - of: pinButton, - matching: find.byIcon(Icons.push_pin_rounded), - ), - findsOneWidget, - ); - await tester.tap(pinButton); - await tester.pumpAndSettle(); - expect(stickyBoardController.boardById('board-launch')?.isPinned, isFalse); - - final boardMouse = await tester.createGesture( - kind: PointerDeviceKind.mouse, - ); - addTearDown(boardMouse.removePointer); - await boardMouse.addPointer(); - await boardMouse.moveTo( - tester.getCenter( - find.byKey(const Key('sticky-board-thumbnail-board-launch')), - ), - ); - await tester.pumpAndSettle(); - final boardRectBeforeConfirmation = tester.getRect( - find.byKey(const Key('sticky-board-thumbnail-board-launch')), - ); - await tester.tap(find.byKey(const Key('delete-sticky-board-board-launch'))); - await tester.pumpAndSettle(); - - final deleteConfirmation = find.byKey( - const Key('sticky-board-delete-confirmation-board-launch'), - ); - final cancelDeleteBoard = find.byKey( - const Key('cancel-delete-sticky-board-board-launch'), - ); - final confirmDeleteBoard = find.byKey( - const Key('confirm-delete-sticky-board-board-launch'), - ); - expect(deleteConfirmation, findsOneWidget); - expect(find.byType(AlertDialog), findsOneWidget); - expect(cancelDeleteBoard, findsOneWidget); - expect(confirmDeleteBoard, findsOneWidget); - expect( - find.descendant(of: confirmDeleteBoard, matching: find.text('Confirm')), - findsOneWidget, - ); - expect(find.text('Delete sticky board'), findsNothing); - expect( - tester.getRect( - find.byKey(const Key('sticky-board-thumbnail-board-launch')), - ), - boardRectBeforeConfirmation, - ); - expect(tester.takeException(), isNull); - - await tester.tap(cancelDeleteBoard); - await tester.pumpAndSettle(); - expect(deleteConfirmation, findsNothing); - expect(stickyBoardController.boardById('board-launch'), isNotNull); - await boardMouse.moveTo(Offset.zero); - await tester.pumpAndSettle(); - - await tester.tap(find.byKey(const Key('sticky-board-board-launch'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('sticky-board-detail-drawer')), findsOneWidget); - - tester.widget(find.byType(TodoPanel)).onCollapse(); - await tester.pumpAndSettle(); - expect(windowBridge.expandedValues, [true, false]); - windowBridge.expandRequestHandler?.call(WindowExpansionAnchor.topRight); - await tester.pumpAndSettle(); - expect(windowBridge.expandedValues, [true, false, true]); - expect(find.byKey(const Key('sticky-board-detail-drawer')), findsOneWidget); - expect( - tester - .widget( - find.byKey(const Key('sticky-board-drawer-slide')), - ) - .offset, - Offset.zero, - ); - - await tester.tap(find.byKey(const Key('sticky-board-add-existing'))); - await tester.pumpAndSettle(); - expect( - find.byKey(const Key('sticky-board-todo-picker-drawer')), - findsOneWidget, - ); - final pickerTile = tester.widget( - find.byKey(const Key('sticky-board-picker-existing-todo')), - ); - final pickerShape = pickerTile.shape as RoundedRectangleBorder; - expect( - pickerShape.borderRadius, - const BorderRadius.all(Radius.circular(11)), - ); - expect( - find.ancestor( - of: find.byKey(const Key('sticky-board-picker-existing-todo')), - matching: find.byWidgetPredicate( - (widget) => - widget is Material && - widget.clipBehavior == Clip.antiAlias && - widget.shape == pickerShape, - ), - ), - findsOneWidget, - ); - await tester.enterText( - find.byKey(const Key('sticky-board-todo-search')), - 'Private launch phrase', - ); - await tester.pump(); - expect( - find.byKey(const Key('sticky-board-picker-content-only-todo')), - findsNothing, - ); - await tester.enterText( - find.byKey(const Key('sticky-board-todo-search')), - 'Focus', - ); - await tester.pump(); - expect( - find.byKey(const Key('sticky-board-picker-existing-todo')), - findsOneWidget, - ); - await tester.tap( - find.byKey(const Key('sticky-board-picker-existing-todo')), - ); - await tester.pumpAndSettle(); - await tester.tap(find.byTooltip('Back to Sticky Boards')); - await tester.pumpAndSettle(); - expect(stickyBoardController.todoIdsForBoard('board-launch'), [ - 'existing-todo', - ]); - expect( - find.byKey(const Key('sticky-board-todo-existing-todo')), - findsOneWidget, - ); - final stickyBoardDetail = find.byKey( - const Key('sticky-board-detail-drawer'), - ); - expect( - find.descendant( - of: stickyBoardDetail, - matching: find.byKey( - const Key('sticky-board-managed-tag-existing-todo-tag-focus'), - ), - ), - findsOneWidget, - ); - expect( - find.descendant( - of: stickyBoardDetail, - matching: find.byKey( - const Key('sticky-board-managed-time-existing-todo'), - ), - ), - findsOneWidget, - ); - expect( - find.descendant( - of: stickyBoardDetail, - matching: find.byKey(const Key('toggle-todo-existing-todo')), - ), - findsNothing, - ); - expect( - find.descendant( - of: stickyBoardDetail, - matching: find.byKey(const Key('edit-todo-existing-todo')), - ), - findsNothing, - ); - expect( - find.descendant( - of: stickyBoardDetail, - matching: find.byKey(const Key('view-todo-existing-todo')), - ), - findsNothing, - ); - expect( - find.descendant( - of: stickyBoardDetail, - matching: find.byKey(const Key('archive-todo-existing-todo')), - ), - findsNothing, - ); - expect( - find.descendant( - of: stickyBoardDetail, - matching: find.byKey(const Key('assign-tags-existing-todo')), - ), - findsNothing, - ); - expect( - find.descendant( - of: stickyBoardDetail, - matching: find.byKey(const Key('remove-from-board-existing-todo')), - ), - findsOneWidget, - ); - - await tester.tap( - find.descendant( - of: stickyBoardDetail, - matching: find.byKey( - const Key('sticky-board-managed-open-details-existing-todo'), - ), - ), - ); - await tester.pump(kDoubleTapMinTime); - await tester.tap( - find.descendant( - of: stickyBoardDetail, - matching: find.byKey( - const Key('sticky-board-managed-open-details-existing-todo'), - ), - ), - ); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('sticky-board-todo-details')), findsOneWidget); - expect(find.byKey(const Key('sticky-board-details-edit')), findsNothing); - await tester.tap(find.byKey(const Key('sticky-board-details-back'))); - await tester.pumpAndSettle(); - - await tester.tap(find.byKey(const Key('sticky-board-new-todo'))); - await tester.pumpAndSettle(); - await tester.enterText( - find.byKey(const Key('todo-title-field')), - 'Share the release notes', - ); - stickyBoardRepository.failNextSave = true; - await tester.pump(); - expect( - tester - .widget(find.byKey(const Key('save-todo-details'))) - .onPressed, - isNotNull, - ); - await tester.tap(find.byKey(const Key('save-todo-details'))); - await tester.pumpAndSettle(); - - expect( - todoController.items.where( - (item) => item.title == 'Share the release notes', - ), - hasLength(1), - ); - expect(stickyBoardController.todoCountForBoard('board-launch'), 1); - expect(find.byKey(const Key('todo-title-field')), findsOneWidget); - - await tester.tap(find.byKey(const Key('save-todo-details'))); - await tester.pumpAndSettle(); - - expect( - todoController.items.map((item) => item.id), - contains('created-todo-1'), - ); - expect(stickyBoardController.todoCountForBoard('board-launch'), 2); - expect(stickyBoardController.todoIdsForBoard('board-launch'), [ - 'existing-todo', - 'created-todo-1', - ]); - expect(find.text('Share the release notes'), findsWidgets); - - stickyBoardWindowCoordinator.requestMainWindow( - const StickyBoardMainWindowRequest( - boardId: 'board-launch', - destination: StickyBoardMainWindowDestination.todoEdit, - todoId: 'existing-todo', - ), - ); - await tester.pumpAndSettle(); - expect(find.text('Edit todo'), findsOneWidget); - expect( - tester.widget(find.byType(TodoEditorDrawer)).item?.id, - 'existing-todo', - ); - expect( - tester - .widget(find.byKey(const Key('todo-title-field'))) - .controller - ?.text, - 'Review the launch checklist', - ); - await tester.tap(find.byKey(const Key('todo-drawer-close'))); - await tester.pumpAndSettle(); - await tester.tap(find.byTooltip('Close Sticky Boards')); - await tester.pumpAndSettle(); - - tester.widget(find.byType(TodoPanel)).onCollapse(); - await tester.pumpAndSettle(); - windowBridge.expandRequestHandler?.call(WindowExpansionAnchor.topRight); - await tester.pumpAndSettle(); - expect( - tester - .widget( - find.byKey(const Key('sticky-board-drawer-slide')), - ) - .offset, - isNot(Offset.zero), - ); - expect(find.byKey(const Key('search-field')).hitTestable(), findsOneWidget); - - await tester.tap(find.byKey(const Key('sticky-boards-button'))); - await tester.pumpAndSettle(); - await boardMouse.moveTo( - tester.getCenter( - find.byKey(const Key('sticky-board-thumbnail-board-launch')), - ), - ); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('delete-sticky-board-board-launch'))); - await tester.pumpAndSettle(); - await tester.tap( - find.byKey(const Key('confirm-delete-sticky-board-board-launch')), - ); - await tester.pumpAndSettle(); - - expect(stickyBoardController.boardById('board-launch'), isNull); - expect(todoController.itemById('existing-todo'), isNotNull); - expect(todoController.itemById('created-todo-1'), isNotNull); - expect(tester.takeException(), isNull); - }); - - testWidgets('permanent deletion waits for sticky board cleanup', ( - WidgetTester tester, - ) async { - tester.view.physicalSize = const Size(500, 760); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - final archivedTodo = TodoItem( - id: 'archived-linked', - title: 'Archived linked todo', - createdAt: DateTime.utc(2026, 7, 26, 8), - archivedAt: DateTime.utc(2026, 7, 26, 9), - ); - final todoRepository = _WidgetTestRepository() - ..savedItems = [archivedTodo]; - final boardRepository = _WidgetTestStickyBoardRepository() - ..savedWorkspace = StickyBoardWorkspace( - boards: [ - StickyBoard( - id: 'board-linked', - name: 'Linked', - colorValue: 0xFF20B8A8, - createdAt: DateTime.utc(2026, 7, 26, 7), - ), - ], - boardTodoIds: const >{ - 'board-linked': ['archived-linked'], - }, - ); - final todoController = TodoViewModel( - todoRepository: todoRepository, - tagRepository: _WidgetTestTagRepository(), - ); - final boardController = StickyBoardViewModel(repository: boardRepository); - final settingsController = SettingsViewModel( - settingsRepository: _WidgetTestSettingsRepository(), - loginItemRepository: _WidgetTestLoginItemRepository(), - ); - final updateController = UpdateViewModel( - updateRepository: _WidgetTestUpdateRepository(), - ); - final windowBridge = _WidgetTestWindowBridge(); - final coordinator = StickyBoardWindowCoordinator( - boardController: boardController, - todoController: todoController, - windowBridge: windowBridge, - ); - await Future.wait(>[ - todoController.load(), - boardController.load(), - settingsController.load(), - updateController.load(), - ]); - - await tester.pumpWidget( - FloatickApp( - controller: todoController, - settingsController: settingsController, - updateController: updateController, - stickyBoardController: boardController, - stickyBoardWindowCoordinator: coordinator, - windowBridge: windowBridge, - locale: const Locale('en'), - ), - ); - windowBridge.expandRequestHandler?.call(WindowExpansionAnchor.topRight); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('archive-scope-button'))); - await tester.pumpAndSettle(); - expect(find.text('Archive · 1'), findsOneWidget); - expect(find.text('Archive 1'), findsNothing); - - final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); - addTearDown(mouse.removePointer); - await mouse.addPointer(); - await mouse.moveTo(tester.getCenter(find.text('Archived linked todo'))); - await tester.pumpAndSettle(); - - boardRepository.failNextSave = true; - await tester.tap(find.byKey(const Key('more-todo-archived-linked'))); - await tester.pumpAndSettle(); - await tester.tap( - find.byKey(const Key('todo-action-delete-archived-linked')), - ); - await tester.pumpAndSettle(); - await tester.tap( - find.byKey(const Key('confirm-delete-todo-archived-linked')), - ); - await tester.pumpAndSettle(); - - expect(todoController.itemById('archived-linked'), archivedTodo); - expect(todoRepository.savedItems, [archivedTodo]); - expect(boardController.todoIdsForBoard('board-linked'), [ - 'archived-linked', - ]); - expect(find.text("Floatick couldn't save to .floatick."), findsOneWidget); - }); - testWidgets('English locale translates the primary todo experience', ( WidgetTester tester, ) async { @@ -1471,27 +1013,16 @@ void main() { final updateController = UpdateViewModel( updateRepository: _WidgetTestUpdateRepository(), ); - final stickyBoardController = StickyBoardViewModel( - repository: _WidgetTestStickyBoardRepository(), - ); final windowBridge = _WidgetTestWindowBridge(); - final stickyBoardWindowCoordinator = StickyBoardWindowCoordinator( - boardController: stickyBoardController, - todoController: controller, - windowBridge: windowBridge, - ); await controller.load(); await settingsController.load(); await updateController.load(); - await stickyBoardController.load(); await tester.pumpWidget( FloatickApp( controller: controller, settingsController: settingsController, updateController: updateController, - stickyBoardController: stickyBoardController, - stickyBoardWindowCoordinator: stickyBoardWindowCoordinator, windowBridge: windowBridge, locale: const Locale('en'), ), @@ -1503,11 +1034,10 @@ void main() { expect(find.text('All clear for today'), findsOneWidget); expect(find.text('Search todos'), findsOneWidget); expect( - find.descendant( - of: find.byKey(const Key('add-todo-button')), - matching: find.text('New'), - ), - findsOneWidget, + tester + .widget(find.byKey(const Key('add-todo-button'))) + .tooltip, + 'New', ); expect(find.text('今天已经清空'), findsNothing); @@ -1554,27 +1084,16 @@ void main() { final updateController = UpdateViewModel( updateRepository: _WidgetTestUpdateRepository(), ); - final stickyBoardController = StickyBoardViewModel( - repository: _WidgetTestStickyBoardRepository(), - ); final windowBridge = _WidgetTestWindowBridge(); - final stickyBoardWindowCoordinator = StickyBoardWindowCoordinator( - boardController: stickyBoardController, - todoController: controller, - windowBridge: windowBridge, - ); await controller.load(); await settingsController.load(); await updateController.load(); - await stickyBoardController.load(); await tester.pumpWidget( FloatickApp( controller: controller, settingsController: settingsController, updateController: updateController, - stickyBoardController: stickyBoardController, - stickyBoardWindowCoordinator: stickyBoardWindowCoordinator, windowBridge: windowBridge, ), ); @@ -1706,26 +1225,6 @@ class _WidgetTestTagRepository implements TagRepository { } } -class _WidgetTestStickyBoardRepository implements StickyBoardRepository { - StickyBoardWorkspace savedWorkspace = StickyBoardWorkspace.empty(); - bool failNextSave = false; - - @override - String get storagePath => '/tmp/floatick-widget-test/sticky_boards.json'; - - @override - Future load() async => savedWorkspace; - - @override - Future save(StickyBoardWorkspace workspace) async { - if (failNextSave) { - failNextSave = false; - throw const StorageFailure(kind: StorageFailureKind.write); - } - savedWorkspace = workspace; - } -} - class _WidgetTestUpdateRepository implements UpdateRepository { bool automaticallyChecksForUpdates = true; bool feedUnavailable = false; @@ -1808,13 +1307,4 @@ class _WidgetTestWindowBridge 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 {} } diff --git a/test/app/theme/floatick_theme_test.dart b/test/app/theme/floatick_theme_test.dart index f63aab2..9cf1bb3 100644 --- a/test/app/theme/floatick_theme_test.dart +++ b/test/app/theme/floatick_theme_test.dart @@ -12,6 +12,17 @@ void main() { expect(buildFloatickTheme(Brightness.light).colorScheme.surface.a, 1); }); + test('dark theme uses the quiet focus surface hierarchy', () { + final theme = buildFloatickTheme(Brightness.dark); + + expect(theme.colorScheme.surface, FloatickColors.darkSurface); + expect(theme.colorScheme.onSurface, FloatickColors.darkOnSurface); + expect( + theme.inputDecorationTheme.fillColor, + FloatickColors.darkSurfaceElevated, + ); + }); + for (final brightness in Brightness.values) { test( '$brightness icon buttons use color feedback without a state fill', diff --git a/test/core/platform/window_bridge_test.dart b/test/core/platform/window_bridge_test.dart index 0408b61..da081cd 100644 --- a/test/core/platform/window_bridge_test.dart +++ b/test/core/platform/window_bridge_test.dart @@ -12,44 +12,6 @@ void main() { .setMockMethodCallHandler(channel, null); }); - test('configures the requested secondary window as borderless', () async { - final calls = []; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - calls.add(call); - return null; - }); - final bridge = MethodChannelWindowBridge(); - - await bridge.configureBorderlessSecondaryWindow( - 42, - positionAdjacentToMainWindow: true, - ); - - expect(calls, hasLength(1)); - expect(calls.single.method, 'configureBorderlessSecondaryWindow'); - expect(calls.single.arguments, { - 'viewId': 42, - 'positionAdjacentToMainWindow': true, - }); - }); - - test('reveals a configured secondary window', () async { - final calls = []; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - calls.add(call); - return null; - }); - final bridge = MethodChannelWindowBridge(); - - await bridge.revealBorderlessSecondaryWindow(42); - - expect(calls, hasLength(1)); - expect(calls.single.method, 'revealBorderlessSecondaryWindow'); - expect(calls.single.arguments, 42); - }); - test('coordinates the fixed main window and native floating icon', () async { final calls = []; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger @@ -115,4 +77,114 @@ void main() { expect(collapseRequestCount, 1); }); + + test('sends reminder payloads and decodes native actions', () async { + final calls = []; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return null; + }); + final bridge = MethodChannelWindowBridge(); + final actions = []; + bridge.setDeadlineReminderActionHandler(actions.add); + + await bridge.showDeadlineReminder( + const DeadlineReminderPayload( + notificationId: 'todo-1:deadline:1', + todoId: 'todo-1', + title: 'Ship release', + dueLabel: 'Aug 7 · 18:00', + isOverdue: true, + isAdvanceReminder: false, + tagLabel: 'floatick', + ), + ); + await messenger.handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall( + const MethodCall('deadlineReminderAction', { + 'todoId': 'todo-1', + 'action': 'open', + }), + ), + null, + ); + + expect(calls.single.method, 'showDeadlineReminder'); + expect(calls.single.arguments, { + 'notificationId': 'todo-1:deadline:1', + 'todoId': 'todo-1', + 'title': 'Ship release', + 'dueLabel': 'Aug 7 · 18:00', + 'isOverdue': true, + 'isAdvanceReminder': false, + 'tagLabel': 'floatick', + }); + expect(actions.single.kind, DeadlineReminderActionKind.open); + expect(actions.single.todoId, 'todo-1'); + }); + + test('synchronizes native schedules and decodes delivery events', () async { + final calls = []; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return null; + }); + final bridge = MethodChannelWindowBridge(); + final deliveries = []; + bridge.setDeadlineReminderDeliveryHandler(deliveries.add); + final triggerAt = DateTime.utc(2026, 8, 7, 10); + + await bridge + .synchronizeScheduledDeadlineReminders([ + ScheduledDeadlineReminder( + notificationId: 'todo-1:deadline:1', + triggerAt: triggerAt, + deliveryKind: DeadlineReminderDeliveryKind.deadline, + payload: const DeadlineReminderPayload( + notificationId: 'todo-1:deadline:1', + todoId: 'todo-1', + title: 'Ship release', + dueLabel: 'Aug 7 · 18:00', + isOverdue: false, + isAdvanceReminder: false, + ), + ), + ]); + await messenger.handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall( + const MethodCall('deadlineReminderDelivered', { + 'notificationId': 'todo-1:deadline:1', + 'todoId': 'todo-1', + 'deliveryKind': 'deadline', + }), + ), + null, + ); + + expect(calls.single.method, 'synchronizeScheduledDeadlineReminders'); + expect(calls.single.arguments, >[ + { + 'notificationId': 'todo-1:deadline:1', + 'triggerAtMilliseconds': triggerAt.millisecondsSinceEpoch, + 'deliveryKind': 'deadline', + 'payload': { + 'notificationId': 'todo-1:deadline:1', + 'todoId': 'todo-1', + 'title': 'Ship release', + 'dueLabel': 'Aug 7 · 18:00', + 'isOverdue': false, + 'isAdvanceReminder': false, + }, + }, + ]); + expect(deliveries.single.notificationId, 'todo-1:deadline:1'); + expect(deliveries.single.todoId, 'todo-1'); + expect(deliveries.single.kind, DeadlineReminderDeliveryKind.deadline); + }); } diff --git a/test/features/notes/presentation/note_panel_content_test.dart b/test/features/notes/presentation/note_panel_content_test.dart index fc8eaaa..fb021c3 100644 --- a/test/features/notes/presentation/note_panel_content_test.dart +++ b/test/features/notes/presentation/note_panel_content_test.dart @@ -8,6 +8,81 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + testWidgets('note list groups active notes by their last edited date', ( + WidgetTester tester, + ) async { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final yesterday = today.subtract(const Duration(days: 1)); + final olderDay = today.subtract(const Duration(days: 3)); + final controller = NoteViewModel( + repository: _MemoryNoteRepository([ + _note(id: 'today-new', day: today, hour: 15), + _note(id: 'today-old', day: today, hour: 9), + _note(id: 'previous-day', day: yesterday, hour: 16), + _note(id: 'older', day: olderDay, hour: 12), + ]), + ); + await controller.load(); + + await tester.pumpWidget( + _NotePanelTestApp(controller: controller, archived: false), + ); + await tester.pumpAndSettle(); + + expect(find.text('Today'), findsOneWidget); + expect(find.text('Yesterday'), findsOneWidget); + final context = tester.element(find.byType(NotePanelContent)); + expect( + find.text(MaterialLocalizations.of(context).formatFullDate(olderDay)), + findsOneWidget, + ); + expect( + tester.getTopLeft(find.text('Today New')).dy, + lessThan(tester.getTopLeft(find.text('Today Old')).dy), + ); + expect( + tester.getTopLeft(find.text('Today Old')).dy, + lessThan(tester.getTopLeft(find.text('Previous Day')).dy), + ); + }); + + testWidgets('archived notes are grouped by archive date', ( + WidgetTester tester, + ) async { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final yesterday = today.subtract(const Duration(days: 1)); + final editedDay = today.subtract(const Duration(days: 10)); + final controller = NoteViewModel( + repository: _MemoryNoteRepository([ + _note( + id: 'archived-today', + day: editedDay, + hour: 8, + archivedAt: today.add(const Duration(hours: 14)), + ), + _note( + id: 'archived-yesterday', + day: editedDay, + hour: 9, + archivedAt: yesterday.add(const Duration(hours: 11)), + ), + ]), + ); + await controller.load(); + + await tester.pumpWidget( + _NotePanelTestApp(controller: controller, archived: true), + ); + await tester.pumpAndSettle(); + + expect(find.text('Today'), findsOneWidget); + expect(find.text('Yesterday'), findsOneWidget); + expect(find.text('Archived Today'), findsOneWidget); + expect(find.text('Archived Yesterday'), findsOneWidget); + }); + testWidgets('note list shows shared tags and applies the tag filter', ( WidgetTester tester, ) async { @@ -80,6 +155,56 @@ void main() { }); } +NoteItem _note({ + required String id, + required DateTime day, + required int hour, + DateTime? archivedAt, +}) { + final timestamp = DateTime(day.year, day.month, day.day, hour); + return NoteItem( + id: id, + title: id + .split('-') + .map((part) => '${part[0].toUpperCase()}${part.substring(1)}') + .join(' '), + content: '', + createdAt: timestamp, + updatedAt: timestamp, + archivedAt: archivedAt, + ); +} + +class _NotePanelTestApp extends StatelessWidget { + const _NotePanelTestApp({required this.controller, required this.archived}); + + final NoteViewModel controller; + final bool archived; + + @override + Widget build(BuildContext context) { + return MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SizedBox( + width: 440, + height: 600, + child: NotePanelContent( + controller: controller, + archived: archived, + query: '', + availableTags: const [], + selectedTagIds: const {}, + onOpen: (_) {}, + ), + ), + ), + ); + } +} + class _MemoryNoteRepository implements NoteRepository { _MemoryNoteRepository(this.items); diff --git a/test/features/sticky_boards/data/sticky_board_repository_test.dart b/test/features/sticky_boards/data/sticky_board_repository_test.dart deleted file mode 100644 index 732b940..0000000 --- a/test/features/sticky_boards/data/sticky_board_repository_test.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'dart:io'; - -import 'package:floatick/features/sticky_boards/data/sticky_board_repository.dart'; -import 'package:floatick/features/sticky_boards/domain/sticky_board.dart'; -import 'package:floatick/features/sticky_boards/domain/sticky_board_workspace.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - test('local repository creates storage and persists a workspace', () async { - final root = await Directory.systemTemp.createTemp( - 'floatick-sticky-boards-', - ); - addTearDown(() => root.delete(recursive: true)); - final repository = LocalStickyBoardRepository(rootDirectory: root); - final workspace = StickyBoardWorkspace( - boards: [ - StickyBoard( - id: 'board-1', - name: 'This week', - colorValue: 0xFF4C8FF5, - createdAt: DateTime.utc(2026, 7, 26), - ), - ], - boardTodoIds: const >{ - 'board-1': ['todo-1'], - }, - ); - - await repository.save(workspace); - - expect(await repository.load(), isNot(same(workspace))); - expect((await repository.load()).boards, workspace.boards); - expect((await repository.load()).todoIdsForBoard('board-1'), [ - 'todo-1', - ]); - expect(await File(repository.storagePath).exists(), isTrue); - }); -} diff --git a/test/features/sticky_boards/domain/sticky_board_workspace_test.dart b/test/features/sticky_boards/domain/sticky_board_workspace_test.dart deleted file mode 100644 index e4f6007..0000000 --- a/test/features/sticky_boards/domain/sticky_board_workspace_test.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:floatick/features/sticky_boards/domain/sticky_board.dart'; -import 'package:floatick/features/sticky_boards/domain/sticky_board_workspace.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - test('workspace round-trips boards, ordered todo relations, and frame', () { - final workspace = StickyBoardWorkspace( - boards: [ - StickyBoard( - id: 'board-1', - name: 'Launch', - colorValue: 0xFF20B8A8, - createdAt: DateTime.utc(2026, 7, 26), - isPinned: true, - windowFrame: const StickyBoardWindowFrame( - left: 120, - top: 80, - width: 380, - height: 460, - ), - ), - ], - boardTodoIds: const >{ - 'board-1': ['todo-2', 'todo-1'], - }, - ); - - final restored = StickyBoardWorkspace.fromJson(workspace.toJson()); - - expect(restored.boards, workspace.boards); - expect(restored.todoIdsForBoard('board-1'), ['todo-2', 'todo-1']); - }); - - test('workspace rejects relations that reference an unknown board', () { - expect( - () => StickyBoardWorkspace.fromJson({ - 'version': 1, - 'boards': [], - 'boardTodoIds': { - 'missing-board': ['todo-1'], - }, - }), - throwsFormatException, - ); - }); -} diff --git a/test/features/sticky_boards/presentation/sticky_board_frame_save_scheduler_test.dart b/test/features/sticky_boards/presentation/sticky_board_frame_save_scheduler_test.dart deleted file mode 100644 index ba1de8b..0000000 --- a/test/features/sticky_boards/presentation/sticky_board_frame_save_scheduler_test.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:floatick/features/sticky_boards/presentation/sticky_board_frame_save_scheduler.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - testWidgets('coalesces rapid frame changes into the latest save', ( - WidgetTester tester, - ) async { - final scheduler = StickyBoardFrameSaveScheduler(); - addTearDown(scheduler.cancel); - var saveCount = 0; - - scheduler.schedule(() => saveCount += 1); - scheduler.schedule(() => saveCount += 1); - scheduler.schedule(() => saveCount += 1); - - await tester.pump(const Duration(milliseconds: 199)); - expect(saveCount, 0); - - await tester.pump(const Duration(milliseconds: 1)); - expect(saveCount, 1); - }); - - testWidgets('cancel prevents a pending frame save', ( - WidgetTester tester, - ) async { - final scheduler = StickyBoardFrameSaveScheduler(); - var saveCount = 0; - - scheduler.schedule(() => saveCount += 1); - scheduler.cancel(); - await tester.pump(const Duration(milliseconds: 200)); - - expect(saveCount, 0); - }); -} diff --git a/test/features/sticky_boards/presentation/sticky_board_palette_test.dart b/test/features/sticky_boards/presentation/sticky_board_palette_test.dart deleted file mode 100644 index d880599..0000000 --- a/test/features/sticky_boards/presentation/sticky_board_palette_test.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:floatick/features/sticky_boards/presentation/sticky_board_palette.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - test('uses the board color across the complete themed surface', () { - const baseColor = Color(0xFF20282C); - - final blueSurface = StickyBoardPalette.surfaceColor( - value: StickyBoardPalette.blue, - baseColor: baseColor, - brightness: Brightness.dark, - ); - final orangeSurface = StickyBoardPalette.surfaceColor( - value: StickyBoardPalette.orange, - baseColor: baseColor, - brightness: Brightness.dark, - ); - - expect(blueSurface, isNot(baseColor)); - expect(orangeSurface, isNot(baseColor)); - expect(blueSurface, isNot(orangeSurface)); - }); - - test('strengthens the complete board surface on hover', () { - const baseColor = Color(0xFFF4F6F5); - - final restingSurface = StickyBoardPalette.surfaceColor( - value: StickyBoardPalette.purple, - baseColor: baseColor, - brightness: Brightness.light, - ); - final hoveredSurface = StickyBoardPalette.surfaceColor( - value: StickyBoardPalette.purple, - baseColor: baseColor, - brightness: Brightness.light, - hovered: true, - ); - - expect(hoveredSurface, isNot(restingSurface)); - }); -} diff --git a/test/features/sticky_boards/presentation/sticky_board_view_model_test.dart b/test/features/sticky_boards/presentation/sticky_board_view_model_test.dart deleted file mode 100644 index 295c434..0000000 --- a/test/features/sticky_boards/presentation/sticky_board_view_model_test.dart +++ /dev/null @@ -1,124 +0,0 @@ -import 'package:floatick/features/sticky_boards/data/sticky_board_repository.dart'; -import 'package:floatick/features/sticky_boards/domain/sticky_board.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:flutter_test/flutter_test.dart'; - -void main() { - test( - 'CRUD keeps todos independent and supports many-to-many membership', - () async { - var idSequence = 0; - final repository = _MemoryStickyBoardRepository(); - final controller = StickyBoardViewModel( - repository: repository, - clock: () => DateTime.utc(2026, 7, 26), - idGenerator: () => 'board-${++idSequence}', - ); - await controller.load(); - - expect( - await controller.createBoard(name: ' Work ', colorValue: 0xFF20B8A8), - StickyBoardMutationResult.success, - ); - expect( - await controller.createBoard(name: 'work', colorValue: 0xFF4C8FF5), - StickyBoardMutationResult.duplicateName, - ); - expect( - await controller.createBoard(name: 'Personal', colorValue: 0xFF4C8FF5), - StickyBoardMutationResult.success, - ); - - await controller.addTodo(boardId: 'board-1', todoId: 'todo-1'); - await controller.addTodo(boardId: 'board-2', todoId: 'todo-1'); - await controller.addTodo(boardId: 'board-1', todoId: 'todo-2'); - - expect(controller.todoIdsForBoard('board-1'), [ - 'todo-1', - 'todo-2', - ]); - expect(controller.todoIdsForBoard('board-2'), ['todo-1']); - - await controller.deleteBoard('board-1'); - - expect(controller.boardById('board-1'), isNull); - expect(controller.todoIdsForBoard('board-2'), ['todo-1']); - expect(repository.savedWorkspace.boardTodoIds, >{ - 'board-2': ['todo-1'], - }); - }, - ); - - test( - 'pin state and window frame persist without changing relations', - () async { - final repository = _MemoryStickyBoardRepository(); - final controller = StickyBoardViewModel( - repository: repository, - idGenerator: () => 'board-1', - ); - await controller.load(); - await controller.createBoard(name: 'Today', colorValue: 0xFF20B8A8); - await controller.addTodo(boardId: 'board-1', todoId: 'todo-1'); - - expect(await controller.setPinned('board-1', true), isTrue); - expect( - await controller.saveWindowFrame( - boardId: 'board-1', - frame: const StickyBoardWindowFrame( - left: 10, - top: 20, - width: 380, - height: 460, - ), - ), - isTrue, - ); - - final board = controller.boardById('board-1'); - expect(board?.isPinned, isTrue); - expect(board?.windowFrame?.left, 10); - expect(controller.todoIdsForBoard('board-1'), ['todo-1']); - }, - ); - - test('removing a deleted todo cleans every board relation', () async { - var idSequence = 0; - final repository = _MemoryStickyBoardRepository(); - final controller = StickyBoardViewModel( - repository: repository, - idGenerator: () => 'board-${++idSequence}', - ); - await controller.load(); - await controller.createBoard(name: 'Work', colorValue: 0xFF20B8A8); - await controller.createBoard(name: 'Later', colorValue: 0xFF4C8FF5); - await controller.addTodo(boardId: 'board-1', todoId: 'todo-1'); - await controller.addTodo(boardId: 'board-1', todoId: 'todo-2'); - await controller.addTodo(boardId: 'board-2', todoId: 'todo-1'); - - expect(await controller.removeTodoFromAllBoards('todo-1'), isTrue); - - expect(controller.todoIdsForBoard('board-1'), ['todo-2']); - expect(controller.todoIdsForBoard('board-2'), isEmpty); - expect(controller.boards.length, 2); - expect(repository.savedWorkspace.boardTodoIds, >{ - 'board-1': ['todo-2'], - }); - }); -} - -class _MemoryStickyBoardRepository implements StickyBoardRepository { - StickyBoardWorkspace savedWorkspace = StickyBoardWorkspace.empty(); - - @override - String get storagePath => '/tmp/floatick-test/sticky_boards.json'; - - @override - Future load() async => savedWorkspace; - - @override - Future save(StickyBoardWorkspace workspace) async { - savedWorkspace = workspace; - } -} diff --git a/test/features/sticky_boards/presentation/sticky_board_window_coordinator_test.dart b/test/features/sticky_boards/presentation/sticky_board_window_coordinator_test.dart deleted file mode 100644 index 0966a37..0000000 --- a/test/features/sticky_boards/presentation/sticky_board_window_coordinator_test.dart +++ /dev/null @@ -1,285 +0,0 @@ -import 'package:floatick/core/platform/window_bridge.dart'; -import 'package:floatick/features/sticky_boards/data/sticky_board_repository.dart'; -import 'package:floatick/features/sticky_boards/domain/sticky_board.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'; -import 'package:floatick/features/todos/domain/todo_item.dart'; -import 'package:floatick/features/todos/presentation/todo_view_model.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - test('forwards a typed main-window navigation request', () { - final coordinator = StickyBoardWindowCoordinator( - boardController: StickyBoardViewModel( - repository: _MemoryStickyBoardRepository(), - ), - todoController: TodoViewModel( - todoRepository: _MemoryTodoRepository(), - tagRepository: _MemoryTagRepository(), - ), - windowBridge: _MemoryWindowBridge(), - ); - StickyBoardMainWindowRequest? receivedRequest; - coordinator.setMainWindowRequestHandler((request) { - receivedRequest = request; - }); - - coordinator.requestMainWindow( - const StickyBoardMainWindowRequest( - boardId: 'board-1', - destination: StickyBoardMainWindowDestination.todoEdit, - todoId: 'todo-1', - ), - ); - - expect(receivedRequest?.boardId, 'board-1'); - expect( - receivedRequest?.destination, - StickyBoardMainWindowDestination.todoEdit, - ); - expect(receivedRequest?.todoId, 'todo-1'); - }); - - test('continues restoring boards and retries only failed windows', () async { - final boardController = StickyBoardViewModel( - repository: _MemoryStickyBoardRepository( - workspace: StickyBoardWorkspace( - boards: [ - StickyBoard( - id: 'board-retry', - name: 'Retry', - colorValue: 0xFF20B8A8, - createdAt: DateTime.utc(2026, 7, 27), - isPinned: true, - ), - StickyBoard( - id: 'board-ready', - name: 'Ready', - colorValue: 0xFF4C8FF5, - createdAt: DateTime.utc(2026, 7, 27), - isPinned: true, - ), - ], - boardTodoIds: const >{}, - ), - ), - ); - await boardController.load(); - final launchCounts = {}; - final coordinator = StickyBoardWindowCoordinator( - boardController: boardController, - todoController: TodoViewModel( - todoRepository: _MemoryTodoRepository(), - tagRepository: _MemoryTagRepository(), - ), - windowBridge: _MemoryWindowBridge(), - windowLauncher: (boardId) async { - final attempt = (launchCounts[boardId] ?? 0) + 1; - launchCounts[boardId] = attempt; - if (boardId == 'board-retry' && attempt == 1) { - throw StateError('first launch failed'); - } - }, - ); - - await coordinator.restorePinnedBoards(); - expect(launchCounts, {'board-retry': 1, 'board-ready': 1}); - - await coordinator.restorePinnedBoards(); - expect(launchCounts, {'board-retry': 2, 'board-ready': 1}); - - await coordinator.restorePinnedBoards(); - expect(launchCounts, {'board-retry': 2, 'board-ready': 1}); - }); - - test('does not persist pinned state when the window cannot open', () async { - final boardController = StickyBoardViewModel( - repository: _MemoryStickyBoardRepository( - workspace: StickyBoardWorkspace( - boards: [ - StickyBoard( - id: 'board-failed', - name: 'Failed', - colorValue: 0xFF20B8A8, - createdAt: DateTime.utc(2026, 7, 27), - ), - ], - boardTodoIds: const >{}, - ), - ), - ); - await boardController.load(); - final coordinator = StickyBoardWindowCoordinator( - boardController: boardController, - todoController: TodoViewModel( - todoRepository: _MemoryTodoRepository(), - tagRepository: _MemoryTagRepository(), - ), - windowBridge: _MemoryWindowBridge(), - windowLauncher: (_) => throw StateError('window unavailable'), - ); - - await coordinator.pin('board-failed'); - - expect(boardController.boardById('board-failed')?.isPinned, isFalse); - }); - - test('a pinned board can always be toggled back to unpinned', () async { - final boardController = StickyBoardViewModel( - repository: _MemoryStickyBoardRepository( - workspace: StickyBoardWorkspace( - boards: [ - StickyBoard( - id: 'board-toggle', - name: 'Toggle', - colorValue: 0xFF20B8A8, - createdAt: DateTime.utc(2026, 7, 27), - ), - ], - boardTodoIds: const >{}, - ), - ), - ); - final hiddenBoardIds = []; - await boardController.load(); - final coordinator = StickyBoardWindowCoordinator( - boardController: boardController, - todoController: TodoViewModel( - todoRepository: _MemoryTodoRepository(), - tagRepository: _MemoryTagRepository(), - ), - windowBridge: _MemoryWindowBridge(), - windowLauncher: (_) async {}, - windowHider: (boardId) async => hiddenBoardIds.add(boardId), - ); - - await coordinator.togglePin('board-toggle'); - expect(boardController.boardById('board-toggle')?.isPinned, isTrue); - - await coordinator.togglePin('board-toggle'); - expect(boardController.boardById('board-toggle')?.isPinned, isFalse); - expect(hiddenBoardIds, ['board-toggle']); - - await coordinator.togglePin('board-toggle'); - expect(boardController.boardById('board-toggle')?.isPinned, isTrue); - - await coordinator.togglePin('board-toggle'); - expect(boardController.boardById('board-toggle')?.isPinned, isFalse); - expect(hiddenBoardIds, ['board-toggle', 'board-toggle']); - }); - - test('restores pinned state when hiding the board window fails', () async { - final boardController = StickyBoardViewModel( - repository: _MemoryStickyBoardRepository( - workspace: StickyBoardWorkspace( - boards: [ - StickyBoard( - id: 'board-hide-failure', - name: 'Hide failure', - colorValue: 0xFF20B8A8, - createdAt: DateTime.utc(2026, 7, 27), - isPinned: true, - ), - ], - boardTodoIds: const >{}, - ), - ), - ); - await boardController.load(); - final coordinator = StickyBoardWindowCoordinator( - boardController: boardController, - todoController: TodoViewModel( - todoRepository: _MemoryTodoRepository(), - tagRepository: _MemoryTagRepository(), - ), - windowBridge: _MemoryWindowBridge(), - windowHider: (_) => throw StateError('window unavailable'), - ); - - await coordinator.unpin('board-hide-failure'); - - expect(boardController.boardById('board-hide-failure')?.isPinned, isTrue); - }); -} - -class _MemoryWindowBridge implements WindowBridge { - @override - void setCollapseRequestHandler(CollapseRequestHandler? handler) {} - - @override - Future synchronizeCollapsedState() async {} - - @override - Future configureBorderlessSecondaryWindow( - int viewId, { - bool positionAdjacentToMainWindow = false, - }) async {} - - @override - Future revealBorderlessSecondaryWindow(int viewId) async {} - - @override - Future preferredExpansionAnchor() async { - return WindowExpansionAnchor.topRight; - } - - @override - void setExpandRequestHandler(ExpandRequestHandler? handler) {} - - @override - Future setExpanded(bool expanded, {bool animated = true}) async {} - - @override - Future setFloatingIconCount(int activeCount) async {} - - @override - Future setPreferredLanguage(String? languageCode) async {} - - @override - Future setPreferredTheme(String themePreference) async {} - - @override - Future setAlwaysOnTop(bool alwaysOnTop) async {} -} - -class _MemoryStickyBoardRepository implements StickyBoardRepository { - _MemoryStickyBoardRepository({StickyBoardWorkspace? workspace}) - : _workspace = workspace ?? StickyBoardWorkspace.empty(); - - final StickyBoardWorkspace _workspace; - - @override - String get storagePath => '/tmp/floatick-sticky-board-coordinator-test.json'; - - @override - Future load() async => _workspace; - - @override - Future save(StickyBoardWorkspace workspace) async {} -} - -class _MemoryTodoRepository implements TodoRepository { - @override - String get storagePath => '/tmp/floatick-sticky-board-todos-test.json'; - - @override - Future> load() async => const []; - - @override - Future save(List items) async {} -} - -class _MemoryTagRepository implements TagRepository { - @override - String get storagePath => '/tmp/floatick-sticky-board-tags-test.json'; - - @override - Future load() async => TagWorkspace.empty(); - - @override - Future save(TagWorkspace workspace) async {} -} diff --git a/test/features/sticky_boards/presentation/widgets/sticky_board_management_todo_row_test.dart b/test/features/sticky_boards/presentation/widgets/sticky_board_management_todo_row_test.dart deleted file mode 100644 index 5d3133b..0000000 --- a/test/features/sticky_boards/presentation/widgets/sticky_board_management_todo_row_test.dart +++ /dev/null @@ -1,92 +0,0 @@ -import 'package:floatick/features/sticky_boards/presentation/widgets/sticky_board_management_todo_row.dart'; -import 'package:floatick/features/todos/domain/todo_item.dart'; -import 'package:floatick/features/todos/domain/todo_tag.dart'; -import 'package:floatick/l10n/app_localizations.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - testWidgets( - 'shows read-only todo metadata and only exposes details and removal', - (tester) async { - var detailsCount = 0; - var removeCount = 0; - final item = TodoItem( - id: 'todo-1', - title: 'Review candidate', - content: 'Read-only content', - createdAt: DateTime.utc(2026, 7, 27, 2), - completedAt: DateTime.utc(2026, 7, 27, 3), - ); - final tag = TodoTag( - id: 'tag-1', - name: 'Release', - colorValue: 0xFF20BFAF, - createdAt: DateTime.utc(2026, 7, 27, 1), - ); - - await tester.pumpWidget( - MaterialApp( - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: Scaffold( - body: SizedBox( - width: 380, - child: StickyBoardManagementTodoRow( - item: item, - tags: [tag], - assignedTagIds: const ['tag-1'], - onOpenDetails: () => detailsCount += 1, - onRemove: () => removeCount += 1, - ), - ), - ), - ), - ); - - expect(find.text('Review candidate'), findsOneWidget); - expect(find.text('Release'), findsOneWidget); - expect( - find.byKey(const Key('sticky-board-managed-tag-todo-1-tag-1')), - findsOneWidget, - ); - expect( - find.byKey(const Key('sticky-board-managed-time-todo-1')), - findsOneWidget, - ); - expect(find.byKey(const Key('toggle-todo-todo-1')), findsNothing); - expect(find.byKey(const Key('edit-todo-todo-1')), findsNothing); - expect(find.byKey(const Key('view-todo-todo-1')), findsNothing); - expect(find.byKey(const Key('archive-todo-todo-1')), findsNothing); - expect(find.byKey(const Key('assign-tags-todo-1')), findsNothing); - - await tester.tap( - find.byKey(const Key('sticky-board-managed-completion-status-todo-1')), - ); - await tester.pump(); - expect(detailsCount, 0); - expect(removeCount, 0); - - await tester.tap( - find.byKey(const Key('sticky-board-managed-open-details-todo-1')), - ); - await tester.pump(kDoubleTapMinTime); - await tester.tap( - find.byKey(const Key('sticky-board-managed-open-details-todo-1')), - ); - await tester.pumpAndSettle(); - expect(detailsCount, 1); - - final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); - await mouse.addPointer(); - await mouse.moveTo( - tester.getCenter(find.byType(StickyBoardManagementTodoRow)), - ); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('remove-from-board-todo-1'))); - await tester.pump(); - expect(removeCount, 1); - }, - ); -} diff --git a/test/features/sticky_boards/presentation/widgets/sticky_board_read_only_todo_row_test.dart b/test/features/sticky_boards/presentation/widgets/sticky_board_read_only_todo_row_test.dart deleted file mode 100644 index d837a63..0000000 --- a/test/features/sticky_boards/presentation/widgets/sticky_board_read_only_todo_row_test.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:floatick/features/sticky_boards/presentation/widgets/sticky_board_read_only_todo_row.dart'; -import 'package:floatick/features/todos/domain/todo_item.dart'; -import 'package:floatick/l10n/app_localizations.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - testWidgets('toggles completion and opens local details on double tap', ( - tester, - ) async { - var detailsCount = 0; - var completionToggleCount = 0; - final item = TodoItem( - id: 'todo-1', - title: 'Review candidate', - content: 'Read-only content', - createdAt: DateTime.utc(2026, 7, 27, 2), - ); - - await tester.pumpWidget( - MaterialApp( - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: Scaffold( - body: SizedBox( - width: 380, - child: StickyBoardReadOnlyTodoRow( - item: item, - onToggleCompletion: () => completionToggleCount += 1, - onOpenDetails: () => detailsCount += 1, - ), - ), - ), - ), - ); - - expect(find.text('Review candidate'), findsOneWidget); - expect(find.byType(IconButton), findsNothing); - expect(find.byKey(const Key('assign-tags-todo-1')), findsNothing); - expect(find.byKey(const Key('edit-todo-todo-1')), findsNothing); - expect( - find.byKey(const Key('sticky-board-todo-time-todo-1')), - findsNothing, - ); - expect( - find.byKey(const Key('sticky-board-todo-tag-todo-1-tag-1')), - findsNothing, - ); - - await tester.tap( - find.byKey(const Key('sticky-board-completion-toggle-todo-1')), - ); - await tester.pump(); - - expect(completionToggleCount, 1); - expect(detailsCount, 0); - - await tester.tap( - find.byKey(const Key('sticky-board-open-details-region-todo-1')), - ); - await tester.pump(kDoubleTapTimeout); - expect(detailsCount, 0); - - await tester.tap( - find.byKey(const Key('sticky-board-open-details-region-todo-1')), - ); - await tester.pump(kDoubleTapMinTime); - await tester.tap( - find.byKey(const Key('sticky-board-open-details-region-todo-1')), - ); - await tester.pumpAndSettle(); - - expect(detailsCount, 1); - }); -} diff --git a/test/features/sticky_boards/presentation/widgets/sticky_board_todo_details_test.dart b/test/features/sticky_boards/presentation/widgets/sticky_board_todo_details_test.dart deleted file mode 100644 index 4c06800..0000000 --- a/test/features/sticky_boards/presentation/widgets/sticky_board_todo_details_test.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'package:floatick/features/sticky_boards/presentation/widgets/sticky_board_todo_details.dart'; -import 'package:floatick/features/todos/domain/todo_item.dart'; -import 'package:floatick/features/todos/domain/todo_tag.dart'; -import 'package:floatick/l10n/app_localizations.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - late String clipboardText; - - setUp(() { - clipboardText = ''; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(SystemChannels.platform, (call) async { - if (call.method == 'Clipboard.setData') { - clipboardText = - (call.arguments as Map)['text'] as String; - } - return null; - }); - }); - - tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(SystemChannels.platform, null); - }); - - testWidgets('shows todo content locally without edit actions', ( - tester, - ) async { - var backCount = 0; - final item = TodoItem( - id: 'todo-1', - title: 'Prepare release', - content: '## Checklist\n\n- Verify the DMG', - createdAt: DateTime.utc(2026, 7, 27, 2), - ); - final tag = TodoTag( - id: 'tag-1', - name: 'Release', - colorValue: 0xFF20BFAF, - createdAt: DateTime.utc(2026, 7, 27, 1), - ); - - await tester.pumpWidget( - MaterialApp( - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: Scaffold( - body: SizedBox( - width: 380, - height: 460, - child: StickyBoardTodoDetails( - item: item, - tags: [tag], - onBack: () => backCount += 1, - ), - ), - ), - ), - ); - - expect(find.byKey(const Key('sticky-board-details-title')), findsOneWidget); - expect(find.text('Prepare release'), findsOneWidget); - expect(find.text('Checklist'), findsOneWidget); - expect(find.text('Verify the DMG'), findsOneWidget); - expect(find.text('Release'), findsOneWidget); - expect(find.byKey(const Key('sticky-board-details-edit')), findsNothing); - - await tester.tap(find.byKey(const Key('sticky-board-details-copy'))); - await tester.pump(); - expect( - clipboardText, - '# Prepare release\n\n## Checklist\n\n- Verify the DMG', - ); - - await tester.tap(find.byKey(const Key('sticky-board-details-back'))); - - expect(backCount, 1); - }); -} diff --git a/test/features/todos/data/todo_repository_test.dart b/test/features/todos/data/todo_repository_test.dart index 369fb13..0dce6ce 100644 --- a/test/features/todos/data/todo_repository_test.dart +++ b/test/features/todos/data/todo_repository_test.dart @@ -41,6 +41,7 @@ void main() { title: 'Finish the Flutter shell', content: '## Notes\n\n- Keep it **fast**.', createdAt: DateTime.utc(2026, 7, 23, 6, 30), + startedAt: DateTime.utc(2026, 7, 23, 6, 45), completedAt: DateTime.utc(2026, 7, 23, 7), ); @@ -55,6 +56,7 @@ void main() { 'title': 'Finish the Flutter shell', 'content': '## Notes\n\n- Keep it **fast**.', 'createdAt': '2026-07-23T06:30:00.000Z', + 'startedAt': '2026-07-23T06:45:00.000Z', 'completedAt': '2026-07-23T07:00:00.000Z', }, ]); @@ -76,8 +78,30 @@ void main() { expect(item.title, 'Existing todo'); expect(item.content, isEmpty); + expect(item.startedAt, isNull); + expect(item.isDoing, isFalse); }); + test( + 'deadline and reminder delivery state round-trip through JSON', + () async { + final item = TodoItem( + id: 'scheduled', + title: 'Ship release', + createdAt: DateTime.utc(2026, 8, 7, 9), + dueAt: DateTime.utc(2026, 8, 7, 10), + reminderAt: DateTime.utc(2026, 8, 7, 9, 50), + notifyAtDeadline: false, + reminderNotifiedAt: DateTime.utc(2026, 8, 7, 9, 50), + snoozedUntil: DateTime.utc(2026, 8, 7, 10, 10), + ); + + await repository.save([item]); + + expect(await repository.load(), [item]); + }, + ); + test('damaged storage is reported and left unchanged', () async { await repository.rootDirectory.create(recursive: true); final file = File(repository.storagePath); diff --git a/test/features/todos/presentation/deadline_reminder_scheduler_test.dart b/test/features/todos/presentation/deadline_reminder_scheduler_test.dart new file mode 100644 index 0000000..9c30d06 --- /dev/null +++ b/test/features/todos/presentation/deadline_reminder_scheduler_test.dart @@ -0,0 +1,287 @@ +import 'package:floatick/core/platform/window_bridge.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'; +import 'package:floatick/features/todos/domain/todo_item.dart'; +import 'package:floatick/features/todos/presentation/deadline_reminder_scheduler.dart'; +import 'package:floatick/features/todos/presentation/todo_view_model.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('an overdue deadline is shown once and marked delivered', () async { + final now = DateTime.utc(2026, 8, 7, 10); + final repository = _MemoryTodoRepository([ + TodoItem( + id: 'todo-1', + title: 'Ship release', + createdAt: now.subtract(const Duration(days: 1)), + dueAt: now.subtract(const Duration(minutes: 1)), + reminderAt: now.subtract(const Duration(hours: 1)), + ), + ]); + final viewModel = TodoViewModel( + todoRepository: repository, + tagRepository: _MemoryTagRepository(), + clock: () => now, + ); + await viewModel.load(); + final bridge = _MemoryDeadlineReminderBridge(); + final scheduler = DeadlineReminderScheduler( + todoViewModel: viewModel, + bridge: bridge, + onOpenTodo: (_) {}, + clock: () => now, + ); + + await _flushAsyncWork(); + + expect(bridge.payloads, hasLength(1)); + expect(bridge.payloads.single.todoId, 'todo-1'); + expect(viewModel.items.single.deadlineNotifiedAt, now); + expect(viewModel.items.single.reminderNotifiedAt, now); + scheduler.dispose(); + }); + + test('native open action opens the todo and dismiss is a no-op', () async { + final now = DateTime.utc(2026, 8, 7, 10); + final repository = _MemoryTodoRepository([ + TodoItem( + id: 'todo-1', + title: 'Ship release', + createdAt: now.subtract(const Duration(days: 1)), + dueAt: now.add(const Duration(hours: 1)), + ), + ]); + final viewModel = TodoViewModel( + todoRepository: repository, + tagRepository: _MemoryTagRepository(), + clock: () => now, + ); + await viewModel.load(); + final bridge = _MemoryDeadlineReminderBridge(); + final openedTodoIds = []; + final scheduler = DeadlineReminderScheduler( + todoViewModel: viewModel, + bridge: bridge, + onOpenTodo: openedTodoIds.add, + clock: () => now, + ); + + bridge.send( + const DeadlineReminderAction( + kind: DeadlineReminderActionKind.open, + todoId: 'todo-1', + ), + ); + expect(openedTodoIds, ['todo-1']); + bridge.send( + const DeadlineReminderAction( + kind: DeadlineReminderActionKind.dismiss, + todoId: 'todo-1', + ), + ); + expect(openedTodoIds, ['todo-1']); + expect(viewModel.items.single.isCompleted, isFalse); + expect(viewModel.items.single.snoozedUntil, isNull); + scheduler.dispose(); + }); + + testWidgets('resume dispatches a deadline missed while inactive', ( + WidgetTester tester, + ) async { + var now = DateTime.utc(2026, 8, 7, 10); + final repository = _MemoryTodoRepository([ + TodoItem( + id: 'todo-1', + title: 'Ship release', + createdAt: now.subtract(const Duration(days: 1)), + dueAt: now.add(const Duration(hours: 1)), + ), + ]); + final viewModel = TodoViewModel( + todoRepository: repository, + tagRepository: _MemoryTagRepository(), + clock: () => now, + ); + await viewModel.load(); + final bridge = _MemoryDeadlineReminderBridge(); + final scheduler = DeadlineReminderScheduler( + todoViewModel: viewModel, + bridge: bridge, + onOpenTodo: (_) {}, + clock: () => now, + ); + + now = now.add(const Duration(hours: 2)); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + for (var index = 0; index < 8; index++) { + await tester.pump(); + } + + expect(bridge.payloads, hasLength(1)); + expect(viewModel.items.single.deadlineNotifiedAt, now); + scheduler.dispose(); + }); + + test('native delivery marks a reminder while Flutter is inactive', () async { + var now = DateTime.utc(2026, 8, 7, 10); + final repository = _MemoryTodoRepository([ + TodoItem( + id: 'todo-1', + title: 'Ship release', + createdAt: now.subtract(const Duration(days: 1)), + dueAt: now.add(const Duration(hours: 1)), + ), + ]); + final viewModel = TodoViewModel( + todoRepository: repository, + tagRepository: _MemoryTagRepository(), + clock: () => now, + ); + await viewModel.load(); + final bridge = _MemoryDeadlineReminderBridge(); + final scheduler = DeadlineReminderScheduler( + todoViewModel: viewModel, + bridge: bridge, + onOpenTodo: (_) {}, + clock: () => now, + ); + + final scheduled = bridge.scheduledBatches.last.single; + expect(scheduled.triggerAt, now.add(const Duration(hours: 1))); + expect(scheduled.deliveryKind, DeadlineReminderDeliveryKind.deadline); + + now = now.add(const Duration(hours: 2)); + bridge.deliveryHandler?.call( + const DeadlineReminderDelivery( + notificationId: 'stale-notification', + todoId: 'todo-1', + kind: DeadlineReminderDeliveryKind.deadline, + ), + ); + await _flushAsyncWork(); + expect(viewModel.items.single.deadlineNotifiedAt, isNull); + + bridge.deliver(scheduled); + await _flushAsyncWork(); + + expect(viewModel.items.single.deadlineNotifiedAt, now); + expect(viewModel.items.single.reminderNotifiedAt, isNull); + scheduler.dispose(); + }); + + test('updating a deadline resynchronizes the native schedule', () async { + final now = DateTime.utc(2026, 8, 7, 10); + final repository = _MemoryTodoRepository([ + TodoItem(id: 'todo-1', title: 'Ship release', createdAt: now), + ]); + final viewModel = TodoViewModel( + todoRepository: repository, + tagRepository: _MemoryTagRepository(), + clock: () => now, + ); + await viewModel.load(); + final bridge = _MemoryDeadlineReminderBridge(); + final scheduler = DeadlineReminderScheduler( + todoViewModel: viewModel, + bridge: bridge, + onOpenTodo: (_) {}, + clock: () => now, + ); + expect(bridge.scheduledBatches.last, isEmpty); + + final dueAt = now.add(const Duration(minutes: 1)); + await viewModel.updateSchedule( + id: 'todo-1', + schedule: TodoScheduleDraft(dueAt: dueAt), + ); + await _flushAsyncWork(); + + expect(bridge.scheduledBatches.last, hasLength(1)); + expect(bridge.scheduledBatches.last.single.triggerAt, dueAt); + expect( + bridge.scheduledBatches.last.single.deliveryKind, + DeadlineReminderDeliveryKind.deadline, + ); + scheduler.dispose(); + }); +} + +Future _flushAsyncWork() async { + for (var index = 0; index < 8; index++) { + await Future.delayed(Duration.zero); + } +} + +class _MemoryDeadlineReminderBridge implements DeadlineReminderBridge { + final List payloads = []; + final List> scheduledBatches = + >[]; + DeadlineReminderActionHandler? handler; + DeadlineReminderDeliveryHandler? deliveryHandler; + + @override + void setDeadlineReminderActionHandler(DeadlineReminderActionHandler? value) { + handler = value; + } + + @override + void setDeadlineReminderDeliveryHandler( + DeadlineReminderDeliveryHandler? value, + ) { + deliveryHandler = value; + } + + @override + Future showDeadlineReminder(DeadlineReminderPayload payload) async { + payloads.add(payload); + } + + @override + Future synchronizeScheduledDeadlineReminders( + List reminders, + ) async { + scheduledBatches.add(List.of(reminders)); + } + + void send(DeadlineReminderAction action) => handler?.call(action); + + void deliver(ScheduledDeadlineReminder reminder) { + deliveryHandler?.call( + DeadlineReminderDelivery( + notificationId: reminder.notificationId, + todoId: reminder.payload.todoId, + kind: reminder.deliveryKind, + ), + ); + } +} + +class _MemoryTodoRepository implements TodoRepository { + _MemoryTodoRepository(this.items); + + List items; + + @override + String get storagePath => '/tmp/floatick-scheduler-test/todos.json'; + + @override + Future> load() async => List.of(items); + + @override + Future save(List updated) async { + items = List.of(updated); + } +} + +class _MemoryTagRepository implements TagRepository { + @override + String get storagePath => '/tmp/floatick-scheduler-test/tags.json'; + + @override + Future load() async => TagWorkspace.empty(); + + @override + Future save(TagWorkspace workspace) async {} +} diff --git a/test/features/todos/presentation/todo_editor_drawer_test.dart b/test/features/todos/presentation/todo_editor_drawer_test.dart index 7406f7f..da63453 100644 --- a/test/features/todos/presentation/todo_editor_drawer_test.dart +++ b/test/features/todos/presentation/todo_editor_drawer_test.dart @@ -2,6 +2,7 @@ import 'package:floatick/features/todos/presentation/todo_editor_drawer.dart'; import 'package:floatick/features/todos/domain/todo_item.dart'; import 'package:floatick/features/todos/domain/todo_tag.dart'; import 'package:floatick/l10n/app_localizations.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -53,7 +54,7 @@ void main() { onClose: () {}, onEdit: () {}, onOpenTagAssignment: () {}, - onSave: (title, content, tagIds) async { + onSave: (title, content, tagIds, schedule) async { saveCount += 1; return false; }, @@ -105,6 +106,203 @@ void main() { expect(find.byKey(const Key('todo-title-field')), findsOneWidget); }); + testWidgets('create drawer saves a deadline selected in the tall picker', ( + WidgetTester tester, + ) async { + final closeFocusNode = FocusNode(); + addTearDown(closeFocusNode.dispose); + TodoScheduleDraft? savedSchedule; + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SizedBox( + width: 440, + height: 590, + child: TodoEditorDrawer( + mode: TodoEditorDrawerMode.create, + item: null, + availableTags: const [], + originalAssignedTagIds: const [], + assignedTagIds: const [], + isOpen: true, + onClose: () {}, + onEdit: () {}, + onOpenTagAssignment: () {}, + onSave: (title, content, tagIds, schedule) async { + savedSchedule = schedule; + return true; + }, + onSaved: () {}, + closeFocusNode: closeFocusNode, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const Key('todo-title-field')), + 'Ship release', + ); + + final beforeOpeningDeadlinePicker = DateTime.now(); + await tester.tap(find.byKey(const Key('todo-editor-deadline-button'))); + await tester.pumpAndSettle(); + final afterOpeningDeadlinePicker = DateTime.now(); + expect(find.byKey(const Key('todo-deadline-picker')), findsOneWidget); + expect( + tester.getSize(find.byKey(const Key('todo-deadline-picker'))).height, + 590, + ); + final picker = find.byKey(const Key('todo-deadline-picker')); + expect( + find.descendant(of: picker, matching: find.byType(SingleChildScrollView)), + findsNothing, + ); + expect(find.byKey(const Key('todo-reminder-picker')), findsOneWidget); + expect(find.byKey(const Key('deadline-shortcut-row')), findsNothing); + expect( + tester + .getBottomRight(find.byKey(const Key('deadline-picker-content'))) + .dy, + lessThanOrEqualTo( + tester.getTopLeft(find.byKey(const Key('deadline-picker-footer'))).dy, + ), + ); + expect(find.byKey(const Key('save-todo-deadline')).hitTestable(), findsOne); + + expect(find.byKey(const Key('todo-time-picker-dialog')), findsNothing); + final inlineTimeEditor = find.byKey(const Key('todo-inline-time-editor')); + expect(inlineTimeEditor, findsOneWidget); + expect(tester.getSize(inlineTimeEditor), const Size(132, 34)); + final deadlineRowRect = tester.getRect( + find.byKey(const Key('todo-deadline-time-field')), + ); + final inlineTimeEditorRect = tester.getRect(inlineTimeEditor); + expect(deadlineRowRect.contains(inlineTimeEditorRect.topLeft), isTrue); + expect(deadlineRowRect.contains(inlineTimeEditorRect.bottomRight), isTrue); + final hourSpinnerRect = tester.getRect( + find.byKey(const Key('todo-time-hour-spinner')), + ); + final separatorRect = tester.getRect( + find.byKey(const Key('todo-time-separator')), + ); + final minuteSpinnerRect = tester.getRect( + find.byKey(const Key('todo-time-minute-spinner')), + ); + expect(separatorRect.left - hourSpinnerRect.right, greaterThanOrEqualTo(0)); + expect( + minuteSpinnerRect.left - separatorRect.right, + greaterThanOrEqualTo(0), + ); + expect(separatorRect.width, 16); + TextFormField timeInput(Key key) => + tester.widget(find.byKey(key)); + final initialDate = tester + .widget( + find.byKey(const Key('todo-deadline-calendar')), + ) + .initialDate!; + final initialHour = int.parse( + timeInput(const Key('todo-time-hour-input')).controller!.text, + ); + final initialMinute = int.parse( + timeInput(const Key('todo-time-minute-input')).controller!.text, + ); + final initialDueAt = DateTime( + initialDate.year, + initialDate.month, + initialDate.day, + initialHour, + initialMinute, + ); + DateTime atMinutePrecision(DateTime value) => + DateTime(value.year, value.month, value.day, value.hour, value.minute); + expect( + initialDueAt.isBefore(atMinutePrecision(beforeOpeningDeadlinePicker)), + isFalse, + ); + expect( + initialDueAt.isAfter(atMinutePrecision(afterOpeningDeadlinePicker)), + isFalse, + ); + final hourInputDecoration = tester + .widget( + find.descendant( + of: find.byKey(const Key('todo-time-hour-input')), + matching: find.byType(InputDecorator), + ), + ) + .decoration; + expect(hourInputDecoration.filled, isFalse); + expect(hourInputDecoration.hoverColor, Colors.transparent); + + await tester.tap(find.byKey(const Key('todo-time-hour-increment'))); + await tester.pump(); + expect( + timeInput(const Key('todo-time-hour-input')).controller!.text, + ((initialDueAt.hour + 1) % 24).toString().padLeft(2, '0'), + ); + + await tester.tap(find.byKey(const Key('todo-time-hour-input'))); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect( + timeInput(const Key('todo-time-hour-input')).controller!.text, + initialDueAt.hour.toString().padLeft(2, '0'), + ); + + await tester.enterText(find.byKey(const Key('todo-time-hour-input')), '19'); + await tester.enterText( + find.byKey(const Key('todo-time-minute-input')), + '59', + ); + await tester.tap(find.byKey(const Key('todo-time-minute-increment'))); + await tester.pump(); + expect(timeInput(const Key('todo-time-hour-input')).controller!.text, '20'); + expect( + timeInput(const Key('todo-time-minute-input')).controller!.text, + '00', + ); + + final minuteSpinner = find.byKey(const Key('todo-time-minute-spinner')); + await tester.sendEventToBinding( + PointerScrollEvent( + position: tester.getCenter(minuteSpinner), + scrollDelta: const Offset(0, 20), + ), + ); + await tester.pump(); + expect(timeInput(const Key('todo-time-hour-input')).controller!.text, '19'); + expect( + timeInput(const Key('todo-time-minute-input')).controller!.text, + '59', + ); + + await tester.tap(find.byKey(const Key('todo-reminder-picker'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('10 minutes before')); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('save-todo-deadline'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('save-todo-details'))); + await tester.pumpAndSettle(); + + expect(savedSchedule?.dueAt, isNotNull); + expect(savedSchedule!.dueAt!.toLocal().hour, 19); + expect(savedSchedule!.dueAt!.toLocal().minute, 59); + expect(savedSchedule?.notifyAtDeadline, isFalse); + expect( + savedSchedule!.dueAt!.difference(savedSchedule!.reminderAt!), + const Duration(minutes: 10), + ); + }); + testWidgets('create drawer accepts a content-only todo', ( WidgetTester tester, ) async { @@ -132,7 +330,7 @@ void main() { onClose: () {}, onEdit: () {}, onOpenTagAssignment: () {}, - onSave: (title, content, tagIds) async { + onSave: (title, content, tagIds, schedule) async { savedTitle = title; savedContent = content; return true; @@ -198,7 +396,7 @@ void main() { onClose: () {}, onEdit: () {}, onOpenTagAssignment: () {}, - onSave: (title, content, tagIds) async { + onSave: (title, content, tagIds, schedule) async { savedTagIds = tagIds; return true; }, @@ -258,7 +456,7 @@ void main() { onClose: () {}, onEdit: () {}, onOpenTagAssignment: () {}, - onSave: (title, content, tagIds) async => true, + onSave: (title, content, tagIds, schedule) async => true, onSaved: () {}, closeFocusNode: closeFocusNode, ), @@ -319,7 +517,7 @@ void main() { onClose: () {}, onEdit: () {}, onOpenTagAssignment: () {}, - onSave: (title, content, tagIds) async => true, + onSave: (title, content, tagIds, schedule) async => true, onSaved: () {}, closeFocusNode: closeFocusNode, ), @@ -368,7 +566,7 @@ void main() { onClose: () {}, onEdit: () {}, onOpenTagAssignment: () => tagAssignmentRequested = true, - onSave: (title, content, tagIds) async { + onSave: (title, content, tagIds, schedule) async { savedTagIds = tagIds; return true; }, diff --git a/test/features/todos/presentation/todo_list_row_test.dart b/test/features/todos/presentation/todo_list_row_test.dart index 33b2d94..ced8a5c 100644 --- a/test/features/todos/presentation/todo_list_row_test.dart +++ b/test/features/todos/presentation/todo_list_row_test.dart @@ -8,6 +8,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_symbols_icons/symbols.dart'; void main() { late String clipboardText; @@ -36,6 +37,8 @@ void main() { 'hover actions align, copy Markdown, and double-click opens details', (tester) async { var toggleCount = 0; + var doingToggleCount = 0; + var deadlineCount = 0; var detailsCount = 0; final item = TodoItem( id: 'aligned', @@ -65,8 +68,10 @@ void main() { item: item, archivedScope: false, onToggle: () => toggleCount += 1, + onToggleDoing: () => doingToggleCount += 1, onOpenDetails: () => detailsCount += 1, onEdit: () {}, + onSetDeadline: () => deadlineCount += 1, onArchive: () {}, onRestore: () {}, tags: tags, @@ -90,6 +95,8 @@ void main() { .dy; for (final key in [ 'toggle-todo-aligned', + 'toggle-doing-todo-aligned', + 'deadline-todo-aligned', 'copy-todo-aligned', 'more-todo-aligned', ]) { @@ -98,6 +105,20 @@ void main() { closeTo(primaryCenterY, 0.5), ); } + expect( + find.descendant( + of: find.byKey(const Key('toggle-doing-todo-aligned')), + matching: find.byIcon(Symbols.play_arrow), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byKey(const Key('deadline-todo-aligned')), + matching: find.byIcon(Symbols.alarm), + ), + findsOneWidget, + ); final tagCenterY = tester .getCenter(find.byKey(const Key('todo-tag-aligned-tag-work'))) @@ -109,6 +130,12 @@ void main() { expect(tagCenterY, greaterThan(primaryCenterY + 10)); expect(find.byKey(const Key('todo-has-content-aligned')), findsNothing); + await tester.tap(find.byKey(const Key('toggle-doing-todo-aligned'))); + expect(doingToggleCount, 1); + + await tester.tap(find.byKey(const Key('deadline-todo-aligned'))); + expect(deadlineCount, 1); + await tester.tap(find.byKey(const Key('copy-todo-aligned'))); await tester.pump(); expect( @@ -155,6 +182,200 @@ void main() { }, ); + testWidgets('doing row keeps its geometry, status, and pause action', ( + tester, + ) async { + var doingToggleCount = 0; + final todoItem = TodoItem( + id: 'todo', + title: 'Plan progress state', + createdAt: DateTime.utc(2026, 7, 27, 8), + ); + final item = TodoItem( + id: 'doing', + title: 'Implement progress state', + createdAt: DateTime.utc(2026, 7, 27, 8), + startedAt: DateTime.utc(2026, 7, 27, 9), + ); + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TodoListRow( + item: todoItem, + archivedScope: false, + onToggle: () {}, + onToggleDoing: () {}, + onOpenDetails: () {}, + onEdit: () {}, + onArchive: () {}, + onRestore: () {}, + tags: const [], + assignedTagIds: const [], + onOpenTagAssignment: () {}, + ), + TodoListRow( + item: item, + archivedScope: false, + onToggle: () {}, + onToggleDoing: () => doingToggleCount += 1, + onOpenDetails: () {}, + onEdit: () {}, + onArchive: () {}, + onRestore: () {}, + tags: const [], + assignedTagIds: const [], + onOpenTagAssignment: () {}, + ), + ], + ), + ), + ), + ); + + expect(find.byKey(const Key('doing-status-doing')), findsOneWidget); + expect(find.text('Doing'), findsOneWidget); + expect( + find.descendant( + of: find.byKey(const Key('toggle-doing-todo-doing')), + matching: find.byIcon(Icons.pause_rounded), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byKey(const Key('toggle-doing-todo-todo')), + matching: find.byIcon(Symbols.play_arrow), + ), + findsOneWidget, + ); + expect( + tester.getSize(find.byKey(const Key('toggle-doing-todo-doing'))), + tester.getSize(find.byKey(const Key('toggle-doing-todo-todo'))), + ); + final doingAction = tester.widget( + find.descendant( + of: find.byKey(const Key('toggle-doing-todo-doing')), + matching: find.byType(IconButton), + ), + ); + final todoAction = tester.widget( + find.descendant( + of: find.byKey(const Key('toggle-doing-todo-todo')), + matching: find.byType(IconButton), + ), + ); + expect(doingAction.style, isNull); + expect(todoAction.style, isNull); + expect(doingAction.tooltip, isNull); + expect(todoAction.tooltip, isNull); + + final doingActionOpacity = find.descendant( + of: find.byKey(const Key('toggle-doing-todo-doing')), + matching: find.byType(AnimatedOpacity), + ); + expect(tester.widget(doingActionOpacity).opacity, 0); + expect( + find.byKey(const Key('toggle-doing-todo-doing')).hitTestable(), + findsNothing, + ); + + final rowSurface = tester.widget( + find.byKey(const Key('todo-row-surface-doing')), + ); + final decoration = rowSurface.decoration as BoxDecoration; + expect(decoration.color, isNot(Colors.transparent)); + expect(decoration.border, isNull); + expect( + tester.getSize(find.byKey(const Key('todo-row-surface-doing'))), + tester.getSize(find.byKey(const Key('todo-row-surface-todo'))), + ); + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(mouse.removePointer); + await mouse.addPointer(); + await mouse.moveTo( + tester.getCenter(find.byKey(const Key('todo-row-surface-doing'))), + ); + await tester.pumpAndSettle(); + expect(tester.widget(doingActionOpacity).opacity, 1); + expect( + find.byKey(const Key('toggle-doing-todo-doing')).hitTestable(), + findsOneWidget, + ); + + await tester.tap(find.byKey(const Key('toggle-doing-todo-doing'))); + expect(doingToggleCount, 1); + + await mouse.moveTo(Offset.zero); + await tester.pumpAndSettle(); + expect(tester.widget(doingActionOpacity).opacity, 0); + }); + + testWidgets('deadline metadata does not change the todo row height', ( + tester, + ) async { + var editDeadlineCount = 0; + final createdAt = DateTime.utc(2026, 8, 7, 8); + final plainItem = TodoItem( + id: 'plain', + title: 'Plain todo', + createdAt: createdAt, + ); + final scheduledItem = TodoItem( + id: 'scheduled', + title: 'Scheduled todo', + createdAt: createdAt, + dueAt: DateTime.utc(2027, 8, 7, 10), + ); + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final item in [plainItem, scheduledItem]) + TodoListRow( + item: item, + archivedScope: false, + onToggle: () {}, + onToggleDoing: () {}, + onOpenDetails: () {}, + onEdit: () {}, + onSetDeadline: () => editDeadlineCount += 1, + onArchive: () {}, + onRestore: () {}, + tags: const [], + assignedTagIds: const [], + onOpenTagAssignment: () {}, + ), + ], + ), + ), + ), + ); + + expect(find.byKey(const Key('deadline-status-scheduled')), findsOneWidget); + await tester.tap(find.byKey(const Key('deadline-status-scheduled'))); + expect(editDeadlineCount, 1); + expect( + tester + .getSize(find.byKey(const Key('todo-row-surface-scheduled'))) + .height, + tester.getSize(find.byKey(const Key('todo-row-surface-plain'))).height, + ); + }); + testWidgets('external tag action bypasses the inline assignment menu', ( tester, ) async { @@ -183,6 +404,7 @@ void main() { item: item, archivedScope: false, onToggle: () {}, + onToggleDoing: () {}, onOpenDetails: () {}, onEdit: () {}, onArchive: () {}, @@ -252,6 +474,7 @@ void main() { item: item, archivedScope: false, onToggle: () {}, + onToggleDoing: () {}, onOpenDetails: () {}, onEdit: () {}, onArchive: () {}, @@ -350,6 +573,7 @@ void main() { item: item, archivedScope: false, onToggle: () {}, + onToggleDoing: () {}, onOpenDetails: () {}, onEdit: () {}, onArchive: () {}, @@ -467,6 +691,7 @@ void main() { item: item, archivedScope: true, onToggle: () {}, + onToggleDoing: null, onOpenDetails: () => viewCount += 1, onEdit: null, onArchive: () {}, diff --git a/test/features/todos/presentation/todo_view_model_test.dart b/test/features/todos/presentation/todo_view_model_test.dart index 662317e..fef16b2 100644 --- a/test/features/todos/presentation/todo_view_model_test.dart +++ b/test/features/todos/presentation/todo_view_model_test.dart @@ -179,6 +179,49 @@ void main() { }, ); + test( + 'itemsForView filters active doing todos and caches by progress', + () async { + repository.savedItems = [ + TodoItem( + id: 'doing', + title: 'Review the release', + createdAt: DateTime.parse('2026-07-23T12:00:00.000Z'), + startedAt: DateTime.parse('2026-07-23T12:30:00.000Z'), + ), + TodoItem( + id: 'todo', + title: 'Prepare notes', + createdAt: DateTime.parse('2026-07-23T11:00:00.000Z'), + ), + TodoItem( + id: 'completed', + title: 'Completed work', + createdAt: DateTime.parse('2026-07-23T10:00:00.000Z'), + startedAt: DateTime.parse('2026-07-23T10:30:00.000Z'), + completedAt: DateTime.parse('2026-07-23T11:30:00.000Z'), + ), + ]; + await controller.load(); + + final allItems = controller.itemsForView(archived: false, query: ''); + final doingItems = controller.itemsForView( + archived: false, + query: '', + progressFilter: TodoProgressFilter.doing, + ); + final equivalentDoingItems = controller.itemsForView( + archived: false, + query: '', + progressFilter: TodoProgressFilter.doing, + ); + + expect(doingItems.map((item) => item.id), ['doing']); + expect(identical(doingItems, equivalentDoingItems), isTrue); + expect(identical(allItems, doingItems), isFalse); + }, + ); + test('rename trims and persists the updated title', () async { repository.savedItems = [ TodoItem( @@ -281,7 +324,7 @@ void main() { }); test( - 'completion, archive, and restore share one persisted state flow', + 'doing and completion preserve the previous active progress state', () async { repository.savedItems = [ TodoItem( @@ -292,18 +335,76 @@ void main() { ]; await controller.load(); + await controller.toggleDoing('existing'); + expect(controller.items.single.isDoing, isTrue); + expect(controller.items.single.startedAt, DateTime.parse(firstDate)); + await controller.toggleCompletion('existing'); expect(controller.items.single.isCompleted, isTrue); + expect(controller.items.single.isDoing, isFalse); + expect(controller.items.single.startedAt, DateTime.parse(firstDate)); expect(controller.activeCount, 0); + await controller.toggleCompletion('existing'); + expect(controller.items.single.isCompleted, isFalse); + expect(controller.items.single.isDoing, isTrue); + + await controller.toggleDoing('existing'); + expect(controller.items.single.isDoing, isFalse); + expect(controller.items.single.startedAt, isNull); + expect(controller.activeCount, 1); + expect(repository.saveCount, 4); + }, + ); + + test('completed and archived todos cannot enter doing', () async { + repository.savedItems = [ + TodoItem( + id: 'completed', + title: 'Completed todo', + createdAt: DateTime.parse(firstDate), + completedAt: DateTime.parse(firstDate), + ), + TodoItem( + id: 'archived', + title: 'Archived todo', + createdAt: DateTime.parse(firstDate), + archivedAt: DateTime.parse(firstDate), + ), + ]; + await controller.load(); + + await controller.toggleDoing('completed'); + await controller.toggleDoing('archived'); + + expect(controller.itemById('completed')!.isDoing, isFalse); + expect(controller.itemById('archived')!.isDoing, isFalse); + expect(repository.saveCount, 0); + }); + + test( + 'archive and restore preserve a doing todo without exposing it as active', + () async { + repository.savedItems = [ + TodoItem( + id: 'existing', + title: 'Ship it', + createdAt: DateTime.parse(firstDate), + startedAt: DateTime.parse(firstDate), + ), + ]; + await controller.load(); + await controller.archive('existing'); expect(controller.items.single.isArchived, isTrue); + expect(controller.items.single.isDoing, isFalse); expect(controller.archivedCount, 1); await controller.restore('existing'); expect(controller.items.single.isArchived, isFalse); + expect(controller.items.single.isDoing, isTrue); expect(controller.archivedCount, 0); - expect(repository.saveCount, 3); + expect(repository.saveCount, 2); }, ); @@ -345,6 +446,7 @@ void main() { isFalse, ); await controller.toggleCompletion('archived'); + await controller.toggleDoing('archived'); expect(controller.items.single.title, 'Archived todo'); expect(controller.items.single.content, 'Original notes'); @@ -775,6 +877,74 @@ void main() { expect(repository.saveCount, 0); expect(tagRepository.saveCount, 1); }); + + test( + 'create and update persist schedules and reset delivery state', + () async { + await controller.load(); + final created = await controller.create( + 'Ship release', + schedule: TodoScheduleDraft( + dueAt: DateTime.parse('2026-07-24T09:00:00.000Z'), + reminderAt: DateTime.parse('2026-07-24T08:00:00.000Z'), + ), + ); + await controller.markReminderNotificationDelivered(created!.id); + + final updated = await controller.updateDetails( + id: created.id, + title: created.title, + content: created.content, + schedule: TodoScheduleDraft( + dueAt: DateTime.parse('2026-07-25T09:00:00.000Z'), + notifyAtDeadline: false, + ), + ); + + expect(updated, isTrue); + expect( + controller.items.single.dueAt, + DateTime.parse('2026-07-25T09:00:00.000Z'), + ); + expect(controller.items.single.notifyAtDeadline, isFalse); + expect(controller.items.single.reminderNotifiedAt, isNull); + expect(repository.savedItems.single, controller.items.single); + }, + ); + + test('updateSchedule persists a list-row deadline change', () async { + repository.savedItems = [ + TodoItem( + id: 'existing', + title: 'Prepare release', + createdAt: DateTime.parse(firstDate), + dueAt: DateTime.parse('2026-07-24T09:00:00.000Z'), + deadlineNotifiedAt: DateTime.parse('2026-07-24T09:00:00.000Z'), + ), + ]; + await controller.load(); + + await controller.updateSchedule( + id: 'existing', + schedule: TodoScheduleDraft( + dueAt: DateTime.parse('2026-07-25T18:00:00.000Z'), + reminderAt: DateTime.parse('2026-07-25T17:50:00.000Z'), + notifyAtDeadline: false, + ), + ); + + expect( + controller.items.single.dueAt, + DateTime.parse('2026-07-25T18:00:00.000Z'), + ); + expect( + controller.items.single.reminderAt, + DateTime.parse('2026-07-25T17:50:00.000Z'), + ); + expect(controller.items.single.notifyAtDeadline, isFalse); + expect(controller.items.single.deadlineNotifiedAt, isNull); + expect(repository.savedItems.single, controller.items.single); + }); } class _MemoryTodoRepository implements TodoRepository { diff --git a/website/public/images/floatick-hero-environment-v1.jpg b/website/public/images/floatick-hero-environment-v1.jpg new file mode 100644 index 0000000..f0c7ab5 Binary files /dev/null and b/website/public/images/floatick-hero-environment-v1.jpg differ diff --git a/website/public/images/floatick-product-hero-v6.jpg b/website/public/images/floatick-product-hero-v6.jpg new file mode 100644 index 0000000..9a1b452 Binary files /dev/null and b/website/public/images/floatick-product-hero-v6.jpg differ diff --git a/website/public/og.png b/website/public/og.png index a18b77b..1c5734a 100644 Binary files a/website/public/og.png and b/website/public/og.png differ diff --git a/website/src/components/ProductHero3D.astro b/website/src/components/ProductHero3D.astro index 4b9ab7f..eb3a3fc 100644 --- a/website/src/components/ProductHero3D.astro +++ b/website/src/components/ProductHero3D.astro @@ -10,47 +10,53 @@ const { locale } = Astro.props; const demoCopy = { en: { ariaLabel: - 'Interactive three-dimensional Floatick workspace with Todos and Notes tabs, a Sticky Board, an editor, and the floating icon', - remaining: '3 tasks remaining', + 'Interactive three-dimensional Floatick workspace showing Todos, Notes, a task in progress, deadlines, and the floating icon', + remaining: '13 tasks remaining', todosTab: 'Todos', notesTab: 'Notes', search: 'Search todos', - newTodo: 'New', today: 'Today', tasks: [ - { title: 'Plan a focused morning', tag: 'Personal', time: '09:10' }, - { title: 'Review the launch checklist', tag: 'Work', time: '11:30' }, - { title: 'Write down one good idea', tag: 'Ideas', time: '16:20' }, + { title: 'Review the launch checklist', tag: 'Work', time: '09:10' }, + { title: 'Polish the product story', tag: 'Focus', time: '11:30' }, + { title: 'Send the release notes', tag: 'Launch', time: '16:20' }, ], - boardTitle: 'This week', - boardCount: '3 todos', - drawerTitle: 'New todo', - titleValue: 'Prepare tomorrow’s top task', - contentValue: 'Add a short note so the next step is clear.', - save: 'Add todo', - fallback: 'Floatick product preview', + doingLabel: 'Doing', + overdueLabel: 'Overdue', + drawerTitle: 'Set deadline', + calendarMonth: 'August 2026', + calendarWeekdays: ['S', 'M', 'T', 'W', 'T', 'F', 'S'], + deadlineLabel: 'Deadline', + dueDate: 'Aug 12, 2026', + dueTime: '18:30', + reminderLabel: 'Reminder', + reminderValue: 'At deadline', + save: 'Save', }, zh: { ariaLabel: - 'Floatick 的交互式三维工作台,包含 Todo 与 Notes 标签页、便利板、编辑器和圆形悬浮图标', - remaining: '还有 3 项待完成', - todosTab: '待办', - notesTab: '笔记', - search: '搜索待办', - newTodo: '新建', + 'Floatick 的交互式三维工作台,展示 Todo、笔记、进行中状态、截止时间与悬浮图标', + remaining: '还有 13 项待完成', + todosTab: 'Todos', + notesTab: 'Notes', + search: '搜索 Todos', today: '今天', tasks: [ - { title: '规划一个专注的上午', tag: '个人', time: '09:10' }, - { title: '检查今天的任务清单', tag: '工作', time: '11:30' }, - { title: '记录一个值得尝试的想法', tag: '灵感', time: '16:20' }, + { title: '检查发布清单', tag: '工作', time: '09:10' }, + { title: '完善产品介绍', tag: '专注', time: '11:30' }, + { title: '发送版本说明', tag: '发布', time: '16:20' }, ], - boardTitle: '本周计划', - boardCount: '3 项待办', - drawerTitle: '新建待办', - titleValue: '确定明天最重要的一件事', - contentValue: '补充一句简短说明,让下一步足够清晰。', - save: '添加待办', - fallback: 'Floatick 产品预览', + doingLabel: '进行中', + overdueLabel: '已逾期', + drawerTitle: '设置截止时间', + calendarMonth: '2026 年 8 月', + calendarWeekdays: ['日', '一', '二', '三', '四', '五', '六'], + deadlineLabel: '截止时间', + dueDate: '2026 年 8 月 12 日', + dueTime: '18:30', + reminderLabel: '提醒', + reminderValue: '到期时提醒', + save: '保存', }, } as const; @@ -64,7 +70,6 @@ const copy = demoCopy[locale]; data-todos-tab={copy.todosTab} data-notes-tab={copy.notesTab} data-search={copy.search} - data-new-todo={copy.newTodo} data-today={copy.today} data-task-one-title={copy.tasks[0].title} data-task-one-tag={copy.tasks[0].tag} @@ -75,18 +80,28 @@ const copy = demoCopy[locale]; data-task-three-title={copy.tasks[2].title} data-task-three-tag={copy.tasks[2].tag} data-task-three-time={copy.tasks[2].time} - data-board-title={copy.boardTitle} - data-board-count={copy.boardCount} + data-doing-label={copy.doingLabel} + data-overdue-label={copy.overdueLabel} data-drawer-title={copy.drawerTitle} - data-title-value={copy.titleValue} - data-content-value={copy.contentValue} + data-calendar-month={copy.calendarMonth} + data-calendar-weekdays={copy.calendarWeekdays.join('|')} + data-deadline-label={copy.deadlineLabel} + data-due-date={copy.dueDate} + data-due-time={copy.dueTime} + data-reminder-label={copy.reminderLabel} + data-reminder-value={copy.reminderValue} data-save={copy.save} role="img" aria-label={copy.ariaLabel} > diff --git a/website/src/content/changelog.ts b/website/src/content/changelog.ts index ac71a0d..01dfb89 100644 --- a/website/src/content/changelog.ts +++ b/website/src/content/changelog.ts @@ -147,11 +147,10 @@ export const changelogEntries: Record = { version: 'v0.2.0', date: 'July 28, 2026', dateTime: '2026-07-28', - title: 'Sticky Boards, details, and reusable tags', + title: 'Todo details and reusable tags', summary: - 'This release adds desktop boards, Markdown details, and reusable colored tags.', + 'This release adds Markdown details, reusable colored tags, and more macOS controls.', highlights: [ - 'Create and pin color-coded Sticky Boards without duplicating todos.', 'Add Markdown content and open todo details with a double click.', 'Create, edit, search, and combine multiple color-coded tags.', 'Choose appearance, always-on-top behavior, and open-at-login settings.', @@ -242,11 +241,10 @@ export const changelogEntries: Record = { version: 'v0.2.0', date: '2026 年 7 月 28 日', dateTime: '2026-07-28', - title: '便利板、Todo 详情与可复用标签', + title: 'Todo 详情与可复用标签', summary: - '这个版本增加了桌面便利板、Markdown 详情和彩色标签。', + '这个版本增加了 Markdown 详情、可复用彩色标签和更多 macOS 设置。', highlights: [ - '创建并固定彩色便利板,同时不复制 Todo 数据。', '为 Todo 添加 Markdown 内容,并通过双击查看详情。', '创建、编辑、搜索并组合多个彩色标签。', '设置外观、始终置顶与登录时启动。', diff --git a/website/src/content/site-copy.ts b/website/src/content/site-copy.ts index 19b67de..7915c3e 100644 --- a/website/src/content/site-copy.ts +++ b/website/src/content/site-copy.ts @@ -121,7 +121,7 @@ export const siteCopy: Record = { lang: 'en', title: 'Floatick — Floating Todos & Notes for macOS', description: - 'A free, open-source floating todo and notes app for macOS with local storage, Markdown, shared tags, Sticky Boards, and fast desktop capture.', + 'A free, open-source floating todo and quick notes app for macOS with a focused Doing state, deadlines, local reminders, Markdown, and local storage.', canonicalPath: '/', alternatePath: '/zh/', }, @@ -134,26 +134,26 @@ export const siteCopy: Record = { languageLabel: 'Read in Chinese', }, hero: { - eyebrow: 'Floating todos & notes for macOS · Local-first · Open source', - titleBefore: 'Tasks and thoughts,', - titleAccent: 'always one click away.', + eyebrow: 'Floating todos, quick notes & deadlines · Local-first · Open source', + titleBefore: 'Capture fast.', + titleAccent: 'Finish with focus.', body: - 'Floatick lives on your Mac as a small, draggable icon. Click to capture a todo or note, add context and shared tags, then collapse it back to the desktop.', + 'Floatick stays on your Mac as a small, draggable icon. Capture a todo or note, mark the task you are doing, add a deadline, and let a quiet in-app reminder bring it back at the right moment.', download: 'Download for macOS', github: 'Explore on GitHub', compatibility: 'macOS 10.15+ · Apple silicon and Intel', }, proof: [ - { value: 'Free', label: 'No account or subscription' }, - { value: 'Open source', label: 'MIT-licensed on GitHub' }, - { value: 'Local-first', label: 'Todos and notes stay on your Mac' }, - { value: 'Maintained', label: 'New releases and fixes' }, + { value: 'One click', label: 'Capture without changing context' }, + { value: '3 states', label: 'Todo → Doing → Done' }, + { value: 'On time', label: 'Deadlines and in-app reminders' }, + { value: 'Local-first', label: 'Your workspace stays on your Mac' }, ], features: { eyebrow: 'Core features', title: 'Tasks and notes, close to the desktop.', body: - 'Capture, organize, and revisit work or ideas without keeping a full-size productivity app open.', + 'Capture, focus, and finish without keeping a full-size productivity app open.', items: [ { number: '01', @@ -163,27 +163,27 @@ export const siteCopy: Record = { }, { number: '02', - title: 'A lightweight Notes space', + title: 'A visible Doing state', body: - 'Switch from Todos to Notes to capture ideas, work logs, snippets, and anything worth keeping nearby.', + 'Move one task into Doing, give it a calm visual emphasis, and filter the list down to active work.', }, { number: '03', - title: 'Tags and combined filters', + title: 'Deadlines without notification noise', body: - 'Reuse the same colored tags across todos and notes, assign more than one, and filter by several tags at once.', + 'Set a due date and reminder from creation or the list. Floatick surfaces a compact in-app alert when it matters.', }, { number: '04', - title: 'Pinnable Sticky Boards', + title: 'A lightweight Notes space', body: - 'Group existing todos on a colored board and pin it to the desktop. A board never owns or deletes its todos.', + 'Capture ideas, logs, and snippets with date grouping, search, pinning, autosave, and Markdown preview.', }, { number: '05', - title: 'Pin, archive, and restore', + title: 'Shared tags and fast retrieval', body: - 'Keep useful notes pinned, move finished items into the archive, and restore them whenever they matter again.', + 'Reuse colored tags across todos and notes, combine filters, archive finished work, and restore it later.', }, { number: '06', @@ -195,27 +195,27 @@ export const siteCopy: Record = { }, workflow: { eyebrow: 'From capture to action', - title: 'Start with a title. Keep writing in one surface.', + title: 'A small, explicit path from idea to done.', body: - 'Todos and notes share a focused title-and-content editor, with Markdown available when you need structure.', + 'Floatick keeps status and timing visible without turning a lightweight list into project management software.', steps: [ { label: 'Capture', - title: 'Capture a todo or note from the desktop.', + title: 'Capture a todo or quick note from the desktop.', body: 'Open Floatick, choose the right space, add a title, and get back to what you were doing.', }, { - label: 'Organize', - title: 'Use shared tags and Sticky Boards.', + label: 'Focus', + title: 'Mark what you are doing and set the time.', body: - 'Connect related todos and notes with tags, then group active tasks on desktop boards.', + 'Use Doing to spotlight active work, then add a deadline or reminder only when the task needs one.', }, { - label: 'Share', - title: 'Copy the whole task as Markdown.', + label: 'Finish', + title: 'Complete it with one check.', body: - 'Send the title and notes to a document, teammate, or chat in one paste.', + 'Check the task off, archive it when you are ready, or copy the full context as Markdown.', }, ], }, @@ -278,7 +278,7 @@ export const siteCopy: Record = { eyebrow: 'FAQ', title: 'Before you install.', body: - 'Quick answers about notes, storage, compatibility, Sticky Boards, and Markdown copy.', + 'Quick answers about deadlines, notes, storage, compatibility, and Markdown copy.', items: [ { question: 'What is Floatick?', @@ -296,9 +296,9 @@ export const siteCopy: Record = { 'Yes. Notes have their own searchable workspace with pinning, archiving, shared tags, automatic saving, and Markdown preview.', }, { - question: 'What is a Sticky Board?', + question: 'How do deadline reminders work?', answer: - 'A Sticky Board is a colored desktop group for existing todos. You can pin it to the desktop, and deleting the board never deletes its todos.', + 'Set a deadline while creating or editing a todo, or directly from the list. Floatick shows a compact in-app reminder and keeps overdue work visible; it does not require macOS system notifications.', }, { question: 'Does Floatick support Apple silicon and Intel Macs?', @@ -316,7 +316,7 @@ export const siteCopy: Record = { eyebrow: 'For macOS', title: 'Keep tasks and thoughts on the desktop.', body: - 'Download the latest universal build, or view the source on GitHub.', + 'Capture quickly, focus on what is active, and finish on time with a local-first Universal macOS app.', download: 'Download Floatick', github: 'View source', }, @@ -333,7 +333,7 @@ export const siteCopy: Record = { lang: 'zh-CN', title: 'Floatick — 免费开源的 macOS 悬浮 Todo 与笔记', description: - '免费开源的 macOS 桌面悬浮 Todo 与轻量笔记,支持本地存储、Markdown、共享标签、便利板和快速记录。', + '免费开源的 macOS 桌面悬浮 Todo 与轻量笔记,支持 Doing 状态、截止时间、本地提醒、Markdown 和本地存储。', canonicalPath: '/zh/', alternatePath: '/', }, @@ -346,26 +346,26 @@ export const siteCopy: Record = { languageLabel: 'Read in English', }, hero: { - eyebrow: 'macOS 悬浮 Todo 与笔记 · 本地优先 · 开源', - titleBefore: '待办和灵感,', - titleAccent: '点一下就到。', + eyebrow: 'macOS 悬浮 Todo、Quick Notes 与 DDL · 本地优先 · 开源', + titleBefore: '随手记下,', + titleAccent: '专注完成。', body: - 'Floatick 平时是桌面上的一个可拖动图标。点击记录 Todo 或笔记、补充内容和共享标签,用完后再收回原位。', + 'Floatick 平时是桌面上的可拖动图标。记录 Todo 或笔记、标记正在做的任务、设置 DDL;到时间后,用克制的应用内提醒把它带回视线。', download: '下载 macOS 版', github: '在 GitHub 查看', compatibility: '支持 macOS 10.15+ · Apple 芯片与 Intel', }, proof: [ - { value: '免费', label: '无需账号或订阅' }, - { value: '开源', label: 'GitHub 上的 MIT 项目' }, - { value: '本地优先', label: 'Todo 和笔记保存在这台 Mac' }, - { value: '持续维护', label: '持续发布功能与修复' }, + { value: '一步记录', label: '不必切换当前工作环境' }, + { value: '3 个状态', label: 'Todo → Doing → Done' }, + { value: '按时提醒', label: 'DDL 与应用内提醒' }, + { value: '本地优先', label: '工作空间留在这台 Mac' }, ], features: { eyebrow: '核心功能', title: 'Todo 和笔记,就在桌面旁边。', body: - '无需常驻一个完整的效率工具,也能随时记录、整理和找回任务或想法。', + '无需常驻一个完整的效率工具,也能完成记录、专注与按时收尾。', items: [ { number: '01', @@ -375,27 +375,27 @@ export const siteCopy: Record = { }, { number: '02', - title: '轻量的 Notes 空间', + title: '清晰的 Doing 状态', body: - '从 Todo 切换到 Notes,随手记录灵感、工作日志、片段和任何值得留下的内容。', + '把正在推进的任务标记为 Doing,用克制的视觉强调它,也可以只筛选进行中的任务。', }, { number: '03', - title: '标签与组合筛选', + title: '不打扰的 DDL 与提醒', body: - 'Todo 与笔记复用同一套彩色标签,支持添加多个标签并同时组合筛选。', + '新建、编辑或直接在列表中设置截止时间;到点后显示紧凑的应用内提醒。', }, { number: '04', - title: '可固定的便利板', + title: '轻量的 Notes 空间', body: - '把已有 Todo 放进彩色便利板并固定在桌面。删除便利板不会删除 Todo。', + '随手记录灵感、工作日志和片段,支持按日期分组、搜索、置顶、自动保存与 Markdown 预览。', }, { number: '05', - title: '置顶、归档与恢复', + title: '共享标签与快速找回', body: - '置顶常用笔记,把完成的内容移入归档,并在需要时随时恢复。', + 'Todo 与笔记复用彩色标签,支持组合筛选、归档与恢复。', }, { number: '06', @@ -407,9 +407,9 @@ export const siteCopy: Record = { }, workflow: { eyebrow: '从记录到执行', - title: '先写标题,在同一个区域继续记录。', + title: '从想法到完成,路径小而明确。', body: - 'Todo 和笔记使用一致的标题与内容编辑器,需要结构时可以继续使用 Markdown。', + '状态和时间始终清晰可见,但 Floatick 不会因此变成一套沉重的项目管理工具。', steps: [ { label: '记录', @@ -418,16 +418,16 @@ export const siteCopy: Record = { '展开 Floatick、选择合适的空间、写下标题,然后继续手上的工作。', }, { - label: '组织', - title: '使用共享标签和便利板。', + label: '专注', + title: '标记正在做的任务和时间。', body: - '用标签连接相关 Todo 与笔记,再把活跃任务放进桌面便利板。', + '用 Doing 聚焦当前任务;只有确实需要时,再补充 DDL 或提醒。', }, { - label: '复制', - title: '把整个任务复制为 Markdown。', + label: '完成', + title: '勾选一下,结束任务。', body: - '一次复制标题和内容,直接粘贴到文档、聊天或发给同事。', + '完成后勾选,准备好时再归档;需要交接时也可以复制完整 Markdown。', }, ], }, @@ -489,7 +489,7 @@ export const siteCopy: Record = { eyebrow: '常见问题', title: '安装前,你可能想知道这些。', body: - '快速了解笔记、数据存储、系统兼容性、便利板和 Markdown 复制。', + '快速了解 DDL、笔记、数据存储、系统兼容性和 Markdown 复制。', items: [ { question: 'Floatick 是什么?', @@ -507,9 +507,9 @@ export const siteCopy: Record = { '可以。Notes 有独立的可搜索空间,支持置顶、归档、共享标签、自动保存和 Markdown 预览。', }, { - question: '便利板是什么?', + question: 'DDL 到时间后如何提醒?', answer: - '便利板是已有 Todo 的彩色桌面分组,可以固定在桌面。删除便利板不会删除其中的 Todo。', + '新建或编辑 Todo 时可以设置 DDL,也可以直接从首页列表设置。到时间后 Floatick 会显示紧凑的应用内提醒,并持续标记逾期任务;不依赖 macOS 系统通知。', }, { question: '支持 Apple 芯片和 Intel Mac 吗?', @@ -527,7 +527,7 @@ export const siteCopy: Record = { eyebrow: 'macOS', title: '把待办和灵感放在桌面旁边。', body: - '下载最新 Universal 安装包,或前往 GitHub 查看源代码。', + '随手记录、聚焦进行中的任务,并在截止时间前完成。下载本地优先的 Universal macOS 应用。', download: '下载 Floatick', github: '查看源代码', }, diff --git a/website/src/layouts/SiteLayout.astro b/website/src/layouts/SiteLayout.astro index 2bb64eb..08eb881 100644 --- a/website/src/layouts/SiteLayout.astro +++ b/website/src/layouts/SiteLayout.astro @@ -41,8 +41,8 @@ const softwareId = new URL('/#software', Astro.site).href; const webpageId = `${canonicalUrl.href}#webpage`; const socialImageAlt = lang === 'en' - ? 'Floatick floating todos and notes for macOS with shared tags, Sticky Boards, and Markdown' - : 'Floatick macOS 悬浮 Todo 与笔记,展示共享标签、便利板与 Markdown 内容'; + ? 'Floatick floating todos and quick notes for macOS with Doing status, deadlines, reminders, and shared tags' + : 'Floatick macOS 悬浮 Todo 与 Quick Notes,展示 Doing 状态、DDL、提醒与共享标签'; const websiteSchema = { '@type': 'WebSite', @@ -101,12 +101,14 @@ const softwareSchema = { }, featureList: [ 'Floating desktop todo list', + 'Todo, Doing, and Done task states', + 'Deadlines and compact in-app reminders', 'Local-first task storage', 'Lightweight local notes', + 'Notes grouped by date with automatic saving', 'Shared tags for todos and notes', 'Markdown task notes', 'Color-coded tags and filtering', - 'Pinnable Sticky Boards', 'One-click Markdown copy', ], offers: { diff --git a/website/src/scripts/product-hero-scene.ts b/website/src/scripts/product-hero-scene.ts index f5ff56f..243e2ad 100644 --- a/website/src/scripts/product-hero-scene.ts +++ b/website/src/scripts/product-hero-scene.ts @@ -47,14 +47,18 @@ type ProductSceneCopy = { todosTab: string; notesTab: string; search: string; - newTodo: string; today: string; tasks: [TaskPreview, TaskPreview, TaskPreview]; - boardTitle: string; - boardCount: string; + doingLabel: string; + overdueLabel: string; drawerTitle: string; - titleValue: string; - contentValue: string; + calendarMonth: string; + calendarWeekdays: string[]; + deadlineLabel: string; + dueDate: string; + dueTime: string; + reminderLabel: string; + reminderValue: string; save: string; }; @@ -86,7 +90,6 @@ const COLORS = { blue: '#5b8ff9', purple: '#8a72eb', orange: '#ff8550', - board: '#48528a', } as const; const TEXTURE_PIXEL_RATIO = 1.5; @@ -138,36 +141,44 @@ const PRODUCT_FRAME = { } as const; const DEFAULT_COPY: ProductSceneCopy = { - remaining: '3 tasks remaining', + remaining: '13 tasks remaining', todosTab: 'Todos', notesTab: 'Notes', search: 'Search todos', - newTodo: 'New', today: 'Today', tasks: [ - { title: 'Plan a focused morning', tag: 'Personal', time: '09:10' }, - { title: 'Review the launch checklist', tag: 'Work', time: '11:30' }, - { title: 'Write down one good idea', tag: 'Ideas', time: '16:20' }, + { title: 'Review the launch checklist', tag: 'Work', time: '09:10' }, + { title: 'Polish the product story', tag: 'Focus', time: '11:30' }, + { title: 'Send the release notes', tag: 'Launch', time: '16:20' }, ], - boardTitle: 'This week', - boardCount: '3 todos', - drawerTitle: 'New todo', - titleValue: 'Prepare tomorrow’s top task', - contentValue: 'Add a short note so the next step is clear.', - save: 'Add todo', + doingLabel: 'Doing', + overdueLabel: 'Overdue', + drawerTitle: 'Set deadline', + calendarMonth: 'August 2026', + calendarWeekdays: ['S', 'M', 'T', 'W', 'T', 'F', 'S'], + deadlineLabel: 'Deadline', + dueDate: 'Aug 12, 2026', + dueTime: '18:30', + reminderLabel: 'Reminder', + reminderValue: 'At deadline', + save: 'Save', }; const mountedStages = new WeakSet(); const PANEL_LAYOUT = { main: new Vector3(-0.38, -0.06, 0.48), - boardCollapsed: new Vector3(-1.68, -2.62, -0.12), - boardExpanded: new Vector3(-4.8, -1.72, 1.16), drawerCollapsed: new Vector3(0.9, 0.98, -0.28), drawerExpanded: new Vector3(4.8, 0.7, 1.02), } as const; const COIN_BASE_POSITION = new Vector3(3.52, -3.22, 2.16); +const HERO_CALENDAR = { + firstWeekdayIndex: 6, + dayCount: 31, + todayDate: 11, + selectedDate: 12, +} as const; function sceneViewportLayout(width: number) { if (width < NARROW_VIEWPORT_MAX_WIDTH) { @@ -184,13 +195,25 @@ function dataValue(stage: HTMLElement, key: keyof DOMStringMap, fallback: string return value?.trim() || fallback; } +function dataList( + stage: HTMLElement, + key: keyof DOMStringMap, + fallback: string[], +) { + const value = stage.dataset[key]; + if (!value) return fallback; + const items = value.split('|').map((item) => item.trim()); + return items.length === fallback.length && items.every(Boolean) + ? items + : fallback; +} + function readSceneCopy(stage: HTMLElement): ProductSceneCopy { return { remaining: dataValue(stage, 'remaining', DEFAULT_COPY.remaining), todosTab: dataValue(stage, 'todosTab', DEFAULT_COPY.todosTab), notesTab: dataValue(stage, 'notesTab', DEFAULT_COPY.notesTab), search: dataValue(stage, 'search', DEFAULT_COPY.search), - newTodo: dataValue(stage, 'newTodo', DEFAULT_COPY.newTodo), today: dataValue(stage, 'today', DEFAULT_COPY.today), tasks: [ { @@ -221,11 +244,36 @@ function readSceneCopy(stage: HTMLElement): ProductSceneCopy { time: dataValue(stage, 'taskThreeTime', DEFAULT_COPY.tasks[2].time), }, ], - boardTitle: dataValue(stage, 'boardTitle', DEFAULT_COPY.boardTitle), - boardCount: dataValue(stage, 'boardCount', DEFAULT_COPY.boardCount), + doingLabel: dataValue(stage, 'doingLabel', DEFAULT_COPY.doingLabel), + overdueLabel: dataValue(stage, 'overdueLabel', DEFAULT_COPY.overdueLabel), drawerTitle: dataValue(stage, 'drawerTitle', DEFAULT_COPY.drawerTitle), - titleValue: dataValue(stage, 'titleValue', DEFAULT_COPY.titleValue), - contentValue: dataValue(stage, 'contentValue', DEFAULT_COPY.contentValue), + calendarMonth: dataValue( + stage, + 'calendarMonth', + DEFAULT_COPY.calendarMonth, + ), + calendarWeekdays: dataList( + stage, + 'calendarWeekdays', + DEFAULT_COPY.calendarWeekdays, + ), + deadlineLabel: dataValue( + stage, + 'deadlineLabel', + DEFAULT_COPY.deadlineLabel, + ), + dueDate: dataValue(stage, 'dueDate', DEFAULT_COPY.dueDate), + dueTime: dataValue(stage, 'dueTime', DEFAULT_COPY.dueTime), + reminderLabel: dataValue( + stage, + 'reminderLabel', + DEFAULT_COPY.reminderLabel, + ), + reminderValue: dataValue( + stage, + 'reminderValue', + DEFAULT_COPY.reminderValue, + ), save: dataValue(stage, 'save', DEFAULT_COPY.save), }; } @@ -414,38 +462,6 @@ function drawArchiveIcon( context.restore(); } -function drawStickyBoardIcon( - context: CanvasRenderingContext2D, - x: number, - y: number, - size: number, -) { - const half = size / 2; - const fold = size * 0.28; - context.save(); - context.translate(x, y); - context.strokeStyle = COLORS.textSoft; - context.lineWidth = 4; - context.lineCap = 'round'; - context.lineJoin = 'round'; - context.beginPath(); - context.moveTo(-half, -half); - context.lineTo(half, -half); - context.lineTo(half, half - fold); - context.lineTo(half - fold, half); - context.lineTo(-half, half); - context.closePath(); - context.moveTo(half - fold, half); - context.lineTo(half - fold, half - fold); - context.lineTo(half, half - fold); - context.moveTo(-half + 8, -5); - context.lineTo(half - 9, -5); - context.moveTo(-half + 8, 7); - context.lineTo(half - 16, 7); - context.stroke(); - context.restore(); -} - function drawSettingsIcon( context: CanvasRenderingContext2D, x: number, @@ -536,6 +552,121 @@ function drawTagIcon( context.restore(); } +function drawPlayIcon( + context: CanvasRenderingContext2D, + centerX: number, + centerY: number, + size: number, + color: string, +) { + context.save(); + context.translate(centerX, centerY); + context.fillStyle = color; + context.beginPath(); + context.moveTo(-size * 0.34, -size * 0.5); + context.lineTo(size * 0.52, 0); + context.lineTo(-size * 0.34, size * 0.5); + context.closePath(); + context.fill(); + context.restore(); +} + +function drawAlarmIcon( + context: CanvasRenderingContext2D, + centerX: number, + centerY: number, + size: number, + color: string, +) { + const radius = size * 0.34; + context.save(); + context.translate(centerX, centerY); + context.strokeStyle = color; + context.lineWidth = Math.max(3, size * 0.09); + context.lineCap = 'round'; + context.lineJoin = 'round'; + context.beginPath(); + context.arc(0, 2, radius, 0, Math.PI * 2); + context.stroke(); + context.beginPath(); + context.moveTo(0, 2); + context.lineTo(0, -radius * 0.56); + context.moveTo(0, 2); + context.lineTo(radius * 0.55, radius * 0.22); + context.moveTo(-radius * 0.72, -radius * 0.78); + context.lineTo(-radius * 1.05, -radius * 0.46); + context.moveTo(radius * 0.72, -radius * 0.78); + context.lineTo(radius * 1.05, -radius * 0.46); + context.moveTo(-radius * 0.5, radius * 0.86); + context.lineTo(-radius * 0.74, radius * 1.14); + context.moveTo(radius * 0.5, radius * 0.86); + context.lineTo(radius * 0.74, radius * 1.14); + context.stroke(); + context.restore(); +} + +function drawCalendarIcon( + context: CanvasRenderingContext2D, + centerX: number, + centerY: number, + size: number, + color: string, +) { + context.save(); + context.translate(centerX, centerY); + context.strokeStyle = color; + context.lineWidth = Math.max(3, size * 0.09); + context.lineCap = 'round'; + context.lineJoin = 'round'; + roundedRect( + context, + -size * 0.44, + -size * 0.34, + size * 0.88, + size * 0.76, + size * 0.08, + 'transparent', + color, + ); + context.beginPath(); + context.moveTo(-size * 0.44, -size * 0.12); + context.lineTo(size * 0.44, -size * 0.12); + context.moveTo(-size * 0.22, -size * 0.48); + context.lineTo(-size * 0.22, -size * 0.25); + context.moveTo(size * 0.22, -size * 0.48); + context.lineTo(size * 0.22, -size * 0.25); + context.stroke(); + context.restore(); +} + +function drawBellIcon( + context: CanvasRenderingContext2D, + centerX: number, + centerY: number, + size: number, + color: string, +) { + context.save(); + context.translate(centerX, centerY); + context.strokeStyle = color; + context.lineWidth = Math.max(3, size * 0.09); + context.lineCap = 'round'; + context.lineJoin = 'round'; + context.beginPath(); + context.moveTo(-size * 0.4, size * 0.3); + context.quadraticCurveTo(-size * 0.24, size * 0.08, -size * 0.24, -size * 0.14); + context.quadraticCurveTo(-size * 0.24, -size * 0.46, 0, -size * 0.5); + context.quadraticCurveTo(size * 0.24, -size * 0.46, size * 0.24, -size * 0.14); + context.quadraticCurveTo(size * 0.24, size * 0.08, size * 0.4, size * 0.3); + context.closePath(); + context.stroke(); + context.beginPath(); + context.moveTo(-size * 0.13, size * 0.43); + context.quadraticCurveTo(0, size * 0.58, size * 0.13, size * 0.43); + context.stroke(); + context.restore(); +} + function drawCopyIcon( context: CanvasRenderingContext2D, x: number, @@ -602,6 +733,24 @@ function drawTag( return width; } +function drawStatusBadge( + context: CanvasRenderingContext2D, + x: number, + y: number, + label: string, + color: string, +) { + context.font = '700 21px Inter, -apple-system, BlinkMacSystemFont, sans-serif'; + const width = Math.min(170, context.measureText(label).width + 54); + roundedRect(context, x, y, width, 38, 19, `${color}18`, `${color}aa`); + context.beginPath(); + context.arc(x + 18, y + 19, 4.5, 0, Math.PI * 2); + context.fillStyle = color; + context.fill(); + context.fillText(label, x + 31, y + 27); + return width; +} + function createCanvasTexture( width: number, height: number, @@ -650,232 +799,210 @@ function createMainPanelTexture(copy: ProductSceneCopy) { context.fillRect(0, 0, 1000, 1340); const headerGradient = context.createLinearGradient(0, 0, 1000, 0); - headerGradient.addColorStop(0, '#22373a'); - headerGradient.addColorStop(1, '#182a2d'); + headerGradient.addColorStop(0, '#1e3134'); + headerGradient.addColorStop(1, '#17272a'); context.fillStyle = headerGradient; - context.fillRect(0, 0, 1000, 190); + context.fillRect(0, 0, 1000, 176); - roundedRect(context, 58, 46, 102, 102, 28, '#183136', '#426568'); - drawCheckmark(context, 83, 77, 52, COLORS.accent); + roundedRect(context, 58, 42, 92, 92, 26, '#183136', '#426568'); + drawCheckmark(context, 82, 70, 46, COLORS.accent); context.fillStyle = COLORS.textSoft; context.font = - '650 31px Inter, -apple-system, BlinkMacSystemFont, sans-serif'; - context.fillText(copy.remaining, 190, 110); + '650 30px Inter, -apple-system, BlinkMacSystemFont, sans-serif'; + context.fillText(copy.remaining, 182, 99); context.globalAlpha = 0.72; - drawArchiveIcon(context, 688, 94, 32); - drawStickyBoardIcon(context, 766, 94, 32); - drawSettingsIcon(context, 844, 94, 15); - drawCollapseIcon(context, 922, 94, 12); + drawArchiveIcon(context, 704, 88, 30); + drawSettingsIcon(context, 814, 88, 14); + drawCollapseIcon(context, 920, 88, 11); context.globalAlpha = 1; - roundedRect( - context, - 58, - 218, - 886, - 88, - 24, - COLORS.panelDark, - COLORS.line, - ); - roundedRect( - context, - 64, - 224, - 431, - 76, - 20, - 'rgba(45, 212, 199, 0.16)', - ); context.font = '720 28px Inter, -apple-system, BlinkMacSystemFont, sans-serif'; context.textAlign = 'center'; context.fillStyle = COLORS.accent; - context.fillText(copy.todosTab, 279, 273); + context.fillText(copy.todosTab, 278, 247); context.fillStyle = COLORS.textMuted; - context.fillText(copy.notesTab, 716, 273); + context.fillText(copy.notesTab, 716, 247); context.textAlign = 'start'; roundedRect( context, 58, - 336, - 626, - 104, - 27, + 292, + 562, + 96, + 25, COLORS.panelRaised, COLORS.line, ); - drawSearchIcon(context, 103, 383, 14); + drawSearchIcon(context, 102, 340, 13); context.fillStyle = COLORS.textMuted; context.font = - '540 30px Inter, -apple-system, BlinkMacSystemFont, sans-serif'; - context.fillText(copy.search, 151, 395); + '540 28px Inter, -apple-system, BlinkMacSystemFont, sans-serif'; + context.fillText(copy.search, 148, 351); roundedRect( context, - 704, - 336, - 104, - 104, - 27, + 640, + 292, + 88, + 96, + 25, COLORS.panelRaised, COLORS.line, ); - drawTagIcon(context, 756, 388, 34, COLORS.textSoft); + drawPlayIcon(context, 684, 340, 28, COLORS.textSoft); - roundedRect(context, 826, 336, 118, 104, 34, '#34554f'); - context.fillStyle = COLORS.text; - context.font = - '720 28px Inter, -apple-system, BlinkMacSystemFont, sans-serif'; - drawCenteredIconLabel( + roundedRect( + context, + 744, + 292, + 88, + 96, + 25, + COLORS.panelRaised, + COLORS.line, + ); + drawTagIcon(context, 788, 340, 31, COLORS.textSoft); + + roundedRect( context, - copy.newTodo, - 885, - 388, - 18, - 10, - COLORS.text, - drawPlusIcon, + 848, + 292, + 96, + 96, + 27, + 'rgba(45, 212, 199, 0.07)', + 'rgba(45, 212, 199, 0.62)', ); + drawPlusIcon(context, 896, 340, 28, COLORS.accent); context.fillStyle = COLORS.textMuted; context.font = '700 26px Inter, -apple-system, BlinkMacSystemFont, sans-serif'; - context.fillText(copy.today, 64, 520); - context.fillStyle = '#526467'; - context.fillRect(156, 503, 788, 2); + context.fillText(copy.today, 64, 458); - const tagColors = [COLORS.accent, COLORS.blue, COLORS.purple]; - const taskStarts = [560, 818, 1076]; + const tagColors = [COLORS.orange, COLORS.blue, COLORS.purple]; + const taskStarts = [500, 770, 1040]; copy.tasks.forEach((task, index) => { const y = taskStarts[index]; - const completed = index === 1; - if (index > 0) { - context.fillStyle = 'rgba(202, 229, 226, 0.07)'; - context.fillRect(60, y - 28, 884, 2); - } - if (index === 0) { + const isDoing = index === 1; + const isOverdue = index === 2; + if (isDoing) { + roundedRect( + context, + 56, + y - 18, + 888, + 206, + 22, + 'rgba(21, 116, 110, 0.18)', + 'rgba(45, 212, 199, 0.34)', + ); + } else if (index === 0) { roundedRect( context, 56, y - 18, 888, - 174, + 206, 22, - 'rgba(255, 255, 255, 0.035)', + 'rgba(255, 255, 255, 0.032)', ); } roundedRect( context, 66, - y, + y + 4, 54, 54, 16, - completed ? COLORS.accent : 'transparent', - completed ? COLORS.accent : '#6e7e80', + 'transparent', + '#6e7e80', ); - if (completed) { - drawCheckmark(context, 80, y + 16, 27, COLORS.ink); - } - context.fillStyle = completed ? COLORS.textMuted : COLORS.text; + context.fillStyle = COLORS.text; context.font = '690 31px Inter, -apple-system, BlinkMacSystemFont, sans-serif'; - context.fillText(task.title, 150, y + 40); - if (completed) { - const textWidth = Math.min(600, context.measureText(task.title).width); - context.fillStyle = COLORS.textMuted; - context.fillRect(150, y + 24, textWidth, 3); - } + context.fillText(task.title, 150, y + 43); const tagWidth = drawTag( context, 150, - y + 72, + y + 78, task.tag, tagColors[index], ); drawTagIcon( context, 150 + tagWidth + 25, - y + 91, + y + 97, 18, COLORS.accent, ); + + if (isDoing) { + drawStatusBadge( + context, + 150 + tagWidth + 58, + y + 78, + copy.doingLabel, + COLORS.accent, + ); + } + if (isOverdue) { + const overdueWidth = drawStatusBadge( + context, + 150, + y + 128, + copy.overdueLabel, + '#ff9e88', + ); + drawAlarmIcon( + context, + 150 + overdueWidth + 31, + y + 147, + 27, + '#ff9e88', + ); + } + context.fillStyle = COLORS.textMuted; context.font = '560 23px Inter, -apple-system, BlinkMacSystemFont, sans-serif'; context.textAlign = 'right'; - context.fillText(task.time, 918, y + 101); + context.fillText(task.time, 918, y + 145); context.textAlign = 'start'; - if (index === 0) { - context.globalAlpha = 0.82; - drawCopyIcon(context, 852, y + 30, 28); - drawMoreIcon(context, 914, y + 30); - context.globalAlpha = 1; - } - }); - context.restore(); - }); -} - -function createBoardTexture(copy: ProductSceneCopy) { - return createCanvasTexture(760, 560, (context) => { - context.save(); - const gradient = context.createLinearGradient(0, 0, 760, 560); - gradient.addColorStop(0, '#56609a'); - gradient.addColorStop(1, COLORS.board); - context.fillStyle = gradient; - context.fillRect(0, 0, 760, 560); + if (isDoing) { + const actionCenterY = y + 38; + const actionCenters = [738, 794, 850, 906] as const; - context.fillStyle = COLORS.text; - context.font = - '760 40px Inter, -apple-system, BlinkMacSystemFont, sans-serif'; - context.fillText(copy.boardTitle, 48, 76); - - context.strokeStyle = 'rgba(238, 242, 255, 0.16)'; - context.lineWidth = 2; - context.beginPath(); - context.moveTo(0, 118); - context.lineTo(760, 118); - context.stroke(); + context.globalAlpha = 0.88; + drawPlayIcon( + context, + actionCenters[0], + actionCenterY, + 24, + COLORS.accent, + ); + drawAlarmIcon( + context, + actionCenters[1], + actionCenterY, + 28, + COLORS.accent, + ); - copy.tasks.slice(0, 2).forEach((task, index) => { - const y = 166 + index * 104; - const completed = index === 1; - roundedRect( - context, - 48, - y, - 42, - 42, - 12, - completed ? COLORS.accent : 'transparent', - completed ? COLORS.accent : 'rgba(235, 240, 255, 0.48)', - ); - if (completed) drawCheckmark(context, 59, y + 12, 21, COLORS.ink); - context.fillStyle = completed - ? 'rgba(238, 242, 255, 0.55)' - : '#f5f6ff'; - context.font = - '650 29px Inter, -apple-system, BlinkMacSystemFont, sans-serif'; - context.fillText(task.title, 116, y + 31); - if (completed) { - const textWidth = Math.min(520, context.measureText(task.title).width); - context.fillRect(116, y + 18, textWidth, 3); + context.globalAlpha = 0.68; + drawCopyIcon(context, actionCenters[2], actionCenterY, 24); + drawMoreIcon(context, actionCenters[3], actionCenterY); + context.globalAlpha = 1; } }); - - context.fillStyle = 'rgba(239, 241, 255, 0.62)'; - context.font = - '680 25px Inter, -apple-system, BlinkMacSystemFont, sans-serif'; - context.fillText(copy.boardCount, 48, 506); context.restore(); }); } @@ -898,52 +1025,96 @@ function createDrawerTexture(copy: ProductSceneCopy) { context.fillStyle = 'rgba(205, 229, 226, 0.14)'; context.fillRect(0, 118, 720, 2); - drawTagIcon(context, 70, 174, 30, COLORS.textSoft); - roundedRect( - context, - 48, - 212, - 624, - 534, - 22, - '#162426', - 'rgba(45, 212, 199, 0.62)', - ); + context.fillStyle = COLORS.textMuted; + context.font = + '700 25px Inter, -apple-system, BlinkMacSystemFont, sans-serif'; + context.fillText(copy.calendarMonth, 62, 179); context.fillStyle = COLORS.textSoft; context.font = '700 31px Inter, -apple-system, BlinkMacSystemFont, sans-serif'; - context.fillText(copy.titleValue, 74, 282); + context.fillText('‹', 596, 181); + context.fillText('›', 646, 181); - context.fillStyle = 'rgba(205, 229, 226, 0.18)'; - context.fillRect(74, 322, 572, 2); - context.fillStyle = COLORS.textSoft; - context.font = - '540 25px Inter, -apple-system, BlinkMacSystemFont, sans-serif'; - const words = copy.contentValue.split(' '); - let line = ''; - let lineY = 378; - words.forEach((word) => { - const nextLine = `${line}${word} `; - if (context.measureText(nextLine).width > 540 && line) { - context.fillText(line.trim(), 74, lineY); - line = `${word} `; - lineY += 42; - } else { - line = nextLine; + const calendarLeft = 72; + const calendarTop = 230; + const columnWidth = 82; + copy.calendarWeekdays.forEach((day, index) => { + context.fillStyle = COLORS.textMuted; + context.font = + '650 20px Inter, -apple-system, BlinkMacSystemFont, sans-serif'; + context.textAlign = 'center'; + context.fillText(day, calendarLeft + index * columnWidth, calendarTop); + }); + + const calendarCells = [ + ...Array(HERO_CALENDAR.firstWeekdayIndex).fill(null), + ...Array.from( + { length: HERO_CALENDAR.dayCount }, + (_, index) => index + 1, + ), + ]; + calendarCells.forEach((date, index) => { + if (date === null) return; + const column = index % 7; + const row = Math.floor(index / 7); + const x = calendarLeft + column * columnWidth; + const y = calendarTop + 52 + row * 52; + if (date === HERO_CALENDAR.selectedDate) { + context.beginPath(); + context.arc(x, y - 9, 24, 0, Math.PI * 2); + context.fillStyle = COLORS.accent; + context.fill(); + } else if (date === HERO_CALENDAR.todayDate) { + context.beginPath(); + context.arc(x, y - 9, 23, 0, Math.PI * 2); + context.strokeStyle = 'rgba(45, 212, 199, 0.65)'; + context.lineWidth = 2; + context.stroke(); } + context.fillStyle = + date === HERO_CALENDAR.selectedDate ? COLORS.ink : COLORS.textSoft; + context.font = + '650 23px Inter, -apple-system, BlinkMacSystemFont, sans-serif'; + context.textAlign = 'center'; + context.fillText(String(date), x, y); }); - context.fillText(line.trim(), 74, lineY); + context.textAlign = 'start'; + + context.fillStyle = COLORS.textMuted; + context.font = + '700 21px Inter, -apple-system, BlinkMacSystemFont, sans-serif'; + context.fillText(copy.deadlineLabel, 48, 568); + roundedRect(context, 48, 590, 624, 98, 20, '#203236', COLORS.line); + drawCalendarIcon(context, 86, 640, 36, COLORS.textSoft); + context.fillStyle = COLORS.textSoft; + context.font = + '650 25px Inter, -apple-system, BlinkMacSystemFont, sans-serif'; + context.fillText(copy.dueDate, 122, 649); + context.textAlign = 'right'; + context.fillText(copy.dueTime, 640, 649); + context.textAlign = 'start'; - roundedRect(context, 386, 864, 286, 106, 53, COLORS.accent); + context.fillStyle = COLORS.textMuted; + context.font = + '700 21px Inter, -apple-system, BlinkMacSystemFont, sans-serif'; + context.fillText(copy.reminderLabel, 48, 748); + roundedRect(context, 48, 770, 624, 98, 20, '#203236', COLORS.line); + drawBellIcon(context, 86, 820, 36, COLORS.textSoft); + context.fillStyle = COLORS.textSoft; context.font = - '760 28px Inter, -apple-system, BlinkMacSystemFont, sans-serif'; + '650 25px Inter, -apple-system, BlinkMacSystemFont, sans-serif'; + context.fillText(copy.reminderValue, 122, 829); + + roundedRect(context, 430, 910, 242, 82, 41, COLORS.accent); + context.font = + '760 26px Inter, -apple-system, BlinkMacSystemFont, sans-serif'; drawCenteredIconLabel( context, copy.save, - 529, - 917, - 27, - 16, + 551, + 951, + 24, + 14, COLORS.ink, drawCenteredCheckmark, ); @@ -1384,25 +1555,6 @@ function buildProductScene( mainPanel.group.rotation.set(0.018, -0.06, -0.012); productContent.add(mainPanel.group); - const boardPanel = createPanel( - 3.65, - 2.72, - 0.46, - 0.28, - COLORS.board, - createBoardTexture(copy), - disposables, - ); - boardPanel.group.position.copy( - reducedMotion ? PANEL_LAYOUT.boardExpanded : PANEL_LAYOUT.boardCollapsed, - ); - boardPanel.group.rotation.set( - -0.025, - reducedMotion ? 0.22 : 0.015, - reducedMotion ? 0.035 : -0.018, - ); - productContent.add(boardPanel.group); - const drawerPanel = createPanel( 3.55, 5.32, @@ -1522,7 +1674,6 @@ function buildProductScene( let isDisposed = false; let isExpanded = reducedMotion; const clock = new Clock(); - const animatedBoardTarget = new Vector3(); const animatedDrawerTarget = new Vector3(); const render = () => { @@ -1580,30 +1731,14 @@ function buildProductScene( mainPanel.group.position.y = PANEL_LAYOUT.main.y + Math.sin(elapsed * 0.72) * 0.035; - const boardTarget = isExpanded - ? PANEL_LAYOUT.boardExpanded - : PANEL_LAYOUT.boardCollapsed; const drawerTarget = isExpanded ? PANEL_LAYOUT.drawerExpanded : PANEL_LAYOUT.drawerCollapsed; - animatedBoardTarget.copy(boardTarget); animatedDrawerTarget.copy(drawerTarget); if (isExpanded) { - animatedBoardTarget.y += Math.sin(elapsed * 0.82 + 1.4) * 0.045; animatedDrawerTarget.y += Math.sin(elapsed * 0.68 + 2.2) * 0.04; } - boardPanel.group.position.lerp(animatedBoardTarget, 0.075); drawerPanel.group.position.lerp(animatedDrawerTarget, 0.075); - boardPanel.group.rotation.y = MathUtils.lerp( - boardPanel.group.rotation.y, - isExpanded ? 0.22 : 0.015, - 0.075, - ); - boardPanel.group.rotation.z = MathUtils.lerp( - boardPanel.group.rotation.z, - isExpanded ? 0.035 : -0.018, - 0.075, - ); drawerPanel.group.rotation.y = MathUtils.lerp( drawerPanel.group.rotation.y, isExpanded ? -0.22 : -0.02, diff --git a/website/src/styles/global.css b/website/src/styles/global.css index 96c3948..48a3424 100644 --- a/website/src/styles/global.css +++ b/website/src/styles/global.css @@ -614,12 +614,18 @@ img { width: 100%; min-height: 820px; overflow: hidden; - border: 0; - border-radius: 0; + border: 1px solid rgba(103, 177, 174, 0.2); + border-radius: 44px; cursor: default; perspective: none; - background: transparent; - box-shadow: none; + background: + linear-gradient(180deg, rgba(5, 17, 19, 0.08), rgba(3, 12, 14, 0.3)), + url("/images/floatick-hero-environment-v1.jpg") center / cover no-repeat, + #071113; + box-shadow: + inset 0 1px rgba(255, 255, 255, 0.045), + 0 38px 100px rgba(0, 0, 0, 0.28), + 0 0 80px rgba(41, 197, 184, 0.055); isolation: isolate; transform: translateZ(0); } @@ -645,6 +651,16 @@ img { pointer-events: none; } +.product-hero-stage::after { + position: absolute; + z-index: 3; + inset: 14px; + border: 1px solid rgba(167, 225, 220, 0.075); + border-radius: 32px; + content: ""; + pointer-events: none; +} + .product-hero-stage:active { cursor: default; } @@ -671,39 +687,20 @@ img { .product-scene-fallback { z-index: 1; - display: grid; - place-items: center; + overflow: hidden; opacity: 1; transition: - opacity 300ms ease, - transform 500ms ease; -} - -.product-scene-fallback::before { - position: absolute; - width: min(56%, 430px); - aspect-ratio: 1; - border: 1px solid rgba(78, 225, 211, 0.14); - border-radius: 50%; - box-shadow: - 0 0 0 58px rgba(78, 225, 211, 0.025), - 0 0 0 116px rgba(78, 225, 211, 0.016); - content: ""; + opacity 300ms ease; } .product-scene-fallback img { - position: relative; - z-index: 1; - width: 132px; - height: 132px; - filter: - drop-shadow(0 24px 28px rgba(0, 0, 0, 0.38)) - drop-shadow(0 0 28px rgba(41, 197, 184, 0.24)); + width: 100%; + height: 100%; + object-fit: cover; } .product-hero-stage.is-webgl-ready .product-scene-fallback { opacity: 0; - transform: scale(0.9); } .product-hero-stage.has-webgl-error .product-scene-canvas { @@ -1646,6 +1643,12 @@ img { .product-hero-stage { min-height: 550px; border-radius: 28px; + background-position: center bottom; + } + + .product-hero-stage::after { + inset: 8px; + border-radius: 21px; } .proof {