diff --git a/README.md b/README.md
index 4461ecb..c495981 100644
--- a/README.md
+++ b/README.md
@@ -2,12 +2,12 @@
-
Floatick — Floating Todo List for macOS
- An open-source, local-first macOS todo app and lightweight task manager.
+ Floatick — Floating Todos and Notes for macOS
+ An open-source, local-first macOS app for lightweight tasks and notes.
- Capture and organize tasks one click away—without an account or cloud service.
+ Capture tasks and thoughts one click away—without an account or cloud service.
@@ -24,19 +24,21 @@
-Floatick is an offline-friendly desktop todo app that rests above your workspace
-as a small, draggable icon. Click it and the icon expands into a focused todo
-panel; collapse it and the icon returns to the same anchor. The panel chooses
+Floatick is an offline-friendly desktop todo and 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.
-## A focused macOS task manager
+## 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.
-- **Local by default** — no account, cloud service, or telemetry. Your todo data
- remains in `~/.floatick`.
+- **Lightweight notes** — capture ideas, logs, and snippets with search, pinning,
+ archiving, shared tags, autosave, and Markdown preview.
+- **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,
context-menu Quit, and support for Reduce Motion.
- **Comfortable in any workspace** — system, light, and dark themes with English
@@ -70,10 +72,12 @@ Only download Floatick from this repository's Releases page.
| Action | How |
| --- | --- |
| Reposition Floatick | Drag the floating icon |
-| Open the todo list | Click the floating icon |
+| Open Floatick | Click the floating icon |
| Collapse the panel | Click the collapse button or press `Esc` |
| Create a todo | Press `⌘N`, or use the input at the top |
-| Search | Press `⌘F` |
+| Switch between todos and notes | Use the tabs at the top of the panel |
+| Create a note | Open Notes and choose New |
+| Search the current workspace | Press `⌘F` |
| Edit a todo | Hover over the item and choose Edit |
| Complete a todo | Select its checkbox |
| Archive or restore | Use the action at the end of the item |
@@ -86,11 +90,13 @@ Floatick creates its working directory on first launch:
| Path | Purpose |
| --- | --- |
| `~/.floatick/todos.json` | Todos, completion state, 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 |
Sparkle stores the automatic-update preference in standard macOS application
preferences. Floatick does not require an account and does not upload your todo
-data. Network access is used only to check for and download application updates.
+or note data. Network access is used only to check for and download application updates.
## Development
@@ -135,7 +141,7 @@ the macOS system boundaries that remain in Draft acceptance.
lib/
app/ App composition and themes
core/ Shared platform, storage, and UI primitives
- features/ Todo, settings, and update features
+ features/ Todo, notes, settings, and update features
l10n/ English and Simplified Chinese resources
macos/Runner/ AppKit window shell and Sparkle integration
test/ Repository, ViewModel, and widget tests
@@ -144,7 +150,7 @@ tool/ Icon and release tooling
```
Flutter owns product UI and state. A small AppKit shell owns macOS-specific
-window behavior and Sparkle. Todo data never crosses the platform channel.
+window behavior and Sparkle. Product data never crosses the platform channel.
See [Architecture](./docs/ARCHITECTURE.md) for the dependency boundaries.
## Development and release model
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 519ff55..f3d6566 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -2,12 +2,12 @@
- Floatick — macOS 悬浮待办清单
- 一款开源、仅存本地的 macOS 待办应用与轻量任务管理工具。
+ Floatick — macOS 悬浮 Todo 与笔记
+ 一款开源、本地优先的 macOS 轻量 Todo 与笔记应用。
- 无需账号或云服务,让任务记录与整理始终触手可及。
+ 无需账号或云服务,让任务和想法始终触手可及。
@@ -24,16 +24,18 @@
-Floatick 是一款支持离线使用的 macOS 桌面待办应用。它平时以一个小巧、
-可拖动的图标悬浮在工作区上方;点击图标会展开专注的待办面板,收起后则会准确
+Floatick 是一款支持离线使用的 macOS 桌面 Todo 与笔记应用。它平时以一个小巧、
+可拖动的图标悬浮在工作区上方;点击图标会展开专注的面板,收起后则会准确
回到原来的锚点。面板还会根据图标附近的屏幕空间自动选择展开方向,因此放在
屏幕边缘也能自然使用。
-## 专注而轻量的 macOS 任务管理工具
+## 专注而轻量的 macOS Todo 与笔记空间
- **随时可用**——图标可以拖到任意位置,点击即可展开。
- **完整待办流程**——支持创建、编辑、完成、搜索、归档和恢复,并按天自动分组。
-- **默认仅存本地**——不需要账号、云服务或遥测,待办数据保存在 `~/.floatick`。
+- **轻量笔记空间**——记录灵感、日志和片段,支持搜索、置顶、归档、共享标签、
+ 自动保存与 Markdown 预览。
+- **默认仅存本地**——不需要账号、云服务或遥测,Todo 与笔记数据保存在 `~/.floatick`。
- **贴合 macOS**——使用 AppKit 管理透明悬浮窗口,支持快捷键、右键退出和
“减少动态效果”。
- **适应不同工作环境**——支持跟随系统、浅色和深色主题,以及英文和简体中文。
@@ -64,10 +66,12 @@ Floatick 目前仍处于早期预览阶段,下载包暂未使用 Apple Develop
| 操作 | 使用方式 |
| --- | --- |
| 调整位置 | 拖动悬浮图标 |
-| 展开待办列表 | 点击悬浮图标 |
+| 展开 Floatick | 点击悬浮图标 |
| 收起面板 | 点击收起按钮或按 `Esc` |
| 创建待办 | 按 `⌘N`,或使用顶部输入框 |
-| 搜索 | 按 `⌘F` |
+| 切换 Todo 与笔记 | 使用面板顶部的标签页 |
+| 创建笔记 | 打开 Notes 并点击新建 |
+| 搜索当前空间 | 按 `⌘F` |
| 编辑待办 | 将鼠标悬浮到待办上并点击编辑 |
| 完成待办 | 点击待办前的复选框 |
| 归档或恢复 | 使用待办末尾的操作按钮 |
@@ -80,10 +84,12 @@ Floatick 第一次启动时会自动创建工作目录:
| 路径 | 用途 |
| --- | --- |
| `~/.floatick/todos.json` | 待办、完成状态与归档状态 |
+| `~/.floatick/notes.json` | 笔记、标签、置顶与归档状态 |
+| `~/.floatick/tags.json` | Todo 与笔记共享的可复用标签 |
| `~/.floatick/settings.json` | 主题与语言设置 |
Sparkle 会将自动更新偏好保存在 macOS 标准应用偏好中。Floatick 不需要账号,
-也不会上传待办数据;网络访问仅用于检查和下载应用更新。
+也不会上传 Todo 或笔记数据;网络访问仅用于检查和下载应用更新。
## 本地开发
@@ -128,7 +134,7 @@ Release 应用位于
lib/
app/ 应用装配与主题
core/ 共享的平台、存储和 UI 基元
- features/ Todo、设置和更新功能
+ features/ Todo、笔记、设置和更新功能
l10n/ 英文与简体中文资源
macos/Runner/ AppKit 窗口外壳与 Sparkle 集成
test/ Repository、ViewModel 和 Widget 测试
@@ -137,7 +143,7 @@ tool/ 图标与发布工具
```
Flutter 负责产品 UI 和状态;轻量 AppKit 外壳负责 macOS 专属窗口行为与
-Sparkle。待办数据不会经过平台通道。依赖边界详见
+Sparkle。产品数据不会经过平台通道。依赖边界详见
[架构说明](./docs/ARCHITECTURE.md)。
## 开发与发布模型
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 674b369..ac5950e 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -33,6 +33,10 @@ lib/
storage/
ui/
features/
+ notes/
+ data/
+ domain/
+ presentation/
settings/
data/
domain/
@@ -63,16 +67,16 @@ tool/
- `l10n`: English source copy, Simplified Chinese translations, and generated
Flutter localization accessors.
- `macos/Runner`: transparent floating window behavior and the Sparkle update
- service; todo data does not cross either platform channel.
+ service; todo and note data does not cross either platform channel.
## State and persistence
-`TodoViewModel`, `SettingsViewModel`, and `UpdateViewModel` own presentation
-state. Repositories own file I/O, JSON compatibility, or typed platform-channel
+`TodoViewModel`, `NoteViewModel`, `SettingsViewModel`, and `UpdateViewModel` own
+presentation state. Repositories own file I/O, JSON compatibility, or typed platform-channel
boundaries. Repositories are constructor-injected so state behavior can be
tested without touching the user's home directory or launching Sparkle.
-Todo data and Floatick-owned interface settings only use `~/.floatick`.
+Todo data, note data, shared tags, and Floatick-owned interface settings only use `~/.floatick`.
Repositories create it on first load and never read or write another hidden
application directory. Sparkle owns its automatic-check preference in the
standard macOS application `UserDefaults`; Floatick does not duplicate that
@@ -84,9 +88,9 @@ channel to a typed, informational state in Settings; other connectivity
failures remain recoverable errors. Sparkle still owns appcast parsing,
signature validation, download, and installation once the feed is available.
-Writes are serialized by `TodoViewModel` to prevent overlapping mutations from
-losing updates. A write failure leaves the last persisted in-memory state
-unchanged and exposes a recoverable UI error.
+Writes are serialized by `TodoViewModel` and `NoteViewModel` to prevent
+overlapping mutations from losing updates. A write failure leaves the last
+persisted in-memory state unchanged and exposes a recoverable UI error.
## Testing
diff --git a/docs/RELEASING.md b/docs/RELEASING.md
index 5e18a51..c3187f8 100644
--- a/docs/RELEASING.md
+++ b/docs/RELEASING.md
@@ -88,6 +88,8 @@ Download the DMG from the Draft Release and verify at least:
- floating icon drag, expand direction, collapse position, and right-click
Quit work;
- create, edit, complete, search, archive, and restore work;
+- switch to Notes, then create, edit, search, pin, tag, archive, restore, and
+ relaunch with note content preserved;
- Chinese, English, system/light/dark themes, and Settings persistence work;
- relaunch preserves `~/.floatick` data;
- before the first stable release, manual update checks show the compact
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 747ad30..4c6f341 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -33,6 +33,8 @@ tool/test/run_ui_tests.sh
| 双击详情、完成、归档、恢复、搜索 | Integration |
| 退出前后的本地 JSON 持久化 | Integration |
| Tag 创建、Todo 关联、多选 OR 筛选和清空 | Integration |
+| Notes 切换、创建、标签入口与空草稿处理 | Widget |
+| Note 搜索、置顶、归档、恢复、共享标签与失败回滚 | Unit + Widget |
| Sticky Board 创建、添加现有 Todo、Pin/Unpin | Integration |
| 置顶、登录启动、主题等设置与原生调用边界 | Integration |
| 悬浮图标的 macOS Accessibility button/press contract | XCTest |
diff --git a/lib/app/floatick_app.dart b/lib/app/floatick_app.dart
index 52aa66f..062592d 100644
--- a/lib/app/floatick_app.dart
+++ b/lib/app/floatick_app.dart
@@ -5,6 +5,7 @@ import 'package:flutter/services.dart';
import '../core/platform/window_bridge.dart';
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';
@@ -18,6 +19,7 @@ import 'theme/floatick_theme.dart';
class FloatickApp extends StatelessWidget {
const FloatickApp({
required this.controller,
+ this.noteController,
required this.settingsController,
required this.updateController,
required this.stickyBoardController,
@@ -28,6 +30,7 @@ class FloatickApp extends StatelessWidget {
});
final TodoViewModel controller;
+ final NoteViewModel? noteController;
final SettingsViewModel settingsController;
final UpdateViewModel updateController;
final StickyBoardViewModel stickyBoardController;
@@ -61,6 +64,7 @@ class FloatickApp extends StatelessWidget {
},
home: _FloatickShell(
controller: controller,
+ noteController: noteController,
settingsController: settingsController,
updateController: updateController,
stickyBoardController: stickyBoardController,
@@ -76,6 +80,7 @@ class FloatickApp extends StatelessWidget {
class _FloatickShell extends StatefulWidget {
const _FloatickShell({
required this.controller,
+ required this.noteController,
required this.settingsController,
required this.updateController,
required this.stickyBoardController,
@@ -84,6 +89,7 @@ class _FloatickShell extends StatefulWidget {
});
final TodoViewModel controller;
+ final NoteViewModel? noteController;
final SettingsViewModel settingsController;
final UpdateViewModel updateController;
final StickyBoardViewModel stickyBoardController;
@@ -470,6 +476,7 @@ class _FloatickShellState extends State<_FloatickShell> {
visible: _panelTooltipsEnabled,
child: TodoPanel(
controller: widget.controller,
+ noteController: widget.noteController,
settingsController: widget.settingsController,
updateController: widget.updateController,
stickyBoardController: widget.stickyBoardController,
diff --git a/lib/core/ui/floatick_editor_components.dart b/lib/core/ui/floatick_editor_components.dart
new file mode 100644
index 0000000..73d3865
--- /dev/null
+++ b/lib/core/ui/floatick_editor_components.dart
@@ -0,0 +1,476 @@
+import 'package:flutter/material.dart';
+
+import '../../l10n/l10n.dart';
+import 'floatick_hover_motion.dart';
+
+const _editorDrawerRadius = Radius.circular(22);
+
+abstract final class FloatickEditorMetrics {
+ static const bodyPadding = EdgeInsets.fromLTRB(20, 16, 20, 12);
+ static const double sectionGap = 12;
+}
+
+class FloatickEditorDrawerSurface extends StatelessWidget {
+ const FloatickEditorDrawerSurface({
+ required this.title,
+ required this.closeTooltip,
+ required this.onClose,
+ required this.closeFocusNode,
+ required this.child,
+ this.headerActions = const [],
+ this.closeButtonKey,
+ super.key,
+ });
+
+ final String title;
+ final String closeTooltip;
+ final VoidCallback onClose;
+ final FocusNode closeFocusNode;
+ final List headerActions;
+ final Key? closeButtonKey;
+ final Widget child;
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final isDark = theme.brightness == Brightness.dark;
+ return DecoratedBox(
+ decoration: BoxDecoration(
+ color: isDark ? const Color(0xFF202A2E) : const Color(0xFFF9FBFA),
+ borderRadius: const BorderRadius.vertical(top: _editorDrawerRadius),
+ border: Border(
+ top: BorderSide(
+ color: isDark
+ ? Colors.white.withValues(alpha: 0.11)
+ : Colors.black.withValues(alpha: 0.07),
+ ),
+ ),
+ ),
+ child: ClipRRect(
+ borderRadius: const BorderRadius.vertical(top: _editorDrawerRadius),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ FloatickEditorDrawerHeader(
+ title: title,
+ closeTooltip: closeTooltip,
+ onClose: onClose,
+ closeFocusNode: closeFocusNode,
+ actions: headerActions,
+ closeButtonKey: closeButtonKey,
+ ),
+ const FloatickEditorDivider(),
+ Expanded(child: child),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class FloatickEditorDrawerHeader extends StatelessWidget {
+ const FloatickEditorDrawerHeader({
+ required this.title,
+ required this.closeTooltip,
+ required this.onClose,
+ required this.closeFocusNode,
+ this.actions = const [],
+ this.closeButtonKey,
+ super.key,
+ });
+
+ final String title;
+ final String closeTooltip;
+ final VoidCallback onClose;
+ final FocusNode closeFocusNode;
+ final List actions;
+ final Key? closeButtonKey;
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: const EdgeInsets.fromLTRB(20, 12, 10, 11),
+ child: Row(
+ children: [
+ Expanded(
+ child: Text(
+ title,
+ style: Theme.of(
+ context,
+ ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600),
+ ),
+ ),
+ ...actions,
+ IconButton(
+ key: closeButtonKey ?? const Key('editor-drawer-close'),
+ focusNode: closeFocusNode,
+ tooltip: closeTooltip,
+ onPressed: onClose,
+ icon: const Icon(Icons.close_rounded, size: 19),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class FloatickEditorSectionLabel extends StatelessWidget {
+ const FloatickEditorSectionLabel(this.label, {super.key});
+
+ final String label;
+
+ @override
+ Widget build(BuildContext context) {
+ return Text(
+ label,
+ style: Theme.of(
+ context,
+ ).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600),
+ );
+ }
+}
+
+class FloatickDocumentEditor extends StatelessWidget {
+ const FloatickDocumentEditor({
+ required this.titleController,
+ required this.contentController,
+ required this.titleFocusNode,
+ required this.contentFocusNode,
+ required this.titleHint,
+ required this.contentHint,
+ required this.titleSemanticsLabel,
+ required this.contentSemanticsLabel,
+ required this.showPreview,
+ required this.preview,
+ required this.onPreviewChanged,
+ this.toolbarLeading,
+ this.enabled = true,
+ this.onTitleChanged,
+ this.onContentChanged,
+ this.editorSurfaceKey = const Key('floatick-document-editor'),
+ this.titleFieldKey = const Key('floatick-document-title-field'),
+ this.contentFieldKey = const Key('floatick-document-content-field'),
+ this.modeSwitchKey = const Key('floatick-document-mode-switch'),
+ this.writeTabKey = const Key('floatick-document-write-tab'),
+ this.previewTabKey = const Key('floatick-document-preview-tab'),
+ super.key,
+ });
+
+ final TextEditingController titleController;
+ final TextEditingController contentController;
+ final FocusNode titleFocusNode;
+ final FocusNode contentFocusNode;
+ final String titleHint;
+ final String contentHint;
+ final String titleSemanticsLabel;
+ final String contentSemanticsLabel;
+ final bool showPreview;
+ final Widget preview;
+ final ValueChanged onPreviewChanged;
+ final Widget? toolbarLeading;
+ final bool enabled;
+ final ValueChanged? onTitleChanged;
+ final ValueChanged? onContentChanged;
+ final Key editorSurfaceKey;
+ final Key titleFieldKey;
+ final Key contentFieldKey;
+ final Key modeSwitchKey;
+ final Key writeTabKey;
+ final Key previewTabKey;
+
+ void _focusContent() {
+ if (!enabled) {
+ return;
+ }
+ contentFocusNode.requestFocus();
+ }
+
+ void _handlePreviewChanged(BuildContext context, bool showPreview) {
+ if (showPreview) {
+ contentFocusNode.unfocus();
+ }
+ onPreviewChanged(showPreview);
+ if (!showPreview) {
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (context.mounted && enabled) {
+ contentFocusNode.requestFocus();
+ }
+ });
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final inputDecorationTheme = theme.inputDecorationTheme;
+ final focusListenable = Listenable.merge([
+ titleFocusNode,
+ contentFocusNode,
+ ]);
+ final fieldDecoration = InputDecoration(
+ hintText: titleHint,
+ filled: false,
+ border: InputBorder.none,
+ enabledBorder: InputBorder.none,
+ focusedBorder: InputBorder.none,
+ disabledBorder: InputBorder.none,
+ );
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Row(
+ children: [
+ if (toolbarLeading case final leading?)
+ Expanded(child: leading)
+ else
+ const Spacer(),
+ const SizedBox(width: 8),
+ FloatickEditorModeSwitch(
+ showPreview: showPreview,
+ controlKey: modeSwitchKey,
+ writeTabKey: writeTabKey,
+ previewTabKey: previewTabKey,
+ onChanged: (value) => _handlePreviewChanged(context, value),
+ ),
+ ],
+ ),
+ const SizedBox(height: 7),
+ Expanded(
+ child: ListenableBuilder(
+ listenable: focusListenable,
+ builder: (context, _) {
+ final hasFocus =
+ titleFocusNode.hasFocus || contentFocusNode.hasFocus;
+ final borderColor = hasFocus
+ ? theme.colorScheme.primary
+ : theme.colorScheme.onSurface.withValues(
+ alpha: theme.brightness == Brightness.dark ? 0.08 : 0.06,
+ );
+ return AnimatedContainer(
+ key: editorSurfaceKey,
+ duration: MediaQuery.disableAnimationsOf(context)
+ ? Duration.zero
+ : const Duration(milliseconds: 150),
+ curve: Curves.easeOut,
+ clipBehavior: Clip.antiAlias,
+ decoration: BoxDecoration(
+ color: inputDecorationTheme.fillColor,
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(
+ color: borderColor,
+ width: hasFocus ? 1.5 : 1,
+ ),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Semantics(
+ label: titleSemanticsLabel,
+ textField: true,
+ child: TextField(
+ key: titleFieldKey,
+ controller: titleController,
+ focusNode: titleFocusNode,
+ enabled: enabled,
+ maxLines: 1,
+ textInputAction: TextInputAction.next,
+ style: theme.textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ onChanged: onTitleChanged,
+ onSubmitted: (_) => _focusContent(),
+ decoration: fieldDecoration.copyWith(
+ contentPadding: const EdgeInsets.fromLTRB(
+ 16,
+ 15,
+ 16,
+ 13,
+ ),
+ ),
+ ),
+ ),
+ Divider(
+ key: const Key('floatick-document-title-divider'),
+ height: 1,
+ thickness: 1,
+ indent: 16,
+ endIndent: 16,
+ color: theme.colorScheme.onSurface.withValues(
+ alpha: 0.08,
+ ),
+ ),
+ Expanded(
+ child: showPreview
+ ? preview
+ : Semantics(
+ label: contentSemanticsLabel,
+ textField: true,
+ child: TextField(
+ key: contentFieldKey,
+ controller: contentController,
+ focusNode: contentFocusNode,
+ enabled: enabled,
+ expands: true,
+ minLines: null,
+ maxLines: null,
+ textAlignVertical: TextAlignVertical.top,
+ keyboardType: TextInputType.multiline,
+ onChanged: onContentChanged,
+ decoration: fieldDecoration.copyWith(
+ hintText: contentHint,
+ alignLabelWithHint: true,
+ contentPadding: const EdgeInsets.fromLTRB(
+ 16,
+ 13,
+ 16,
+ 16,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ },
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+class FloatickEditorModeSwitch extends StatelessWidget {
+ const FloatickEditorModeSwitch({
+ required this.showPreview,
+ required this.onChanged,
+ this.controlKey = const Key('floatick-editor-mode-switch'),
+ this.writeTabKey = const Key('markdown-write-tab'),
+ this.previewTabKey = const Key('markdown-preview-tab'),
+ super.key,
+ });
+
+ final bool showPreview;
+ final ValueChanged onChanged;
+ final Key controlKey;
+ final Key writeTabKey;
+ final Key previewTabKey;
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ return Container(
+ key: controlKey,
+ height: 30,
+ padding: const EdgeInsets.all(2),
+ decoration: BoxDecoration(
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.055),
+ borderRadius: BorderRadius.circular(9),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ _EditorModeButton(
+ key: writeTabKey,
+ label: context.l10n.markdownWriteLabel,
+ selected: !showPreview,
+ onPressed: () => onChanged(false),
+ ),
+ _EditorModeButton(
+ key: previewTabKey,
+ label: context.l10n.markdownPreviewLabel,
+ selected: showPreview,
+ onPressed: () => onChanged(true),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class FloatickEditorDivider extends StatelessWidget {
+ const FloatickEditorDivider({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return Divider(
+ height: 1,
+ thickness: 1,
+ color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.08),
+ );
+ }
+}
+
+class FloatickEditorFooter extends StatelessWidget {
+ const FloatickEditorFooter({required this.child, super.key});
+
+ final Widget child;
+
+ @override
+ Widget build(BuildContext context) {
+ return Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ const FloatickEditorDivider(),
+ Padding(
+ padding: const EdgeInsets.fromLTRB(20, 10, 20, 14),
+ child: child,
+ ),
+ ],
+ );
+ }
+}
+
+class _EditorModeButton extends StatelessWidget {
+ const _EditorModeButton({
+ required this.label,
+ required this.selected,
+ required this.onPressed,
+ super.key,
+ });
+
+ final String label;
+ final bool selected;
+ final VoidCallback onPressed;
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ 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(
+ 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,
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/features/todos/presentation/widgets/todo_markdown.dart b/lib/core/ui/floatick_markdown.dart
similarity index 81%
rename from lib/features/todos/presentation/widgets/todo_markdown.dart
rename to lib/core/ui/floatick_markdown.dart
index 1a34152..29e419d 100644
--- a/lib/features/todos/presentation/widgets/todo_markdown.dart
+++ b/lib/core/ui/floatick_markdown.dart
@@ -1,16 +1,34 @@
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
-import '../../../../l10n/l10n.dart';
+import '../../l10n/l10n.dart';
-class TodoMarkdownPreview extends StatelessWidget {
- const TodoMarkdownPreview({required this.content, super.key});
+class FloatickMarkdownPreview extends StatelessWidget {
+ const FloatickMarkdownPreview({
+ required this.content,
+ this.embedded = false,
+ super.key,
+ });
final String content;
+ final bool embedded;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
+ final child = content.trim().isEmpty
+ ? Center(
+ child: Text(
+ context.l10n.markdownPreviewEmptyMessage,
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.42),
+ ),
+ ),
+ )
+ : FloatickMarkdownContent(content: content);
+ if (embedded) {
+ return child;
+ }
return DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.onSurface.withValues(alpha: 0.035),
@@ -19,22 +37,13 @@ class TodoMarkdownPreview extends StatelessWidget {
color: theme.colorScheme.onSurface.withValues(alpha: 0.07),
),
),
- child: content.trim().isEmpty
- ? Center(
- child: Text(
- context.l10n.markdownPreviewEmptyMessage,
- style: theme.textTheme.bodySmall?.copyWith(
- color: theme.colorScheme.onSurface.withValues(alpha: 0.42),
- ),
- ),
- )
- : TodoMarkdownContent(content: content),
+ child: child,
);
}
}
-class TodoMarkdownContent extends StatelessWidget {
- const TodoMarkdownContent({required this.content, super.key});
+class FloatickMarkdownContent extends StatelessWidget {
+ const FloatickMarkdownContent({required this.content, super.key});
final String content;
diff --git a/lib/features/notes/data/note_repository.dart b/lib/features/notes/data/note_repository.dart
new file mode 100644
index 0000000..14d6b58
--- /dev/null
+++ b/lib/features/notes/data/note_repository.dart
@@ -0,0 +1,103 @@
+import 'dart:convert';
+import 'dart:io';
+
+import '../../../core/storage/storage_failure.dart';
+import '../../todos/data/todo_repository.dart';
+import '../domain/note_item.dart';
+
+abstract interface class NoteRepository {
+ String get storagePath;
+
+ Future> load();
+
+ Future save(List items);
+}
+
+class LocalNoteRepository implements NoteRepository {
+ LocalNoteRepository({Directory? rootDirectory})
+ : rootDirectory =
+ rootDirectory ?? Directory(_defaultStorageDirectoryPath());
+
+ static const fileName = 'notes.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 [];
+ }
+
+ final decoded = jsonDecode(await _storageFile.readAsString());
+ if (decoded is! List) {
+ throw const FormatException('Note storage root must be a JSON array.');
+ }
+ final items = decoded
+ .map((entry) {
+ if (entry is! Map) {
+ throw const FormatException('Each note must be a JSON object.');
+ }
+ return NoteItem.fromJson(Map.from(entry));
+ })
+ .toList(growable: false);
+ if (items.map((item) => item.id).toSet().length != items.length) {
+ throw const FormatException('Note ids must be unique.');
+ }
+ return items;
+ } 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(List items) async {
+ final temporaryFile = File(
+ '${_storageFile.path}.tmp-$pid-${DateTime.now().microsecondsSinceEpoch}',
+ );
+
+ try {
+ await rootDirectory.create(recursive: true);
+ final encoded = const JsonEncoder.withIndent(
+ ' ',
+ ).convert(items.map((item) => item.toJson()).toList(growable: false));
+ 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 String _defaultStorageDirectoryPath() {
+ final homeDirectory = Platform.environment['HOME'];
+ if (homeDirectory == null || homeDirectory.trim().isEmpty) {
+ throw const StorageFailure(
+ kind: StorageFailureKind.homeDirectoryUnavailable,
+ );
+ }
+ return '$homeDirectory/${LocalTodoRepository.directoryName}';
+ }
+}
diff --git a/lib/features/notes/domain/note_item.dart b/lib/features/notes/domain/note_item.dart
new file mode 100644
index 0000000..2cc0c84
--- /dev/null
+++ b/lib/features/notes/domain/note_item.dart
@@ -0,0 +1,189 @@
+import 'package:flutter/foundation.dart';
+
+class NoteItem {
+ NoteItem({
+ required this.id,
+ required this.title,
+ required this.content,
+ required this.createdAt,
+ required this.updatedAt,
+ Iterable tagIds = const [],
+ this.pinnedAt,
+ this.archivedAt,
+ }) : tagIds = List.unmodifiable(tagIds.toSet());
+
+ final String id;
+ final String title;
+ final String content;
+ final DateTime createdAt;
+ final DateTime updatedAt;
+ final List tagIds;
+ final DateTime? pinnedAt;
+ final DateTime? archivedAt;
+
+ bool get isPinned => pinnedAt != null;
+ bool get isArchived => archivedAt != null;
+
+ NoteItem withDetails({
+ required String title,
+ required String content,
+ required DateTime updatedAt,
+ required Iterable tagIds,
+ }) {
+ return NoteItem(
+ id: id,
+ title: title,
+ content: content,
+ createdAt: createdAt,
+ updatedAt: updatedAt,
+ tagIds: tagIds,
+ pinnedAt: pinnedAt,
+ archivedAt: archivedAt,
+ );
+ }
+
+ NoteItem withPinnedAt(DateTime? value) {
+ return NoteItem(
+ id: id,
+ title: title,
+ content: content,
+ createdAt: createdAt,
+ updatedAt: updatedAt,
+ tagIds: tagIds,
+ pinnedAt: value,
+ archivedAt: archivedAt,
+ );
+ }
+
+ NoteItem withArchivedAt(DateTime? value, {required DateTime updatedAt}) {
+ return NoteItem(
+ id: id,
+ title: title,
+ content: content,
+ createdAt: createdAt,
+ updatedAt: updatedAt,
+ tagIds: tagIds,
+ pinnedAt: value == null ? pinnedAt : null,
+ archivedAt: value,
+ );
+ }
+
+ factory NoteItem.fromJson(Map json) {
+ final createdAt = _requiredDate(json, 'createdAt');
+ return NoteItem(
+ id: _requiredString(json, 'id'),
+ title: _requiredString(json, 'title'),
+ content: _optionalString(json, 'content'),
+ createdAt: createdAt,
+ updatedAt: _optionalDate(json, 'updatedAt') ?? createdAt,
+ tagIds: _optionalStringList(json, 'tagIds'),
+ pinnedAt: _optionalDate(json, 'pinnedAt'),
+ archivedAt: _optionalDate(json, 'archivedAt'),
+ );
+ }
+
+ Map toJson() {
+ return {
+ 'id': id,
+ 'title': title,
+ if (content.isNotEmpty) 'content': content,
+ if (tagIds.isNotEmpty) 'tagIds': tagIds,
+ 'createdAt': createdAt.toUtc().toIso8601String(),
+ 'updatedAt': updatedAt.toUtc().toIso8601String(),
+ if (pinnedAt != null) 'pinnedAt': pinnedAt!.toUtc().toIso8601String(),
+ if (archivedAt != null)
+ 'archivedAt': archivedAt!.toUtc().toIso8601String(),
+ };
+ }
+
+ static String _requiredString(Map json, String key) {
+ final value = json[key];
+ if (value is! String || value.trim().isEmpty) {
+ throw FormatException('Note field "$key" must be a non-empty string.');
+ }
+ return value;
+ }
+
+ static DateTime _requiredDate(Map json, String key) {
+ final value = json[key];
+ if (value is! String) {
+ throw FormatException('Note field "$key" must be an ISO-8601 string.');
+ }
+ return DateTime.parse(value);
+ }
+
+ static String _optionalString(Map json, String key) {
+ final value = json[key];
+ if (value == null) {
+ return '';
+ }
+ if (value is! String) {
+ throw FormatException('Note field "$key" must be a string.');
+ }
+ return value;
+ }
+
+ static DateTime? _optionalDate(Map json, String key) {
+ final value = json[key];
+ if (value == null) {
+ return null;
+ }
+ if (value is! String) {
+ throw FormatException('Note field "$key" must be an ISO-8601 string.');
+ }
+ return DateTime.parse(value);
+ }
+
+ static List _optionalStringList(
+ Map json,
+ String key,
+ ) {
+ final value = json[key];
+ if (value == null) {
+ return const [];
+ }
+ if (value is! List) {
+ throw FormatException('Note field "$key" must be a string array.');
+ }
+ final values = [];
+ for (final entry in value) {
+ if (entry is! String || entry.trim().isEmpty) {
+ throw FormatException(
+ 'Note field "$key" must contain non-empty strings.',
+ );
+ }
+ values.add(entry);
+ }
+ if (values.toSet().length != values.length) {
+ throw FormatException('Note field "$key" must not contain duplicates.');
+ }
+ return values;
+ }
+
+ @override
+ bool operator ==(Object other) {
+ return other is NoteItem &&
+ other.id == id &&
+ other.title == title &&
+ other.content == content &&
+ listEquals(other.tagIds, tagIds) &&
+ other.createdAt == createdAt &&
+ other.updatedAt == updatedAt &&
+ other.pinnedAt == pinnedAt &&
+ other.archivedAt == archivedAt;
+ }
+
+ @override
+ int get hashCode {
+ return Object.hash(
+ id,
+ title,
+ content,
+ Object.hashAll(tagIds),
+ createdAt,
+ updatedAt,
+ pinnedAt,
+ archivedAt,
+ );
+ }
+}
diff --git a/lib/features/notes/presentation/note_editor_drawer.dart b/lib/features/notes/presentation/note_editor_drawer.dart
new file mode 100644
index 0000000..0f274f4
--- /dev/null
+++ b/lib/features/notes/presentation/note_editor_drawer.dart
@@ -0,0 +1,363 @@
+import 'dart:async';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+
+import '../../../core/ui/floatick_editor_components.dart';
+import '../../../core/ui/floatick_markdown.dart';
+import '../../../l10n/l10n.dart';
+import '../../todos/domain/todo_tag.dart';
+import '../../todos/presentation/widgets/editor_tag_selector.dart';
+import '../domain/note_item.dart';
+
+const Duration _autoSaveDelay = Duration(milliseconds: 450);
+
+typedef SaveNoteDraft =
+ Future Function({
+ String? id,
+ required String title,
+ required String content,
+ required List tagIds,
+ });
+
+class NoteEditorDrawer extends StatefulWidget {
+ const NoteEditorDrawer({
+ required this.item,
+ required this.availableTags,
+ required this.assignedTagIds,
+ required this.isOpen,
+ required this.onSave,
+ required this.onOpenTagAssignment,
+ required this.onClose,
+ required this.closeFocusNode,
+ super.key,
+ });
+
+ final NoteItem? item;
+ final List availableTags;
+ final List assignedTagIds;
+ final bool isOpen;
+ final SaveNoteDraft onSave;
+ final VoidCallback onOpenTagAssignment;
+ final VoidCallback onClose;
+ final FocusNode closeFocusNode;
+
+ @override
+ State createState() => NoteEditorDrawerState();
+}
+
+class NoteEditorDrawerState extends State {
+ final _titleController = TextEditingController();
+ final _contentController = TextEditingController();
+ final _titleFocusNode = FocusNode();
+ final _contentFocusNode = FocusNode();
+ Timer? _autoSaveTimer;
+ Future? _saveFuture;
+ String? _noteId;
+ String _lastSavedTitle = '';
+ String _lastSavedContent = '';
+ List _lastSavedTagIds = const [];
+ bool _saveAgain = false;
+ bool _isSaving = false;
+ bool _saveFailed = false;
+ bool _showPreview = false;
+
+ bool get _isCreating => widget.item == null;
+ bool get _hasUnsavedChanges =>
+ _titleController.text != _lastSavedTitle ||
+ _contentController.text != _lastSavedContent ||
+ !listEquals(_currentTagIds, _lastSavedTagIds);
+ List get _currentTagIds => _effectiveTagIdsFor(
+ availableTags: widget.availableTags,
+ assignedTagIds: widget.assignedTagIds,
+ );
+
+ @override
+ void initState() {
+ super.initState();
+ _loadItem(widget.item);
+ _titleController.addListener(_handleInputChanged);
+ _contentController.addListener(_handleInputChanged);
+ if (widget.isOpen) {
+ _requestInitialFocus();
+ }
+ }
+
+ @override
+ void didUpdateWidget(covariant NoteEditorDrawer oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ final previousTagIds = _effectiveTagIdsFor(
+ availableTags: oldWidget.availableTags,
+ assignedTagIds: oldWidget.assignedTagIds,
+ );
+ if (!listEquals(previousTagIds, _currentTagIds)) {
+ _handleInputChanged();
+ }
+ if (!oldWidget.isOpen && widget.isOpen) {
+ _requestInitialFocus();
+ }
+ }
+
+ @override
+ void dispose() {
+ _autoSaveTimer?.cancel();
+ _titleController
+ ..removeListener(_handleInputChanged)
+ ..dispose();
+ _contentController
+ ..removeListener(_handleInputChanged)
+ ..dispose();
+ _titleFocusNode.dispose();
+ _contentFocusNode.dispose();
+ super.dispose();
+ }
+
+ Future flush() async {
+ _autoSaveTimer?.cancel();
+ if (!_hasUnsavedChanges) {
+ return !_saveFailed;
+ }
+ return _requestSave();
+ }
+
+ void _loadItem(NoteItem? item) {
+ _noteId = item?.id;
+ _lastSavedTitle = item?.title ?? '';
+ _lastSavedContent = item?.content ?? '';
+ _lastSavedTagIds = item?.tagIds ?? const [];
+ _titleController.text = _lastSavedTitle;
+ _contentController.text = _lastSavedContent;
+ }
+
+ void _requestInitialFocus() {
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (mounted && widget.isOpen) {
+ _titleController.selection = TextSelection(
+ baseOffset: 0,
+ extentOffset: _titleController.text.length,
+ );
+ _titleFocusNode.requestFocus();
+ }
+ });
+ }
+
+ void _handleInputChanged() {
+ if (!_hasUnsavedChanges) {
+ return;
+ }
+ if (_saveFailed) {
+ setState(() => _saveFailed = false);
+ }
+ _autoSaveTimer?.cancel();
+ _autoSaveTimer = Timer(_autoSaveDelay, () {
+ unawaited(_requestSave());
+ });
+ }
+
+ Future _requestSave() {
+ _saveAgain = true;
+ final activeSave = _saveFuture;
+ if (activeSave != null) {
+ return activeSave;
+ }
+ final save = _runSaveLoop();
+ _saveFuture = save;
+ unawaited(
+ save.whenComplete(() {
+ if (identical(_saveFuture, save)) {
+ _saveFuture = null;
+ }
+ }),
+ );
+ return save;
+ }
+
+ Future _runSaveLoop() async {
+ while (_saveAgain) {
+ _saveAgain = false;
+ final title = _titleController.text;
+ final content = _contentController.text;
+ final tagIds = _currentTagIds;
+ if (title == _lastSavedTitle &&
+ content == _lastSavedContent &&
+ listEquals(tagIds, _lastSavedTagIds)) {
+ continue;
+ }
+ if (_noteId == null && title.trim().isEmpty && content.trim().isEmpty) {
+ _lastSavedTitle = title;
+ _lastSavedContent = content;
+ _lastSavedTagIds = tagIds;
+ continue;
+ }
+
+ if (mounted) {
+ setState(() {
+ _isSaving = true;
+ _saveFailed = false;
+ });
+ }
+ NoteItem? saved;
+ try {
+ saved = await widget.onSave(
+ id: _noteId,
+ title: title,
+ content: content,
+ tagIds: tagIds,
+ );
+ } on Object catch (error, stackTrace) {
+ debugPrint('Floatick could not autosave a note: $error');
+ debugPrintStack(stackTrace: stackTrace);
+ }
+ if (saved == null) {
+ if (mounted) {
+ setState(() {
+ _isSaving = false;
+ _saveFailed = true;
+ });
+ }
+ return false;
+ }
+ _noteId = saved.id;
+ _lastSavedTitle = title;
+ _lastSavedContent = content;
+ _lastSavedTagIds = tagIds;
+ if (_titleController.text != title ||
+ _contentController.text != content ||
+ !listEquals(_currentTagIds, tagIds)) {
+ _saveAgain = true;
+ }
+ }
+ if (mounted) {
+ setState(() {
+ _isSaving = false;
+ _saveFailed = false;
+ });
+ }
+ return true;
+ }
+
+ Future _closeAfterFlush() async {
+ FocusManager.instance.primaryFocus?.unfocus();
+ if (await flush() && mounted) {
+ widget.onClose();
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final onSurface = theme.colorScheme.onSurface;
+ return FloatickEditorDrawerSurface(
+ key: const Key('note-editor-drawer'),
+ title: _isCreating
+ ? context.l10n.newNoteDrawerTitle
+ : context.l10n.editNoteDrawerTitle,
+ closeTooltip: context.l10n.closeNoteDrawerTooltip,
+ closeButtonKey: const Key('close-note-editor'),
+ onClose: () => unawaited(_closeAfterFlush()),
+ closeFocusNode: widget.closeFocusNode,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Expanded(
+ child: Padding(
+ padding: FloatickEditorMetrics.bodyPadding,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Expanded(
+ child: FloatickDocumentEditor(
+ editorSurfaceKey: const Key('note-document-editor'),
+ titleFieldKey: const Key('note-title-field'),
+ contentFieldKey: const Key('note-content-field'),
+ modeSwitchKey: const Key('note-editor-mode-switch'),
+ writeTabKey: const Key('note-markdown-write-tab'),
+ previewTabKey: const Key('note-markdown-preview-tab'),
+ titleController: _titleController,
+ contentController: _contentController,
+ titleFocusNode: _titleFocusNode,
+ contentFocusNode: _contentFocusNode,
+ titleHint: context.l10n.noteTitleHint,
+ contentHint: context.l10n.noteContentHint,
+ titleSemanticsLabel: context.l10n.noteTitleLabel,
+ contentSemanticsLabel: context.l10n.noteContentLabel,
+ toolbarLeading: EditorTagSelector(
+ availableTags: widget.availableTags,
+ selectedTagIds: widget.assignedTagIds.toSet(),
+ buttonKey: const Key('note-editor-tag-button'),
+ tagKeyPrefix: 'note-editor-tag',
+ onPressed: widget.onOpenTagAssignment,
+ ),
+ showPreview: _showPreview,
+ preview: FloatickMarkdownPreview(
+ key: const Key('note-content-preview'),
+ content: _contentController.text,
+ embedded: true,
+ ),
+ onPreviewChanged: (value) =>
+ setState(() => _showPreview = value),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ FloatickEditorFooter(
+ key: const Key('note-editor-footer'),
+ child: Row(
+ children: [
+ Icon(
+ _saveFailed
+ ? Icons.error_outline_rounded
+ : _isSaving
+ ? Icons.sync_rounded
+ : Icons.check_rounded,
+ size: 16,
+ color: _saveFailed
+ ? theme.colorScheme.error
+ : onSurface.withValues(alpha: 0.48),
+ ),
+ const SizedBox(width: 7),
+ Expanded(
+ child: Text(
+ _saveFailed
+ ? context.l10n.noteAutoSaveFailed
+ : _isSaving
+ ? context.l10n.noteAutoSaving
+ : _noteId == null
+ ? context.l10n.noteEmptyDraftHint
+ : context.l10n.noteAutoSaved,
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: _saveFailed
+ ? theme.colorScheme.error
+ : onSurface.withValues(alpha: 0.48),
+ ),
+ ),
+ ),
+ TextButton(
+ key: const Key('finish-note-editor'),
+ onPressed: _isSaving
+ ? null
+ : () => unawaited(_closeAfterFlush()),
+ child: Text(context.l10n.finishNoteAction),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ static List _effectiveTagIdsFor({
+ required List availableTags,
+ required List assignedTagIds,
+ }) {
+ final selectedTagIds = assignedTagIds.toSet();
+ return List.unmodifiable(
+ availableTags
+ .where((tag) => selectedTagIds.contains(tag.id))
+ .map((tag) => tag.id),
+ );
+ }
+}
diff --git a/lib/features/notes/presentation/note_panel_content.dart b/lib/features/notes/presentation/note_panel_content.dart
new file mode 100644
index 0000000..8c6796a
--- /dev/null
+++ b/lib/features/notes/presentation/note_panel_content.dart
@@ -0,0 +1,422 @@
+import 'package:flutter/material.dart';
+
+import '../../../l10n/l10n.dart';
+import '../../todos/domain/todo_tag.dart';
+import '../../todos/presentation/widgets/floatick_tag_chip.dart';
+import '../domain/note_item.dart';
+import 'note_view_model.dart';
+
+class NotePanelContent extends StatelessWidget {
+ const NotePanelContent({
+ required this.controller,
+ required this.archived,
+ required this.query,
+ required this.availableTags,
+ required this.selectedTagIds,
+ required this.onOpen,
+ super.key,
+ });
+
+ final NoteViewModel controller;
+ final bool archived;
+ final String query;
+ final List availableTags;
+ final Set selectedTagIds;
+ final ValueChanged onOpen;
+
+ @override
+ Widget build(BuildContext context) {
+ final items = controller.itemsForView(
+ archived: archived,
+ query: query,
+ selectedTagIds: selectedTagIds,
+ );
+ if (items.isEmpty) {
+ return _EmptyNotes(
+ archived: archived,
+ hasQuery: query.isNotEmpty || selectedTagIds.isNotEmpty,
+ );
+ }
+
+ final pinned = archived
+ ? const []
+ : items.where((item) => item.isPinned).toList(growable: false);
+ final regular = archived
+ ? items
+ : items.where((item) => !item.isPinned).toList(growable: false);
+ return ListView(
+ key: const Key('note-list'),
+ padding: const EdgeInsets.fromLTRB(14, 10, 14, 18),
+ children: [
+ if (pinned.isNotEmpty) ...[
+ _SectionLabel(
+ icon: Icons.push_pin_outlined,
+ label: context.l10n.pinnedNotesLabel,
+ ),
+ ...pinned.map(
+ (item) => _NoteListRow(
+ item: item,
+ archived: false,
+ controller: controller,
+ availableTags: availableTags,
+ onOpen: () => onOpen(item.id),
+ ),
+ ),
+ 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),
+ ),
+ ),
+ ],
+ ],
+ );
+ }
+}
+
+class _SectionLabel extends StatelessWidget {
+ const _SectionLabel({required this.icon, required this.label});
+
+ final IconData icon;
+ final String label;
+
+ @override
+ Widget build(BuildContext context) {
+ final color = Theme.of(
+ context,
+ ).colorScheme.onSurface.withValues(alpha: 0.48);
+ return Padding(
+ padding: const EdgeInsets.fromLTRB(7, 6, 7, 7),
+ child: Row(
+ children: [
+ Icon(icon, size: 14, color: color),
+ const SizedBox(width: 7),
+ Text(
+ label,
+ style: Theme.of(context).textTheme.labelMedium?.copyWith(
+ color: color,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _NoteListRow extends StatefulWidget {
+ const _NoteListRow({
+ required this.item,
+ required this.archived,
+ required this.controller,
+ required this.availableTags,
+ required this.onOpen,
+ });
+
+ final NoteItem item;
+ final bool archived;
+ final NoteViewModel controller;
+ final List availableTags;
+ final VoidCallback onOpen;
+
+ @override
+ State<_NoteListRow> createState() => _NoteListRowState();
+}
+
+class _NoteListRowState extends State<_NoteListRow> {
+ bool _hovered = false;
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final onSurface = theme.colorScheme.onSurface;
+ final preview = _previewFor(widget.item.content);
+ final assignedTagIds = widget.item.tagIds.toSet();
+ final assignedTags = widget.availableTags
+ .where((tag) => assignedTagIds.contains(tag.id))
+ .toList(growable: false);
+ return Semantics(
+ button: true,
+ label: widget.item.title,
+ child: MouseRegion(
+ cursor: SystemMouseCursors.click,
+ onEnter: (_) => setState(() => _hovered = true),
+ onExit: (_) => setState(() => _hovered = false),
+ child: InkWell(
+ key: ValueKey('note-row-${widget.item.id}'),
+ onTap: widget.onOpen,
+ borderRadius: BorderRadius.circular(12),
+ hoverColor: theme.colorScheme.primary.withValues(alpha: 0.05),
+ child: Container(
+ constraints: const BoxConstraints(minHeight: 64),
+ padding: const EdgeInsets.fromLTRB(11, 9, 5, 9),
+ decoration: BoxDecoration(
+ border: Border(
+ bottom: BorderSide(color: onSurface.withValues(alpha: 0.065)),
+ ),
+ ),
+ child: Row(
+ children: [
+ Container(
+ width: 32,
+ height: 32,
+ decoration: BoxDecoration(
+ color: theme.colorScheme.primary.withValues(alpha: 0.08),
+ borderRadius: BorderRadius.circular(9),
+ ),
+ child: Icon(
+ Icons.description_outlined,
+ size: 17,
+ color: theme.colorScheme.primary,
+ ),
+ ),
+ const SizedBox(width: 10),
+ Expanded(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Expanded(
+ child: Text(
+ widget.item.title,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: theme.textTheme.bodyMedium?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ),
+ if (widget.item.isPinned && !_hovered)
+ Icon(
+ Icons.push_pin_rounded,
+ size: 13,
+ color: theme.colorScheme.primary,
+ ),
+ ],
+ ),
+ const SizedBox(height: 3),
+ Row(
+ children: [
+ Expanded(
+ child: Text(
+ preview.isEmpty
+ ? context.l10n.noteWithoutContent
+ : preview,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: onSurface.withValues(alpha: 0.46),
+ ),
+ ),
+ ),
+ const SizedBox(width: 8),
+ Text(
+ _formatUpdatedTime(context, widget.item.updatedAt),
+ style: theme.textTheme.labelSmall?.copyWith(
+ color: onSurface.withValues(alpha: 0.36),
+ ),
+ ),
+ ],
+ ),
+ if (assignedTags.isNotEmpty) ...[
+ const SizedBox(height: 5),
+ Wrap(
+ spacing: 4,
+ runSpacing: 3,
+ children: [
+ for (final tag in assignedTags)
+ FloatickTagChip(
+ key: ValueKey(
+ 'note-tag-${widget.item.id}-${tag.id}',
+ ),
+ tag: tag,
+ compact: true,
+ ),
+ ],
+ ),
+ ],
+ ],
+ ),
+ ),
+ AnimatedSwitcher(
+ duration: MediaQuery.disableAnimationsOf(context)
+ ? Duration.zero
+ : const Duration(milliseconds: 140),
+ child: _hovered
+ ? _NoteRowActions(
+ key: const ValueKey('actions'),
+ item: widget.item,
+ archived: widget.archived,
+ controller: widget.controller,
+ )
+ : const SizedBox(
+ key: ValueKey('spacing'),
+ width: 8,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _NoteRowActions extends StatelessWidget {
+ const _NoteRowActions({
+ required this.item,
+ required this.archived,
+ required this.controller,
+ super.key,
+ });
+
+ final NoteItem item;
+ final bool archived;
+ final NoteViewModel controller;
+
+ @override
+ Widget build(BuildContext context) {
+ if (archived) {
+ return Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ IconButton(
+ key: ValueKey('restore-note-${item.id}'),
+ tooltip: context.l10n.restoreNoteTooltip,
+ onPressed: () => controller.restore(item.id),
+ icon: const Icon(Icons.unarchive_outlined, size: 17),
+ ),
+ IconButton(
+ key: ValueKey('delete-note-${item.id}'),
+ tooltip: context.l10n.deleteNoteTooltip,
+ onPressed: () => controller.deletePermanently(item.id),
+ icon: const Icon(Icons.delete_outline_rounded, size: 17),
+ ),
+ ],
+ );
+ }
+ return Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ IconButton(
+ key: ValueKey('pin-note-${item.id}'),
+ tooltip: item.isPinned
+ ? context.l10n.unpinNoteTooltip
+ : context.l10n.pinNoteTooltip,
+ onPressed: () => controller.togglePin(item.id),
+ icon: Icon(
+ item.isPinned ? Icons.push_pin_rounded : Icons.push_pin_outlined,
+ size: 17,
+ ),
+ ),
+ IconButton(
+ key: ValueKey('archive-note-${item.id}'),
+ tooltip: context.l10n.archiveNoteTooltip,
+ onPressed: () => controller.archive(item.id),
+ icon: const Icon(Icons.archive_outlined, size: 17),
+ ),
+ ],
+ );
+ }
+}
+
+class _EmptyNotes extends StatelessWidget {
+ const _EmptyNotes({required this.archived, required this.hasQuery});
+
+ final bool archived;
+ final bool hasQuery;
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ return Center(
+ child: Padding(
+ padding: const EdgeInsets.all(32),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Container(
+ width: 56,
+ height: 56,
+ decoration: BoxDecoration(
+ color: theme.colorScheme.primary.withValues(alpha: 0.09),
+ borderRadius: BorderRadius.circular(18),
+ ),
+ child: Icon(
+ hasQuery
+ ? Icons.search_off_rounded
+ : archived
+ ? Icons.inventory_2_outlined
+ : Icons.edit_note_rounded,
+ size: 26,
+ color: theme.colorScheme.primary,
+ ),
+ ),
+ const SizedBox(height: 14),
+ Text(
+ hasQuery
+ ? context.l10n.noSearchResultsTitle
+ : archived
+ ? context.l10n.emptyNoteArchiveTitle
+ : context.l10n.emptyNotesTitle,
+ textAlign: TextAlign.center,
+ style: theme.textTheme.bodyMedium?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ const SizedBox(height: 5),
+ Text(
+ hasQuery
+ ? context.l10n.noSearchResultsMessage
+ : archived
+ ? context.l10n.emptyNoteArchiveMessage
+ : context.l10n.emptyNotesMessage,
+ textAlign: TextAlign.center,
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.44),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+String _previewFor(String content) {
+ return content
+ .replaceAll(RegExp(r'[#>*_`~\[\]()]'), ' ')
+ .replaceAll(RegExp(r'\s+'), ' ')
+ .trim();
+}
+
+String _formatUpdatedTime(BuildContext context, DateTime value) {
+ final local = value.toLocal();
+ 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,
+ );
+ }
+ return '${local.month}/${local.day}';
+}
diff --git a/lib/features/notes/presentation/note_view_model.dart b/lib/features/notes/presentation/note_view_model.dart
new file mode 100644
index 0000000..2235c5d
--- /dev/null
+++ b/lib/features/notes/presentation/note_view_model.dart
@@ -0,0 +1,295 @@
+import 'dart:math';
+
+import 'package:flutter/foundation.dart';
+
+import '../../../core/storage/storage_failure.dart';
+import '../data/note_repository.dart';
+import '../domain/note_item.dart';
+
+typedef NoteClock = DateTime Function();
+typedef NoteIdGenerator = String Function();
+
+class NoteViewModel extends ChangeNotifier {
+ NoteViewModel({
+ required this._repository,
+ NoteClock? clock,
+ NoteIdGenerator? idGenerator,
+ }) : _clock = clock ?? DateTime.now,
+ _idGenerator = idGenerator ?? _generateUuidV4;
+
+ static const String untitledFallback = 'Untitled note';
+
+ final NoteRepository _repository;
+ final NoteClock _clock;
+ final NoteIdGenerator _idGenerator;
+
+ List _items = const [];
+ Map _itemsById = const {};
+ StorageFailure? _error;
+ bool _isLoading = false;
+ Future _mutationQueue = Future.value();
+
+ List get items => _items;
+ StorageFailure? get error => _error;
+ bool get isLoading => _isLoading;
+ int get activeCount => _items.where((item) => !item.isArchived).length;
+ int get archivedCount => _items.where((item) => item.isArchived).length;
+
+ NoteItem? itemById(String id) => _itemsById[id];
+
+ List itemsForView({
+ required bool archived,
+ required String query,
+ Iterable selectedTagIds = const [],
+ }) {
+ final normalizedQuery = query.trim().toLowerCase();
+ final selectedTags = selectedTagIds.toSet();
+ final visibleItems = _items
+ .where((item) {
+ if (item.isArchived != archived) {
+ return false;
+ }
+ if (selectedTags.isNotEmpty &&
+ !selectedTags.any(item.tagIds.contains)) {
+ return false;
+ }
+ return normalizedQuery.isEmpty ||
+ item.title.toLowerCase().contains(normalizedQuery) ||
+ item.content.toLowerCase().contains(normalizedQuery);
+ })
+ .toList(growable: false);
+ visibleItems.sort((left, right) {
+ if (!archived && left.isPinned != right.isPinned) {
+ return left.isPinned ? -1 : 1;
+ }
+ if (archived) {
+ final leftDate = left.archivedAt ?? left.updatedAt;
+ final rightDate = right.archivedAt ?? right.updatedAt;
+ return rightDate.compareTo(leftDate);
+ }
+ return right.updatedAt.compareTo(left.updatedAt);
+ });
+ return List.unmodifiable(visibleItems);
+ }
+
+ Map tagUsageCountsFor(Iterable tagIds) {
+ final requestedTagIds = tagIds.toSet();
+ final counts = {for (final tagId in requestedTagIds) tagId: 0};
+ for (final item in _items) {
+ for (final tagId in item.tagIds) {
+ if (requestedTagIds.contains(tagId)) {
+ counts[tagId] = counts[tagId]! + 1;
+ }
+ }
+ }
+ return Map.unmodifiable(counts);
+ }
+
+ Future load() async {
+ _isLoading = true;
+ _error = null;
+ notifyListeners();
+ try {
+ _setItems(await _repository.load());
+ } on StorageFailure catch (error) {
+ _error = error;
+ } finally {
+ _isLoading = false;
+ notifyListeners();
+ }
+ }
+
+ Future save({
+ String? id,
+ required String title,
+ required String content,
+ Iterable? tagIds,
+ }) {
+ final normalizedTitle = title.trim();
+ if (id == null && normalizedTitle.isEmpty && content.trim().isEmpty) {
+ return Future.value(null);
+ }
+
+ return _enqueueMutation(() async {
+ final now = _clock().toUtc();
+ if (id == null) {
+ final resolvedTitle = normalizedTitle.isEmpty
+ ? untitledFallback
+ : normalizedTitle;
+ final generatedId = _idGenerator();
+ if (_itemsById.containsKey(generatedId)) {
+ return null;
+ }
+ final item = NoteItem(
+ id: generatedId,
+ title: resolvedTitle,
+ content: content,
+ createdAt: now,
+ updatedAt: now,
+ tagIds: _normalizeTagIds(tagIds ?? const []),
+ );
+ return await _commit([..._items, item]) ? item : null;
+ }
+
+ final index = _items.indexWhere((item) => item.id == id);
+ if (index == -1 || _items[index].isArchived) {
+ return null;
+ }
+ final existing = _items[index];
+ final resolvedTagIds = tagIds == null
+ ? existing.tagIds
+ : _normalizeTagIds(tagIds);
+ final resolvedTitle = normalizedTitle.isEmpty
+ ? untitledFallback
+ : normalizedTitle;
+ if (existing.title == resolvedTitle &&
+ existing.content == content &&
+ listEquals(existing.tagIds, resolvedTagIds)) {
+ return existing;
+ }
+ final updated = existing.withDetails(
+ title: resolvedTitle,
+ content: content,
+ updatedAt: now,
+ tagIds: resolvedTagIds,
+ );
+ final items = List.of(_items)..[index] = updated;
+ return await _commit(items) ? updated : null;
+ });
+ }
+
+ static List _normalizeTagIds(Iterable tagIds) {
+ return List.unmodifiable(
+ tagIds
+ .map((tagId) => tagId.trim())
+ .where((tagId) => tagId.isNotEmpty)
+ .toSet(),
+ );
+ }
+
+ Future togglePin(String id) {
+ return _update(id, (item) {
+ if (item.isArchived) {
+ return item;
+ }
+ return item.withPinnedAt(item.isPinned ? null : _clock().toUtc());
+ });
+ }
+
+ Future archive(String id) {
+ final now = _clock().toUtc();
+ return _update(
+ id,
+ (item) =>
+ item.isArchived ? item : item.withArchivedAt(now, updatedAt: now),
+ );
+ }
+
+ Future restore(String id) {
+ final now = _clock().toUtc();
+ return _update(
+ id,
+ (item) =>
+ !item.isArchived ? item : item.withArchivedAt(null, updatedAt: now),
+ );
+ }
+
+ Future deletePermanently(String id) {
+ return _enqueueMutation(() async {
+ final item = _itemsById[id];
+ if (item == null || !item.isArchived) {
+ return false;
+ }
+ return _commit(_items.where((candidate) => candidate.id != id).toList());
+ });
+ }
+
+ Future removeTag(String tagId) {
+ return _enqueueMutation(() async {
+ var changed = false;
+ final updatedItems = _items
+ .map((item) {
+ if (!item.tagIds.contains(tagId)) {
+ return item;
+ }
+ changed = true;
+ return item.withDetails(
+ title: item.title,
+ content: item.content,
+ updatedAt: item.updatedAt,
+ tagIds: item.tagIds.where((candidate) => candidate != tagId),
+ );
+ })
+ .toList(growable: false);
+ return !changed || await _commit(updatedItems);
+ });
+ }
+
+ void dismissError() {
+ if (_error == null) {
+ return;
+ }
+ _error = null;
+ notifyListeners();
+ }
+
+ Future _update(String id, NoteItem Function(NoteItem item) update) {
+ return _enqueueMutation(() async {
+ final index = _items.indexWhere((item) => item.id == id);
+ if (index == -1) {
+ return false;
+ }
+ final updated = update(_items[index]);
+ if (updated == _items[index]) {
+ return true;
+ }
+ final items = List.of(_items)..[index] = updated;
+ return _commit(items);
+ });
+ }
+
+ Future _enqueueMutation(Future Function() mutation) {
+ final operation = _mutationQueue.then((_) => mutation());
+ _mutationQueue = operation.then(
+ (_) {},
+ onError: (Object _, StackTrace _) {},
+ );
+ return operation;
+ }
+
+ Future _commit(List updatedItems) async {
+ try {
+ await _repository.save(updatedItems);
+ _setItems(updatedItems);
+ _error = null;
+ notifyListeners();
+ return true;
+ } on StorageFailure catch (error) {
+ _error = error;
+ notifyListeners();
+ return false;
+ }
+ }
+
+ void _setItems(Iterable items) {
+ _items = List.unmodifiable(items);
+ _itemsById = Map.unmodifiable({
+ for (final item in _items) item.id: item,
+ });
+ }
+
+ 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/widgets/sticky_board_todo_details.dart b/lib/features/sticky_boards/presentation/widgets/sticky_board_todo_details.dart
index 258f604..57e83d1 100644
--- a/lib/features/sticky_boards/presentation/widgets/sticky_board_todo_details.dart
+++ b/lib/features/sticky_boards/presentation/widgets/sticky_board_todo_details.dart
@@ -6,7 +6,7 @@ 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 '../../../todos/presentation/widgets/todo_markdown.dart';
+import '../../../../core/ui/floatick_markdown.dart';
class StickyBoardTodoDetails extends StatefulWidget {
const StickyBoardTodoDetails({
@@ -113,7 +113,7 @@ class _StickyBoardTodoDetailsState extends State {
Expanded(
child: item.content.trim().isEmpty
? _EmptyStickyBoardTodoContent(onSurface: onSurface)
- : TodoMarkdownContent(
+ : FloatickMarkdownContent(
key: const Key('sticky-board-details-markdown'),
content: item.content,
),
diff --git a/lib/features/todos/presentation/tag_filter_drawer.dart b/lib/features/todos/presentation/tag_filter_drawer.dart
index c2a63f7..16de408 100644
--- a/lib/features/todos/presentation/tag_filter_drawer.dart
+++ b/lib/features/todos/presentation/tag_filter_drawer.dart
@@ -16,6 +16,7 @@ class TagFilterDrawer extends StatelessWidget {
required this.onManageTags,
required this.onClose,
required this.closeFocusNode,
+ this.usageCounts,
super.key,
}) : mode = TagDrawerSelectionMode.filter;
@@ -29,7 +30,8 @@ class TagFilterDrawer extends StatelessWidget {
required this.closeFocusNode,
super.key,
}) : mode = TagDrawerSelectionMode.assignment,
- onClear = null;
+ onClear = null,
+ usageCounts = null;
final TagDrawerSelectionMode mode;
final TodoViewModel controller;
@@ -40,6 +42,7 @@ class TagFilterDrawer extends StatelessWidget {
final VoidCallback onManageTags;
final VoidCallback onClose;
final FocusNode closeFocusNode;
+ final Map? usageCounts;
@override
Widget build(BuildContext context) {
@@ -75,9 +78,9 @@ class TagFilterDrawer extends StatelessWidget {
final effectiveSelectedTagIds = selectedTagIds
.where(knownTagIds.contains)
.toSet();
- final usageCounts = controller.tagUsageCountsFor(
- tags.map((tag) => tag.id),
- );
+ final resolvedUsageCounts =
+ usageCounts ??
+ controller.tagUsageCountsFor(tags.map((tag) => tag.id));
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@@ -182,7 +185,7 @@ class TagFilterDrawer extends StatelessWidget {
),
tag: tag,
label: tag.name,
- trailing: '${usageCounts[tag.id] ?? 0}',
+ trailing: '${resolvedUsageCounts[tag.id] ?? 0}',
selected: effectiveSelectedTagIds.contains(tag.id),
onPressed: () => onToggled(tag.id),
);
diff --git a/lib/features/todos/presentation/tag_management_drawer.dart b/lib/features/todos/presentation/tag_management_drawer.dart
index 89fed1a..5b90e94 100644
--- a/lib/features/todos/presentation/tag_management_drawer.dart
+++ b/lib/features/todos/presentation/tag_management_drawer.dart
@@ -19,6 +19,8 @@ class TagManagementDrawer extends StatefulWidget {
required this.borderOnLeft,
required this.onClose,
required this.closeFocusNode,
+ this.additionalUsageCounts = const {},
+ this.onTagDeleted,
super.key,
});
@@ -27,6 +29,8 @@ class TagManagementDrawer extends StatefulWidget {
final bool borderOnLeft;
final VoidCallback onClose;
final FocusNode closeFocusNode;
+ final Map additionalUsageCounts;
+ final Future Function(String tagId)? onTagDeleted;
@override
State createState() => _TagManagementDrawerState();
@@ -147,7 +151,13 @@ class _TagManagementDrawerState extends State {
return;
}
setState(() => _isSaving = true);
- final result = await widget.controller.deleteTag(tagId);
+ var result = await widget.controller.deleteTag(tagId);
+ if (result == TagMutationResult.success && widget.onTagDeleted != null) {
+ final removedFromDependents = await widget.onTagDeleted!(tagId);
+ if (!removedFromDependents) {
+ result = TagMutationResult.storageFailure;
+ }
+ }
if (!mounted) {
return;
}
@@ -213,9 +223,15 @@ class _TagManagementDrawerState extends State {
return query.isEmpty || tag.name.toLowerCase().contains(query);
})
.toList(growable: false);
- final usageCounts = widget.controller.tagUsageCountsFor(
+ final todoUsageCounts = widget.controller.tagUsageCountsFor(
filteredTags.map((tag) => tag.id),
);
+ final usageCounts = {
+ for (final tag in filteredTags)
+ tag.id:
+ (todoUsageCounts[tag.id] ?? 0) +
+ (widget.additionalUsageCounts[tag.id] ?? 0),
+ };
final canSubmit = !_isSaving;
return Column(
diff --git a/lib/features/todos/presentation/todo_editor_drawer.dart b/lib/features/todos/presentation/todo_editor_drawer.dart
index 3a951a6..e347cdd 100644
--- a/lib/features/todos/presentation/todo_editor_drawer.dart
+++ b/lib/features/todos/presentation/todo_editor_drawer.dart
@@ -4,14 +4,15 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
-import '../../../core/ui/floatick_hover_motion.dart';
+import '../../../core/ui/floatick_editor_components.dart';
+import '../../../core/ui/floatick_markdown.dart';
import '../../../l10n/l10n.dart';
import '../domain/todo_item.dart';
import '../domain/todo_tag.dart';
import 'todo_clipboard_controller.dart';
+import 'widgets/editor_tag_selector.dart';
import 'widgets/floatick_tag_chip.dart';
import 'widgets/todo_copy_button.dart';
-import 'widgets/todo_markdown.dart';
enum TodoEditorDrawerMode { create, details, edit }
@@ -124,11 +125,11 @@ class _TodoEditorDrawerState extends State {
return;
}
if (_isEditing) {
- _titleFocusNode.requestFocus();
_titleController.selection = TextSelection(
baseOffset: 0,
extentOffset: _titleController.text.length,
);
+ _titleFocusNode.requestFocus();
} else {
widget.closeFocusNode.requestFocus();
}
@@ -136,7 +137,10 @@ class _TodoEditorDrawerState extends State {
}
bool get _canSave {
- if (_isSaving || _titleController.text.trim().isEmpty) {
+ final hasText =
+ _titleController.text.trim().isNotEmpty ||
+ _contentController.text.trim().isNotEmpty;
+ if (_isSaving || !hasText) {
return false;
}
if (widget.mode == TodoEditorDrawerMode.create) {
@@ -185,159 +189,79 @@ class _TodoEditorDrawerState extends State {
@override
Widget build(BuildContext context) {
- final theme = Theme.of(context);
- final isDark = theme.brightness == Brightness.dark;
final availableTags = widget.availableTags;
- return DecoratedBox(
- key: const Key('todo-editor-drawer'),
- decoration: BoxDecoration(
- color: isDark ? const Color(0xFF202A2E) : const Color(0xFFF9FBFA),
- borderRadius: const BorderRadius.vertical(top: Radius.circular(22)),
- border: Border(
- top: BorderSide(
- color: isDark
- ? Colors.white.withValues(alpha: 0.11)
- : Colors.black.withValues(alpha: 0.07),
- ),
- ),
- ),
- child: ClipRRect(
- borderRadius: const BorderRadius.vertical(top: Radius.circular(22)),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- _DrawerHeader(
- mode: widget.mode,
- item: widget.item,
- canEdit: widget.canEdit,
- copyController: _copyController,
- onEdit: widget.onEdit,
- onClose: widget.onClose,
- closeFocusNode: widget.closeFocusNode,
- ),
- Divider(
- height: 1,
- thickness: 1,
- color: isDark
- ? Colors.white.withValues(alpha: 0.08)
- : Colors.black.withValues(alpha: 0.06),
- ),
- Expanded(
- child: AnimatedSwitcher(
- duration: MediaQuery.disableAnimationsOf(context)
- ? Duration.zero
- : const Duration(milliseconds: 160),
- switchInCurve: Curves.easeOut,
- switchOutCurve: Curves.easeIn,
- child: widget.mode == TodoEditorDrawerMode.details
- ? _TodoDetails(
- key: ValueKey(
- 'details-${widget.item?.id ?? 'missing'}',
- ),
- item: widget.item,
- canEdit: widget.canEdit,
- tags: availableTags
- .where(
- (tag) => widget.assignedTagIds.contains(tag.id),
- )
- .toList(growable: false),
- )
- : _TodoEditor(
- key: const ValueKey('todo-editor'),
- formKey: _formKey,
- titleController: _titleController,
- contentController: _contentController,
- titleFocusNode: _titleFocusNode,
- contentFocusNode: _contentFocusNode,
- availableTags: availableTags,
- selectedTagIds: widget.assignedTagIds.toSet(),
- showPreview: _showPreview,
- isSaving: _isSaving,
- saveFailed: _saveFailed,
- canSave: _canSave,
- mode: widget.mode,
- onChanged: () {
- setState(() {
- _saveFailed = false;
- });
- },
- onPreviewChanged: (showPreview) {
- setState(() => _showPreview = showPreview);
- },
- onOpenTagAssignment: widget.onOpenTagAssignment,
- onSubmit: () => unawaited(_submit()),
- onCancel: widget.onClose,
- ),
- ),
- ),
- ],
- ),
- ),
- );
- }
-}
-
-class _DrawerHeader extends StatelessWidget {
- const _DrawerHeader({
- required this.mode,
- required this.item,
- required this.canEdit,
- required this.copyController,
- required this.onEdit,
- required this.onClose,
- required this.closeFocusNode,
- });
-
- final TodoEditorDrawerMode mode;
- final TodoItem? item;
- final bool canEdit;
- final TodoClipboardController copyController;
- final VoidCallback onEdit;
- final VoidCallback onClose;
- final FocusNode closeFocusNode;
-
- @override
- Widget build(BuildContext context) {
- final title = switch (mode) {
+ final title = switch (widget.mode) {
TodoEditorDrawerMode.create => context.l10n.newTodoDrawerTitle,
TodoEditorDrawerMode.details => context.l10n.todoDetailsDrawerTitle,
TodoEditorDrawerMode.edit => context.l10n.editTodoDrawerTitle,
};
- return Padding(
- padding: const EdgeInsets.fromLTRB(20, 12, 10, 11),
- child: Row(
- children: [
- Expanded(
- child: Text(
- title,
- style: Theme.of(
- context,
- ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600),
- ),
+ return FloatickEditorDrawerSurface(
+ key: const Key('todo-editor-drawer'),
+ title: title,
+ closeTooltip: context.l10n.closeTodoDrawerTooltip,
+ closeButtonKey: const Key('todo-drawer-close'),
+ onClose: widget.onClose,
+ closeFocusNode: widget.closeFocusNode,
+ headerActions: [
+ if (widget.mode == TodoEditorDrawerMode.details && widget.item != null)
+ TodoCopyButton(
+ key: const Key('todo-details-copy'),
+ item: widget.item!,
+ controller: _copyController,
+ dimension: 40,
+ iconSize: 19,
),
- if (mode == TodoEditorDrawerMode.details && item != null)
- TodoCopyButton(
- key: const Key('todo-details-copy'),
- item: item!,
- controller: copyController,
- dimension: 40,
- iconSize: 19,
- ),
- if (mode == TodoEditorDrawerMode.details && canEdit)
- IconButton(
- key: const Key('todo-details-edit'),
- onPressed: onEdit,
- tooltip: context.l10n.editTodoAction,
- icon: const Icon(Icons.edit_outlined, size: 19),
- ),
+ if (widget.mode == TodoEditorDrawerMode.details && widget.canEdit)
IconButton(
- key: const Key('todo-drawer-close'),
- focusNode: closeFocusNode,
- tooltip: context.l10n.closeTodoDrawerTooltip,
- onPressed: onClose,
- icon: const Icon(Icons.close_rounded, size: 19),
+ key: const Key('todo-details-edit'),
+ onPressed: widget.onEdit,
+ tooltip: context.l10n.editTodoAction,
+ icon: const Icon(Icons.edit_outlined, size: 19),
),
- ],
+ ],
+ child: AnimatedSwitcher(
+ duration: MediaQuery.disableAnimationsOf(context)
+ ? Duration.zero
+ : const Duration(milliseconds: 160),
+ switchInCurve: Curves.easeOut,
+ switchOutCurve: Curves.easeIn,
+ child: widget.mode == TodoEditorDrawerMode.details
+ ? _TodoDetails(
+ key: ValueKey(
+ 'details-${widget.item?.id ?? 'missing'}',
+ ),
+ item: widget.item,
+ canEdit: widget.canEdit,
+ tags: availableTags
+ .where((tag) => widget.assignedTagIds.contains(tag.id))
+ .toList(growable: false),
+ )
+ : _TodoEditor(
+ key: const ValueKey('todo-editor'),
+ formKey: _formKey,
+ titleController: _titleController,
+ contentController: _contentController,
+ titleFocusNode: _titleFocusNode,
+ contentFocusNode: _contentFocusNode,
+ availableTags: availableTags,
+ selectedTagIds: widget.assignedTagIds.toSet(),
+ showPreview: _showPreview,
+ isSaving: _isSaving,
+ saveFailed: _saveFailed,
+ canSave: _canSave,
+ mode: widget.mode,
+ onChanged: () {
+ setState(() {
+ _saveFailed = false;
+ });
+ },
+ onPreviewChanged: (showPreview) {
+ setState(() => _showPreview = showPreview);
+ },
+ onOpenTagAssignment: widget.onOpenTagAssignment,
+ onSubmit: () => unawaited(_submit()),
+ onCancel: widget.onClose,
+ ),
),
);
}
@@ -415,145 +339,46 @@ class _TodoEditor extends StatelessWidget {
children: [
Expanded(
child: Padding(
- padding: const EdgeInsets.fromLTRB(20, 16, 20, 12),
+ padding: FloatickEditorMetrics.bodyPadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
- Text(
- context.l10n.todoTitleLabel,
- style: Theme.of(context).textTheme.labelLarge?.copyWith(
- fontWeight: FontWeight.w600,
- ),
- ),
- const SizedBox(height: 7),
- TextFormField(
- key: const Key('todo-title-field'),
- controller: titleController,
- focusNode: titleFocusNode,
- enabled: !isSaving,
- maxLines: 1,
- textInputAction: TextInputAction.next,
- onChanged: (_) => onChanged(),
- onFieldSubmitted: (_) =>
- contentFocusNode.requestFocus(),
- validator: (value) {
- return value == null || value.trim().isEmpty
- ? context.l10n.todoTitleRequiredHint
- : null;
- },
- decoration: InputDecoration(
- hintText: context.l10n.todoTitleFieldHint,
- ),
- ),
- const SizedBox(height: 12),
- Row(
- crossAxisAlignment: CrossAxisAlignment.center,
- children: [
- Text(
- context.l10n.assignTagsTitle,
- style: Theme.of(context).textTheme.labelLarge
- ?.copyWith(fontWeight: FontWeight.w600),
- ),
- const SizedBox(width: 12),
- Expanded(
- child: Wrap(
- alignment: WrapAlignment.end,
- crossAxisAlignment: WrapCrossAlignment.center,
- spacing: 5,
- runSpacing: 5,
- children: [
- for (final tag in availableTags)
- if (selectedTagIds.contains(tag.id))
- FloatickTagChip(
- key: ValueKey(
- 'todo-editor-tag-${tag.id}',
- ),
- tag: tag,
- compact: true,
- ),
- IconButton(
- key: const Key('todo-editor-tag-button'),
- tooltip: context.l10n.assignTagsTooltip,
- onPressed: isSaving
- ? null
- : onOpenTagAssignment,
- style: IconButton.styleFrom(
- minimumSize: const Size.square(30),
- maximumSize: const Size.square(30),
- padding: EdgeInsets.zero,
- tapTargetSize:
- MaterialTapTargetSize.shrinkWrap,
- foregroundColor: selectedTagIds.isEmpty
- ? Theme.of(context)
- .colorScheme
- .onSurface
- .withValues(alpha: 0.56)
- : Theme.of(context).colorScheme.primary,
- ),
- icon: Icon(
- selectedTagIds.isEmpty
- ? Icons.sell_outlined
- : Icons.sell_rounded,
- size: 17,
- ),
- ),
- ],
- ),
+ Expanded(
+ child: FloatickDocumentEditor(
+ editorSurfaceKey: const Key('todo-document-editor'),
+ titleFieldKey: const Key('todo-title-field'),
+ contentFieldKey: const Key('todo-content-field'),
+ modeSwitchKey: const Key(
+ 'floatick-editor-mode-switch',
),
- ],
- ),
- const SizedBox(height: 12),
- Row(
- children: [
- Expanded(
- child: Text(
- context.l10n.todoContentLabel,
- style: Theme.of(context).textTheme.labelLarge
- ?.copyWith(fontWeight: FontWeight.w600),
- ),
+ writeTabKey: const Key('markdown-write-tab'),
+ previewTabKey: const Key('markdown-preview-tab'),
+ titleController: titleController,
+ contentController: contentController,
+ titleFocusNode: titleFocusNode,
+ contentFocusNode: contentFocusNode,
+ titleHint: context.l10n.todoTitleFieldHint,
+ 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,
),
- _EditorModeSwitch(
- showPreview: showPreview,
- onChanged: onPreviewChanged,
+ enabled: !isSaving,
+ showPreview: showPreview,
+ preview: FloatickMarkdownPreview(
+ key: const Key('todo-content-preview'),
+ content: contentController.text,
+ embedded: true,
),
- ],
- ),
- const SizedBox(height: 7),
- Expanded(
- child: AnimatedSwitcher(
- duration: MediaQuery.disableAnimationsOf(context)
- ? Duration.zero
- : const Duration(milliseconds: 140),
- child: showPreview
- ? TodoMarkdownPreview(
- key: const Key('todo-content-preview'),
- content: contentController.text,
- )
- : TextFormField(
- key: const Key('todo-content-field'),
- controller: contentController,
- focusNode: contentFocusNode,
- enabled: !isSaving,
- expands: true,
- minLines: null,
- maxLines: null,
- textAlignVertical: TextAlignVertical.top,
- keyboardType: TextInputType.multiline,
- onChanged: (_) => onChanged(),
- decoration: InputDecoration(
- hintText: context.l10n.todoContentFieldHint,
- alignLabelWithHint: true,
- ),
- ),
- ),
- ),
- const SizedBox(height: 7),
- Text(
- context.l10n.markdownSupportedHint,
- style: Theme.of(context).textTheme.labelSmall?.copyWith(
- color: Theme.of(
- context,
- ).colorScheme.onSurface.withValues(alpha: 0.42),
+ onTitleChanged: (_) => onChanged(),
+ onContentChanged: (_) => onChanged(),
+ onPreviewChanged: onPreviewChanged,
),
),
],
@@ -570,15 +395,8 @@ class _TodoEditor extends StatelessWidget {
),
),
),
- Divider(
- height: 1,
- thickness: 1,
- color: Theme.of(
- context,
- ).colorScheme.onSurface.withValues(alpha: 0.08),
- ),
- Padding(
- padding: const EdgeInsets.fromLTRB(20, 10, 20, 14),
+ FloatickEditorFooter(
+ key: const Key('todo-editor-footer'),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
@@ -616,95 +434,6 @@ class _TodoEditor extends StatelessWidget {
}
}
-class _EditorModeSwitch extends StatelessWidget {
- const _EditorModeSwitch({required this.showPreview, required this.onChanged});
-
- final bool showPreview;
- final ValueChanged onChanged;
-
- @override
- Widget build(BuildContext context) {
- final theme = Theme.of(context);
- return Container(
- height: 30,
- padding: const EdgeInsets.all(2),
- decoration: BoxDecoration(
- color: theme.colorScheme.onSurface.withValues(alpha: 0.055),
- borderRadius: BorderRadius.circular(9),
- ),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- _EditorModeButton(
- key: const Key('markdown-write-tab'),
- label: context.l10n.markdownWriteLabel,
- selected: !showPreview,
- onPressed: () => onChanged(false),
- ),
- _EditorModeButton(
- key: const Key('markdown-preview-tab'),
- label: context.l10n.markdownPreviewLabel,
- selected: showPreview,
- onPressed: () => onChanged(true),
- ),
- ],
- ),
- );
- }
-}
-
-class _EditorModeButton extends StatelessWidget {
- const _EditorModeButton({
- required this.label,
- required this.selected,
- required this.onPressed,
- super.key,
- });
-
- final String label;
- final bool selected;
- final VoidCallback onPressed;
-
- @override
- Widget build(BuildContext context) {
- final theme = Theme.of(context);
- 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(
- 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,
- ),
- ),
- ),
- ),
- ),
- );
- }
-}
-
class _TodoDetails extends StatelessWidget {
const _TodoDetails({
required this.item,
@@ -754,7 +483,7 @@ class _TodoDetails extends StatelessWidget {
Expanded(
child: item.content.trim().isEmpty
? _EmptyTodoContent(canEdit: canEdit)
- : TodoMarkdownContent(
+ : FloatickMarkdownContent(
key: const Key('todo-details-markdown'),
content: item.content,
),
diff --git a/lib/features/todos/presentation/todo_panel.dart b/lib/features/todos/presentation/todo_panel.dart
index a64cfe6..c7907f7 100644
--- a/lib/features/todos/presentation/todo_panel.dart
+++ b/lib/features/todos/presentation/todo_panel.dart
@@ -10,6 +10,9 @@ import '../../../core/ui/floatick_brand_mark.dart';
import '../../../core/ui/floatick_surface_metrics.dart';
import '../../../l10n/l10n.dart';
import '../../../l10n/storage_failure_localizations.dart';
+import '../../notes/presentation/note_editor_drawer.dart';
+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';
@@ -28,6 +31,7 @@ const double _settingsDrawerWidth = 268;
const double _tagDrawerWidth = 292;
const double _stickyBoardDrawerWidth = 336;
const double _todoDrawerHeight = 520;
+const double _noteDrawerHeight = 590;
const Duration _drawerSlideDuration = Duration(milliseconds: 220);
const Duration _drawerScrimDuration = Duration(milliseconds: 160);
const Duration _scrollHoverResumeDelay = Duration(milliseconds: 120);
@@ -35,6 +39,8 @@ const double _todoListCacheExtentViewportFraction = 0.75;
enum TodoListScope { active, archived }
+enum _PanelContentKind { todos, notes }
+
enum _TodoPanelDrawerMode {
none,
settings,
@@ -47,13 +53,22 @@ enum _TodoPanelDrawerMode {
createTodo,
todoDetails,
editTodo,
+ createNote,
+ editNote,
}
-enum _TodoPanelDrawerFamily { settings, tags, stickyBoards, todoEditor }
+enum _TodoPanelDrawerFamily {
+ settings,
+ tags,
+ stickyBoards,
+ todoEditor,
+ noteEditor,
+}
class TodoPanel extends StatefulWidget {
const TodoPanel({
required this.controller,
+ this.noteController,
required this.settingsController,
required this.updateController,
required this.stickyBoardController,
@@ -67,6 +82,7 @@ class TodoPanel extends StatefulWidget {
});
final TodoViewModel controller;
+ final NoteViewModel? noteController;
final SettingsViewModel settingsController;
final UpdateViewModel updateController;
final StickyBoardViewModel stickyBoardController;
@@ -91,8 +107,13 @@ class _TodoPanelState extends State {
final _tagManagementCloseFocusNode = FocusNode();
final _stickyBoardCloseFocusNode = FocusNode();
final _todoDrawerCloseFocusNode = FocusNode();
+ final _noteDrawerCloseFocusNode = FocusNode();
+ GlobalKey _noteEditorKey =
+ GlobalKey();
+ _PanelContentKind _contentKind = _PanelContentKind.todos;
TodoListScope _scope = TodoListScope.active;
+ bool _noteArchived = false;
String _query = '';
final Set _selectedTagIds = {};
String? _selectedTodoId;
@@ -104,12 +125,14 @@ class _TodoPanelState extends State {
_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;
@@ -138,6 +161,7 @@ class _TodoPanelState extends State {
_tagManagementCloseFocusNode.dispose();
_stickyBoardCloseFocusNode.dispose();
_todoDrawerCloseFocusNode.dispose();
+ _noteDrawerCloseFocusNode.dispose();
super.dispose();
}
@@ -183,6 +207,9 @@ class _TodoPanelState extends State {
}
_selectedStickyBoardId = boardId;
+ if (_contentKind != _PanelContentKind.todos) {
+ setState(() => _contentKind = _PanelContentKind.todos);
+ }
if (!_mountedDrawerFamilies.contains(_TodoPanelDrawerFamily.stickyBoards)) {
setState(
() => _mountedDrawerFamilies.add(_TodoPanelDrawerFamily.stickyBoards),
@@ -276,6 +303,84 @@ class _TodoPanelState extends State {
_showDrawer(_TodoPanelDrawerMode.tagFilter);
}
+ void _selectContentKind(_PanelContentKind kind) {
+ if (_contentKind == kind || _drawerMode != _TodoPanelDrawerMode.none) {
+ return;
+ }
+ _searchController.clear();
+ setState(() {
+ _contentKind = kind;
+ _query = '';
+ });
+ }
+
+ void _toggleNoteArchive() {
+ setState(() => _noteArchived = !_noteArchived);
+ }
+
+ void _openNoteCreate() {
+ if (widget.noteController == null) {
+ return;
+ }
+ _showNoteDrawer(
+ _TodoPanelDrawerMode.createNote,
+ initialTagIds: const [],
+ );
+ }
+
+ void _openNote(String noteId) {
+ if (widget.noteController?.itemById(noteId) == null) {
+ return;
+ }
+ _showNoteDrawer(
+ _TodoPanelDrawerMode.editNote,
+ noteId: noteId,
+ initialTagIds: widget.noteController!.itemById(noteId)!.tagIds,
+ );
+ }
+
+ void _showNoteDrawer(
+ _TodoPanelDrawerMode mode, {
+ required Iterable initialTagIds,
+ String? noteId,
+ }) {
+ assert(
+ mode == _TodoPanelDrawerMode.createNote ||
+ mode == _TodoPanelDrawerMode.editNote,
+ );
+ final requestSerial = ++_drawerRequestSerial;
+ _noteEditorKey = GlobalKey();
+ final needsMount = !_mountedDrawerFamilies.contains(
+ _TodoPanelDrawerFamily.noteEditor,
+ );
+ setState(() {
+ _selectedNoteId = noteId;
+ _noteEditorTagIds = initialTagIds.toSet();
+ if (needsMount) {
+ _mountedDrawerFamilies.add(_TodoPanelDrawerFamily.noteEditor);
+ _pendingDrawerMode = mode;
+ } else {
+ _pendingDrawerMode = null;
+ _drawerMode = mode;
+ }
+ if (mode == _TodoPanelDrawerMode.createNote) {
+ _noteArchived = false;
+ }
+ });
+ if (!needsMount) {
+ return;
+ }
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (!mounted || requestSerial != _drawerRequestSerial) {
+ return;
+ }
+ setState(() {
+ _pendingDrawerMode = null;
+ _drawerMode = mode;
+ });
+ });
+ }
+
void _openTagManagement() {
_tagManagementReturnMode = null;
_tagAssignmentReturnMode = null;
@@ -402,9 +507,11 @@ class _TodoPanelState extends State {
return linked;
}
- void _openTagAssignmentFromTodo() {
+ void _openTagAssignmentFromEditor() {
if (_drawerMode != _TodoPanelDrawerMode.createTodo &&
- _drawerMode != _TodoPanelDrawerMode.editTodo) {
+ _drawerMode != _TodoPanelDrawerMode.editTodo &&
+ _drawerMode != _TodoPanelDrawerMode.createNote &&
+ _drawerMode != _TodoPanelDrawerMode.editNote) {
return;
}
_tagAssignmentReturnMode = _drawerMode;
@@ -419,13 +526,19 @@ class _TodoPanelState extends State {
_showDrawer(_TodoPanelDrawerMode.tagManagement);
}
- void _toggleTodoEditorTag(String tagId) {
+ void _toggleEditorTag(String tagId) {
if (widget.controller.tagById(tagId) == null) {
return;
}
setState(() {
- if (!_todoEditorTagIds.add(tagId)) {
- _todoEditorTagIds.remove(tagId);
+ final isNoteAssignment =
+ _tagAssignmentReturnMode == _TodoPanelDrawerMode.createNote ||
+ _tagAssignmentReturnMode == _TodoPanelDrawerMode.editNote;
+ final editorTagIds = isNoteAssignment
+ ? _noteEditorTagIds
+ : _todoEditorTagIds;
+ if (!editorTagIds.add(tagId)) {
+ editorTagIds.remove(tagId);
}
});
}
@@ -437,6 +550,7 @@ class _TodoPanelState extends State {
_tagManagementCloseFocusNode.unfocus();
_stickyBoardCloseFocusNode.unfocus();
_todoDrawerCloseFocusNode.unfocus();
+ _noteDrawerCloseFocusNode.unfocus();
}
void _showDrawer(_TodoPanelDrawerMode mode) {
@@ -496,7 +610,9 @@ class _TodoPanelState extends State {
_TodoPanelDrawerMode.tagManagement ||
_TodoPanelDrawerMode.createTodo ||
_TodoPanelDrawerMode.todoDetails ||
- _TodoPanelDrawerMode.editTodo => null,
+ _TodoPanelDrawerMode.editTodo ||
+ _TodoPanelDrawerMode.createNote ||
+ _TodoPanelDrawerMode.editNote => null,
};
focusNode?.requestFocus();
});
@@ -604,6 +720,10 @@ class _TodoPanelState extends State {
_todoCreationBoardId = null;
_pendingCreatedTodoId = null;
}
+ if (_isNoteDrawerMode(closedMode)) {
+ _selectedNoteId = null;
+ _noteEditorTagIds = {};
+ }
});
if (returnMode != null) {
_requestDrawerFocus(returnMode);
@@ -618,6 +738,16 @@ class _TodoPanelState extends State {
}
}
+ Future _requestCloseActiveDrawer() async {
+ if (_isNoteDrawerMode(_drawerMode)) {
+ final canClose = await _noteEditorKey.currentState?.flush() ?? true;
+ if (!canClose || !mounted) {
+ return;
+ }
+ }
+ _closeActiveDrawer();
+ }
+
void _restorePanelFocus() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _drawerMode == _TodoPanelDrawerMode.none) {
@@ -650,6 +780,11 @@ class _TodoPanelState extends State {
mode == _TodoPanelDrawerMode.editTodo;
}
+ bool _isNoteDrawerMode(_TodoPanelDrawerMode mode) {
+ return mode == _TodoPanelDrawerMode.createNote ||
+ mode == _TodoPanelDrawerMode.editNote;
+ }
+
_TodoPanelDrawerFamily? _drawerFamilyFor(_TodoPanelDrawerMode mode) {
return switch (mode) {
_TodoPanelDrawerMode.settings => _TodoPanelDrawerFamily.settings,
@@ -663,6 +798,8 @@ class _TodoPanelState extends State {
_TodoPanelDrawerMode.createTodo ||
_TodoPanelDrawerMode.todoDetails ||
_TodoPanelDrawerMode.editTodo => _TodoPanelDrawerFamily.todoEditor,
+ _TodoPanelDrawerMode.createNote ||
+ _TodoPanelDrawerMode.editNote => _TodoPanelDrawerFamily.noteEditor,
_TodoPanelDrawerMode.none => null,
};
}
@@ -696,6 +833,9 @@ class _TodoPanelState extends State {
_drawerMode == _TodoPanelDrawerMode.createTodo ||
_drawerMode == _TodoPanelDrawerMode.todoDetails ||
_drawerMode == _TodoPanelDrawerMode.editTodo;
+ final isNoteDrawerOpen =
+ _drawerMode == _TodoPanelDrawerMode.createNote ||
+ _drawerMode == _TodoPanelDrawerMode.editNote;
final hasSettingsDrawer = _mountedDrawerFamilies.contains(
_TodoPanelDrawerFamily.settings,
);
@@ -708,14 +848,27 @@ class _TodoPanelState extends State {
final hasTodoDrawer = _mountedDrawerFamilies.contains(
_TodoPanelDrawerFamily.todoEditor,
);
- final isTodoContextOverlayOpen =
+ final hasNoteDrawer = _mountedDrawerFamilies.contains(
+ _TodoPanelDrawerFamily.noteEditor,
+ );
+ final isEditorContextOverlayOpen =
isTagAssignmentOpen ||
(isTagManagementOpen &&
_tagManagementReturnMode == _TodoPanelDrawerMode.tagAssignment);
- final isTodoDrawerVisible = isTodoDrawerOpen || isTodoContextOverlayOpen;
+ final tagAssignmentOwner = _tagAssignmentReturnMode;
+ final isTodoTagContext =
+ isEditorContextOverlayOpen &&
+ (tagAssignmentOwner == _TodoPanelDrawerMode.createTodo ||
+ tagAssignmentOwner == _TodoPanelDrawerMode.editTodo);
+ final isNoteTagContext =
+ isEditorContextOverlayOpen &&
+ (tagAssignmentOwner == _TodoPanelDrawerMode.createNote ||
+ tagAssignmentOwner == _TodoPanelDrawerMode.editNote);
+ final isTodoDrawerVisible = isTodoDrawerOpen || isTodoTagContext;
+ final isNoteDrawerVisible = isNoteDrawerOpen || isNoteTagContext;
final isStickyBoardContextVisible =
(_todoDrawerReturnMode == _TodoPanelDrawerMode.stickyBoardDetail &&
- (isTodoDrawerOpen || isTodoContextOverlayOpen));
+ (isTodoDrawerOpen || isTodoTagContext));
final isStickyBoardDrawerVisible =
isStickyBoardDrawerOpen || isStickyBoardContextVisible;
final visibleTagDrawerMode = isTagDrawerOpen
@@ -732,10 +885,20 @@ class _TodoPanelState extends State {
final selectedTodo = _selectedTodoId == null
? null
: widget.controller.itemById(_selectedTodoId!);
+ final selectedNote = _selectedNoteId == null
+ ? null
+ : widget.noteController?.itemById(_selectedNoteId!);
final todoEditorTagIds = widget.controller.tags
.where((tag) => _todoEditorTagIds.contains(tag.id))
.map((tag) => tag.id)
.toList(growable: false);
+ final noteEditorTagIds = widget.controller.tags
+ .where((tag) => _noteEditorTagIds.contains(tag.id))
+ .map((tag) => tag.id)
+ .toList(growable: false);
+ final assignmentTagIds = isNoteTagContext
+ ? _noteEditorTagIds
+ : _todoEditorTagIds;
final originalTodoTagIds = selectedTodo == null
? const []
: widget.controller.tagIdsForTodo(selectedTodo.id);
@@ -756,7 +919,7 @@ class _TodoPanelState extends State {
_CollapseIntent: CallbackAction<_CollapseIntent>(
onInvoke: (_) {
if (isDrawerOpen) {
- _closeActiveDrawer();
+ unawaited(_requestCloseActiveDrawer());
} else {
widget.onCollapse();
}
@@ -771,7 +934,11 @@ class _TodoPanelState extends State {
),
_NewTodoIntent: CallbackAction<_NewTodoIntent>(
onInvoke: (_) {
- _openTodoCreate();
+ if (_contentKind == _PanelContentKind.notes) {
+ _openNoteCreate();
+ } else {
+ _openTodoCreate();
+ }
return null;
},
),
@@ -813,7 +980,12 @@ class _TodoPanelState extends State {
ExcludeFocus(
excluding: isDrawerOpen,
child: AnimatedBuilder(
- animation: widget.controller,
+ animation: widget.noteController == null
+ ? widget.controller
+ : Listenable.merge([
+ widget.controller,
+ widget.noteController!,
+ ]),
builder: (context, _) {
final selectedTags = widget.controller.tags
.where(
@@ -830,11 +1002,33 @@ class _TodoPanelState extends State {
activeCount: widget.controller.activeCount,
archivedCount:
widget.controller.archivedCount,
+ notesSelected:
+ _contentKind == _PanelContentKind.notes,
+ noteArchived: _noteArchived,
+ noteActiveCount:
+ widget.noteController?.activeCount ?? 0,
+ noteArchivedCount:
+ widget.noteController?.archivedCount ??
+ 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,
@@ -858,14 +1052,25 @@ class _TodoPanelState extends State {
},
decoration: InputDecoration(
hintText:
- _scope ==
- TodoListScope.active
- ? context
- .l10n
- .searchTodosHint
- : context
- .l10n
- .searchArchiveHint,
+ _contentKind ==
+ _PanelContentKind
+ .notes
+ ? (_noteArchived
+ ? context
+ .l10n
+ .searchNoteArchiveHint
+ : context
+ .l10n
+ .searchNotesHint)
+ : (_scope ==
+ TodoListScope
+ .active
+ ? context
+ .l10n
+ .searchTodosHint
+ : context
+ .l10n
+ .searchArchiveHint),
prefixIcon: const Icon(
Icons.search_rounded,
size: 19,
@@ -900,16 +1105,32 @@ class _TodoPanelState extends State {
.length,
onPressed: _openTagFilter,
),
- if (_scope ==
- TodoListScope.active) ...[
+ if ((_contentKind ==
+ _PanelContentKind
+ .todos &&
+ _scope ==
+ TodoListScope.active) ||
+ (_contentKind ==
+ _PanelContentKind
+ .notes &&
+ !_noteArchived)) ...[
const SizedBox(width: 9),
SizedBox(
height: 42,
child: FilledButton.tonalIcon(
- key: const Key(
- 'add-todo-button',
+ key: Key(
+ _contentKind ==
+ _PanelContentKind
+ .notes
+ ? 'add-note-button'
+ : 'add-todo-button',
),
- onPressed: _openTodoCreate,
+ onPressed:
+ _contentKind ==
+ _PanelContentKind
+ .notes
+ ? _openNoteCreate
+ : _openTodoCreate,
style: FilledButton.styleFrom(
padding:
const EdgeInsets.symmetric(
@@ -921,7 +1142,15 @@ class _TodoPanelState extends State {
size: 18,
),
label: Text(
- context.l10n.newTodoAction,
+ _contentKind ==
+ _PanelContentKind
+ .notes
+ ? context
+ .l10n
+ .newNoteAction
+ : context
+ .l10n
+ .newTodoAction,
),
),
),
@@ -931,7 +1160,8 @@ class _TodoPanelState extends State {
],
),
),
- if (widget.controller.error != null)
+ if (_contentKind == _PanelContentKind.todos &&
+ widget.controller.error != null)
_ErrorBanner(
message: context.l10n
.messageForStorageFailure(
@@ -939,23 +1169,34 @@ class _TodoPanelState extends State {
),
onDismiss: widget.controller.dismissError,
),
- 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,
- );
- },
- ),
+ if (_contentKind == _PanelContentKind.notes &&
+ widget.noteController?.error != null)
+ _ErrorBanner(
+ message: context.l10n
+ .messageForStorageFailure(
+ widget.noteController!.error!,
+ ),
+ 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,
@@ -964,18 +1205,32 @@ class _TodoPanelState extends State {
: Colors.black.withValues(alpha: 0.055),
),
Expanded(
- child: _TodoList(
- controller: widget.controller,
- scope: _scope,
- query: _query,
- selectedTagIds: effectiveSelectedTagIds,
- onClearTagFilters: _clearTagFilters,
- onOpenTagManagement: _openTagManagement,
- onOpenDetails: _openTodoDetails,
- onEditTodo: _openTodoEdit,
- onDeleteTodo:
- _deleteArchivedTodoPermanently,
- ),
+ 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,
+ ),
),
],
);
@@ -997,7 +1252,8 @@ class _TodoPanelState extends State {
child: GestureDetector(
key: const Key('panel-drawer-dismiss'),
behavior: HitTestBehavior.opaque,
- onTap: _closeActiveDrawer,
+ onTap: () =>
+ unawaited(_requestCloseActiveDrawer()),
child: ColoredBox(
color: Colors.black.withValues(
alpha: isDark ? 0.22 : 0.12,
@@ -1177,7 +1433,7 @@ class _TodoPanelState extends State {
}
},
onOpenTagAssignment:
- _openTagAssignmentFromTodo,
+ _openTagAssignmentFromEditor,
onSave: (title, content, tagIds) {
if (todoEditorMode ==
TodoEditorDrawerMode.create) {
@@ -1222,11 +1478,67 @@ class _TodoPanelState extends State {
),
),
),
- if (hasTodoDrawer && hasTagDrawer)
+ if (hasNoteDrawer)
+ Positioned(
+ left: 0,
+ right: 0,
+ bottom: 0,
+ height: _noteDrawerHeight,
+ child: IgnorePointer(
+ key: const Key('note-drawer-pointer'),
+ ignoring: !isNoteDrawerOpen,
+ child: ExcludeFocus(
+ excluding: !isNoteDrawerOpen,
+ child: ExcludeSemantics(
+ excluding: !isNoteDrawerOpen,
+ child: AnimatedSlide(
+ key: const Key('note-drawer-slide'),
+ duration: reduceMotion
+ ? Duration.zero
+ : _drawerSlideDuration,
+ curve: Curves.easeOutCubic,
+ offset: isNoteDrawerVisible
+ ? Offset.zero
+ : const Offset(0, 1),
+ child: FocusTraversalGroup(
+ child: NoteEditorDrawer(
+ key: _noteEditorKey,
+ item: selectedNote,
+ availableTags: widget.controller.tags,
+ assignedTagIds: noteEditorTagIds,
+ isOpen: isNoteDrawerOpen,
+ onOpenTagAssignment:
+ _openTagAssignmentFromEditor,
+ onSave:
+ ({
+ id,
+ required title,
+ required content,
+ required tagIds,
+ }) {
+ return widget.noteController!
+ .save(
+ id: id,
+ title: title,
+ content: content,
+ tagIds: tagIds,
+ );
+ },
+ onClose: _closeActiveDrawer,
+ closeFocusNode:
+ _noteDrawerCloseFocusNode,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ if ((hasTodoDrawer || hasNoteDrawer) && hasTagDrawer)
Positioned.fill(
child: IgnorePointer(
key: const Key('todo-context-scrim-pointer'),
- ignoring: !isTodoContextOverlayOpen,
+ ignoring: !isEditorContextOverlayOpen,
child: ExcludeSemantics(
child: AnimatedOpacity(
key: const Key('todo-context-scrim'),
@@ -1234,7 +1546,7 @@ class _TodoPanelState extends State {
? Duration.zero
: _drawerScrimDuration,
curve: Curves.easeOut,
- opacity: isTodoContextOverlayOpen ? 1 : 0,
+ opacity: isEditorContextOverlayOpen ? 1 : 0,
child: GestureDetector(
key: const Key('todo-context-dismiss'),
behavior: HitTestBehavior.opaque,
@@ -1290,6 +1602,16 @@ class _TodoPanelState extends State {
'tag-management-drawer-content',
),
controller: widget.controller,
+ additionalUsageCounts:
+ widget.noteController
+ ?.tagUsageCountsFor(
+ widget.controller.tags
+ .map((tag) => tag.id),
+ ) ??
+ const {},
+ onTagDeleted: widget
+ .noteController
+ ?.removeTag,
isOpen: isTagManagementOpen,
borderOnLeft: !tagDrawerOnLeft,
onClose: _closeActiveDrawer,
@@ -1302,9 +1624,9 @@ class _TodoPanelState extends State {
'tag-assignment-drawer-content',
),
controller: widget.controller,
- selectedTagIds: _todoEditorTagIds,
+ selectedTagIds: assignmentTagIds,
borderOnLeft: !tagDrawerOnLeft,
- onToggled: _toggleTodoEditorTag,
+ onToggled: _toggleEditorTag,
onManageTags:
_openTagManagementFromTagAssignment,
onClose: _closeActiveDrawer,
@@ -1318,6 +1640,15 @@ class _TodoPanelState extends State {
controller: widget.controller,
selectedTagIds: _selectedTagIds,
borderOnLeft: !tagDrawerOnLeft,
+ usageCounts:
+ _contentKind ==
+ _PanelContentKind.notes
+ ? widget.noteController
+ ?.tagUsageCountsFor(
+ widget.controller.tags
+ .map((tag) => tag.id),
+ )
+ : null,
onToggled: _toggleTagFilter,
onClear: _clearTagFilters,
onManageTags: _openTagManagement,
@@ -1345,12 +1676,102 @@ class _TodoPanelState extends State {
}
}
+class _ContentSwitcher extends StatelessWidget {
+ const _ContentSwitcher({required this.selected, required this.onSelected});
+
+ final _PanelContentKind selected;
+ final ValueChanged<_PanelContentKind> onSelected;
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final onSurface = theme.colorScheme.onSurface;
+ return Container(
+ 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(
+ key: const Key('todo-tab'),
+ label: context.l10n.todoTabLabel,
+ 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),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _ContentSwitcherButton extends StatelessWidget {
+ const _ContentSwitcherButton({
+ required this.label,
+ required this.selected,
+ required this.onPressed,
+ super.key,
+ });
+
+ final String label;
+ final bool selected;
+ final VoidCallback onPressed;
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ return Expanded(
+ child: Semantics(
+ button: true,
+ selected: selected,
+ child: Material(
+ color: selected
+ ? theme.colorScheme.primary.withValues(alpha: 0.13)
+ : 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,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
class _PanelHeader extends StatelessWidget {
const _PanelHeader({
required this.scope,
required this.activeCount,
required this.archivedCount,
+ required this.notesSelected,
+ required this.noteArchived,
+ required this.noteActiveCount,
+ required this.noteArchivedCount,
required this.onToggleArchive,
+ required this.onToggleNoteArchive,
required this.onOpenStickyBoards,
required this.onOpenSettings,
required this.onCollapse,
@@ -1359,7 +1780,12 @@ class _PanelHeader extends StatelessWidget {
final TodoListScope scope;
final int activeCount;
final int archivedCount;
+ final bool notesSelected;
+ final bool noteArchived;
+ final int noteActiveCount;
+ final int noteArchivedCount;
final VoidCallback onToggleArchive;
+ final VoidCallback onToggleNoteArchive;
final VoidCallback onOpenStickyBoards;
final VoidCallback onOpenSettings;
final VoidCallback onCollapse;
@@ -1368,7 +1794,14 @@ class _PanelHeader extends StatelessWidget {
Widget build(BuildContext context) {
final onSurface = Theme.of(context).colorScheme.onSurface;
final localizations = context.l10n;
- final statusText = scope == TodoListScope.archived
+ 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
@@ -1389,30 +1822,31 @@ class _PanelHeader extends StatelessWidget {
),
),
Semantics(
- selected: scope == TodoListScope.archived,
+ selected: archiveSelected,
child: IconButton(
key: const Key('archive-scope-button'),
- tooltip: scope == TodoListScope.archived
+ tooltip: archiveSelected
? localizations.activeScopeLabel
: localizations.archiveScopeLabel,
- onPressed: onToggleArchive,
- color: scope == TodoListScope.archived
+ onPressed: notesSelected ? onToggleNoteArchive : onToggleArchive,
+ color: archiveSelected
? Theme.of(context).colorScheme.primary
: null,
icon: Icon(
- scope == TodoListScope.archived
+ archiveSelected
? Icons.archive_rounded
: Icons.archive_outlined,
size: 19,
),
),
),
- IconButton(
- key: const Key('sticky-boards-button'),
- tooltip: localizations.stickyBoardsTooltip,
- onPressed: onOpenStickyBoards,
- icon: const Icon(Icons.sticky_note_2_outlined, size: 19),
- ),
+ 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,
diff --git a/lib/features/todos/presentation/todo_view_model.dart b/lib/features/todos/presentation/todo_view_model.dart
index db1f12a..1bb9cb1 100644
--- a/lib/features/todos/presentation/todo_view_model.dart
+++ b/lib/features/todos/presentation/todo_view_model.dart
@@ -52,6 +52,8 @@ class TodoViewModel extends ChangeNotifier {
final TagIdGenerator _tagIdGenerator;
final FirstRunWorkspaceSeeder? _firstRunWorkspaceSeeder;
+ static const String untitledFallback = 'Untitled todo';
+
List _items = const [];
Map _itemsById = const {};
List _activeViewItems = const [];
@@ -190,9 +192,12 @@ class TodoViewModel extends ChangeNotifier {
Iterable tagIds = const [],
}) {
final normalizedTitle = title.trim();
- if (normalizedTitle.isEmpty) {
+ if (normalizedTitle.isEmpty && content.trim().isEmpty) {
return Future.value(null);
}
+ final resolvedTitle = normalizedTitle.isEmpty
+ ? untitledFallback
+ : normalizedTitle;
final todoId = _idGenerator();
return _enqueueTodoAndTagMutation(() async {
@@ -205,7 +210,7 @@ class TodoViewModel extends ChangeNotifier {
}
final createdItem = TodoItem(
id: todoId,
- title: normalizedTitle,
+ title: resolvedTitle,
content: content,
createdAt: _clock().toUtc(),
);
@@ -262,9 +267,12 @@ class TodoViewModel extends ChangeNotifier {
Iterable? tagIds,
}) {
final normalizedTitle = title.trim();
- if (normalizedTitle.isEmpty) {
+ if (normalizedTitle.isEmpty && content.trim().isEmpty) {
return Future.value(false);
}
+ final resolvedTitle = normalizedTitle.isEmpty
+ ? untitledFallback
+ : normalizedTitle;
return _enqueueTodoAndTagMutation(() async {
final existingIndex = _items.indexWhere((item) => item.id == id);
@@ -283,7 +291,7 @@ class TodoViewModel extends ChangeNotifier {
}
final todoChanged =
- existingItem.title != normalizedTitle ||
+ existingItem.title != resolvedTitle ||
existingItem.content != content;
final tagsChanged = !listEquals(tagIdsForTodo(id), normalizedTagIds);
if (!todoChanged && !tagsChanged) {
@@ -293,7 +301,7 @@ class TodoViewModel extends ChangeNotifier {
final updatedItems = List.of(_items);
if (todoChanged) {
updatedItems[existingIndex] = existingItem.withDetails(
- title: normalizedTitle,
+ title: resolvedTitle,
content: content,
);
}
diff --git a/lib/features/todos/presentation/widgets/editor_tag_selector.dart b/lib/features/todos/presentation/widgets/editor_tag_selector.dart
new file mode 100644
index 0000000..c433b61
--- /dev/null
+++ b/lib/features/todos/presentation/widgets/editor_tag_selector.dart
@@ -0,0 +1,76 @@
+import 'package:flutter/material.dart';
+
+import '../../../../l10n/l10n.dart';
+import '../../domain/todo_tag.dart';
+import 'floatick_tag_chip.dart';
+
+class EditorTagSelector extends StatelessWidget {
+ const EditorTagSelector({
+ required this.availableTags,
+ required this.selectedTagIds,
+ required this.onPressed,
+ this.enabled = true,
+ this.buttonKey = const Key('editor-tag-button'),
+ this.tagKeyPrefix = 'editor-tag',
+ super.key,
+ });
+
+ final List availableTags;
+ final Set selectedTagIds;
+ final VoidCallback onPressed;
+ final bool enabled;
+ final Key buttonKey;
+ final String tagKeyPrefix;
+
+ @override
+ Widget build(BuildContext context) {
+ final selectedTags = availableTags
+ .where((tag) => selectedTagIds.contains(tag.id))
+ .toList(growable: false);
+ final theme = Theme.of(context);
+ return Row(
+ children: [
+ IconButton(
+ key: buttonKey,
+ tooltip: context.l10n.assignTagsTooltip,
+ onPressed: enabled ? onPressed : null,
+ style: IconButton.styleFrom(
+ minimumSize: const Size.square(30),
+ maximumSize: const Size.square(30),
+ padding: EdgeInsets.zero,
+ tapTargetSize: MaterialTapTargetSize.shrinkWrap,
+ foregroundColor: selectedTags.isEmpty
+ ? theme.colorScheme.onSurface.withValues(alpha: 0.56)
+ : theme.colorScheme.primary,
+ ),
+ icon: Icon(
+ selectedTags.isEmpty ? Icons.sell_outlined : Icons.sell_rounded,
+ size: 17,
+ ),
+ ),
+ if (selectedTags.isNotEmpty) ...[
+ const SizedBox(width: 6),
+ Expanded(
+ child: SingleChildScrollView(
+ scrollDirection: Axis.horizontal,
+ child: Row(
+ children: [
+ for (var index = 0; index < selectedTags.length; index++) ...[
+ if (index > 0) const SizedBox(width: 5),
+ FloatickTagChip(
+ key: ValueKey(
+ '$tagKeyPrefix-${selectedTags[index].id}',
+ ),
+ tag: selectedTags[index],
+ compact: true,
+ ),
+ ],
+ ],
+ ),
+ ),
+ ),
+ ],
+ ],
+ );
+ }
+}
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index 0f45ed2..fd88382 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -160,10 +160,9 @@
"editTodoAction": "Edit",
"closeTodoDrawerTooltip": "Close todo drawer",
"todoTitleLabel": "Title",
- "todoTitleFieldHint": "What needs to be done?",
+ "todoTitleFieldHint": "Title",
"todoContentLabel": "Content",
"todoContentFieldHint": "Add notes with Markdown…",
- "markdownSupportedHint": "Markdown supported · ⌘ Return saves",
"markdownWriteLabel": "Write",
"markdownPreviewLabel": "Preview",
"cancelAction": "Cancel",
@@ -176,7 +175,7 @@
"noTodoContentTitle": "No details yet",
"noTodoContentMessage": "Edit this todo to add Markdown notes.",
"markdownPreviewEmptyMessage": "Nothing to preview yet",
- "markdownImageBlockedMessage": "Images are not displayed in todo details.",
+ "markdownImageBlockedMessage": "Images are not displayed in previews.",
"viewTodoDetailsTooltip": "View details",
"copyTodoAsMarkdownTooltip": "Copy as Markdown",
"todoCopiedAsMarkdownMessage": "Copied as Markdown",
@@ -188,7 +187,6 @@
"incompleteStatus": "Incomplete",
"markIncompleteTooltip": "Mark incomplete",
"markCompleteTooltip": "Mark complete",
- "todoTitleRequiredHint": "Todo title cannot be empty",
"editTooltip": "Edit",
"cancelEditTooltip": "Cancel editing",
"restoreTooltip": "Restore to todos",
@@ -206,6 +204,43 @@
"noSearchResultsMessage": "Try another keyword",
"emptyArchiveMessage": "Archived items will appear here",
"emptyTodosMessage": "Add a new item above whenever you like",
+ "todoTabLabel": "Todos",
+ "notesTabLabel": "Notes",
+ "noteCountLabel": "{count, plural, =1{1 note} other{{count} notes}}",
+ "@noteCountLabel": {
+ "placeholders": {
+ "count": {
+ "type": "int"
+ }
+ }
+ },
+ "searchNotesHint": "Search notes",
+ "searchNoteArchiveHint": "Search note archive",
+ "newNoteAction": "New",
+ "newNoteDrawerTitle": "New note",
+ "editNoteDrawerTitle": "Edit note",
+ "closeNoteDrawerTooltip": "Close note drawer",
+ "noteTitleHint": "Title",
+ "noteTitleLabel": "Title",
+ "noteContentLabel": "Content",
+ "noteContentHint": "Capture it now, organize it later…",
+ "noteAutoSaving": "Saving automatically…",
+ "noteAutoSaved": "Saved automatically",
+ "noteAutoSaveFailed": "Autosave failed. Try again.",
+ "noteEmptyDraftHint": "Blank notes are not saved",
+ "finishNoteAction": "Done",
+ "pinnedNotesLabel": "Pinned",
+ "recentNotesLabel": "Recently edited",
+ "noteWithoutContent": "No content yet",
+ "pinNoteTooltip": "Pin note",
+ "unpinNoteTooltip": "Unpin note",
+ "archiveNoteTooltip": "Archive note",
+ "restoreNoteTooltip": "Restore note",
+ "deleteNoteTooltip": "Delete note permanently",
+ "emptyNotesTitle": "Write your first note",
+ "emptyNotesMessage": "Capture ideas, daily updates, and useful tips here",
+ "emptyNoteArchiveTitle": "Note archive is empty",
+ "emptyNoteArchiveMessage": "Archived notes will appear here",
"todayLabel": "Today",
"yesterdayLabel": "Yesterday",
"storageInvalidDataError": "The local data file is damaged and was left unchanged.",
diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart
index edd9157..6b2c3e7 100644
--- a/lib/l10n/app_localizations.dart
+++ b/lib/l10n/app_localizations.dart
@@ -767,7 +767,7 @@ abstract class AppLocalizations {
/// No description provided for @todoTitleFieldHint.
///
/// In en, this message translates to:
- /// **'What needs to be done?'**
+ /// **'Title'**
String get todoTitleFieldHint;
/// No description provided for @todoContentLabel.
@@ -782,12 +782,6 @@ abstract class AppLocalizations {
/// **'Add notes with Markdown…'**
String get todoContentFieldHint;
- /// No description provided for @markdownSupportedHint.
- ///
- /// In en, this message translates to:
- /// **'Markdown supported · ⌘ Return saves'**
- String get markdownSupportedHint;
-
/// No description provided for @markdownWriteLabel.
///
/// In en, this message translates to:
@@ -863,7 +857,7 @@ abstract class AppLocalizations {
/// No description provided for @markdownImageBlockedMessage.
///
/// In en, this message translates to:
- /// **'Images are not displayed in todo details.'**
+ /// **'Images are not displayed in previews.'**
String get markdownImageBlockedMessage;
/// No description provided for @viewTodoDetailsTooltip.
@@ -932,12 +926,6 @@ abstract class AppLocalizations {
/// **'Mark complete'**
String get markCompleteTooltip;
- /// No description provided for @todoTitleRequiredHint.
- ///
- /// In en, this message translates to:
- /// **'Todo title cannot be empty'**
- String get todoTitleRequiredHint;
-
/// No description provided for @editTooltip.
///
/// In en, this message translates to:
@@ -1040,6 +1028,186 @@ abstract class AppLocalizations {
/// **'Add a new item above whenever you like'**
String get emptyTodosMessage;
+ /// No description provided for @todoTabLabel.
+ ///
+ /// In en, this message translates to:
+ /// **'Todos'**
+ String get todoTabLabel;
+
+ /// No description provided for @notesTabLabel.
+ ///
+ /// In en, this message translates to:
+ /// **'Notes'**
+ String get notesTabLabel;
+
+ /// No description provided for @noteCountLabel.
+ ///
+ /// In en, this message translates to:
+ /// **'{count, plural, =1{1 note} other{{count} notes}}'**
+ String noteCountLabel(int count);
+
+ /// No description provided for @searchNotesHint.
+ ///
+ /// In en, this message translates to:
+ /// **'Search notes'**
+ String get searchNotesHint;
+
+ /// No description provided for @searchNoteArchiveHint.
+ ///
+ /// In en, this message translates to:
+ /// **'Search note archive'**
+ String get searchNoteArchiveHint;
+
+ /// No description provided for @newNoteAction.
+ ///
+ /// In en, this message translates to:
+ /// **'New'**
+ String get newNoteAction;
+
+ /// No description provided for @newNoteDrawerTitle.
+ ///
+ /// In en, this message translates to:
+ /// **'New note'**
+ String get newNoteDrawerTitle;
+
+ /// No description provided for @editNoteDrawerTitle.
+ ///
+ /// In en, this message translates to:
+ /// **'Edit note'**
+ String get editNoteDrawerTitle;
+
+ /// No description provided for @closeNoteDrawerTooltip.
+ ///
+ /// In en, this message translates to:
+ /// **'Close note drawer'**
+ String get closeNoteDrawerTooltip;
+
+ /// No description provided for @noteTitleHint.
+ ///
+ /// In en, this message translates to:
+ /// **'Title'**
+ String get noteTitleHint;
+
+ /// No description provided for @noteTitleLabel.
+ ///
+ /// In en, this message translates to:
+ /// **'Title'**
+ String get noteTitleLabel;
+
+ /// No description provided for @noteContentLabel.
+ ///
+ /// In en, this message translates to:
+ /// **'Content'**
+ String get noteContentLabel;
+
+ /// No description provided for @noteContentHint.
+ ///
+ /// In en, this message translates to:
+ /// **'Capture it now, organize it later…'**
+ String get noteContentHint;
+
+ /// No description provided for @noteAutoSaving.
+ ///
+ /// In en, this message translates to:
+ /// **'Saving automatically…'**
+ String get noteAutoSaving;
+
+ /// No description provided for @noteAutoSaved.
+ ///
+ /// In en, this message translates to:
+ /// **'Saved automatically'**
+ String get noteAutoSaved;
+
+ /// No description provided for @noteAutoSaveFailed.
+ ///
+ /// In en, this message translates to:
+ /// **'Autosave failed. Try again.'**
+ String get noteAutoSaveFailed;
+
+ /// No description provided for @noteEmptyDraftHint.
+ ///
+ /// In en, this message translates to:
+ /// **'Blank notes are not saved'**
+ String get noteEmptyDraftHint;
+
+ /// No description provided for @finishNoteAction.
+ ///
+ /// In en, this message translates to:
+ /// **'Done'**
+ String get finishNoteAction;
+
+ /// No description provided for @pinnedNotesLabel.
+ ///
+ /// In en, this message translates to:
+ /// **'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:
+ /// **'No content yet'**
+ String get noteWithoutContent;
+
+ /// No description provided for @pinNoteTooltip.
+ ///
+ /// In en, this message translates to:
+ /// **'Pin note'**
+ String get pinNoteTooltip;
+
+ /// No description provided for @unpinNoteTooltip.
+ ///
+ /// In en, this message translates to:
+ /// **'Unpin note'**
+ String get unpinNoteTooltip;
+
+ /// No description provided for @archiveNoteTooltip.
+ ///
+ /// In en, this message translates to:
+ /// **'Archive note'**
+ String get archiveNoteTooltip;
+
+ /// No description provided for @restoreNoteTooltip.
+ ///
+ /// In en, this message translates to:
+ /// **'Restore note'**
+ String get restoreNoteTooltip;
+
+ /// No description provided for @deleteNoteTooltip.
+ ///
+ /// In en, this message translates to:
+ /// **'Delete note permanently'**
+ String get deleteNoteTooltip;
+
+ /// No description provided for @emptyNotesTitle.
+ ///
+ /// In en, this message translates to:
+ /// **'Write your first note'**
+ String get emptyNotesTitle;
+
+ /// No description provided for @emptyNotesMessage.
+ ///
+ /// In en, this message translates to:
+ /// **'Capture ideas, daily updates, and useful tips here'**
+ String get emptyNotesMessage;
+
+ /// No description provided for @emptyNoteArchiveTitle.
+ ///
+ /// In en, this message translates to:
+ /// **'Note archive is empty'**
+ String get emptyNoteArchiveTitle;
+
+ /// No description provided for @emptyNoteArchiveMessage.
+ ///
+ /// In en, this message translates to:
+ /// **'Archived notes will appear here'**
+ String get emptyNoteArchiveMessage;
+
/// No description provided for @todayLabel.
///
/// In en, this message translates to:
diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart
index 89721a7..45c1a98 100644
--- a/lib/l10n/app_localizations_en.dart
+++ b/lib/l10n/app_localizations_en.dart
@@ -389,7 +389,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get todoTitleLabel => 'Title';
@override
- String get todoTitleFieldHint => 'What needs to be done?';
+ String get todoTitleFieldHint => 'Title';
@override
String get todoContentLabel => 'Content';
@@ -397,9 +397,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get todoContentFieldHint => 'Add notes with Markdown…';
- @override
- String get markdownSupportedHint => 'Markdown supported · ⌘ Return saves';
-
@override
String get markdownWriteLabel => 'Write';
@@ -438,7 +435,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get markdownImageBlockedMessage =>
- 'Images are not displayed in todo details.';
+ 'Images are not displayed in previews.';
@override
String get viewTodoDetailsTooltip => 'View details';
@@ -473,9 +470,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get markCompleteTooltip => 'Mark complete';
- @override
- String get todoTitleRequiredHint => 'Todo title cannot be empty';
-
@override
String get editTooltip => 'Edit';
@@ -527,6 +521,105 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get emptyTodosMessage => 'Add a new item above whenever you like';
+ @override
+ String get todoTabLabel => 'Todos';
+
+ @override
+ String get notesTabLabel => 'Notes';
+
+ @override
+ String noteCountLabel(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count notes',
+ one: '1 note',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String get searchNotesHint => 'Search notes';
+
+ @override
+ String get searchNoteArchiveHint => 'Search note archive';
+
+ @override
+ String get newNoteAction => 'New';
+
+ @override
+ String get newNoteDrawerTitle => 'New note';
+
+ @override
+ String get editNoteDrawerTitle => 'Edit note';
+
+ @override
+ String get closeNoteDrawerTooltip => 'Close note drawer';
+
+ @override
+ String get noteTitleHint => 'Title';
+
+ @override
+ String get noteTitleLabel => 'Title';
+
+ @override
+ String get noteContentLabel => 'Content';
+
+ @override
+ String get noteContentHint => 'Capture it now, organize it later…';
+
+ @override
+ String get noteAutoSaving => 'Saving automatically…';
+
+ @override
+ String get noteAutoSaved => 'Saved automatically';
+
+ @override
+ String get noteAutoSaveFailed => 'Autosave failed. Try again.';
+
+ @override
+ String get noteEmptyDraftHint => 'Blank notes are not saved';
+
+ @override
+ String get finishNoteAction => 'Done';
+
+ @override
+ String get pinnedNotesLabel => 'Pinned';
+
+ @override
+ String get recentNotesLabel => 'Recently edited';
+
+ @override
+ String get noteWithoutContent => 'No content yet';
+
+ @override
+ String get pinNoteTooltip => 'Pin note';
+
+ @override
+ String get unpinNoteTooltip => 'Unpin note';
+
+ @override
+ String get archiveNoteTooltip => 'Archive note';
+
+ @override
+ String get restoreNoteTooltip => 'Restore note';
+
+ @override
+ String get deleteNoteTooltip => 'Delete note permanently';
+
+ @override
+ String get emptyNotesTitle => 'Write your first note';
+
+ @override
+ String get emptyNotesMessage =>
+ 'Capture ideas, daily updates, and useful tips here';
+
+ @override
+ String get emptyNoteArchiveTitle => 'Note archive is empty';
+
+ @override
+ String get emptyNoteArchiveMessage => 'Archived notes will appear here';
+
@override
String get todayLabel => 'Today';
diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart
index dfb70da..3c6b96e 100644
--- a/lib/l10n/app_localizations_zh.dart
+++ b/lib/l10n/app_localizations_zh.dart
@@ -361,7 +361,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get todoTitleLabel => '标题';
@override
- String get todoTitleFieldHint => '要完成什么?';
+ String get todoTitleFieldHint => '标题';
@override
String get todoContentLabel => '内容';
@@ -369,9 +369,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get todoContentFieldHint => '使用 Markdown 添加更多说明…';
- @override
- String get markdownSupportedHint => '支持 Markdown · ⌘ Return 保存';
-
@override
String get markdownWriteLabel => '编辑';
@@ -409,7 +406,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get markdownPreviewEmptyMessage => '暂无可预览内容';
@override
- String get markdownImageBlockedMessage => '待办详情中暂不显示图片。';
+ String get markdownImageBlockedMessage => '预览中暂不显示图片。';
@override
String get viewTodoDetailsTooltip => '查看详情';
@@ -444,9 +441,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get markCompleteTooltip => '标记为已完成';
- @override
- String get todoTitleRequiredHint => '待办内容不能为空';
-
@override
String get editTooltip => '编辑';
@@ -498,6 +492,98 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get emptyTodosMessage => '在上方随时添加新事项';
+ @override
+ String get todoTabLabel => '待办';
+
+ @override
+ String get notesTabLabel => '笔记';
+
+ @override
+ String noteCountLabel(int count) {
+ return '$count 条笔记';
+ }
+
+ @override
+ String get searchNotesHint => '搜索笔记';
+
+ @override
+ String get searchNoteArchiveHint => '搜索笔记归档';
+
+ @override
+ String get newNoteAction => '新建';
+
+ @override
+ String get newNoteDrawerTitle => '新建笔记';
+
+ @override
+ String get editNoteDrawerTitle => '编辑笔记';
+
+ @override
+ String get closeNoteDrawerTooltip => '关闭笔记抽屉';
+
+ @override
+ String get noteTitleHint => '标题';
+
+ @override
+ String get noteTitleLabel => '标题';
+
+ @override
+ String get noteContentLabel => '内容';
+
+ @override
+ String get noteContentHint => '先记下来,稍后再整理…';
+
+ @override
+ String get noteAutoSaving => '正在自动保存…';
+
+ @override
+ String get noteAutoSaved => '已自动保存';
+
+ @override
+ String get noteAutoSaveFailed => '自动保存失败,请重试';
+
+ @override
+ String get noteEmptyDraftHint => '空白笔记不会保存';
+
+ @override
+ String get finishNoteAction => '完成';
+
+ @override
+ String get pinnedNotesLabel => '置顶';
+
+ @override
+ String get recentNotesLabel => '最近编辑';
+
+ @override
+ String get noteWithoutContent => '暂无正文';
+
+ @override
+ String get pinNoteTooltip => '置顶笔记';
+
+ @override
+ String get unpinNoteTooltip => '取消置顶';
+
+ @override
+ String get archiveNoteTooltip => '归档笔记';
+
+ @override
+ String get restoreNoteTooltip => '恢复笔记';
+
+ @override
+ String get deleteNoteTooltip => '永久删除笔记';
+
+ @override
+ String get emptyNotesTitle => '写下第一条笔记';
+
+ @override
+ String get emptyNotesMessage => '灵感、日报和小技巧,都可以随手记在这里';
+
+ @override
+ String get emptyNoteArchiveTitle => '笔记归档还是空的';
+
+ @override
+ String get emptyNoteArchiveMessage => '归档的笔记会保存在这里';
+
@override
String get todayLabel => '今天';
diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb
index 56d1f3e..346af04 100644
--- a/lib/l10n/app_zh.arb
+++ b/lib/l10n/app_zh.arb
@@ -111,10 +111,9 @@
"editTodoAction": "编辑",
"closeTodoDrawerTooltip": "关闭待办抽屉",
"todoTitleLabel": "标题",
- "todoTitleFieldHint": "要完成什么?",
+ "todoTitleFieldHint": "标题",
"todoContentLabel": "内容",
"todoContentFieldHint": "使用 Markdown 添加更多说明…",
- "markdownSupportedHint": "支持 Markdown · ⌘ Return 保存",
"markdownWriteLabel": "编辑",
"markdownPreviewLabel": "预览",
"cancelAction": "取消",
@@ -127,7 +126,7 @@
"noTodoContentTitle": "还没有详细内容",
"noTodoContentMessage": "编辑待办即可添加 Markdown 说明。",
"markdownPreviewEmptyMessage": "暂无可预览内容",
- "markdownImageBlockedMessage": "待办详情中暂不显示图片。",
+ "markdownImageBlockedMessage": "预览中暂不显示图片。",
"viewTodoDetailsTooltip": "查看详情",
"copyTodoAsMarkdownTooltip": "复制为 Markdown",
"todoCopiedAsMarkdownMessage": "已复制为 Markdown",
@@ -139,7 +138,6 @@
"incompleteStatus": "未完成",
"markIncompleteTooltip": "标记为未完成",
"markCompleteTooltip": "标记为已完成",
- "todoTitleRequiredHint": "待办内容不能为空",
"editTooltip": "编辑",
"cancelEditTooltip": "取消编辑",
"restoreTooltip": "恢复到待办",
@@ -157,6 +155,43 @@
"noSearchResultsMessage": "换一个关键词试试",
"emptyArchiveMessage": "归档的事项会保存在这里",
"emptyTodosMessage": "在上方随时添加新事项",
+ "todoTabLabel": "待办",
+ "notesTabLabel": "笔记",
+ "noteCountLabel": "{count} 条笔记",
+ "@noteCountLabel": {
+ "placeholders": {
+ "count": {
+ "type": "int"
+ }
+ }
+ },
+ "searchNotesHint": "搜索笔记",
+ "searchNoteArchiveHint": "搜索笔记归档",
+ "newNoteAction": "新建",
+ "newNoteDrawerTitle": "新建笔记",
+ "editNoteDrawerTitle": "编辑笔记",
+ "closeNoteDrawerTooltip": "关闭笔记抽屉",
+ "noteTitleHint": "标题",
+ "noteTitleLabel": "标题",
+ "noteContentLabel": "内容",
+ "noteContentHint": "先记下来,稍后再整理…",
+ "noteAutoSaving": "正在自动保存…",
+ "noteAutoSaved": "已自动保存",
+ "noteAutoSaveFailed": "自动保存失败,请重试",
+ "noteEmptyDraftHint": "空白笔记不会保存",
+ "finishNoteAction": "完成",
+ "pinnedNotesLabel": "置顶",
+ "recentNotesLabel": "最近编辑",
+ "noteWithoutContent": "暂无正文",
+ "pinNoteTooltip": "置顶笔记",
+ "unpinNoteTooltip": "取消置顶",
+ "archiveNoteTooltip": "归档笔记",
+ "restoreNoteTooltip": "恢复笔记",
+ "deleteNoteTooltip": "永久删除笔记",
+ "emptyNotesTitle": "写下第一条笔记",
+ "emptyNotesMessage": "灵感、日报和小技巧,都可以随手记在这里",
+ "emptyNoteArchiveTitle": "笔记归档还是空的",
+ "emptyNoteArchiveMessage": "归档的笔记会保存在这里",
"todayLabel": "今天",
"yesterdayLabel": "昨天",
"storageInvalidDataError": "本地数据文件已损坏,文件保持不变。",
diff --git a/lib/main.dart b/lib/main.dart
index 5041dee..17b20a1 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -5,6 +5,8 @@ import 'package:multiview_desktop/multiview_desktop.dart';
import 'app/floatick_app.dart';
import 'core/platform/window_bridge.dart';
+import 'features/notes/data/note_repository.dart';
+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';
@@ -30,6 +32,7 @@ Future main() async {
}
final todoRepository = LocalTodoRepository();
+ final noteController = NoteViewModel(repository: LocalNoteRepository());
final tagRepository = LocalTagRepository();
final controller = TodoViewModel(
todoRepository: todoRepository,
@@ -52,6 +55,7 @@ Future main() async {
);
await Future.wait(>[
controller.load(),
+ noteController.load(),
settingsController.load(),
updateController.load(),
stickyBoardController.load(),
@@ -65,6 +69,7 @@ Future main() async {
runMultiApp(
home: (context, viewId) => FloatickApp(
controller: controller,
+ noteController: noteController,
settingsController: settingsController,
updateController: updateController,
stickyBoardController: stickyBoardController,
diff --git a/pubspec.yaml b/pubspec.yaml
index 7f9fc9e..9fcb110 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.2+9
+version: 0.3.3+10
environment:
sdk: ^3.12.2
diff --git a/test/app/floatick_app_test.dart b/test/app/floatick_app_test.dart
index 284a425..615a132 100644
--- a/test/app/floatick_app_test.dart
+++ b/test/app/floatick_app_test.dart
@@ -3,6 +3,9 @@ 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';
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/app_settings.dart';
@@ -46,6 +49,12 @@ void main() {
clock: () => DateTime.utc(2026, 7, 23, 8),
idGenerator: () => 'new-todo',
);
+ final noteRepository = _WidgetTestNoteRepository();
+ final noteController = NoteViewModel(
+ repository: noteRepository,
+ clock: () => DateTime.utc(2026, 7, 23, 8),
+ idGenerator: () => 'new-note',
+ );
final windowBridge = _WidgetTestWindowBridge();
final settingsRepository = _WidgetTestSettingsRepository();
final loginItemRepository = _WidgetTestLoginItemRepository();
@@ -66,6 +75,7 @@ void main() {
windowBridge: windowBridge,
);
await controller.load();
+ await noteController.load();
await settingsController.load();
await updateController.load();
await stickyBoardController.load();
@@ -73,6 +83,7 @@ void main() {
await tester.pumpWidget(
FloatickApp(
controller: controller,
+ noteController: noteController,
settingsController: settingsController,
updateController: updateController,
stickyBoardController: stickyBoardController,
@@ -173,6 +184,36 @@ void main() {
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);
+ await tester.tap(find.byKey(const Key('note-tab')));
+ await tester.pumpAndSettle();
+ expect(find.text('搜索笔记'), findsOneWidget);
+ expect(find.byKey(const Key('tag-filter-button')), findsOneWidget);
+ expect(find.byKey(const Key('add-note-button')), findsOneWidget);
+ expect(find.text('写下第一条笔记'), findsOneWidget);
+
+ await tester.tap(find.byKey(const Key('add-note-button')));
+ await tester.pumpAndSettle();
+ expect(find.byKey(const Key('note-editor-drawer')), findsOneWidget);
+ expect(find.byKey(const Key('note-document-editor')), findsOneWidget);
+ expect(find.byKey(const Key('note-template-daily')), findsNothing);
+ expect(find.byKey(const Key('note-editor-tag-button')), findsOneWidget);
+ await tester.tap(find.byKey(const Key('note-editor-tag-button')));
+ await tester.pumpAndSettle();
+ expect(find.byKey(const Key('tag-assignment-drawer')), findsOneWidget);
+ expect(find.byKey(const Key('note-editor-drawer')), findsOneWidget);
+ await tester.tap(find.byKey(const Key('tag-assignment-close')));
+ await tester.pumpAndSettle();
+ expect(find.byKey(const Key('note-document-editor')), findsOneWidget);
+ await tester.tap(find.byKey(const Key('close-note-editor')));
+ await tester.pumpAndSettle();
+ expect(noteRepository.savedItems, isEmpty);
+
+ await tester.tap(find.byKey(const Key('todo-tab')));
+ await tester.pumpAndSettle();
+ expect(find.text('搜索待办'), findsOneWidget);
+
await tester.tap(find.byKey(const Key('settings-button')));
await tester.pumpAndSettle();
@@ -695,7 +736,7 @@ void main() {
);
expect(
tester
- .widget(find.byKey(const Key('todo-title-field')))
+ .widget(find.byKey(const Key('todo-title-field')))
.controller
?.text,
'Tagged task',
@@ -1267,7 +1308,7 @@ void main() {
);
expect(
tester
- .widget(find.byKey(const Key('todo-title-field')))
+ .widget(find.byKey(const Key('todo-title-field')))
.controller
?.text,
'Review the launch checklist',
@@ -1618,6 +1659,21 @@ class _WidgetTestLoginItemRepository implements LoginItemRepository {
}
}
+class _WidgetTestNoteRepository implements NoteRepository {
+ List savedItems = [];
+
+ @override
+ String get storagePath => '/tmp/floatick-widget-test/notes.json';
+
+ @override
+ Future> load() async => List.of(savedItems);
+
+ @override
+ Future save(List items) async {
+ savedItems = List.of(items);
+ }
+}
+
class _WidgetTestRepository implements TodoRepository {
List savedItems = [];
diff --git a/test/core/ui/floatick_editor_components_test.dart b/test/core/ui/floatick_editor_components_test.dart
new file mode 100644
index 0000000..78c1019
--- /dev/null
+++ b/test/core/ui/floatick_editor_components_test.dart
@@ -0,0 +1,142 @@
+import 'package:floatick/app/theme/floatick_theme.dart';
+import 'package:floatick/core/ui/floatick_editor_components.dart';
+import 'package:floatick/l10n/app_localizations.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ testWidgets('shared editor surface owns header, switch, and footer styling', (
+ WidgetTester tester,
+ ) async {
+ final closeFocusNode = FocusNode();
+ addTearDown(closeFocusNode.dispose);
+ var closeCount = 0;
+ bool? previewSelection;
+
+ await tester.pumpWidget(
+ MaterialApp(
+ locale: const Locale('en'),
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
+ supportedLocales: AppLocalizations.supportedLocales,
+ theme: buildFloatickTheme(Brightness.dark),
+ home: Scaffold(
+ body: SizedBox(
+ width: 440,
+ height: 520,
+ child: FloatickEditorDrawerSurface(
+ key: const Key('shared-editor-surface'),
+ title: 'Editor title',
+ closeTooltip: 'Close editor',
+ closeButtonKey: const Key('shared-editor-close'),
+ closeFocusNode: closeFocusNode,
+ onClose: () => closeCount += 1,
+ child: Column(
+ children: [
+ FloatickEditorModeSwitch(
+ showPreview: false,
+ onChanged: (value) => previewSelection = value,
+ ),
+ const Spacer(),
+ const FloatickEditorFooter(child: Text('Footer')),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ expect(find.text('Editor title'), findsOneWidget);
+ expect(
+ find.byKey(const Key('floatick-editor-mode-switch')),
+ findsOneWidget,
+ );
+ expect(find.text('Footer'), findsOneWidget);
+
+ final surfaceDecoration =
+ tester
+ .widget(
+ find
+ .descendant(
+ of: find.byKey(const Key('shared-editor-surface')),
+ matching: find.byType(DecoratedBox),
+ )
+ .first,
+ )
+ .decoration
+ as BoxDecoration;
+ expect(surfaceDecoration.color, const Color(0xFF202A2E));
+
+ await tester.tap(find.byKey(const Key('markdown-preview-tab')));
+ expect(previewSelection, isTrue);
+ await tester.tap(find.byKey(const Key('shared-editor-close')));
+ expect(closeCount, 1);
+ });
+
+ testWidgets(
+ 'document editor visually unifies separate title and content fields',
+ (WidgetTester tester) async {
+ final titleController = TextEditingController();
+ final contentController = TextEditingController();
+ final titleFocusNode = FocusNode();
+ final contentFocusNode = FocusNode();
+ addTearDown(titleController.dispose);
+ addTearDown(contentController.dispose);
+ addTearDown(titleFocusNode.dispose);
+ addTearDown(contentFocusNode.dispose);
+
+ await tester.pumpWidget(
+ MaterialApp(
+ locale: const Locale('en'),
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
+ supportedLocales: AppLocalizations.supportedLocales,
+ theme: buildFloatickTheme(Brightness.dark),
+ home: Scaffold(
+ body: SizedBox(
+ width: 440,
+ height: 420,
+ child: FloatickDocumentEditor(
+ titleController: titleController,
+ contentController: contentController,
+ titleFocusNode: titleFocusNode,
+ contentFocusNode: contentFocusNode,
+ titleHint: 'Optional title',
+ contentHint: 'Start writing',
+ titleSemanticsLabel: 'Title',
+ contentSemanticsLabel: 'Content',
+ showPreview: false,
+ preview: const Text('Preview body'),
+ onPreviewChanged: (_) {},
+ ),
+ ),
+ ),
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ expect(
+ find.byKey(const Key('floatick-document-title-divider')),
+ findsOneWidget,
+ );
+ final titleField = tester.widget(
+ find.byKey(const Key('floatick-document-title-field')),
+ );
+ final contentField = tester.widget