diff --git a/.github/workflows/candidate.yml b/.github/workflows/candidate.yml
index 73e66ef..d849a7b 100644
--- a/.github/workflows/candidate.yml
+++ b/.github/workflows/candidate.yml
@@ -92,6 +92,11 @@ jobs:
--build-name "$APP_VERSION" \
--build-number "$APP_BUILD_NUMBER"
+ - name: Prepare unsigned candidate app
+ run: |
+ tool/release/prepare_unsigned_app.sh \
+ "build/macos/Build/Products/Release/$PRODUCT_NAME.app"
+
- name: Verify app architectures
shell: bash
run: |
@@ -106,6 +111,11 @@ jobs:
exit 1
fi
+ - name: Smoke-test candidate app
+ run: |
+ tool/release/smoke_test_app.sh \
+ "build/macos/Build/Products/Release/$PRODUCT_NAME.app"
+
- name: Create candidate package
id: package
env:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3bfea8e..9eb8b22 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -36,7 +36,7 @@ jobs:
run: flutter pub get
- name: Verify formatting
- run: dart format --output=none --set-exit-if-changed lib test
+ run: dart format --output=none --set-exit-if-changed lib test integration_test
- name: Analyze
run: flutter analyze
@@ -44,9 +44,17 @@ jobs:
- name: Test
run: flutter test
+ - name: Run macOS UI automation
+ run: tool/test/run_ui_tests.sh
+
- name: Build macOS release app
run: flutter build macos --release
+ - name: Prepare unsigned test app
+ run: |
+ tool/release/prepare_unsigned_app.sh \
+ build/macos/Build/Products/Release/Floatick.app
+
- name: Verify app architectures
shell: bash
run: |
@@ -60,3 +68,8 @@ jobs:
echo "Expected a universal app, found: $architectures" >&2
exit 1
fi
+
+ - name: Smoke-test release app
+ run: |
+ tool/release/smoke_test_app.sh \
+ build/macos/Build/Products/Release/Floatick.app
diff --git a/README.md b/README.md
index 2c5d21b..1bb0ce0 100644
--- a/README.md
+++ b/README.md
@@ -117,14 +117,17 @@ sudo xcodebuild -runFirstLaunch
### Verify a change
```bash
-dart format --output=none --set-exit-if-changed lib test
+dart format --output=none --set-exit-if-changed lib test integration_test
flutter analyze
flutter test
+tool/test/run_ui_tests.sh
flutter build macos --release
```
The release app is written to
`build/macos/Build/Products/Release/Floatick.app`.
+See the [testing guide](./docs/TESTING.md) for the automated user journeys and
+the macOS system boundaries that remain in Draft acceptance.
## Project structure
@@ -136,6 +139,7 @@ lib/
l10n/ English and Simplified Chinese resources
macos/Runner/ AppKit window shell and Sparkle integration
test/ Repository, ViewModel, and widget tests
+integration_test/ Real-engine macOS user journeys
tool/ Icon and release tooling
```
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 1b2548c..0c86c1e 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -110,14 +110,17 @@ sudo xcodebuild -runFirstLaunch
### 验证改动
```bash
-dart format --output=none --set-exit-if-changed lib test
+dart format --output=none --set-exit-if-changed lib test integration_test
flutter analyze
flutter test
+tool/test/run_ui_tests.sh
flutter build macos --release
```
Release 应用位于
`build/macos/Build/Products/Release/Floatick.app`。
+完整自动化用户链路和仍需 Draft 人工验收的 macOS 系统边界见
+[测试指南](./docs/TESTING.md)。
## 项目结构
@@ -129,6 +132,7 @@ lib/
l10n/ 英文与简体中文资源
macos/Runner/ AppKit 窗口外壳与 Sparkle 集成
test/ Repository、ViewModel 和 Widget 测试
+integration_test/ 真实 macOS 引擎用户链路
tool/ 图标与发布工具
```
diff --git a/docs/DEVELOPMENT_WORKFLOW.md b/docs/DEVELOPMENT_WORKFLOW.md
index 68e7fe5..683d7fc 100644
--- a/docs/DEVELOPMENT_WORKFLOW.md
+++ b/docs/DEVELOPMENT_WORKFLOW.md
@@ -62,6 +62,8 @@ git switch -c feature/short-description
- 修改代码前先确认根因和影响范围。
- 核心逻辑补单元测试;交互变更补 Widget 测试。
- 优先运行与改动直接相关的测试。
+- 完整 UI 自动化使用 `tool/test/run_ui_tests.sh`,覆盖真实 macOS Flutter 引擎和
+ AppKit 原生边界;详细范围见 [TESTING.md](TESTING.md)。
- 本地需要观察 UI 时运行:
```bash
@@ -81,8 +83,9 @@ PR 必须合入 `main`。PR CI 会执行:
1. Dart 格式检查;
2. `flutter analyze`;
3. `flutter test`;
-4. macOS Release 构建;
-5. `arm64` 和 `x86_64` 双架构检查。
+4. macOS UI 自动化与 AppKit 原生边界测试;
+5. macOS Release 构建与首次启动烟测;
+6. `arm64` 和 `x86_64` 双架构检查。
CI 通过后才能合并。普通开发不直接推送 `main`。
@@ -93,25 +96,26 @@ CI 通过后才能合并。普通开发不直接推送 `main`。
从准备发布的 `main` 提交创建发布分支:
```bash
+VERSION=X.Y.Z
git fetch origin
git switch main
git pull --ff-only
-git switch -c release/0.1.0
+git switch -c "release/$VERSION"
```
`pubspec.yaml` 必须包含公开版本和递增的构建号:
```yaml
-version: 0.1.0+1
+version: X.Y.Z+N
```
### 2. 生成 Draft Release
```bash
-git push -u origin release/0.1.0
+git push -u origin "release/$VERSION"
```
-每次推送 `release/0.1.0` 都会重新运行候选工作流,生成:
+每次推送 `release/X.Y.Z` 都会重新运行候选工作流,生成:
- Universal macOS DMG;
- SHA-256 校验文件;
@@ -144,11 +148,12 @@ merge commit 合并。不要 squash 或 rebase 候选提交。
### 2. 标记经过测试的准确提交
```bash
+VERSION=X.Y.Z
git fetch origin
-candidate_sha=$(git rev-parse origin/release/0.1.0)
+candidate_sha=$(git rev-parse "origin/release/$VERSION")
git merge-base --is-ancestor "$candidate_sha" origin/main
-git tag -a v0.1.0 "$candidate_sha" -m "Floatick 0.1.0"
-git push origin v0.1.0
+git tag -a "v$VERSION" "$candidate_sha" -m "Floatick $VERSION"
+git push origin "v$VERSION"
```
标签必须指向 Draft 对应的候选提交,不能指向另一个重新构建的提交。
@@ -185,8 +190,8 @@ https://lucaslushuo.github.io/floatick/appcast.xml
```
- 首个正式版本发布前,该文件还不存在,候选包会显示“更新服务暂未就绪”。
-- 发布 `v0.1.0` 并批准 production 后,工作流会部署签名 appcast。
-- `v0.1.0` 需要用户手动下载安装一次。
+- 发布首个 `vX.Y.Z` 并批准 production 后,工作流会部署签名 appcast。
+- 首个带 Sparkle 的正式版本需要用户手动下载安装一次。
- 从后续版本开始,旧版本会通过 Sparkle 发现、下载、验证并安装更新。
Sparkle EdDSA 保护更新链路,但不能替代 Apple Developer ID 签名和公证。
@@ -196,7 +201,9 @@ Sparkle EdDSA 保护更新链路,但不能替代 Apple Developer ID 签名和
生产版本出现紧急问题时,从最新稳定标签创建补丁发布分支:
```bash
-git switch -c release/0.1.1 v0.1.0
+LATEST_TAG=$(git describe --tags --abbrev=0)
+NEXT_VERSION=X.Y.Z
+git switch -c "release/$NEXT_VERSION" "$LATEST_TAG"
```
提高公开版本和构建号,然后继续使用同一套:
diff --git a/docs/RELEASING.md b/docs/RELEASING.md
index 6fb61c3..0948231 100644
--- a/docs/RELEASING.md
+++ b/docs/RELEASING.md
@@ -38,32 +38,35 @@ be used for normal development.
Create a release branch from the commit intended for the release:
```bash
+VERSION=X.Y.Z
git fetch origin
git switch main
git pull --ff-only
-git switch -c release/0.1.0
+git switch -c "release/$VERSION"
```
Set the matching public version and an increasing positive build number:
```yaml
-version: 0.1.0+1
+version: X.Y.Z+N
```
Then push the branch:
```bash
-git push -u origin release/0.1.0
+git push -u origin "release/$VERSION"
```
-Every push to `release/0.1.0` runs the Release Candidate workflow. It:
+Every push to `release/X.Y.Z` runs the Release Candidate workflow. It:
1. validates that the branch name matches `pubspec.yaml`;
2. runs formatting, analysis, and tests;
3. builds the universal release-mode macOS app;
-4. creates the DMG, SHA-256 checksum, and build manifest;
-5. creates or updates a Draft Release associated with
- `candidate/v0.1.0`.
+4. normalizes embedded code to one ad-hoc identity for the unsigned candidate;
+5. verifies both architectures and launches the app on the Apple silicon runner;
+6. creates the DMG, SHA-256 checksum, and build manifest;
+7. creates or updates a Draft Release associated with
+ `candidate/vX.Y.Z`.
Only users with push access can list Draft Releases through the GitHub API.
Because the repository is public, the temporary source tag itself is visible,
@@ -102,7 +105,7 @@ is rejected and a network failure leaves the installed app usable.
## Promote the accepted candidate
-Open a pull request from `release/0.1.0` into `main` and use a merge commit.
+Open a pull request from `release/X.Y.Z` into `main` and use a merge commit.
Do not squash or rebase this release pull request: the accepted release-branch
commit must remain reachable from `main` so the tag can identify the exact
binary that was tested.
@@ -110,11 +113,12 @@ binary that was tested.
After the pull request is merged:
```bash
+VERSION=X.Y.Z
git fetch origin
-candidate_sha=$(git rev-parse origin/release/0.1.0)
+candidate_sha=$(git rev-parse "origin/release/$VERSION")
git merge-base --is-ancestor "$candidate_sha" origin/main
-git tag -a v0.1.0 "$candidate_sha" -m "Floatick 0.1.0"
-git push origin v0.1.0
+git tag -a "v$VERSION" "$candidate_sha" -m "Floatick $VERSION"
+git push origin "v$VERSION"
```
Pushing the stable tag starts the Release workflow. Its preflight job has no
@@ -143,8 +147,9 @@ the already published assets.
After a successful release, delete the release branch:
```bash
-git push origin --delete release/0.1.0
-git branch -d release/0.1.0
+VERSION=X.Y.Z
+git push origin --delete "release/$VERSION"
+git branch -d "release/$VERSION"
```
## Hotfixes
@@ -153,7 +158,9 @@ For a production-only hotfix, branch from the latest stable tag instead of
including unrelated unreleased work:
```bash
-git switch -c release/0.1.1 v0.1.0
+LATEST_TAG=$(git describe --tags --abbrev=0)
+NEXT_VERSION=X.Y.Z
+git switch -c "release/$NEXT_VERSION" "$LATEST_TAG"
```
Apply the fix, increase both the public version and build number, and use the
@@ -182,6 +189,10 @@ For local layout testing only:
```bash
flutter build macos --release
+tool/release/prepare_unsigned_app.sh \
+ build/macos/Build/Products/Release/Floatick.app
+tool/release/smoke_test_app.sh \
+ build/macos/Build/Products/Release/Floatick.app
tool/release/create_dmg.sh \
build/macos/Build/Products/Release/Floatick.app \
build/release/Floatick-local.dmg \
diff --git a/docs/TESTING.md b/docs/TESTING.md
new file mode 100644
index 0000000..de15862
--- /dev/null
+++ b/docs/TESTING.md
@@ -0,0 +1,90 @@
+# Floatick 测试指南
+
+Floatick 的自动化测试分为四层。目标不是追求一个模糊的“覆盖率数字”,而是让每一层
+验证它最擅长的边界。
+
+```mermaid
+flowchart TB
+ A["单元与 Repository 测试
领域规则、失败回滚、JSON 持久化"]
+ B["Widget 测试
组件状态、布局与交互分支"]
+ C["macOS Integration Test
真实 Flutter 引擎、键盘输入与完整用户链路"]
+ D["原生与 Release 烟测
AppKit 可访问入口、启动和首次工作区"]
+ A --> B --> C --> D
+```
+
+## 一键运行 UI 自动化
+
+```bash
+tool/test/run_ui_tests.sh
+```
+
+该命令会:
+
+1. 在真实 macOS Flutter 引擎上运行 `integration_test/floatick_ui_test.dart`;
+2. 使用临时目录隔离数据,不会读写 `~/.floatick`;
+3. 运行 AppKit 原生可访问入口测试。
+
+## 当前自动覆盖
+
+| 场景 | 覆盖层 |
+| --- | --- |
+| 首次启动生成欢迎 Todo 和 Tags | Integration + Release smoke |
+| 创建含 Markdown 内容的 Todo | Integration |
+| 双击详情、完成、归档、恢复、搜索 | Integration |
+| 退出前后的本地 JSON 持久化 | Integration |
+| Tag 创建、Todo 关联、多选 OR 筛选和清空 | Integration |
+| Sticky Board 创建、添加现有 Todo、Pin/Unpin | Integration |
+| 置顶、登录启动、主题等设置与原生调用边界 | Integration |
+| 悬浮图标的 macOS Accessibility button/press contract | XCTest |
+| Release 应用启动、独立首次工作区和 JSON 有效性 | Release smoke |
+
+普通单元与 Widget 测试仍使用:
+
+```bash
+flutter test
+```
+
+只运行真实 macOS 用户链路:
+
+```bash
+flutter test integration_test/floatick_ui_test.dart -d macos
+```
+
+只运行原生边界:
+
+```bash
+xcodebuild test \
+ -workspace macos/Runner.xcworkspace \
+ -scheme Runner \
+ -configuration Debug \
+ -destination 'platform=macOS' \
+ -only-testing:RunnerTests \
+ CODE_SIGNING_ALLOWED=NO \
+ FLUTTER_TARGET=lib/main.dart
+```
+
+## CI
+
+Pull Request CI 会依次执行:
+
+1. 格式与静态检查;
+2. 单元和 Widget 测试;
+3. macOS UI 自动化与原生边界测试;
+4. Universal Release 构建;
+5. Release 应用首次启动烟测。
+
+任何一层失败都会阻止合并。
+
+## 必须人工验收的系统边界
+
+Flutter Integration Test 不能操作 macOS 原生系统界面,因此下列行为仍放在 Draft
+Release 人工验收中:
+
+- DMG 拖拽安装、Gatekeeper 和“仍要打开”;
+- Sparkle 的真实下载、签名验证、替换应用和重启;
+- 多显示器上的悬浮图标拖动与展开方向;
+- 60/120Hz 动画、滚动和窗口缩放的主观流畅度;
+- 真实登录启动以及不同 macOS 版本的窗口层级。
+
+这些项目不是遗漏,而是由操作系统或外部进程控制;自动化负责提前拦截确定性的功能
+回归,Draft 验收负责最终用户环境。
diff --git a/integration_test/floatick_ui_test.dart b/integration_test/floatick_ui_test.dart
new file mode 100644
index 0000000..954f320
--- /dev/null
+++ b/integration_test/floatick_ui_test.dart
@@ -0,0 +1,515 @@
+import 'dart:io';
+
+import 'package:floatick/app/floatick_app.dart';
+import 'package:floatick/core/platform/window_bridge.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/login_item_status.dart';
+import 'package:floatick/features/settings/presentation/settings_view_model.dart';
+import 'package:floatick/features/sticky_boards/data/sticky_board_repository.dart';
+import 'package:floatick/features/sticky_boards/presentation/sticky_board_view_model.dart';
+import 'package:floatick/features/sticky_boards/presentation/sticky_board_window_coordinator.dart';
+import 'package:floatick/features/todos/data/first_run_workspace_seeder.dart';
+import 'package:floatick/features/todos/data/tag_repository.dart';
+import 'package:floatick/features/todos/data/todo_repository.dart';
+import 'package:floatick/features/todos/presentation/todo_view_model.dart';
+import 'package:floatick/features/updates/data/update_repository.dart';
+import 'package:floatick/features/updates/domain/update_settings_snapshot.dart';
+import 'package:floatick/features/updates/presentation/update_view_model.dart';
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:integration_test/integration_test.dart';
+
+void main() {
+ IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+
+ group('Floatick macOS user journeys', () {
+ testWidgets('first run, todo lifecycle, keyboard input, and persistence', (
+ tester,
+ ) async {
+ final harness = await _UiTestHarness.create();
+ addTearDown(() => harness.dispose(tester));
+
+ await harness.pumpApp(tester);
+
+ expect(find.text('Welcome to Floatick'), findsOneWidget);
+ expect(find.text('Try completing this todo'), findsOneWidget);
+ expect(find.text('Welcome'), findsOneWidget);
+ expect(find.text('Start here'), findsOneWidget);
+ expect(await File(harness.todoRepository.storagePath).exists(), isTrue);
+ expect(await File(harness.tagRepository.storagePath).exists(), isTrue);
+
+ await tester.tap(find.byKey(const Key('add-todo-button')));
+ await tester.pumpAndSettle();
+ await tester.enterText(
+ find.byKey(const Key('todo-title-field')),
+ 'Ship the UI automation suite',
+ );
+ await tester.enterText(
+ find.byKey(const Key('todo-content-field')),
+ '## Acceptance\n\n- Works on macOS\n- Persists locally',
+ );
+ await tester.tap(find.byKey(const Key('save-todo-details')));
+ await tester.pumpAndSettle();
+
+ expect(
+ find.text('Ship the UI automation suite').hitTestable(),
+ findsOneWidget,
+ );
+ expect(harness.todoController.activeCount, 3);
+ expect(harness.windowBridge.floatingIconCounts.last, 3);
+
+ final detailsTarget = find.byKey(
+ const Key('todo-open-details-region-ui-todo-1'),
+ );
+ await tester.tap(detailsTarget);
+ await tester.pump(kDoubleTapMinTime);
+ await tester.tap(detailsTarget);
+ await tester.pumpAndSettle();
+ expect(find.byKey(const Key('todo-details-markdown')), findsOneWidget);
+ expect(find.text('Acceptance'), findsOneWidget);
+ await tester.tap(find.byKey(const Key('todo-drawer-close')));
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.byKey(const Key('toggle-todo-ui-todo-1')));
+ await tester.pumpAndSettle();
+ expect(harness.todoController.itemById('ui-todo-1')?.isCompleted, isTrue);
+
+ await tester.tap(find.byKey(const Key('archive-todo-ui-todo-1')));
+ await tester.pumpAndSettle();
+ expect(harness.todoController.itemById('ui-todo-1')?.isArchived, isTrue);
+ await tester.tap(find.byKey(const Key('archive-scope-button')));
+ await tester.pumpAndSettle();
+ expect(find.text('Archive · 1'), findsOneWidget);
+ expect(
+ find.text('Ship the UI automation suite').hitTestable(),
+ findsOneWidget,
+ );
+
+ await tester.tap(find.byKey(const Key('restore-todo-ui-todo-1')));
+ await tester.pumpAndSettle();
+ await tester.tap(find.byKey(const Key('archive-scope-button')));
+ await tester.pumpAndSettle();
+ expect(
+ find.text('Ship the UI automation suite').hitTestable(),
+ findsOneWidget,
+ );
+
+ await tester.enterText(
+ find.byKey(const Key('search-field')),
+ 'automation',
+ );
+ await tester.pump();
+ expect(find.text('Ship the UI automation suite'), findsOneWidget);
+ expect(find.text('Welcome to Floatick'), findsNothing);
+
+ final reloadedController = TodoViewModel(
+ todoRepository: LocalTodoRepository(
+ rootDirectory: harness.rootDirectory,
+ ),
+ tagRepository: LocalTagRepository(rootDirectory: harness.rootDirectory),
+ );
+ await reloadedController.load();
+ addTearDown(reloadedController.dispose);
+ expect(
+ reloadedController.itemById('ui-todo-1')?.content,
+ '## Acceptance\n\n- Works on macOS\n- Persists locally',
+ );
+ expect(reloadedController.itemById('ui-todo-1')?.isCompleted, isTrue);
+ expect(reloadedController.itemById('ui-todo-1')?.isArchived, isFalse);
+ });
+
+ testWidgets('tag assignment and multi-select filtering', (tester) async {
+ final harness = await _UiTestHarness.create(seedWelcomeWorkspace: false);
+ addTearDown(() => harness.dispose(tester));
+ await harness.pumpApp(tester);
+
+ await tester.tap(find.byKey(const Key('tag-filter-button')));
+ await tester.pumpAndSettle();
+ await tester.tap(find.byKey(const Key('manage-tags-button')));
+ await tester.pumpAndSettle();
+
+ await tester.enterText(
+ find.byKey(const Key('tag-search-create-field')),
+ 'Work',
+ );
+ await tester.pump();
+ await tester.testTextInput.receiveAction(TextInputAction.done);
+ await tester.pumpAndSettle();
+ await tester.enterText(
+ find.byKey(const Key('tag-search-create-field')),
+ 'Personal',
+ );
+ await tester.pump();
+ await tester.testTextInput.receiveAction(TextInputAction.done);
+ await tester.pumpAndSettle();
+ expect(harness.todoController.tags.map((tag) => tag.name), [
+ 'Work',
+ 'Personal',
+ ]);
+ await tester.tap(find.byKey(const Key('tag-management-close')));
+ await tester.pumpAndSettle();
+
+ await _createTodoWithTags(
+ tester,
+ title: 'Prepare release notes',
+ tagIds: const ['ui-tag-1'],
+ );
+ await _createTodoWithTags(
+ tester,
+ title: 'Book a design review',
+ tagIds: const ['ui-tag-2'],
+ );
+ await _createTodoWithTags(
+ tester,
+ title: 'Unfiltered task',
+ tagIds: const [],
+ );
+
+ await tester.tap(find.byKey(const Key('tag-filter-button')));
+ await tester.pumpAndSettle();
+ await tester.tap(find.byKey(const Key('tag-filter-ui-tag-1')));
+ await tester.pumpAndSettle();
+ await tester.tap(find.byKey(const Key('tag-filter-ui-tag-2')));
+ await tester.pumpAndSettle();
+ expect(
+ find.descendant(
+ of: find.byKey(const Key('tag-filter-count')),
+ matching: find.text('2'),
+ ),
+ findsOneWidget,
+ );
+ await tester.tap(find.byKey(const Key('tag-filter-close')));
+ await tester.pumpAndSettle();
+
+ expect(find.text('Prepare release notes').hitTestable(), findsOneWidget);
+ expect(find.text('Book a design review').hitTestable(), findsOneWidget);
+ expect(find.text('Unfiltered task').hitTestable(), findsNothing);
+
+ await tester.tap(find.byKey(const Key('tag-filter-button')));
+ await tester.pumpAndSettle();
+ await tester.tap(find.byKey(const Key('tag-filter-all')));
+ await tester.pumpAndSettle();
+ await tester.tap(find.byKey(const Key('tag-filter-close')));
+ await tester.pumpAndSettle();
+ expect(find.text('Unfiltered task').hitTestable(), findsOneWidget);
+ });
+
+ testWidgets('sticky boards and settings retain their native boundaries', (
+ tester,
+ ) async {
+ final harness = await _UiTestHarness.create(seedWelcomeWorkspace: false);
+ addTearDown(() => harness.dispose(tester));
+ await harness.todoController.create('Review the launch checklist');
+ await harness.pumpApp(tester);
+
+ await tester.tap(find.byKey(const Key('sticky-boards-button')));
+ await tester.pumpAndSettle();
+ await tester.enterText(
+ find.byKey(const Key('sticky-board-search-create-field')),
+ 'Launch',
+ );
+ await tester.tap(find.byKey(const Key('submit-sticky-board')));
+ await tester.pumpAndSettle();
+ expect(
+ find.byKey(const Key('sticky-board-thumbnail-ui-board-1')),
+ findsOneWidget,
+ );
+
+ await tester.tap(find.byKey(const Key('sticky-board-ui-board-1')));
+ await tester.pumpAndSettle();
+ await tester.tap(find.byKey(const Key('sticky-board-add-existing')));
+ await tester.pumpAndSettle();
+ await tester.tap(find.byKey(const Key('sticky-board-picker-ui-todo-1')));
+ await tester.pumpAndSettle();
+ await tester.tap(find.byTooltip('Back to Sticky Boards'));
+ await tester.pumpAndSettle();
+ expect(
+ harness.stickyBoardController.todoIdsForBoard('ui-board-1'),
+ ['ui-todo-1'],
+ );
+
+ await tester.tap(find.byKey(const Key('sticky-board-pin')));
+ await tester.pumpAndSettle();
+ expect(harness.launchedBoards, ['ui-board-1']);
+ expect(
+ harness.stickyBoardController.boardById('ui-board-1')?.isPinned,
+ isTrue,
+ );
+ await tester.tap(find.byKey(const Key('sticky-board-pin')));
+ await tester.pumpAndSettle();
+ expect(harness.hiddenBoards, ['ui-board-1']);
+ expect(
+ harness.stickyBoardController.boardById('ui-board-1')?.isPinned,
+ isFalse,
+ );
+
+ await tester.tap(find.byTooltip('Close Sticky Boards'));
+ await tester.pumpAndSettle();
+ await tester.tap(find.byKey(const Key('settings-button')));
+ await tester.pumpAndSettle();
+ await tester.tap(find.byKey(const Key('always-on-top-setting')));
+ await tester.pumpAndSettle();
+ await tester.tap(find.byKey(const Key('open-at-login-setting')));
+ await tester.pumpAndSettle();
+ await tester.tap(find.byKey(const Key('theme-light')));
+ await tester.pumpAndSettle();
+
+ expect(harness.settingsController.alwaysOnTop, isFalse);
+ expect(harness.settingsController.openAtLogin, isTrue);
+ expect(harness.loginItemRepository.enabledValues, [true]);
+ expect(harness.windowBridge.alwaysOnTopValues.last, isFalse);
+ expect(harness.windowBridge.preferredThemeValues.last, 'light');
+ });
+ });
+}
+
+Future _createTodoWithTags(
+ WidgetTester tester, {
+ required String title,
+ required List tagIds,
+}) async {
+ await tester.tap(find.byKey(const Key('add-todo-button')));
+ await tester.pumpAndSettle();
+ await tester.enterText(find.byKey(const Key('todo-title-field')), title);
+ await tester.pump();
+ if (tagIds.isNotEmpty) {
+ await tester.tap(find.byKey(const Key('todo-editor-tag-button')));
+ await tester.pumpAndSettle();
+ for (final tagId in tagIds) {
+ await tester.tap(find.byKey(Key('tag-assignment-$tagId')));
+ await tester.pumpAndSettle();
+ }
+ await tester.tap(find.byKey(const Key('tag-assignment-close')));
+ await tester.pumpAndSettle();
+ }
+ await tester.tap(find.byKey(const Key('save-todo-details')));
+ await tester.pumpAndSettle();
+ expect(find.text(title).hitTestable(), findsOneWidget);
+ expect(
+ tester
+ .widget(find.byKey(const Key('todo-drawer-slide')))
+ .offset,
+ const Offset(0, 1),
+ );
+}
+
+class _UiTestHarness {
+ _UiTestHarness._({
+ required this.rootDirectory,
+ required this.todoRepository,
+ required this.tagRepository,
+ required this.todoController,
+ required this.settingsController,
+ required this.updateController,
+ required this.stickyBoardController,
+ required this.stickyBoardWindowCoordinator,
+ required this.windowBridge,
+ required this.loginItemRepository,
+ required this.launchedBoards,
+ required this.hiddenBoards,
+ });
+
+ static Future<_UiTestHarness> create({
+ bool seedWelcomeWorkspace = true,
+ }) async {
+ final rootDirectory = await Directory.systemTemp.createTemp(
+ 'floatick-ui-test-',
+ );
+ final todoRepository = LocalTodoRepository(rootDirectory: rootDirectory);
+ final tagRepository = LocalTagRepository(rootDirectory: rootDirectory);
+ var todoSequence = 0;
+ var tagSequence = 0;
+ var boardSequence = 0;
+ final todoController = TodoViewModel(
+ todoRepository: todoRepository,
+ tagRepository: tagRepository,
+ firstRunWorkspaceSeeder: seedWelcomeWorkspace
+ ? FirstRunWorkspaceSeeder(
+ todoRepository: todoRepository,
+ tagRepository: tagRepository,
+ languageCode: 'en',
+ clock: () => DateTime.utc(2026, 7, 28, 8),
+ )
+ : null,
+ clock: () => DateTime.utc(2026, 7, 28, 9),
+ idGenerator: () => 'ui-todo-${++todoSequence}',
+ tagIdGenerator: () => 'ui-tag-${++tagSequence}',
+ );
+ final settingsController = SettingsViewModel(
+ settingsRepository: LocalSettingsRepository(rootDirectory: rootDirectory),
+ loginItemRepository: _UiTestLoginItemRepository(),
+ );
+ final updateController = UpdateViewModel(
+ updateRepository: _UiTestUpdateRepository(),
+ );
+ final stickyBoardController = StickyBoardViewModel(
+ repository: LocalStickyBoardRepository(rootDirectory: rootDirectory),
+ clock: () => DateTime.utc(2026, 7, 28, 10, boardSequence),
+ idGenerator: () => 'ui-board-${++boardSequence}',
+ );
+ final windowBridge = _UiTestWindowBridge();
+ final launchedBoards = [];
+ final hiddenBoards = [];
+ final stickyBoardWindowCoordinator = StickyBoardWindowCoordinator(
+ boardController: stickyBoardController,
+ todoController: todoController,
+ windowBridge: windowBridge,
+ windowLauncher: (boardId) async => launchedBoards.add(boardId),
+ windowHider: (boardId) async => hiddenBoards.add(boardId),
+ );
+ await Future.wait(>[
+ todoController.load(),
+ settingsController.load(),
+ updateController.load(),
+ stickyBoardController.load(),
+ ]);
+ return _UiTestHarness._(
+ rootDirectory: rootDirectory,
+ todoRepository: todoRepository,
+ tagRepository: tagRepository,
+ todoController: todoController,
+ settingsController: settingsController,
+ updateController: updateController,
+ stickyBoardController: stickyBoardController,
+ stickyBoardWindowCoordinator: stickyBoardWindowCoordinator,
+ windowBridge: windowBridge,
+ loginItemRepository:
+ settingsController.loginItemRepository as _UiTestLoginItemRepository,
+ launchedBoards: launchedBoards,
+ hiddenBoards: hiddenBoards,
+ );
+ }
+
+ final Directory rootDirectory;
+ final LocalTodoRepository todoRepository;
+ final LocalTagRepository tagRepository;
+ final TodoViewModel todoController;
+ final SettingsViewModel settingsController;
+ final UpdateViewModel updateController;
+ final StickyBoardViewModel stickyBoardController;
+ final StickyBoardWindowCoordinator stickyBoardWindowCoordinator;
+ final _UiTestWindowBridge windowBridge;
+ final _UiTestLoginItemRepository loginItemRepository;
+ final List launchedBoards;
+ final List hiddenBoards;
+
+ Future pumpApp(WidgetTester tester) async {
+ tester.view.physicalSize = const Size(500, 760);
+ tester.view.devicePixelRatio = 1;
+ await tester.pumpWidget(
+ FloatickApp(
+ controller: todoController,
+ settingsController: settingsController,
+ updateController: updateController,
+ stickyBoardController: stickyBoardController,
+ stickyBoardWindowCoordinator: stickyBoardWindowCoordinator,
+ windowBridge: windowBridge,
+ locale: const Locale('en'),
+ ),
+ );
+ windowBridge.expandRequestHandler?.call(WindowExpansionAnchor.topRight);
+ await tester.pumpAndSettle();
+ await tester.pump(const Duration(milliseconds: 250));
+ await tester.pumpAndSettle();
+ }
+
+ Future dispose(WidgetTester tester) async {
+ await tester.pumpWidget(const SizedBox.shrink());
+ tester.view.resetPhysicalSize();
+ tester.view.resetDevicePixelRatio();
+ todoController.dispose();
+ settingsController.dispose();
+ updateController.dispose();
+ stickyBoardController.dispose();
+ if (await rootDirectory.exists()) {
+ await rootDirectory.delete(recursive: true);
+ }
+ }
+}
+
+class _UiTestWindowBridge implements WindowBridge {
+ ExpandRequestHandler? expandRequestHandler;
+ final List expandedValues = [];
+ final List floatingIconCounts = [];
+ final List preferredThemeValues = [];
+ final List alwaysOnTopValues = [];
+
+ @override
+ void setExpandRequestHandler(ExpandRequestHandler? handler) {
+ expandRequestHandler = handler;
+ }
+
+ @override
+ Future preferredExpansionAnchor() async {
+ return WindowExpansionAnchor.topRight;
+ }
+
+ @override
+ Future setExpanded(bool expanded, {bool animated = true}) async {
+ expandedValues.add(expanded);
+ }
+
+ @override
+ Future setFloatingIconCount(int activeCount) async {
+ floatingIconCounts.add(activeCount);
+ }
+
+ @override
+ Future setPreferredLanguage(String? languageCode) async {}
+
+ @override
+ Future setPreferredTheme(String themePreference) async {
+ preferredThemeValues.add(themePreference);
+ }
+
+ @override
+ Future setAlwaysOnTop(bool alwaysOnTop) async {
+ alwaysOnTopValues.add(alwaysOnTop);
+ }
+
+ @override
+ Future configureBorderlessSecondaryWindow(
+ int viewId, {
+ bool positionAdjacentToMainWindow = false,
+ }) async {}
+
+ @override
+ Future revealBorderlessSecondaryWindow(int viewId) async {}
+}
+
+class _UiTestLoginItemRepository implements LoginItemRepository {
+ LoginItemStatus status = LoginItemStatus.disabled;
+ final List enabledValues = [];
+
+ @override
+ Future loadStatus() async => status;
+
+ @override
+ Future setEnabled(bool enabled) async {
+ enabledValues.add(enabled);
+ status = enabled ? LoginItemStatus.enabled : LoginItemStatus.disabled;
+ return status;
+ }
+}
+
+class _UiTestUpdateRepository implements UpdateRepository {
+ bool automaticallyChecksForUpdates = true;
+
+ @override
+ Future loadSettings() async {
+ return UpdateSettingsSnapshot(
+ automaticallyChecksForUpdates: automaticallyChecksForUpdates,
+ currentVersion: '0.2.0',
+ );
+ }
+
+ @override
+ Future setAutomaticallyChecksForUpdates(bool enabled) async {
+ automaticallyChecksForUpdates = enabled;
+ }
+
+ @override
+ Future checkForUpdates() async {}
+}
diff --git a/lib/app/floatick_app.dart b/lib/app/floatick_app.dart
index 552837e..fbe2b29 100644
--- a/lib/app/floatick_app.dart
+++ b/lib/app/floatick_app.dart
@@ -1,15 +1,16 @@
import 'dart:async';
import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
import '../core/platform/window_bridge.dart';
+import '../core/ui/floatick_surface_metrics.dart';
import '../features/settings/domain/app_settings.dart';
import '../features/settings/presentation/settings_view_model.dart';
import '../features/sticky_boards/presentation/sticky_board_view_model.dart';
import '../features/sticky_boards/presentation/sticky_board_window_coordinator.dart';
import '../features/todos/presentation/todo_panel.dart';
import '../features/todos/presentation/todo_view_model.dart';
-import '../features/todos/presentation/widgets/floating_todo_icon.dart';
import '../features/updates/presentation/update_view_model.dart';
import '../l10n/app_localizations.dart';
import 'theme/floatick_theme.dart';
@@ -94,27 +95,40 @@ class _FloatickShell extends StatefulWidget {
}
class _FloatickShellState extends State<_FloatickShell> {
- static const _motionDuration = Duration(milliseconds: 220);
+ static const _expandedPanelSize = Size(440, 700);
bool _isExpanded = false;
bool _isChangingWindow = false;
+ bool _isPanelPrepared = false;
+ bool _panelTooltipsEnabled = false;
bool _hasSyncedPreferredLanguage = false;
+ bool _hasSyncedPreferredTheme = false;
+ bool _hasSyncedAlwaysOnTop = false;
String? _lastSyncedLanguageCode;
+ AppThemePreference? _lastSyncedThemePreference;
+ bool? _lastSyncedAlwaysOnTop;
+ int? _lastSyncedFloatingIconCount;
WindowExpansionAnchor _expansionAnchor = WindowExpansionAnchor.topRight;
- String? _requestedStickyBoardId;
+ StickyBoardMainWindowRequest? _stickyBoardRequest;
int _stickyBoardRequestSerial = 0;
+ Future? _rendererWarmUpFuture;
+ Future? _panelPreparationFuture;
@override
void initState() {
super.initState();
widget.windowBridge.setExpandRequestHandler(_handleNativeExpandRequest);
+ widget.controller.addListener(_handleTodoStateChanged);
widget.settingsController.addListener(_handleSettingsChanged);
widget.stickyBoardWindowCoordinator.setMainWindowRequestHandler(
_handleStickyBoardWindowRequest,
);
unawaited(_syncPreferredLanguage());
+ unawaited(_syncPreferredTheme());
+ unawaited(_syncAlwaysOnTop());
+ unawaited(_syncFloatingIconCount());
WidgetsBinding.instance.addPostFrameCallback((_) {
- unawaited(widget.stickyBoardWindowCoordinator.restorePinnedBoards());
+ unawaited(_preparePanelAndRestorePinnedBoards());
});
}
@@ -125,11 +139,20 @@ class _FloatickShellState extends State<_FloatickShell> {
oldWidget.windowBridge.setExpandRequestHandler(null);
widget.windowBridge.setExpandRequestHandler(_handleNativeExpandRequest);
_hasSyncedPreferredLanguage = false;
+ _hasSyncedPreferredTheme = false;
+ _hasSyncedAlwaysOnTop = false;
+ _lastSyncedFloatingIconCount = null;
+ }
+ if (oldWidget.controller != widget.controller) {
+ oldWidget.controller.removeListener(_handleTodoStateChanged);
+ widget.controller.addListener(_handleTodoStateChanged);
+ _lastSyncedFloatingIconCount = null;
}
if (oldWidget.settingsController != widget.settingsController) {
oldWidget.settingsController.removeListener(_handleSettingsChanged);
widget.settingsController.addListener(_handleSettingsChanged);
_hasSyncedPreferredLanguage = false;
+ _hasSyncedAlwaysOnTop = false;
}
if (oldWidget.stickyBoardWindowCoordinator !=
widget.stickyBoardWindowCoordinator) {
@@ -141,19 +164,29 @@ class _FloatickShellState extends State<_FloatickShell> {
if (!_hasSyncedPreferredLanguage) {
unawaited(_syncPreferredLanguage());
}
+ if (!_hasSyncedPreferredTheme) {
+ unawaited(_syncPreferredTheme());
+ }
+ if (!_hasSyncedAlwaysOnTop) {
+ unawaited(_syncAlwaysOnTop());
+ }
+ if (_lastSyncedFloatingIconCount == null) {
+ unawaited(_syncFloatingIconCount());
+ }
}
@override
void dispose() {
widget.windowBridge.setExpandRequestHandler(null);
+ widget.controller.removeListener(_handleTodoStateChanged);
widget.settingsController.removeListener(_handleSettingsChanged);
widget.stickyBoardWindowCoordinator.setMainWindowRequestHandler(null);
super.dispose();
}
- void _handleStickyBoardWindowRequest(String boardId) {
+ void _handleStickyBoardWindowRequest(StickyBoardMainWindowRequest request) {
setState(() {
- _requestedStickyBoardId = boardId;
+ _stickyBoardRequest = request;
_stickyBoardRequestSerial += 1;
});
unawaited(_setExpanded(true));
@@ -161,6 +194,46 @@ class _FloatickShellState extends State<_FloatickShell> {
void _handleSettingsChanged() {
unawaited(_syncPreferredLanguage());
+ unawaited(_syncPreferredTheme());
+ unawaited(_syncAlwaysOnTop());
+ }
+
+ void _handleTodoStateChanged() {
+ unawaited(_syncFloatingIconCount());
+ }
+
+ void _startRendererWarmUp() {
+ _rendererWarmUpFuture ??= _warmUpRenderer();
+ }
+
+ Future _warmUpRenderer() async {
+ try {
+ await const _FloatickShaderWarmUp().execute();
+ } on Object catch (error, stackTrace) {
+ debugPrint('Floatick could not warm up the renderer: $error');
+ debugPrintStack(stackTrace: stackTrace);
+ }
+ }
+
+ Future _preparePanelAndRestorePinnedBoards() async {
+ await _ensurePanelPrepared();
+ if (!mounted) {
+ return;
+ }
+ await widget.stickyBoardWindowCoordinator.restorePinnedBoards();
+ }
+
+ Future _ensurePanelPrepared() {
+ return _panelPreparationFuture ??= _preparePanel();
+ }
+
+ Future _preparePanel() async {
+ if (!_isPanelPrepared && mounted) {
+ setState(() => _isPanelPrepared = true);
+ await WidgetsBinding.instance.endOfFrame;
+ }
+ _startRendererWarmUp();
+ await _rendererWarmUpFuture;
}
Future _syncPreferredLanguage() async {
@@ -187,52 +260,150 @@ class _FloatickShellState extends State<_FloatickShell> {
}
}
+ Future _syncAlwaysOnTop() async {
+ final alwaysOnTop = widget.settingsController.alwaysOnTop;
+ if (_hasSyncedAlwaysOnTop && alwaysOnTop == _lastSyncedAlwaysOnTop) {
+ return;
+ }
+
+ _hasSyncedAlwaysOnTop = true;
+ _lastSyncedAlwaysOnTop = alwaysOnTop;
+ try {
+ await widget.windowBridge.setAlwaysOnTop(alwaysOnTop);
+ } on Object catch (error, stackTrace) {
+ if (_lastSyncedAlwaysOnTop == alwaysOnTop) {
+ _hasSyncedAlwaysOnTop = false;
+ }
+ debugPrint('Floatick could not update the window level: $error');
+ debugPrintStack(stackTrace: stackTrace);
+ }
+ }
+
+ Future _syncPreferredTheme() async {
+ final themePreference = widget.settingsController.themePreference;
+ if (_hasSyncedPreferredTheme &&
+ themePreference == _lastSyncedThemePreference) {
+ return;
+ }
+
+ _hasSyncedPreferredTheme = true;
+ _lastSyncedThemePreference = themePreference;
+ try {
+ await widget.windowBridge.setPreferredTheme(themePreference.storageValue);
+ } on Object catch (error, stackTrace) {
+ if (_lastSyncedThemePreference == themePreference) {
+ _hasSyncedPreferredTheme = false;
+ }
+ debugPrint('Floatick could not update the native appearance: $error');
+ debugPrintStack(stackTrace: stackTrace);
+ }
+ }
+
+ Future _syncFloatingIconCount() async {
+ final activeCount = widget.controller.activeCount;
+ if (_lastSyncedFloatingIconCount == activeCount) {
+ return;
+ }
+ _lastSyncedFloatingIconCount = activeCount;
+ try {
+ await widget.windowBridge.setFloatingIconCount(activeCount);
+ } on Object catch (error, stackTrace) {
+ if (_lastSyncedFloatingIconCount == activeCount) {
+ _lastSyncedFloatingIconCount = null;
+ }
+ debugPrint('Floatick could not update the floating icon count: $error');
+ debugPrintStack(stackTrace: stackTrace);
+ }
+ }
+
void _handleNativeExpandRequest(WindowExpansionAnchor expansionAnchor) {
unawaited(_setExpanded(true, requestedAnchor: expansionAnchor));
}
+ void _enablePanelTooltips() {
+ if (!_isExpanded || _panelTooltipsEnabled) {
+ return;
+ }
+ setState(() => _panelTooltipsEnabled = true);
+ }
+
+ KeyEventResult _handlePanelKeyEvent(FocusNode _, KeyEvent event) {
+ if (event is KeyDownEvent) {
+ _enablePanelTooltips();
+ }
+ return KeyEventResult.ignored;
+ }
+
Future _setExpanded(
bool expanded, {
WindowExpansionAnchor? requestedAnchor,
}) async {
- if (_isChangingWindow || _isExpanded == expanded) {
+ if (_isChangingWindow) {
+ return;
+ }
+ if (_isExpanded == expanded) {
+ if (expanded) {
+ unawaited(widget.stickyBoardWindowCoordinator.restorePinnedBoards());
+ try {
+ await widget.windowBridge.setExpanded(true, animated: false);
+ } on Object catch (error, stackTrace) {
+ debugPrint('Floatick could not focus the native window: $error');
+ debugPrintStack(stackTrace: stackTrace);
+ }
+ }
return;
}
final reduceMotion =
MediaQuery.maybeOf(context)?.disableAnimations ?? false;
- final motionDuration = reduceMotion ? Duration.zero : _motionDuration;
- setState(() => _isChangingWindow = true);
+ final previousExpanded = _isExpanded;
+ setState(() {
+ _isChangingWindow = true;
+ _panelTooltipsEnabled = false;
+ });
try {
if (expanded) {
+ await _ensurePanelPrepared();
+ if (!mounted) {
+ return;
+ }
+ unawaited(widget.stickyBoardWindowCoordinator.restorePinnedBoards());
final expansionAnchor =
requestedAnchor ??
await widget.windowBridge.preferredExpansionAnchor();
if (!mounted) {
return;
}
- setState(() => _expansionAnchor = expansionAnchor);
- await WidgetsBinding.instance.endOfFrame;
- await widget.windowBridge.setExpanded(true);
+ setState(() {
+ _expansionAnchor = expansionAnchor;
+ _isExpanded = true;
+ });
await WidgetsBinding.instance.endOfFrame;
+ await widget.windowBridge.setExpanded(true, animated: !reduceMotion);
+ } else {
+ await widget.windowBridge.setExpanded(false, animated: !reduceMotion);
if (!mounted) {
return;
}
- setState(() => _isExpanded = true);
- } else {
setState(() => _isExpanded = false);
- await WidgetsBinding.instance.endOfFrame;
- if (motionDuration > Duration.zero) {
- await Future.delayed(motionDuration);
- }
- await widget.windowBridge.setExpanded(false);
}
} on Object catch (error, stackTrace) {
debugPrint('Floatick could not change the native window: $error');
debugPrintStack(stackTrace: stackTrace);
if (mounted) {
- setState(() => _isExpanded = !expanded);
+ try {
+ await widget.windowBridge.setExpanded(
+ previousExpanded,
+ animated: false,
+ );
+ } on Object catch (restoreError, restoreStackTrace) {
+ debugPrint(
+ 'Floatick could not restore the native window state: $restoreError',
+ );
+ debugPrintStack(stackTrace: restoreStackTrace);
+ }
+ setState(() => _isExpanded = previousExpanded);
}
} finally {
if (mounted) {
@@ -243,69 +414,121 @@ class _FloatickShellState extends State<_FloatickShell> {
@override
Widget build(BuildContext context) {
- final reduceMotion = MediaQuery.disableAnimationsOf(context);
- final transitionDuration = reduceMotion ? Duration.zero : _motionDuration;
- final expansionAlignment = switch (_expansionAnchor) {
- WindowExpansionAnchor.topLeft => Alignment.topLeft,
- WindowExpansionAnchor.topRight => Alignment.topRight,
- WindowExpansionAnchor.bottomLeft => Alignment.bottomLeft,
- WindowExpansionAnchor.bottomRight => Alignment.bottomRight,
- };
-
return Scaffold(
backgroundColor: Colors.transparent,
- body: SizedBox.expand(
- child: AnimatedSwitcher(
- duration: transitionDuration,
- reverseDuration: transitionDuration,
- switchInCurve: Curves.easeOutCubic,
- switchOutCurve: Curves.easeInCubic,
- transitionBuilder: (child, animation) {
- final curvedAnimation = CurvedAnimation(
- parent: animation,
- curve: Curves.easeOutCubic,
- reverseCurve: Curves.easeInCubic,
- );
- final isPanel = child.key == const ValueKey('todo-panel');
- final scaleAnimation = Tween(
- begin: isPanel ? 0.80 : 0.92,
- end: 1,
- ).animate(curvedAnimation);
- return FadeTransition(
- opacity: curvedAnimation,
- child: ScaleTransition(
- scale: scaleAnimation,
- alignment: expansionAlignment,
- child: child,
- ),
- );
- },
- child: _isExpanded
- ? TodoPanel(
- key: const ValueKey('todo-panel'),
- controller: widget.controller,
- settingsController: widget.settingsController,
- updateController: widget.updateController,
- stickyBoardController: widget.stickyBoardController,
- stickyBoardWindowCoordinator:
- widget.stickyBoardWindowCoordinator,
- windowBridge: widget.windowBridge,
- expansionAnchor: _expansionAnchor,
- requestedStickyBoardId: _requestedStickyBoardId,
- stickyBoardRequestSerial: _stickyBoardRequestSerial,
- onCollapse: () => unawaited(_setExpanded(false)),
- )
- : Align(
- key: const ValueKey('collapsed-icon-alignment'),
- alignment: expansionAlignment,
- child: FloatingTodoIcon(
- key: const ValueKey('floating-todo-icon'),
- activeCount: widget.controller.activeCount,
- onOpen: () => unawaited(_setExpanded(true)),
+ body: SizedBox.fromSize(
+ size: _expandedPanelSize,
+ child: _isPanelPrepared
+ ? IgnorePointer(
+ ignoring: !_isExpanded || _isChangingWindow,
+ child: TickerMode(
+ enabled: _isExpanded,
+ child: RepaintBoundary(
+ key: const ValueKey('todo-panel'),
+ child: Focus(
+ canRequestFocus: false,
+ onKeyEvent: _handlePanelKeyEvent,
+ child: Listener(
+ behavior: HitTestBehavior.translucent,
+ onPointerHover: (event) {
+ if (event.delta.distanceSquared > 0) {
+ _enablePanelTooltips();
+ }
+ },
+ onPointerDown: (_) => _enablePanelTooltips(),
+ child: TooltipVisibility(
+ key: const Key('panel-tooltip-visibility'),
+ visible: _panelTooltipsEnabled,
+ child: TodoPanel(
+ controller: widget.controller,
+ settingsController: widget.settingsController,
+ updateController: widget.updateController,
+ stickyBoardController: widget.stickyBoardController,
+ stickyBoardWindowCoordinator:
+ widget.stickyBoardWindowCoordinator,
+ windowBridge: widget.windowBridge,
+ expansionAnchor: _expansionAnchor,
+ stickyBoardRequest: _stickyBoardRequest,
+ stickyBoardRequestSerial: _stickyBoardRequestSerial,
+ onCollapse: () => unawaited(_setExpanded(false)),
+ ),
+ ),
+ ),
+ ),
),
),
- ),
+ )
+ : const SizedBox.shrink(),
),
);
}
}
+
+class _FloatickShaderWarmUp extends ShaderWarmUp {
+ const _FloatickShaderWarmUp();
+
+ @override
+ Size get size => const Size.square(120);
+
+ @override
+ Future warmUpOnCanvas(Canvas canvas) {
+ final panelBounds = Rect.fromLTWH(
+ FloatickSurfaceMetrics.windowInset,
+ FloatickSurfaceMetrics.windowInset,
+ size.width - (FloatickSurfaceMetrics.windowInset * 2),
+ size.height - (FloatickSurfaceMetrics.windowInset * 2),
+ );
+ final panelShape = RRect.fromRectAndRadius(
+ panelBounds,
+ const Radius.circular(FloatickSurfaceMetrics.panelRadius),
+ );
+ final gradientPaint = Paint()
+ ..shader = const LinearGradient(
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ colors: [Color(0xFF24383C), Color(0xFF172326)],
+ ).createShader(panelBounds);
+
+ canvas.save();
+ canvas.translate(size.width / 2, size.height / 2);
+ canvas.scale(0.95);
+ canvas.translate(-size.width / 2, -size.height / 2);
+ canvas.drawRRect(panelShape, gradientPaint);
+ canvas.restore();
+
+ canvas.drawCircle(
+ const Offset(34, 34),
+ 16,
+ Paint()..color = const Color(0xFF20BFB2),
+ );
+
+ final checkPaint = Paint()
+ ..color = const Color(0xFF2CCCBD)
+ ..style = PaintingStyle.stroke
+ ..strokeWidth = 5
+ ..strokeCap = StrokeCap.round
+ ..strokeJoin = StrokeJoin.round;
+ final checkPath = Path()
+ ..moveTo(22, 34)
+ ..lineTo(31, 43)
+ ..lineTo(48, 25);
+ canvas.drawPath(checkPath, checkPaint);
+
+ final textPainter = TextPainter(
+ text: const TextSpan(
+ text: 'Floatick 0123456789 待办归档',
+ style: TextStyle(
+ color: Colors.white,
+ fontSize: 14,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ textDirection: TextDirection.ltr,
+ maxLines: 1,
+ )..layout(maxWidth: 100);
+ textPainter.paint(canvas, const Offset(10, 78));
+ textPainter.dispose();
+
+ return Future.value();
+ }
+}
diff --git a/lib/app/theme/floatick_theme.dart b/lib/app/theme/floatick_theme.dart
index f3c8565..c2944f3 100644
--- a/lib/app/theme/floatick_theme.dart
+++ b/lib/app/theme/floatick_theme.dart
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
+import '../../core/ui/floatick_hover_motion.dart';
+
abstract final class FloatickColors {
static const teal = Color(0xFF0F8F83);
static const tealBright = Color(0xFF22B8A7);
@@ -8,6 +10,7 @@ abstract final class FloatickColors {
static const mutedInk = Color(0xFF657178);
static const darkSurface = Color(0xFF182125);
static const darkSurfaceElevated = Color(0xFF222D31);
+ static const lightSurface = Color(0xFFF9FBFA);
}
ThemeData buildFloatickTheme(Brightness brightness) {
@@ -19,7 +22,9 @@ ThemeData buildFloatickTheme(Brightness brightness) {
).copyWith(
primary: isDark ? FloatickColors.tealBright : FloatickColors.teal,
secondary: FloatickColors.orange,
- surface: isDark ? FloatickColors.darkSurface : const Color(0xFFF9FBFA),
+ surface: isDark
+ ? FloatickColors.darkSurface
+ : FloatickColors.lightSurface,
onSurface: isDark ? const Color(0xFFF1F5F3) : FloatickColors.ink,
);
@@ -33,6 +38,51 @@ ThemeData buildFloatickTheme(Brightness brightness) {
canvasColor: Colors.transparent,
splashFactory: NoSplash.splashFactory,
visualDensity: VisualDensity.standard,
+ iconButtonTheme: IconButtonThemeData(
+ style: ButtonStyle(
+ animationDuration: FloatickMotion.hoverDuration,
+ foregroundBuilder: FloatickMotion.iconButtonForegroundBuilder,
+ overlayColor: const WidgetStatePropertyAll(Colors.transparent),
+ foregroundColor: WidgetStateProperty.resolveWith((states) {
+ if (states.contains(WidgetState.disabled)) {
+ return colorScheme.onSurface.withValues(alpha: 0.28);
+ }
+ if (states.contains(WidgetState.selected) ||
+ states.contains(WidgetState.pressed)) {
+ return colorScheme.primary;
+ }
+ if (states.contains(WidgetState.hovered) ||
+ states.contains(WidgetState.focused)) {
+ return colorScheme.onSurface.withValues(alpha: 0.92);
+ }
+ return colorScheme.onSurface.withValues(alpha: 0.62);
+ }),
+ ),
+ ),
+ textButtonTheme: TextButtonThemeData(
+ style: ButtonStyle(
+ animationDuration: FloatickMotion.hoverDuration,
+ foregroundBuilder: FloatickMotion.buttonForegroundBuilder,
+ ),
+ ),
+ filledButtonTheme: FilledButtonThemeData(
+ style: ButtonStyle(
+ animationDuration: FloatickMotion.hoverDuration,
+ foregroundBuilder: FloatickMotion.buttonForegroundBuilder,
+ ),
+ ),
+ outlinedButtonTheme: OutlinedButtonThemeData(
+ style: ButtonStyle(
+ animationDuration: FloatickMotion.hoverDuration,
+ foregroundBuilder: FloatickMotion.buttonForegroundBuilder,
+ ),
+ ),
+ elevatedButtonTheme: ElevatedButtonThemeData(
+ style: ButtonStyle(
+ animationDuration: FloatickMotion.hoverDuration,
+ foregroundBuilder: FloatickMotion.buttonForegroundBuilder,
+ ),
+ ),
textSelectionTheme: TextSelectionThemeData(
cursorColor: colorScheme.primary,
selectionColor: colorScheme.primary.withValues(alpha: 0.22),
diff --git a/lib/core/platform/window_bridge.dart b/lib/core/platform/window_bridge.dart
index 87f174e..f27c088 100644
--- a/lib/core/platform/window_bridge.dart
+++ b/lib/core/platform/window_bridge.dart
@@ -22,9 +22,22 @@ abstract interface class WindowBridge {
Future preferredExpansionAnchor();
- Future setExpanded(bool expanded);
+ Future setExpanded(bool expanded, {bool animated = true});
+
+ Future setFloatingIconCount(int activeCount);
Future setPreferredLanguage(String? languageCode);
+
+ Future setPreferredTheme(String themePreference);
+
+ Future setAlwaysOnTop(bool alwaysOnTop);
+
+ Future configureBorderlessSecondaryWindow(
+ int viewId, {
+ bool positionAdjacentToMainWindow = false,
+ });
+
+ Future revealBorderlessSecondaryWindow(int viewId);
}
class MethodChannelWindowBridge implements WindowBridge {
@@ -49,8 +62,16 @@ class MethodChannelWindowBridge implements WindowBridge {
}
@override
- Future setExpanded(bool expanded) {
- return _channel.invokeMethod('setExpanded', expanded);
+ Future setExpanded(bool expanded, {bool animated = true}) {
+ return _channel.invokeMethod('setExpanded', {
+ 'expanded': expanded,
+ 'animated': animated,
+ });
+ }
+
+ @override
+ Future setFloatingIconCount(int activeCount) {
+ return _channel.invokeMethod('setFloatingIconCount', activeCount);
}
@override
@@ -58,6 +79,38 @@ class MethodChannelWindowBridge implements WindowBridge {
return _channel.invokeMethod('setPreferredLanguage', languageCode);
}
+ @override
+ Future setPreferredTheme(String themePreference) {
+ return _channel.invokeMethod('setPreferredTheme', themePreference);
+ }
+
+ @override
+ Future setAlwaysOnTop(bool alwaysOnTop) {
+ return _channel.invokeMethod('setAlwaysOnTop', alwaysOnTop);
+ }
+
+ @override
+ Future configureBorderlessSecondaryWindow(
+ int viewId, {
+ bool positionAdjacentToMainWindow = false,
+ }) {
+ return _channel.invokeMethod(
+ 'configureBorderlessSecondaryWindow',
+ {
+ 'viewId': viewId,
+ 'positionAdjacentToMainWindow': positionAdjacentToMainWindow,
+ },
+ );
+ }
+
+ @override
+ Future revealBorderlessSecondaryWindow(int viewId) {
+ return _channel.invokeMethod(
+ 'revealBorderlessSecondaryWindow',
+ viewId,
+ );
+ }
+
Future _handleNativeMethod(MethodCall call) async {
if (call.method == 'requestExpand') {
_expandRequestHandler?.call(
diff --git a/lib/core/ui/floatick_hover_motion.dart b/lib/core/ui/floatick_hover_motion.dart
new file mode 100644
index 0000000..bcdedf8
--- /dev/null
+++ b/lib/core/ui/floatick_hover_motion.dart
@@ -0,0 +1,183 @@
+import 'package:flutter/material.dart';
+
+abstract final class FloatickMotion {
+ static const hoverDuration = Duration(milliseconds: 120);
+ static const iconHoverScale = 1.05;
+ static const iconPressedScale = 0.96;
+ static const controlHoverScale = 1.015;
+ static const controlPressedScale = 0.985;
+ static const chipHoverScale = 1.025;
+ static const chipPressedScale = 0.98;
+ static const swatchHoverScale = 1.08;
+ static const swatchPressedScale = 0.94;
+ static const emphasisHoverScale = 1.08;
+ static const emphasisPressedScale = 0.94;
+ static const emphasisHoverTurns = -0.012;
+
+ static Widget iconButtonForegroundBuilder(
+ BuildContext context,
+ Set states,
+ Widget? child,
+ ) {
+ return _FloatickMotionTransform(
+ enabled: !states.contains(WidgetState.disabled),
+ hovered: states.contains(WidgetState.hovered),
+ pressed: states.contains(WidgetState.pressed),
+ hoverScale: iconHoverScale,
+ pressedScale: iconPressedScale,
+ child: child ?? const SizedBox.shrink(),
+ );
+ }
+
+ static Widget buttonForegroundBuilder(
+ BuildContext context,
+ Set states,
+ Widget? child,
+ ) {
+ return _FloatickMotionTransform(
+ enabled: !states.contains(WidgetState.disabled),
+ hovered: states.contains(WidgetState.hovered),
+ pressed: states.contains(WidgetState.pressed),
+ hoverScale: controlHoverScale,
+ pressedScale: controlPressedScale,
+ child: child ?? const SizedBox.shrink(),
+ );
+ }
+
+ static Widget passthroughForegroundBuilder(
+ BuildContext context,
+ Set states,
+ Widget? child,
+ ) {
+ return child ?? const SizedBox.shrink();
+ }
+}
+
+class FloatickHoverMotion extends StatefulWidget {
+ const FloatickHoverMotion({
+ required this.child,
+ this.enabled = true,
+ this.hoverScale = FloatickMotion.iconHoverScale,
+ this.pressedScale = FloatickMotion.iconPressedScale,
+ this.hoverTurns = 0,
+ this.cursor = SystemMouseCursors.click,
+ super.key,
+ }) : assert(hoverScale > 0),
+ assert(pressedScale > 0);
+
+ final Widget child;
+ final bool enabled;
+ final double hoverScale;
+ final double pressedScale;
+ final double hoverTurns;
+ final MouseCursor cursor;
+
+ @override
+ State createState() => _FloatickHoverMotionState();
+}
+
+class _FloatickHoverMotionState extends State {
+ bool _hovered = false;
+ bool _pressed = false;
+
+ void _setHovered(bool value) {
+ if (_hovered == value) {
+ return;
+ }
+ setState(() => _hovered = value);
+ }
+
+ void _setPressed(bool value) {
+ if (_pressed == value) {
+ return;
+ }
+ setState(() => _pressed = value);
+ }
+
+ void _clearInteraction() {
+ if (!_hovered && !_pressed) {
+ return;
+ }
+ setState(() {
+ _hovered = false;
+ _pressed = false;
+ });
+ }
+
+ @override
+ void didUpdateWidget(covariant FloatickHoverMotion oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (!widget.enabled && (_hovered || _pressed)) {
+ _hovered = false;
+ _pressed = false;
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return MouseRegion(
+ cursor: widget.enabled ? widget.cursor : SystemMouseCursors.basic,
+ onEnter: widget.enabled ? (_) => _setHovered(true) : null,
+ onExit: widget.enabled ? (_) => _clearInteraction() : null,
+ child: Listener(
+ behavior: HitTestBehavior.translucent,
+ onPointerDown: widget.enabled ? (_) => _setPressed(true) : null,
+ onPointerUp: widget.enabled ? (_) => _setPressed(false) : null,
+ onPointerCancel: widget.enabled ? (_) => _setPressed(false) : null,
+ child: _FloatickMotionTransform(
+ enabled: widget.enabled,
+ hovered: _hovered,
+ pressed: _pressed,
+ hoverScale: widget.hoverScale,
+ pressedScale: widget.pressedScale,
+ hoverTurns: widget.hoverTurns,
+ child: widget.child,
+ ),
+ ),
+ );
+ }
+}
+
+class _FloatickMotionTransform extends StatelessWidget {
+ const _FloatickMotionTransform({
+ required this.enabled,
+ required this.hovered,
+ required this.pressed,
+ required this.hoverScale,
+ required this.pressedScale,
+ required this.child,
+ this.hoverTurns = 0,
+ });
+
+ final bool enabled;
+ final bool hovered;
+ final bool pressed;
+ final double hoverScale;
+ final double pressedScale;
+ final double hoverTurns;
+ final Widget child;
+
+ @override
+ Widget build(BuildContext context) {
+ if (!enabled || MediaQuery.disableAnimationsOf(context)) {
+ return child;
+ }
+
+ final scale = pressed ? pressedScale : (hovered ? hoverScale : 1.0);
+ final scaledChild = AnimatedScale(
+ scale: scale,
+ duration: FloatickMotion.hoverDuration,
+ curve: Curves.easeOutCubic,
+ child: child,
+ );
+ if (hoverTurns == 0) {
+ return scaledChild;
+ }
+ return AnimatedRotation(
+ turns: hovered && !pressed ? hoverTurns : 0,
+ duration: FloatickMotion.hoverDuration,
+ curve: Curves.easeOutCubic,
+ child: scaledChild,
+ );
+ }
+}
diff --git a/lib/core/ui/floatick_modal_bottom_sheet.dart b/lib/core/ui/floatick_modal_bottom_sheet.dart
new file mode 100644
index 0000000..78e65d6
--- /dev/null
+++ b/lib/core/ui/floatick_modal_bottom_sheet.dart
@@ -0,0 +1,88 @@
+import 'package:flutter/material.dart';
+
+import 'floatick_surface_metrics.dart';
+
+const Duration _floatickModalTransitionDuration = Duration(milliseconds: 180);
+
+Future showFloatickModalBottomSheet({
+ required BuildContext context,
+ required WidgetBuilder builder,
+}) {
+ final theme = Theme.of(context);
+ if (theme.platform != TargetPlatform.macOS) {
+ return showModalBottomSheet(
+ context: context,
+ useSafeArea: true,
+ isScrollControlled: true,
+ isDismissible: true,
+ enableDrag: true,
+ showDragHandle: false,
+ backgroundColor: Colors.transparent,
+ barrierColor: _modalScrimColor(theme.brightness),
+ constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width),
+ builder: builder,
+ );
+ }
+
+ final reduceMotion = MediaQuery.disableAnimationsOf(context);
+ return showGeneralDialog(
+ context: context,
+ barrierDismissible: false,
+ barrierColor: Colors.transparent,
+ barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
+ transitionDuration: reduceMotion
+ ? Duration.zero
+ : _floatickModalTransitionDuration,
+ pageBuilder: (routeContext, animation, secondaryAnimation) {
+ final curvedAnimation = CurvedAnimation(
+ parent: animation,
+ curve: Curves.easeOutCubic,
+ reverseCurve: Curves.easeInCubic,
+ );
+ return Material(
+ type: MaterialType.transparency,
+ child: Padding(
+ padding: const EdgeInsets.all(FloatickSurfaceMetrics.windowInset),
+ child: ClipRRect(
+ key: const Key('floatick-modal-surface-boundary'),
+ borderRadius: BorderRadius.circular(
+ FloatickSurfaceMetrics.panelContentRadius,
+ ),
+ child: Stack(
+ fit: StackFit.expand,
+ children: [
+ FadeTransition(
+ opacity: curvedAnimation,
+ child: GestureDetector(
+ key: const Key('floatick-modal-scrim'),
+ behavior: HitTestBehavior.opaque,
+ onTap: () => Navigator.of(routeContext).pop(),
+ child: ColoredBox(
+ color: _modalScrimColor(theme.brightness),
+ ),
+ ),
+ ),
+ Align(
+ alignment: Alignment.bottomCenter,
+ child: SlideTransition(
+ position: Tween(
+ begin: const Offset(0, 1),
+ end: Offset.zero,
+ ).animate(curvedAnimation),
+ child: builder(routeContext),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ },
+ );
+}
+
+Color _modalScrimColor(Brightness brightness) {
+ return Colors.black.withValues(
+ alpha: brightness == Brightness.dark ? 0.38 : 0.22,
+ );
+}
diff --git a/lib/core/ui/floatick_surface_metrics.dart b/lib/core/ui/floatick_surface_metrics.dart
new file mode 100644
index 0000000..4d7d503
--- /dev/null
+++ b/lib/core/ui/floatick_surface_metrics.dart
@@ -0,0 +1,7 @@
+abstract final class FloatickSurfaceMetrics {
+ static const double windowInset = 0;
+ static const double panelRadius = 26;
+ static const double panelContentRadius = 25;
+ static const double bottomSheetTopRadius = 22;
+ static const double bottomSheetContentBottomInset = 16;
+}
diff --git a/lib/features/settings/data/login_item_repository.dart b/lib/features/settings/data/login_item_repository.dart
new file mode 100644
index 0000000..9a893a3
--- /dev/null
+++ b/lib/features/settings/data/login_item_repository.dart
@@ -0,0 +1,55 @@
+import 'package:flutter/services.dart';
+
+import '../domain/login_item_status.dart';
+
+abstract interface class LoginItemRepository {
+ Future loadStatus();
+
+ Future setEnabled(bool enabled);
+}
+
+class MethodChannelLoginItemRepository implements LoginItemRepository {
+ static const _channel = MethodChannel('floatick/login_item');
+
+ @override
+ Future loadStatus() {
+ return _invokeStatus(
+ method: 'loadStatus',
+ failureKind: LoginItemFailureKind.load,
+ );
+ }
+
+ @override
+ Future setEnabled(bool enabled) {
+ return _invokeStatus(
+ method: 'setEnabled',
+ arguments: enabled,
+ failureKind: LoginItemFailureKind.update,
+ );
+ }
+
+ Future _invokeStatus({
+ required String method,
+ required LoginItemFailureKind failureKind,
+ Object? arguments,
+ }) async {
+ try {
+ final value = await _channel.invokeMethod(method, arguments);
+ if (value == null) {
+ throw const FormatException(
+ 'The native login item service returned no status.',
+ );
+ }
+ return LoginItemStatus.fromPlatformValue(value);
+ } on FormatException catch (error) {
+ throw LoginItemFailure(
+ kind: LoginItemFailureKind.invalidResponse,
+ cause: error,
+ );
+ } on PlatformException catch (error) {
+ throw LoginItemFailure(kind: failureKind, cause: error);
+ } on MissingPluginException catch (error) {
+ throw LoginItemFailure(kind: failureKind, cause: error);
+ }
+ }
+}
diff --git a/lib/features/settings/domain/app_settings.dart b/lib/features/settings/domain/app_settings.dart
index 016c0ef..91c7e58 100644
--- a/lib/features/settings/domain/app_settings.dart
+++ b/lib/features/settings/domain/app_settings.dart
@@ -40,18 +40,22 @@ class AppSettings {
const AppSettings({
this.themePreference = AppThemePreference.system,
this.languagePreference = AppLanguagePreference.system,
+ this.alwaysOnTop = true,
});
final AppThemePreference themePreference;
final AppLanguagePreference languagePreference;
+ final bool alwaysOnTop;
AppSettings copyWith({
AppThemePreference? themePreference,
AppLanguagePreference? languagePreference,
+ bool? alwaysOnTop,
}) {
return AppSettings(
themePreference: themePreference ?? this.themePreference,
languagePreference: languagePreference ?? this.languagePreference,
+ alwaysOnTop: alwaysOnTop ?? this.alwaysOnTop,
);
}
@@ -66,6 +70,11 @@ class AppSettings {
throw const FormatException('Settings language must be a string.');
}
+ final rawAlwaysOnTop = json['alwaysOnTop'];
+ if (rawAlwaysOnTop != null && rawAlwaysOnTop is! bool) {
+ throw const FormatException('Settings alwaysOnTop must be a Boolean.');
+ }
+
return AppSettings(
themePreference: rawTheme == null
? AppThemePreference.system
@@ -73,14 +82,16 @@ class AppSettings {
languagePreference: rawLanguage == null
? AppLanguagePreference.system
: AppLanguagePreference.fromStorageValue(rawLanguage),
+ alwaysOnTop: rawAlwaysOnTop ?? true,
);
}
Map toJson() {
return {
- 'version': 2,
+ 'version': 3,
'theme': themePreference.storageValue,
'language': languagePreference.storageValue,
+ 'alwaysOnTop': alwaysOnTop,
};
}
@@ -88,9 +99,11 @@ class AppSettings {
bool operator ==(Object other) {
return other is AppSettings &&
themePreference == other.themePreference &&
- languagePreference == other.languagePreference;
+ languagePreference == other.languagePreference &&
+ alwaysOnTop == other.alwaysOnTop;
}
@override
- int get hashCode => Object.hash(themePreference, languagePreference);
+ int get hashCode =>
+ Object.hash(themePreference, languagePreference, alwaysOnTop);
}
diff --git a/lib/features/settings/domain/login_item_status.dart b/lib/features/settings/domain/login_item_status.dart
new file mode 100644
index 0000000..36d6b33
--- /dev/null
+++ b/lib/features/settings/domain/login_item_status.dart
@@ -0,0 +1,34 @@
+enum LoginItemStatus {
+ disabled('disabled'),
+ enabled('enabled'),
+ requiresApproval('requiresApproval'),
+ unsupported('unsupported');
+
+ const LoginItemStatus(this.platformValue);
+
+ final String platformValue;
+
+ static LoginItemStatus fromPlatformValue(String value) {
+ return values.firstWhere(
+ (status) => status.platformValue == value,
+ orElse: () {
+ throw FormatException('Unknown login item status: $value');
+ },
+ );
+ }
+}
+
+enum LoginItemFailureKind {
+ load,
+ update,
+ requiresApproval,
+ unsupported,
+ invalidResponse,
+}
+
+class LoginItemFailure implements Exception {
+ const LoginItemFailure({required this.kind, this.cause});
+
+ final LoginItemFailureKind kind;
+ final Object? cause;
+}
diff --git a/lib/features/settings/presentation/settings_drawer.dart b/lib/features/settings/presentation/settings_drawer.dart
index 6564e95..089b455 100644
--- a/lib/features/settings/presentation/settings_drawer.dart
+++ b/lib/features/settings/presentation/settings_drawer.dart
@@ -6,7 +6,9 @@ import '../../../l10n/l10n.dart';
import '../../../l10n/storage_failure_localizations.dart';
import '../../updates/presentation/update_view_model.dart';
import '../domain/app_settings.dart';
+import '../domain/login_item_status.dart';
import 'settings_view_model.dart';
+import 'widgets/compact_settings_toggle.dart';
import 'widgets/update_settings_section.dart';
class SettingsDrawer extends StatelessWidget {
@@ -80,6 +82,34 @@ class SettingsDrawer extends StatelessWidget {
const SizedBox(height: 12),
_LanguagePreferencePicker(viewModel: viewModel),
const SizedBox(height: 28),
+ Text(
+ context.l10n.windowSectionTitle,
+ style: theme.textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ const SizedBox(height: 6),
+ _AlwaysOnTopSetting(viewModel: viewModel),
+ const SizedBox(height: 24),
+ Text(
+ context.l10n.startupSectionTitle,
+ style: theme.textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ const SizedBox(height: 6),
+ _OpenAtLoginSetting(viewModel: viewModel),
+ if (viewModel.loginItemError != null) ...[
+ const SizedBox(height: 8),
+ _SettingsError(
+ message: _messageForLoginItemFailure(
+ context,
+ viewModel.loginItemError!,
+ ),
+ onDismiss: viewModel.dismissLoginItemError,
+ ),
+ ],
+ const SizedBox(height: 28),
UpdateSettingsSection(viewModel: updateViewModel),
const SizedBox(height: 28),
Text(
@@ -124,6 +154,137 @@ class SettingsDrawer extends StatelessWidget {
}
}
+class _AlwaysOnTopSetting extends StatelessWidget {
+ const _AlwaysOnTopSetting({required this.viewModel});
+
+ final SettingsViewModel viewModel;
+
+ @override
+ Widget build(BuildContext context) {
+ final enabled = !viewModel.isSaving;
+ return _SettingsToggleRow(
+ settingKey: const Key('always-on-top-setting'),
+ toggleKey: const Key('always-on-top-toggle'),
+ label: context.l10n.alwaysOnTopLabel,
+ value: viewModel.alwaysOnTop,
+ enabled: enabled,
+ onTap: enabled
+ ? () {
+ unawaited(viewModel.setAlwaysOnTop(!viewModel.alwaysOnTop));
+ }
+ : null,
+ );
+ }
+}
+
+class _OpenAtLoginSetting extends StatelessWidget {
+ const _OpenAtLoginSetting({required this.viewModel});
+
+ final SettingsViewModel viewModel;
+
+ @override
+ Widget build(BuildContext context) {
+ final enabled = viewModel.canChangeOpenAtLogin;
+ return _SettingsToggleRow(
+ settingKey: const Key('open-at-login-setting'),
+ toggleKey: const Key('open-at-login-toggle'),
+ label: context.l10n.openAtLoginLabel,
+ value: viewModel.openAtLogin,
+ enabled: enabled,
+ onTap: enabled
+ ? () {
+ unawaited(viewModel.setOpenAtLogin(!viewModel.openAtLogin));
+ }
+ : null,
+ );
+ }
+}
+
+class _SettingsToggleRow extends StatelessWidget {
+ const _SettingsToggleRow({
+ required this.settingKey,
+ required this.toggleKey,
+ required this.label,
+ required this.value,
+ required this.enabled,
+ required this.onTap,
+ });
+
+ final Key settingKey;
+ final Key toggleKey;
+ final String label;
+ final bool value;
+ final bool enabled;
+ final VoidCallback? onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ return Semantics(
+ label: label,
+ toggled: value,
+ enabled: enabled,
+ child: ExcludeSemantics(
+ child: Material(
+ color: Colors.transparent,
+ child: InkWell(
+ key: settingKey,
+ borderRadius: BorderRadius.circular(8),
+ hoverColor: theme.colorScheme.primary.withValues(alpha: 0.06),
+ highlightColor: theme.colorScheme.primary.withValues(alpha: 0.10),
+ onTap: onTap,
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(minHeight: 34),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 4),
+ child: Row(
+ children: [
+ Expanded(
+ child: Text(
+ label,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: theme.textTheme.bodyMedium?.copyWith(
+ color: enabled
+ ? null
+ : theme.colorScheme.onSurface.withValues(
+ alpha: 0.38,
+ ),
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ ),
+ const SizedBox(width: 12),
+ CompactSettingsToggle(
+ key: toggleKey,
+ value: value,
+ enabled: enabled,
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+String _messageForLoginItemFailure(
+ BuildContext context,
+ LoginItemFailure failure,
+) {
+ return switch (failure.kind) {
+ LoginItemFailureKind.load ||
+ LoginItemFailureKind.invalidResponse => context.l10n.openAtLoginLoadError,
+ LoginItemFailureKind.update => context.l10n.openAtLoginUpdateError,
+ LoginItemFailureKind.requiresApproval =>
+ context.l10n.openAtLoginApprovalRequired,
+ LoginItemFailureKind.unsupported => context.l10n.openAtLoginUnsupported,
+ };
+}
+
class _SettingsHeader extends StatelessWidget {
const _SettingsHeader({required this.onClose, required this.closeFocusNode});
diff --git a/lib/features/settings/presentation/settings_view_model.dart b/lib/features/settings/presentation/settings_view_model.dart
index 2fa857b..f50c1fe 100644
--- a/lib/features/settings/presentation/settings_view_model.dart
+++ b/lib/features/settings/presentation/settings_view_model.dart
@@ -1,41 +1,76 @@
import 'package:flutter/foundation.dart';
import '../../../core/storage/storage_failure.dart';
+import '../data/login_item_repository.dart';
import '../data/settings_repository.dart';
import '../domain/app_settings.dart';
+import '../domain/login_item_status.dart';
class SettingsViewModel extends ChangeNotifier {
- SettingsViewModel({required SettingsRepository settingsRepository})
- : _repository = settingsRepository;
+ SettingsViewModel({
+ required SettingsRepository settingsRepository,
+ required this.loginItemRepository,
+ }) : _repository = settingsRepository;
final SettingsRepository _repository;
+ final LoginItemRepository loginItemRepository;
AppSettings _settings = const AppSettings();
+ LoginItemStatus _loginItemStatus = LoginItemStatus.disabled;
StorageFailure? _error;
+ LoginItemFailure? _loginItemError;
bool _isLoading = false;
bool _isSaving = false;
+ bool _isUpdatingLoginItem = false;
AppSettings get settings => _settings;
AppThemePreference get themePreference => _settings.themePreference;
AppLanguagePreference get languagePreference => _settings.languagePreference;
+ bool get alwaysOnTop => _settings.alwaysOnTop;
+ LoginItemStatus get loginItemStatus => _loginItemStatus;
+ bool get openAtLogin => _loginItemStatus == LoginItemStatus.enabled;
+ bool get canChangeOpenAtLogin =>
+ !_isLoading &&
+ !_isUpdatingLoginItem &&
+ _loginItemStatus != LoginItemStatus.unsupported;
StorageFailure? get error => _error;
+ LoginItemFailure? get loginItemError => _loginItemError;
bool get isLoading => _isLoading;
bool get isSaving => _isSaving;
+ bool get isUpdatingLoginItem => _isUpdatingLoginItem;
String get storagePath => _repository.storagePath;
Future load() async {
_isLoading = true;
_error = null;
+ _loginItemError = null;
notifyListeners();
+ await Future.wait(>[
+ _loadStoredSettings(),
+ _loadLoginItemStatus(),
+ ]);
+
+ _isLoading = false;
+ notifyListeners();
+ }
+
+ Future _loadStoredSettings() async {
try {
_settings = await _repository.load();
} on StorageFailure catch (error) {
_settings = const AppSettings();
_error = error;
- } finally {
- _isLoading = false;
- notifyListeners();
+ }
+ }
+
+ Future _loadLoginItemStatus() async {
+ try {
+ _loginItemStatus = await loginItemRepository.loadStatus();
+ _loginItemError = _issueForStatus(_loginItemStatus);
+ } on LoginItemFailure catch (error) {
+ _loginItemStatus = LoginItemStatus.disabled;
+ _loginItemError = error;
}
}
@@ -55,6 +90,51 @@ class SettingsViewModel extends ChangeNotifier {
await _save(_settings.copyWith(languagePreference: preference));
}
+ Future setAlwaysOnTop(bool alwaysOnTop) async {
+ if (_isSaving || alwaysOnTop == _settings.alwaysOnTop) {
+ return;
+ }
+
+ await _save(_settings.copyWith(alwaysOnTop: alwaysOnTop));
+ }
+
+ Future setOpenAtLogin(bool enabled) async {
+ if (!canChangeOpenAtLogin || enabled == openAtLogin) {
+ return;
+ }
+
+ final previousStatus = _loginItemStatus;
+ _loginItemStatus = enabled
+ ? LoginItemStatus.enabled
+ : LoginItemStatus.disabled;
+ _loginItemError = null;
+ _isUpdatingLoginItem = true;
+ notifyListeners();
+
+ try {
+ _loginItemStatus = await loginItemRepository.setEnabled(enabled);
+ _loginItemError = _issueForStatus(_loginItemStatus);
+ } on LoginItemFailure catch (error) {
+ _loginItemStatus = previousStatus;
+ _loginItemError = error;
+ } finally {
+ _isUpdatingLoginItem = false;
+ notifyListeners();
+ }
+ }
+
+ LoginItemFailure? _issueForStatus(LoginItemStatus status) {
+ return switch (status) {
+ LoginItemStatus.requiresApproval => const LoginItemFailure(
+ kind: LoginItemFailureKind.requiresApproval,
+ ),
+ LoginItemStatus.unsupported => const LoginItemFailure(
+ kind: LoginItemFailureKind.unsupported,
+ ),
+ LoginItemStatus.disabled || LoginItemStatus.enabled => null,
+ };
+ }
+
Future _save(AppSettings nextSettings) async {
final previousSettings = _settings;
_settings = nextSettings;
@@ -80,4 +160,12 @@ class SettingsViewModel extends ChangeNotifier {
_error = null;
notifyListeners();
}
+
+ void dismissLoginItemError() {
+ if (_loginItemError == null) {
+ return;
+ }
+ _loginItemError = null;
+ notifyListeners();
+ }
}
diff --git a/lib/features/settings/presentation/widgets/compact_settings_toggle.dart b/lib/features/settings/presentation/widgets/compact_settings_toggle.dart
new file mode 100644
index 0000000..956b078
--- /dev/null
+++ b/lib/features/settings/presentation/widgets/compact_settings_toggle.dart
@@ -0,0 +1,57 @@
+import 'package:flutter/material.dart';
+
+const compactSettingsToggleSize = Size(32, 18);
+const _compactSettingsToggleThumbSize = 14.0;
+const _compactSettingsToggleDuration = Duration(milliseconds: 140);
+
+class CompactSettingsToggle extends StatelessWidget {
+ const CompactSettingsToggle({
+ required this.value,
+ required this.enabled,
+ super.key,
+ });
+
+ final bool value;
+ final bool enabled;
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ final activeTrack = colorScheme.primary;
+ final inactiveTrack = colorScheme.onSurface.withValues(alpha: 0.18);
+
+ return Opacity(
+ opacity: enabled ? 1 : 0.5,
+ child: SizedBox.fromSize(
+ size: compactSettingsToggleSize,
+ child: AnimatedContainer(
+ duration: _compactSettingsToggleDuration,
+ curve: Curves.easeOutCubic,
+ padding: const EdgeInsets.all(2),
+ decoration: BoxDecoration(
+ color: value ? activeTrack : inactiveTrack,
+ borderRadius: BorderRadius.circular(
+ compactSettingsToggleSize.height / 2,
+ ),
+ ),
+ child: AnimatedAlign(
+ duration: _compactSettingsToggleDuration,
+ curve: Curves.easeOutCubic,
+ alignment: value ? Alignment.centerRight : Alignment.centerLeft,
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ color: value
+ ? colorScheme.onPrimary
+ : colorScheme.surfaceContainerHighest,
+ shape: BoxShape.circle,
+ ),
+ child: const SizedBox.square(
+ dimension: _compactSettingsToggleThumbSize,
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/features/settings/presentation/widgets/update_settings_section.dart b/lib/features/settings/presentation/widgets/update_settings_section.dart
index ad48ef6..d514a01 100644
--- a/lib/features/settings/presentation/widgets/update_settings_section.dart
+++ b/lib/features/settings/presentation/widgets/update_settings_section.dart
@@ -4,12 +4,10 @@ import 'package:flutter/material.dart';
import '../../../../l10n/l10n.dart';
import '../../../updates/presentation/update_view_model.dart';
+import 'compact_settings_toggle.dart';
const _updateRowHeight = 34.0;
const _updateRowRadius = 8.0;
-const _compactToggleSize = Size(32, 18);
-const _compactToggleThumbSize = 14.0;
-const _interactionDuration = Duration(milliseconds: 140);
class UpdateSettingsSection extends StatelessWidget {
const UpdateSettingsSection({required this.viewModel, super.key});
@@ -74,7 +72,7 @@ class UpdateSettingsSection extends StatelessWidget {
),
);
},
- trailing: _CompactToggle(
+ trailing: CompactSettingsToggle(
key: const Key('automatic-update-toggle'),
value: viewModel.automaticallyChecksForUpdates,
enabled: !viewModel.isLoading && !viewModel.isSaving,
@@ -185,50 +183,6 @@ class _UpdateSettingRow extends StatelessWidget {
}
}
-class _CompactToggle extends StatelessWidget {
- const _CompactToggle({required this.value, required this.enabled, super.key});
-
- final bool value;
- final bool enabled;
-
- @override
- Widget build(BuildContext context) {
- final colorScheme = Theme.of(context).colorScheme;
- final activeTrack = colorScheme.primary;
- final inactiveTrack = colorScheme.onSurface.withValues(alpha: 0.18);
-
- return Opacity(
- opacity: enabled ? 1 : 0.5,
- child: SizedBox.fromSize(
- size: _compactToggleSize,
- child: AnimatedContainer(
- duration: _interactionDuration,
- curve: Curves.easeOutCubic,
- padding: const EdgeInsets.all(2),
- decoration: BoxDecoration(
- color: value ? activeTrack : inactiveTrack,
- borderRadius: BorderRadius.circular(_compactToggleSize.height / 2),
- ),
- child: AnimatedAlign(
- duration: _interactionDuration,
- curve: Curves.easeOutCubic,
- alignment: value ? Alignment.centerRight : Alignment.centerLeft,
- child: DecoratedBox(
- decoration: BoxDecoration(
- color: value
- ? colorScheme.onPrimary
- : colorScheme.surfaceContainerHighest,
- shape: BoxShape.circle,
- ),
- child: const SizedBox.square(dimension: _compactToggleThumbSize),
- ),
- ),
- ),
- ),
- );
- }
-}
-
class _UpdateStatus extends StatelessWidget {
const _UpdateStatus({
required this.message,
diff --git a/lib/features/sticky_boards/presentation/pinned_sticky_board_window.dart b/lib/features/sticky_boards/presentation/pinned_sticky_board_window.dart
index 359f1b2..5b0d297 100644
--- a/lib/features/sticky_boards/presentation/pinned_sticky_board_window.dart
+++ b/lib/features/sticky_boards/presentation/pinned_sticky_board_window.dart
@@ -3,17 +3,18 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:multiview_desktop/multiview_desktop.dart';
+import '../../../app/theme/floatick_theme.dart';
+import '../../../core/ui/floatick_hover_motion.dart';
import '../../../l10n/l10n.dart';
import '../../todos/domain/todo_item.dart';
-import '../../todos/presentation/tag_filter_drawer.dart';
-import '../../todos/presentation/todo_editor_drawer.dart';
import '../../todos/presentation/todo_view_model.dart';
-import '../../todos/presentation/widgets/todo_list_row.dart';
import '../domain/sticky_board.dart';
+import 'sticky_board_frame_save_scheduler.dart';
+import 'sticky_board_palette.dart';
import 'sticky_board_view_model.dart';
import 'sticky_board_window_coordinator.dart';
-
-enum _PinnedDrawerMode { none, create, details, edit, tagAssignment }
+import 'widgets/sticky_board_read_only_todo_row.dart';
+import 'widgets/sticky_board_todo_details.dart';
class PinnedStickyBoardWindow extends StatefulWidget {
const PinnedStickyBoardWindow({
@@ -38,15 +39,10 @@ class PinnedStickyBoardWindow extends StatefulWidget {
class _PinnedStickyBoardWindowState extends State
with WindowListener {
- final _todoDrawerCloseFocusNode = FocusNode();
- final _tagDrawerCloseFocusNode = FocusNode();
-
- _PinnedDrawerMode _drawerMode = _PinnedDrawerMode.none;
- _PinnedDrawerMode _todoDrawerMode = _PinnedDrawerMode.create;
- String? _selectedTodoId;
- Set _todoEditorTagIds = {};
- int _editorSession = 0;
- bool _isClosing = false;
+ final StickyBoardFrameSaveScheduler _frameSaveScheduler =
+ StickyBoardFrameSaveScheduler();
+ bool _isUnpinning = false;
+ String? _detailsTodoId;
@override
void initState() {
@@ -67,107 +63,106 @@ class _PinnedStickyBoardWindowState extends State
@override
void dispose() {
+ _frameSaveScheduler.cancel();
widget.boardController.removeListener(_handleModelChanged);
widget.todoController.removeListener(_handleModelChanged);
- _todoDrawerCloseFocusNode.dispose();
- _tagDrawerCloseFocusNode.dispose();
- widget.coordinator.forgetWindow(widget.boardId);
+ widget.coordinator.forgetWindow(
+ boardId: widget.boardId,
+ viewId: widget.viewId,
+ );
super.dispose();
}
void _handleModelChanged() {
- if (mounted) {
- setState(() {});
+ if (!mounted) {
+ return;
+ }
+ final detailsTodoId = _detailsTodoId;
+ if (detailsTodoId != null) {
+ final item = widget.todoController.itemById(detailsTodoId);
+ final belongsToBoard = widget.boardController
+ .todoIdsForBoard(widget.boardId)
+ .contains(detailsTodoId);
+ if (item == null || item.isArchived || !belongsToBoard) {
+ _detailsTodoId = null;
+ }
}
+ setState(() {});
}
@override
void onWindowClose() {
- if (!_isClosing) {
- unawaited(widget.coordinator.unpin(widget.boardId));
+ if (!_isUnpinning) {
+ _frameSaveScheduler.cancel();
+ unawaited(_persistBoundsAndUnpin());
}
}
@override
void onWindowMoved() {
- unawaited(_persistBounds());
+ _scheduleBoundsSave();
}
@override
void onWindowResized() {
- unawaited(_persistBounds());
+ _scheduleBoundsSave();
}
- Future _persistBounds() async {
- if (!mounted || _isClosing) {
+ void _scheduleBoundsSave() {
+ _frameSaveScheduler.schedule(() => unawaited(_persistBounds()));
+ }
+
+ Future _persistBounds({bool allowClosing = false}) async {
+ if (!mounted || (_isUnpinning && !allowClosing)) {
return;
}
- final bounds = await MultiViewDesktop.of(context).getBounds();
- await widget.coordinator.saveWindowFrame(
- boardId: widget.boardId,
- bounds: bounds,
- );
+ try {
+ final bounds = await MultiViewDesktop.of(context).getBounds();
+ await widget.coordinator.saveWindowFrame(
+ boardId: widget.boardId,
+ bounds: bounds,
+ );
+ } on Object catch (error, stackTrace) {
+ debugPrint(
+ 'Floatick could not save sticky board ${widget.boardId} bounds: '
+ '$error',
+ );
+ debugPrintStack(stackTrace: stackTrace);
+ }
}
Future _unpin() async {
- if (_isClosing) {
+ if (_isUnpinning) {
return;
}
- _isClosing = true;
- await widget.coordinator.unpin(widget.boardId);
- }
-
- void _openCreate() {
- setState(() {
- _editorSession += 1;
- _selectedTodoId = null;
- _todoEditorTagIds = {};
- _todoDrawerMode = _PinnedDrawerMode.create;
- _drawerMode = _PinnedDrawerMode.create;
- });
- }
-
- void _openDetails(String todoId) {
- setState(() {
- _selectedTodoId = todoId;
- _todoEditorTagIds = widget.todoController.tagIdsForTodo(todoId).toSet();
- _todoDrawerMode = _PinnedDrawerMode.details;
- _drawerMode = _PinnedDrawerMode.details;
- });
- }
-
- void _openEdit(String todoId) {
- setState(() {
- _selectedTodoId = todoId;
- _todoEditorTagIds = widget.todoController.tagIdsForTodo(todoId).toSet();
- _todoDrawerMode = _PinnedDrawerMode.edit;
- _drawerMode = _PinnedDrawerMode.edit;
- });
+ _frameSaveScheduler.cancel();
+ _isUnpinning = true;
+ try {
+ await widget.coordinator.unpin(widget.boardId);
+ } finally {
+ _isUnpinning = false;
+ }
}
- void _openTagAssignment() {
- if (_drawerMode == _PinnedDrawerMode.create ||
- _drawerMode == _PinnedDrawerMode.edit) {
- setState(() => _drawerMode = _PinnedDrawerMode.tagAssignment);
+ Future _persistBoundsAndUnpin() async {
+ if (_isUnpinning) {
+ return;
+ }
+ _isUnpinning = true;
+ try {
+ await _persistBounds(allowClosing: true);
+ await widget.coordinator.unpin(widget.boardId);
+ } finally {
+ _isUnpinning = false;
}
}
- void _closeDrawer() {
- setState(() {
- if (_drawerMode == _PinnedDrawerMode.tagAssignment) {
- _drawerMode = _todoDrawerMode;
- } else {
- _drawerMode = _PinnedDrawerMode.none;
- }
- });
+ void _showTodoDetails(String todoId) {
+ setState(() => _detailsTodoId = todoId);
}
- void _toggleEditorTag(String tagId) {
- setState(() {
- if (!_todoEditorTagIds.add(tagId)) {
- _todoEditorTagIds.remove(tagId);
- }
- });
+ void _closeTodoDetails() {
+ setState(() => _detailsTodoId = null);
}
@override
@@ -181,175 +176,70 @@ class _PinnedStickyBoardWindowState extends State
});
return const SizedBox.shrink();
}
- final isDark = Theme.of(context).brightness == Brightness.dark;
- final isTodoDrawerOpen =
- _drawerMode == _PinnedDrawerMode.create ||
- _drawerMode == _PinnedDrawerMode.details ||
- _drawerMode == _PinnedDrawerMode.edit;
- final isTagAssignmentOpen = _drawerMode == _PinnedDrawerMode.tagAssignment;
- final selectedTodo = _selectedTodoId == null
+ final theme = Theme.of(context);
+ final isDark = theme.brightness == Brightness.dark;
+ final boardColor = StickyBoardPalette.color(board.colorValue);
+ final surfaceColor = StickyBoardPalette.surfaceColor(
+ value: board.colorValue,
+ baseColor: isDark
+ ? FloatickColors.darkSurface
+ : FloatickColors.lightSurface,
+ brightness: theme.brightness,
+ );
+
+ final detailsItem = _detailsTodoId == null
? null
- : widget.todoController.itemById(_selectedTodoId!);
- final editorMode = switch (_todoDrawerMode) {
- _PinnedDrawerMode.details => TodoEditorDrawerMode.details,
- _PinnedDrawerMode.edit => TodoEditorDrawerMode.edit,
- _ => TodoEditorDrawerMode.create,
- };
+ : widget.todoController.itemById(_detailsTodoId!);
+ const borderRadius = BorderRadius.all(Radius.circular(22));
return Material(
type: MaterialType.transparency,
- child: Padding(
- padding: const EdgeInsets.all(8),
+ child: ClipRRect(
+ borderRadius: borderRadius,
+ clipBehavior: Clip.antiAlias,
child: DecoratedBox(
decoration: BoxDecoration(
- color: isDark ? const Color(0xF7182226) : const Color(0xFAFAFCFB),
- borderRadius: BorderRadius.circular(22),
+ color: surfaceColor,
+ borderRadius: borderRadius,
border: Border.all(
- color: isDark
- ? Colors.white.withValues(alpha: 0.12)
- : Colors.white.withValues(alpha: 0.90),
+ color: boardColor.withValues(alpha: isDark ? 0.44 : 0.34),
),
- boxShadow: [
- BoxShadow(
- color: Colors.black.withValues(alpha: isDark ? 0.24 : 0.12),
- blurRadius: 24,
- offset: const Offset(0, 8),
- ),
- ],
),
- child: ClipRRect(
- borderRadius: BorderRadius.circular(21),
- child: Stack(
- fit: StackFit.expand,
- children: [
- Column(
- children: [
- _PinnedHeader(
- board: board,
- onUnpin: () => unawaited(_unpin()),
- ),
- Divider(
- height: 1,
- color: Theme.of(
- context,
- ).colorScheme.onSurface.withValues(alpha: 0.08),
- ),
- Expanded(child: _buildTodoList(board)),
- _PinnedFooter(
- onAddTodo: _openCreate,
- onOpenMain: () =>
- widget.coordinator.requestMainWindow(board.id),
- ),
- ],
- ),
- if (_drawerMode != _PinnedDrawerMode.none)
- GestureDetector(
- behavior: HitTestBehavior.opaque,
- onTap: _closeDrawer,
- child: ColoredBox(
- color: Colors.black.withValues(
- alpha: isDark ? 0.22 : 0.12,
- ),
- ),
- ),
- Positioned.fill(
- child: IgnorePointer(
- ignoring: !isTodoDrawerOpen,
- child: AnimatedSlide(
- duration: const Duration(milliseconds: 210),
- curve: Curves.easeOutCubic,
- offset: isTodoDrawerOpen
- ? Offset.zero
- : const Offset(0, 1),
- child: TodoEditorDrawer(
- key: ValueKey(_editorSession),
- mode: editorMode,
- item: selectedTodo,
- availableTags: widget.todoController.tags,
- originalAssignedTagIds: selectedTodo == null
- ? const []
- : widget.todoController.tagIdsForTodo(
- selectedTodo.id,
- ),
- assignedTagIds: widget.todoController.tags
- .where((tag) => _todoEditorTagIds.contains(tag.id))
- .map((tag) => tag.id)
- .toList(growable: false),
- isOpen: isTodoDrawerOpen,
- onClose: _closeDrawer,
- onEdit: () {
- if (selectedTodo != null) {
- _openEdit(selectedTodo.id);
- }
- },
- onOpenTagAssignment: _openTagAssignment,
- onSave: (title, content, tagIds) async {
- if (editorMode == TodoEditorDrawerMode.create) {
- final item = await widget.todoController.create(
- title,
- content: content,
- tagIds: tagIds,
- );
- if (item == null) {
- return false;
- }
- return widget.boardController.addTodo(
- boardId: board.id,
- todoId: item.id,
- );
- }
- if (selectedTodo == null) {
- return false;
- }
- return widget.todoController.updateDetails(
- id: selectedTodo.id,
- title: title,
- content: content,
- tagIds: tagIds,
- );
- },
- onSaved: () {
- if (editorMode == TodoEditorDrawerMode.edit &&
- selectedTodo != null) {
- _openDetails(selectedTodo.id);
- } else {
- _closeDrawer();
- }
- },
- closeFocusNode: _todoDrawerCloseFocusNode,
- ),
- ),
- ),
- ),
- Positioned(
- top: 0,
- right: 0,
- bottom: 0,
- width: 292,
- child: IgnorePointer(
- ignoring: !isTagAssignmentOpen,
- child: AnimatedSlide(
- duration: const Duration(milliseconds: 210),
- curve: Curves.easeOutCubic,
- offset: isTagAssignmentOpen
- ? Offset.zero
- : const Offset(1, 0),
- child: TagFilterDrawer.assignment(
- controller: widget.todoController,
- selectedTagIds: _todoEditorTagIds,
- borderOnLeft: true,
- onToggled: _toggleEditorTag,
- onManageTags: () {
- widget.coordinator.requestMainWindow(board.id);
- },
- onClose: _closeDrawer,
- closeFocusNode: _tagDrawerCloseFocusNode,
- ),
- ),
- ),
+ child: Column(
+ children: [
+ _PinnedHeader(board: board, onUnpin: () => unawaited(_unpin())),
+ Divider(
+ height: 1,
+ color: Theme.of(
+ context,
+ ).colorScheme.onSurface.withValues(alpha: 0.08),
+ ),
+ Expanded(
+ child: AnimatedSwitcher(
+ duration: MediaQuery.disableAnimationsOf(context)
+ ? Duration.zero
+ : const Duration(milliseconds: 150),
+ switchInCurve: Curves.easeOut,
+ switchOutCurve: Curves.easeIn,
+ child: detailsItem == null
+ ? _buildTodoList(board)
+ : StickyBoardTodoDetails(
+ key: ValueKey(
+ 'sticky-board-details-${detailsItem.id}',
+ ),
+ item: detailsItem,
+ tags: widget.todoController.tags
+ .where(
+ (tag) => widget.todoController
+ .tagIdsForTodo(detailsItem.id)
+ .contains(tag.id),
+ )
+ .toList(growable: false),
+ onBack: _closeTodoDetails,
+ ),
),
- ],
- ),
+ ),
+ ],
),
),
),
@@ -365,10 +255,18 @@ class _PinnedStickyBoardWindowState extends State
.toList(growable: false);
if (items.isEmpty) {
return Center(
- child: TextButton.icon(
- onPressed: _openCreate,
- icon: const Icon(Icons.add_rounded, size: 17),
- label: Text(context.l10n.newTodoInStickyBoardAction),
+ child: Padding(
+ padding: const EdgeInsets.all(24),
+ child: Text(
+ key: const Key('pinned-sticky-board-empty'),
+ context.l10n.emptyPinnedStickyBoardMessage,
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ color: Theme.of(
+ context,
+ ).colorScheme.onSurface.withValues(alpha: 0.52),
+ ),
+ ),
),
);
}
@@ -377,32 +275,12 @@ class _PinnedStickyBoardWindowState extends State
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
- return TodoListRow(
+ return StickyBoardReadOnlyTodoRow(
key: ValueKey('pinned-sticky-board-todo-${item.id}'),
item: item,
- archivedScope: false,
- onToggle: () =>
+ onToggleCompletion: () =>
unawaited(widget.todoController.toggleCompletion(item.id)),
- onOpenDetails: () => _openDetails(item.id),
- onEdit: () => _openEdit(item.id),
- onArchive: () => unawaited(widget.todoController.archive(item.id)),
- onRestore: () => unawaited(widget.todoController.restore(item.id)),
- tags: widget.todoController.tags,
- assignedTagIds: widget.todoController.tagIdsForTodo(item.id),
- onToggleTag: (tagId) => widget.todoController.toggleTagForTodo(
- todoId: item.id,
- tagId: tagId,
- ),
- onOpenTagManagement: () {
- widget.coordinator.requestMainWindow(board.id);
- },
- onRemoveFromStickyBoard: () => unawaited(
- widget.boardController.removeTodo(
- boardId: board.id,
- todoId: item.id,
- ),
- ),
- compact: true,
+ onOpenDetails: () => _showTodoDetails(item.id),
);
},
);
@@ -422,15 +300,6 @@ class _PinnedHeader extends StatelessWidget {
padding: const EdgeInsets.fromLTRB(14, 9, 8, 9),
child: Row(
children: [
- Container(
- width: 10,
- height: 10,
- decoration: BoxDecoration(
- color: Color(board.colorValue),
- shape: BoxShape.circle,
- ),
- ),
- const SizedBox(width: 9),
Expanded(
child: Text(
board.name,
@@ -441,14 +310,23 @@ class _PinnedHeader extends StatelessWidget {
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
),
),
- IconButton(
- key: const Key('pinned-sticky-board-unpin'),
- tooltip: context.l10n.unpinStickyBoardTooltip,
- onPressed: onUnpin,
- icon: Icon(
- Icons.push_pin_rounded,
- size: 17,
- color: Theme.of(context).colorScheme.primary,
+ FloatickHoverMotion(
+ hoverScale: FloatickMotion.emphasisHoverScale,
+ pressedScale: FloatickMotion.emphasisPressedScale,
+ hoverTurns: FloatickMotion.emphasisHoverTurns,
+ child: IconButton(
+ key: const Key('pinned-sticky-board-unpin'),
+ tooltip: context.l10n.unpinStickyBoardTooltip,
+ onPressed: onUnpin,
+ style: const ButtonStyle(
+ foregroundBuilder:
+ FloatickMotion.passthroughForegroundBuilder,
+ ),
+ icon: Icon(
+ Icons.push_pin_rounded,
+ size: 17,
+ color: Theme.of(context).colorScheme.primary,
+ ),
),
),
],
@@ -457,35 +335,3 @@ class _PinnedHeader extends StatelessWidget {
);
}
}
-
-class _PinnedFooter extends StatelessWidget {
- const _PinnedFooter({required this.onAddTodo, required this.onOpenMain});
-
- final VoidCallback onAddTodo;
- final VoidCallback onOpenMain;
-
- @override
- Widget build(BuildContext context) {
- return Padding(
- padding: const EdgeInsets.fromLTRB(12, 8, 8, 11),
- child: Row(
- children: [
- Expanded(
- child: FilledButton.tonalIcon(
- key: const Key('pinned-sticky-board-add-todo'),
- onPressed: onAddTodo,
- icon: const Icon(Icons.add_rounded, size: 17),
- label: Text(context.l10n.newTodoInStickyBoardAction),
- ),
- ),
- const SizedBox(width: 6),
- IconButton(
- tooltip: context.l10n.openMainListTooltip,
- onPressed: onOpenMain,
- icon: const Icon(Icons.open_in_new_rounded, size: 17),
- ),
- ],
- ),
- );
- }
-}
diff --git a/lib/features/sticky_boards/presentation/sticky_board_drawers.dart b/lib/features/sticky_boards/presentation/sticky_board_drawers.dart
index f286cab..4d04fb7 100644
--- a/lib/features/sticky_boards/presentation/sticky_board_drawers.dart
+++ b/lib/features/sticky_boards/presentation/sticky_board_drawers.dart
@@ -3,17 +3,22 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
+import '../../../core/ui/floatick_hover_motion.dart';
import '../../../l10n/l10n.dart';
import '../../todos/domain/todo_item.dart';
import '../../todos/presentation/todo_view_model.dart';
-import '../../todos/presentation/widgets/todo_list_row.dart';
import '../domain/sticky_board.dart';
import 'sticky_board_palette.dart';
import 'sticky_board_view_model.dart';
+import 'widgets/sticky_board_management_todo_row.dart';
+import 'widgets/sticky_board_todo_details.dart';
+
+const BorderRadius _selectionRowRadius = BorderRadius.all(Radius.circular(11));
class StickyBoardManagementDrawer extends StatefulWidget {
const StickyBoardManagementDrawer({
required this.controller,
+ required this.todoController,
required this.isOpen,
required this.borderOnLeft,
required this.onClose,
@@ -25,6 +30,7 @@ class StickyBoardManagementDrawer extends StatefulWidget {
});
final StickyBoardViewModel controller;
+ final TodoViewModel todoController;
final bool isOpen;
final bool borderOnLeft;
final VoidCallback onClose;
@@ -44,7 +50,6 @@ class _StickyBoardManagementDrawerState
final _queryFocusNode = FocusNode();
String? _editingBoardId;
- String? _pendingDeleteBoardId;
String? _validationMessage;
int _selectedColorValue = StickyBoardPalette.teal;
bool _isSaving = false;
@@ -114,7 +119,6 @@ class _StickyBoardManagementDrawerState
_isSaving = false;
if (result == StickyBoardMutationResult.success) {
_editingBoardId = null;
- _pendingDeleteBoardId = null;
_queryController.clear();
_selectedColorValue = StickyBoardPalette.teal;
} else {
@@ -129,7 +133,6 @@ class _StickyBoardManagementDrawerState
void _beginEditing(StickyBoard board) {
setState(() {
_editingBoardId = board.id;
- _pendingDeleteBoardId = null;
_validationMessage = null;
_selectedColorValue = board.colorValue;
_queryController.text = board.name;
@@ -151,6 +154,46 @@ class _StickyBoardManagementDrawerState
_queryFocusNode.requestFocus();
}
+ Future _requestDeleteConfirmation(StickyBoard board) async {
+ final confirmed = await showDialog(
+ context: context,
+ builder: (dialogContext) {
+ final theme = Theme.of(dialogContext);
+ return AlertDialog(
+ key: ValueKey('sticky-board-delete-confirmation-${board.id}'),
+ title: Text(dialogContext.l10n.deleteStickyBoardTitle),
+ content: Text(dialogContext.l10n.deleteStickyBoardMessage),
+ actions: [
+ TextButton(
+ key: ValueKey('cancel-delete-sticky-board-${board.id}'),
+ onPressed: () => Navigator.of(dialogContext).pop(false),
+ child: Text(dialogContext.l10n.cancelAction),
+ ),
+ TextButton(
+ key: ValueKey('confirm-delete-sticky-board-${board.id}'),
+ onPressed: () => Navigator.of(dialogContext).pop(true),
+ style: TextButton.styleFrom(
+ foregroundColor: theme.colorScheme.error,
+ ),
+ child: Text(dialogContext.l10n.confirmAction),
+ ),
+ ],
+ );
+ },
+ );
+ if (!mounted || confirmed != true) {
+ return;
+ }
+ if (_editingBoardId == board.id) {
+ setState(() {
+ _editingBoardId = null;
+ _queryController.clear();
+ _selectedColorValue = StickyBoardPalette.teal;
+ });
+ }
+ widget.onDeleteBoard(board.id);
+ }
+
String _messageForResult(StickyBoardMutationResult result) {
return switch (result) {
StickyBoardMutationResult.emptyName =>
@@ -212,10 +255,7 @@ class _StickyBoardManagementDrawerState
),
],
onChanged: (_) {
- setState(() {
- _validationMessage = null;
- _pendingDeleteBoardId = null;
- });
+ setState(() => _validationMessage = null);
},
onSubmitted: (_) => unawaited(_submit()),
decoration: InputDecoration(
@@ -336,32 +376,38 @@ class _StickyBoardManagementDrawerState
if (boards.isEmpty) {
return _EmptyBoards(hasQuery: hasQuery);
}
- return ListView.separated(
- padding: const EdgeInsets.fromLTRB(9, 8, 9, 8),
- itemCount: boards.length,
- separatorBuilder: (_, _) => const SizedBox(height: 3),
- itemBuilder: (context, index) {
- final board = boards[index];
- if (_pendingDeleteBoardId == board.id) {
- return _DeleteConfirmation(
- board: board,
- onKeep: () => setState(() => _pendingDeleteBoardId = null),
- onDelete: () {
- setState(() => _pendingDeleteBoardId = null);
- widget.onDeleteBoard(board.id);
- },
- );
- }
- return _ManagedBoardRow(
- key: ValueKey('sticky-board-${board.id}'),
- board: board,
- todoCount: widget.controller.todoCountForBoard(board.id),
- isEditing: _editingBoardId == board.id,
- onOpen: () => widget.onOpenBoard(board.id),
- onEdit: () => _beginEditing(board),
- onTogglePin: () => widget.onTogglePin(board.id),
- onDelete: () {
- setState(() => _pendingDeleteBoardId = board.id);
+ return LayoutBuilder(
+ builder: (context, constraints) {
+ final columnCount = constraints.maxWidth >= 360 ? 2 : 1;
+ return GridView.builder(
+ key: const Key('sticky-board-thumbnail-grid'),
+ padding: const EdgeInsets.fromLTRB(10, 10, 10, 12),
+ gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
+ crossAxisCount: columnCount,
+ crossAxisSpacing: 10,
+ mainAxisSpacing: 10,
+ mainAxisExtent: 150,
+ ),
+ itemCount: boards.length,
+ itemBuilder: (context, index) {
+ final board = boards[index];
+ final previewItems = widget.controller
+ .todoIdsForBoard(board.id)
+ .map(widget.todoController.itemById)
+ .whereType()
+ .take(2)
+ .toList(growable: false);
+ return _ManagedBoardCard(
+ key: ValueKey('sticky-board-${board.id}'),
+ board: board,
+ previewItems: previewItems,
+ todoCount: widget.controller.todoCountForBoard(board.id),
+ isEditing: _editingBoardId == board.id,
+ onOpen: () => widget.onOpenBoard(board.id),
+ onEdit: () => _beginEditing(board),
+ onTogglePin: () => widget.onTogglePin(board.id),
+ onDelete: () => unawaited(_requestDeleteConfirmation(board)),
+ );
},
);
},
@@ -369,7 +415,7 @@ class _StickyBoardManagementDrawerState
}
}
-class StickyBoardDetailDrawer extends StatelessWidget {
+class StickyBoardDetailDrawer extends StatefulWidget {
const StickyBoardDetailDrawer({
required this.board,
required this.todoController,
@@ -380,9 +426,6 @@ class StickyBoardDetailDrawer extends StatelessWidget {
required this.onTogglePin,
required this.onAddExisting,
required this.onCreateTodo,
- required this.onOpenDetails,
- required this.onEditTodo,
- required this.onOpenTagManagement,
required this.closeFocusNode,
super.key,
});
@@ -396,22 +439,47 @@ class StickyBoardDetailDrawer extends StatelessWidget {
final VoidCallback onTogglePin;
final VoidCallback onAddExisting;
final VoidCallback onCreateTodo;
- final ValueChanged onOpenDetails;
- final ValueChanged onEditTodo;
- final VoidCallback onOpenTagManagement;
final FocusNode closeFocusNode;
+ @override
+ State createState() =>
+ _StickyBoardDetailDrawerState();
+}
+
+class _StickyBoardDetailDrawerState extends State {
+ String? _detailsTodoId;
+
+ void _openDetails(String todoId) {
+ setState(() => _detailsTodoId = todoId);
+ }
+
+ void _closeDetails() {
+ setState(() => _detailsTodoId = null);
+ }
+
@override
Widget build(BuildContext context) {
- final boardTodoIds = boardController.todoIdsForBoard(board.id);
+ final boardTodoIds = widget.boardController.todoIdsForBoard(
+ widget.board.id,
+ );
final items = boardTodoIds
- .map(todoController.itemById)
+ .map(widget.todoController.itemById)
.whereType()
.where((item) => !item.isArchived)
.toList(growable: false);
+ final detailsCandidate = _detailsTodoId == null
+ ? null
+ : widget.todoController.itemById(_detailsTodoId!);
+ final detailsItem =
+ detailsCandidate != null &&
+ !detailsCandidate.isArchived &&
+ boardTodoIds.contains(detailsCandidate.id)
+ ? detailsCandidate
+ : null;
+
return _StickyBoardDrawerSurface(
key: const Key('sticky-board-detail-drawer'),
- borderOnLeft: borderOnLeft,
+ borderOnLeft: widget.borderOnLeft,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@@ -422,14 +490,14 @@ class StickyBoardDetailDrawer extends StatelessWidget {
IconButton(
key: const Key('sticky-board-back'),
tooltip: context.l10n.backToStickyBoardsTooltip,
- onPressed: onBack,
+ onPressed: widget.onBack,
icon: const Icon(Icons.arrow_back_rounded, size: 18),
),
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
- color: Color(board.colorValue),
+ color: Color(widget.board.colorValue),
shape: BoxShape.circle,
),
),
@@ -439,7 +507,7 @@ class StickyBoardDetailDrawer extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
- board.name,
+ widget.board.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
@@ -457,96 +525,127 @@ class StickyBoardDetailDrawer extends StatelessWidget {
],
),
),
- IconButton(
- key: const Key('sticky-board-pin'),
- tooltip: board.isPinned
- ? context.l10n.unpinStickyBoardTooltip
- : context.l10n.pinStickyBoardTooltip,
- onPressed: onTogglePin,
- icon: Icon(
- board.isPinned
- ? Icons.push_pin_rounded
- : Icons.push_pin_outlined,
- size: 18,
- color: board.isPinned
- ? Theme.of(context).colorScheme.primary
- : null,
+ FloatickHoverMotion(
+ hoverScale: FloatickMotion.emphasisHoverScale,
+ pressedScale: FloatickMotion.emphasisPressedScale,
+ hoverTurns: FloatickMotion.emphasisHoverTurns,
+ child: IconButton(
+ key: const Key('sticky-board-pin'),
+ tooltip: widget.board.isPinned
+ ? context.l10n.unpinStickyBoardTooltip
+ : context.l10n.pinStickyBoardTooltip,
+ onPressed: widget.onTogglePin,
+ style: const ButtonStyle(
+ foregroundBuilder:
+ FloatickMotion.passthroughForegroundBuilder,
+ ),
+ icon: Icon(
+ widget.board.isPinned
+ ? Icons.push_pin_rounded
+ : Icons.push_pin_outlined,
+ size: 18,
+ color: widget.board.isPinned
+ ? Theme.of(context).colorScheme.primary
+ : null,
+ ),
),
),
IconButton(
- focusNode: closeFocusNode,
+ focusNode: widget.closeFocusNode,
tooltip: context.l10n.closeStickyBoardsTooltip,
- onPressed: onClose,
+ onPressed: widget.onClose,
icon: const Icon(Icons.close_rounded, size: 18),
),
],
),
),
const _DrawerDivider(),
- Padding(
- padding: const EdgeInsets.fromLTRB(13, 12, 13, 8),
- child: Row(
- children: [
- Expanded(
- child: OutlinedButton.icon(
- key: const Key('sticky-board-add-existing'),
- onPressed: onAddExisting,
- icon: const Icon(Icons.playlist_add_rounded, size: 17),
- label: Text(context.l10n.addExistingTodoAction),
- ),
+ Expanded(
+ child: AnimatedSwitcher(
+ duration: MediaQuery.disableAnimationsOf(context)
+ ? Duration.zero
+ : const Duration(milliseconds: 150),
+ switchInCurve: Curves.easeOut,
+ switchOutCurve: Curves.easeIn,
+ child: detailsItem == null
+ ? _buildBoardMembers(items)
+ : StickyBoardTodoDetails(
+ key: ValueKey(
+ 'sticky-board-managed-details-${detailsItem.id}',
+ ),
+ item: detailsItem,
+ tags: widget.todoController.tags
+ .where(
+ (tag) => widget.todoController
+ .tagIdsForTodo(detailsItem.id)
+ .contains(tag.id),
+ )
+ .toList(growable: false),
+ onBack: _closeDetails,
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildBoardMembers(List items) {
+ return Column(
+ key: ValueKey('sticky-board-members-${widget.board.id}'),
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Padding(
+ padding: const EdgeInsets.fromLTRB(13, 12, 13, 8),
+ child: Row(
+ children: [
+ Expanded(
+ child: OutlinedButton.icon(
+ key: const Key('sticky-board-add-existing'),
+ onPressed: widget.onAddExisting,
+ icon: const Icon(Icons.playlist_add_rounded, size: 17),
+ label: Text(context.l10n.addExistingTodoAction),
),
- const SizedBox(width: 8),
- Expanded(
- child: FilledButton.tonalIcon(
- key: const Key('sticky-board-new-todo'),
- onPressed: onCreateTodo,
- icon: const Icon(Icons.add_rounded, size: 17),
- label: Text(context.l10n.newTodoInStickyBoardAction),
- ),
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: FilledButton.tonalIcon(
+ key: const Key('sticky-board-new-todo'),
+ onPressed: widget.onCreateTodo,
+ icon: const Icon(Icons.add_rounded, size: 17),
+ label: Text(context.l10n.newTodoInStickyBoardAction),
),
- ],
- ),
+ ),
+ ],
),
- Expanded(
- child: items.isEmpty
- ? _EmptyBoardTodos(onCreateTodo: onCreateTodo)
- : ListView.builder(
- padding: const EdgeInsets.fromLTRB(10, 4, 10, 14),
- itemCount: items.length,
- itemBuilder: (context, index) {
- final item = items[index];
- return TodoListRow(
- key: ValueKey('sticky-board-todo-${item.id}'),
- item: item,
- archivedScope: false,
- onToggle: () =>
- unawaited(todoController.toggleCompletion(item.id)),
- onOpenDetails: () => onOpenDetails(item.id),
- onEdit: () => onEditTodo(item.id),
- onArchive: () =>
- unawaited(todoController.archive(item.id)),
- onRestore: () =>
- unawaited(todoController.restore(item.id)),
- tags: todoController.tags,
- assignedTagIds: todoController.tagIdsForTodo(item.id),
- onToggleTag: (tagId) => todoController.toggleTagForTodo(
+ ),
+ Expanded(
+ child: items.isEmpty
+ ? _EmptyBoardTodos(onCreateTodo: widget.onCreateTodo)
+ : ListView.builder(
+ padding: const EdgeInsets.fromLTRB(10, 4, 10, 14),
+ itemCount: items.length,
+ itemBuilder: (context, index) {
+ final item = items[index];
+ return StickyBoardManagementTodoRow(
+ key: ValueKey('sticky-board-todo-${item.id}'),
+ item: item,
+ tags: widget.todoController.tags,
+ assignedTagIds: widget.todoController.tagIdsForTodo(
+ item.id,
+ ),
+ onOpenDetails: () => _openDetails(item.id),
+ onRemove: () => unawaited(
+ widget.boardController.removeTodo(
+ boardId: widget.board.id,
todoId: item.id,
- tagId: tagId,
- ),
- onOpenTagManagement: onOpenTagManagement,
- onRemoveFromStickyBoard: () => unawaited(
- boardController.removeTodo(
- boardId: board.id,
- todoId: item.id,
- ),
),
- compact: true,
- );
- },
- ),
- ),
- ],
- ),
+ ),
+ );
+ },
+ ),
+ ),
+ ],
);
}
}
@@ -588,16 +687,11 @@ class _StickyBoardTodoPickerDrawerState
@override
Widget build(BuildContext context) {
- final query = _searchController.text.trim().toLowerCase();
- final items = widget.todoController.items
- .where(
- (item) =>
- !item.isArchived &&
- (query.isEmpty ||
- item.title.toLowerCase().contains(query) ||
- item.content.toLowerCase().contains(query)),
- )
- .toList(growable: false);
+ final theme = Theme.of(context);
+ final items = widget.todoController.itemsForView(
+ archived: false,
+ query: _searchController.text,
+ );
return _StickyBoardDrawerSurface(
key: const Key('sticky-board-todo-picker-drawer'),
borderOnLeft: widget.borderOnLeft,
@@ -668,32 +762,45 @@ class _StickyBoardTodoPickerDrawerState
boardId: widget.board.id,
todoId: item.id,
);
- return Material(
- type: MaterialType.transparency,
- child: CheckboxListTile(
- key: ValueKey(
- 'sticky-board-picker-${item.id}',
- ),
- value: selected,
- onChanged: (value) {
- unawaited(
- widget.boardController.setTodoMembership(
- boardId: widget.board.id,
- todoId: item.id,
- selected: value ?? false,
- ),
- );
- },
- dense: true,
- controlAffinity: ListTileControlAffinity.leading,
- contentPadding: const EdgeInsets.symmetric(
- horizontal: 4,
+ return Padding(
+ padding: const EdgeInsets.only(bottom: 2),
+ child: Material(
+ type: MaterialType.transparency,
+ shape: const RoundedRectangleBorder(
+ borderRadius: _selectionRowRadius,
),
- title: Text(
- item.title,
- maxLines: 2,
- overflow: TextOverflow.ellipsis,
- style: Theme.of(context).textTheme.bodyMedium,
+ clipBehavior: Clip.antiAlias,
+ child: CheckboxListTile(
+ key: ValueKey(
+ 'sticky-board-picker-${item.id}',
+ ),
+ value: selected,
+ onChanged: (value) {
+ unawaited(
+ widget.boardController.setTodoMembership(
+ boardId: widget.board.id,
+ todoId: item.id,
+ selected: value ?? false,
+ ),
+ );
+ },
+ dense: true,
+ controlAffinity: ListTileControlAffinity.leading,
+ contentPadding: const EdgeInsets.symmetric(
+ horizontal: 4,
+ ),
+ shape: const RoundedRectangleBorder(
+ borderRadius: _selectionRowRadius,
+ ),
+ hoverColor: theme.colorScheme.onSurface.withValues(
+ alpha: 0.045,
+ ),
+ title: Text(
+ item.title,
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: theme.textTheme.bodyMedium,
+ ),
),
),
);
@@ -812,36 +919,42 @@ class _ColorButton extends StatelessWidget {
button: true,
selected: selected,
label: context.l10n.tagColorSemanticsLabel,
- child: GestureDetector(
- onTap: enabled ? onPressed : null,
- child: AnimatedContainer(
- duration: MediaQuery.disableAnimationsOf(context)
- ? Duration.zero
- : const Duration(milliseconds: 150),
- width: 23,
- height: 23,
- decoration: BoxDecoration(
- color: color,
- shape: BoxShape.circle,
- border: Border.all(
- color: selected
- ? Theme.of(context).colorScheme.onSurface
- : Colors.transparent,
- width: 2,
+ child: FloatickHoverMotion(
+ enabled: enabled,
+ hoverScale: FloatickMotion.swatchHoverScale,
+ pressedScale: FloatickMotion.swatchPressedScale,
+ child: GestureDetector(
+ onTap: enabled ? onPressed : null,
+ child: AnimatedContainer(
+ duration: MediaQuery.disableAnimationsOf(context)
+ ? Duration.zero
+ : const Duration(milliseconds: 150),
+ width: 23,
+ height: 23,
+ decoration: BoxDecoration(
+ color: color,
+ shape: BoxShape.circle,
+ border: Border.all(
+ color: selected
+ ? Theme.of(context).colorScheme.onSurface
+ : Colors.transparent,
+ width: 2,
+ ),
),
+ child: selected
+ ? const Icon(Icons.check_rounded, size: 13, color: Colors.white)
+ : null,
),
- child: selected
- ? const Icon(Icons.check_rounded, size: 13, color: Colors.white)
- : null,
),
),
);
}
}
-class _ManagedBoardRow extends StatefulWidget {
- const _ManagedBoardRow({
+class _ManagedBoardCard extends StatefulWidget {
+ const _ManagedBoardCard({
required this.board,
+ required this.previewItems,
required this.todoCount,
required this.isEditing,
required this.onOpen,
@@ -852,6 +965,7 @@ class _ManagedBoardRow extends StatefulWidget {
});
final StickyBoard board;
+ final List previewItems;
final int todoCount;
final bool isEditing;
final VoidCallback onOpen;
@@ -860,113 +974,196 @@ class _ManagedBoardRow extends StatefulWidget {
final VoidCallback onDelete;
@override
- State<_ManagedBoardRow> createState() => _ManagedBoardRowState();
+ State<_ManagedBoardCard> createState() => _ManagedBoardCardState();
}
-class _ManagedBoardRowState extends State<_ManagedBoardRow> {
+class _ManagedBoardCardState extends State<_ManagedBoardCard> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
+ final boardColor = Color(widget.board.colorValue);
final showActions = _hovered || widget.isEditing;
- return MouseRegion(
- onEnter: (_) => setState(() => _hovered = true),
- onExit: (_) => setState(() => _hovered = false),
- child: InkWell(
- onTap: widget.onOpen,
- borderRadius: BorderRadius.circular(11),
- child: Container(
- padding: const EdgeInsets.fromLTRB(10, 8, 5, 8),
+ final reduceMotion = MediaQuery.disableAnimationsOf(context);
+ final cardBackground = StickyBoardPalette.surfaceColor(
+ value: widget.board.colorValue,
+ baseColor: theme.colorScheme.surfaceContainerHighest,
+ brightness: theme.brightness,
+ hovered: _hovered,
+ );
+ return Semantics(
+ button: true,
+ label: widget.board.name,
+ child: MouseRegion(
+ cursor: SystemMouseCursors.click,
+ onEnter: (_) => setState(() => _hovered = true),
+ onExit: (_) => setState(() => _hovered = false),
+ child: AnimatedContainer(
+ key: ValueKey('sticky-board-thumbnail-${widget.board.id}'),
+ duration: reduceMotion
+ ? Duration.zero
+ : const Duration(milliseconds: 160),
+ curve: Curves.easeOutCubic,
decoration: BoxDecoration(
- color: _hovered || widget.isEditing
- ? theme.colorScheme.onSurface.withValues(alpha: 0.045)
- : Colors.transparent,
- borderRadius: BorderRadius.circular(11),
+ color: cardBackground,
+ borderRadius: BorderRadius.circular(14),
+ border: Border.all(
+ color: widget.isEditing
+ ? theme.colorScheme.primary
+ : _hovered
+ ? boardColor.withValues(alpha: 0.58)
+ : theme.colorScheme.onSurface.withValues(alpha: 0.10),
+ ),
),
- child: Row(
- children: [
- Container(
- width: 11,
- height: 11,
- decoration: BoxDecoration(
- color: Color(widget.board.colorValue),
- shape: BoxShape.circle,
- ),
- ),
- const SizedBox(width: 10),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- widget.board.name,
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: theme.textTheme.bodyMedium?.copyWith(
- fontWeight: FontWeight.w600,
- ),
- ),
- Text(
- context.l10n.stickyBoardTodoCount(widget.todoCount),
- style: theme.textTheme.labelSmall?.copyWith(
- color: theme.colorScheme.onSurface.withValues(
- alpha: 0.42,
+ child: Material(
+ color: Colors.transparent,
+ borderRadius: BorderRadius.circular(14),
+ clipBehavior: Clip.antiAlias,
+ child: InkWell(
+ onTap: widget.onOpen,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Padding(
+ padding: const EdgeInsets.fromLTRB(11, 7, 5, 4),
+ child: Row(
+ children: [
+ Expanded(
+ child: Text(
+ widget.board.name,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: theme.textTheme.bodyMedium?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
),
- ),
+ FloatickHoverMotion(
+ hoverScale: FloatickMotion.emphasisHoverScale,
+ pressedScale: FloatickMotion.emphasisPressedScale,
+ hoverTurns: FloatickMotion.emphasisHoverTurns,
+ child: IconButton(
+ key: ValueKey(
+ 'toggle-sticky-board-pin-${widget.board.id}',
+ ),
+ tooltip: widget.board.isPinned
+ ? context.l10n.unpinStickyBoardTooltip
+ : context.l10n.pinStickyBoardTooltip,
+ onPressed: widget.onTogglePin,
+ constraints: const BoxConstraints.tightFor(
+ width: 30,
+ height: 30,
+ ),
+ padding: const EdgeInsets.all(6),
+ style: const ButtonStyle(
+ foregroundBuilder:
+ FloatickMotion.passthroughForegroundBuilder,
+ ),
+ icon: Icon(
+ widget.board.isPinned
+ ? Icons.push_pin_rounded
+ : Icons.push_pin_outlined,
+ size: 15,
+ color: widget.board.isPinned
+ ? theme.colorScheme.primary
+ : null,
+ ),
+ ),
+ ),
+ ],
),
- ],
- ),
- ),
- if (widget.board.isPinned)
- Tooltip(
- message: context.l10n.stickyBoardPinnedLabel,
- child: Icon(
- Icons.push_pin_rounded,
- size: 14,
- color: theme.colorScheme.primary,
),
- ),
- AnimatedOpacity(
- duration: MediaQuery.disableAnimationsOf(context)
- ? Duration.zero
- : const Duration(milliseconds: 140),
- opacity: showActions ? 1 : 0,
- child: IgnorePointer(
- ignoring: !showActions,
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- IconButton(
- tooltip: context.l10n.renameStickyBoardTooltip,
- onPressed: widget.onEdit,
- icon: const Icon(Icons.edit_outlined, size: 16),
- ),
- IconButton(
- tooltip: widget.board.isPinned
- ? context.l10n.unpinStickyBoardTooltip
- : context.l10n.pinStickyBoardTooltip,
- onPressed: widget.onTogglePin,
- icon: Icon(
- widget.board.isPinned
- ? Icons.push_pin_rounded
- : Icons.push_pin_outlined,
- size: 16,
+ Divider(
+ height: 1,
+ thickness: 1,
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.07),
+ ),
+ Expanded(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(10, 6, 10, 2),
+ child: widget.previewItems.isEmpty
+ ? const _EmptyBoardPreview()
+ : Column(
+ children: [
+ for (final item in widget.previewItems)
+ _BoardPreviewTodoLine(item: item),
+ ],
+ ),
+ ),
+ ),
+ Padding(
+ padding: const EdgeInsets.fromLTRB(10, 0, 5, 5),
+ child: Row(
+ children: [
+ Icon(
+ Icons.view_agenda_outlined,
+ size: 12,
+ color: theme.colorScheme.onSurface.withValues(
+ alpha: 0.38,
+ ),
),
- ),
- IconButton(
- tooltip: context.l10n.deleteStickyBoardTooltip,
- onPressed: widget.onDelete,
- icon: const Icon(
- Icons.delete_outline_rounded,
- size: 16,
+ const SizedBox(width: 5),
+ Text(
+ context.l10n.stickyBoardTodoCount(widget.todoCount),
+ style: theme.textTheme.labelSmall?.copyWith(
+ color: theme.colorScheme.onSurface.withValues(
+ alpha: 0.46,
+ ),
+ ),
),
- ),
- ],
+ const Spacer(),
+ AnimatedOpacity(
+ duration: reduceMotion
+ ? Duration.zero
+ : const Duration(milliseconds: 140),
+ opacity: showActions ? 1 : 0,
+ child: IgnorePointer(
+ ignoring: !showActions,
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ IconButton(
+ tooltip:
+ context.l10n.renameStickyBoardTooltip,
+ onPressed: widget.onEdit,
+ constraints: const BoxConstraints.tightFor(
+ width: 28,
+ height: 28,
+ ),
+ padding: const EdgeInsets.all(6),
+ icon: const Icon(
+ Icons.edit_outlined,
+ size: 14,
+ ),
+ ),
+ IconButton(
+ key: ValueKey(
+ 'delete-sticky-board-${widget.board.id}',
+ ),
+ tooltip:
+ context.l10n.deleteStickyBoardTooltip,
+ onPressed: widget.onDelete,
+ constraints: const BoxConstraints.tightFor(
+ width: 28,
+ height: 28,
+ ),
+ padding: const EdgeInsets.all(6),
+ icon: const Icon(
+ Icons.delete_outline_rounded,
+ size: 14,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
),
- ),
+ ],
),
- ],
+ ),
),
),
),
@@ -974,60 +1171,57 @@ class _ManagedBoardRowState extends State<_ManagedBoardRow> {
}
}
-class _DeleteConfirmation extends StatelessWidget {
- const _DeleteConfirmation({
- required this.board,
- required this.onKeep,
- required this.onDelete,
- });
+class _BoardPreviewTodoLine extends StatelessWidget {
+ const _BoardPreviewTodoLine({required this.item});
- final StickyBoard board;
- final VoidCallback onKeep;
- final VoidCallback onDelete;
+ final TodoItem item;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
- return Container(
- padding: const EdgeInsets.all(12),
- decoration: BoxDecoration(
- color: theme.colorScheme.errorContainer.withValues(alpha: 0.38),
- borderRadius: BorderRadius.circular(12),
- ),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
+ final mutedColor = theme.colorScheme.onSurface.withValues(alpha: 0.42);
+ return SizedBox(
+ height: 24,
+ child: Row(
children: [
- Text(
- context.l10n.deleteStickyBoardTitle,
- style: theme.textTheme.bodyMedium?.copyWith(
- fontWeight: FontWeight.w600,
- ),
- ),
- const SizedBox(height: 4),
- Text(
- context.l10n.deleteStickyBoardMessage,
- style: theme.textTheme.bodySmall?.copyWith(
- color: theme.colorScheme.onSurface.withValues(alpha: 0.58),
+ Container(
+ width: 11,
+ height: 11,
+ decoration: BoxDecoration(
+ color: item.isCompleted
+ ? theme.colorScheme.primary.withValues(alpha: 0.78)
+ : Colors.transparent,
+ borderRadius: BorderRadius.circular(3),
+ border: Border.all(
+ color: item.isCompleted
+ ? theme.colorScheme.primary
+ : mutedColor,
+ width: 1.2,
+ ),
),
+ child: item.isCompleted
+ ? Icon(
+ Icons.check_rounded,
+ size: 8,
+ color: theme.colorScheme.onPrimary,
+ )
+ : null,
),
- const SizedBox(height: 9),
- Row(
- mainAxisAlignment: MainAxisAlignment.end,
- children: [
- TextButton(
- onPressed: onKeep,
- child: Text(context.l10n.keepStickyBoardAction),
- ),
- const SizedBox(width: 5),
- FilledButton(
- onPressed: onDelete,
- style: FilledButton.styleFrom(
- backgroundColor: theme.colorScheme.error,
- foregroundColor: theme.colorScheme.onError,
+ const SizedBox(width: 7),
+ Expanded(
+ child: Text(
+ item.title,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: theme.textTheme.labelSmall?.copyWith(
+ color: theme.colorScheme.onSurface.withValues(
+ alpha: item.isCompleted ? 0.40 : 0.66,
),
- child: Text(context.l10n.confirmDeleteStickyBoardAction),
+ decoration: item.isCompleted
+ ? TextDecoration.lineThrough
+ : null,
),
- ],
+ ),
),
],
),
@@ -1035,6 +1229,53 @@ class _DeleteConfirmation extends StatelessWidget {
}
}
+class _EmptyBoardPreview extends StatelessWidget {
+ const _EmptyBoardPreview();
+
+ @override
+ Widget build(BuildContext context) {
+ final color = Theme.of(
+ context,
+ ).colorScheme.onSurface.withValues(alpha: 0.12);
+ return Column(
+ children: [
+ for (final widthFactor in [0.86, 0.64])
+ SizedBox(
+ height: 24,
+ child: Row(
+ children: [
+ Container(
+ width: 11,
+ height: 11,
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(3),
+ border: Border.all(color: color, width: 1.2),
+ ),
+ ),
+ const SizedBox(width: 7),
+ Expanded(
+ child: Align(
+ alignment: Alignment.centerLeft,
+ child: FractionallySizedBox(
+ widthFactor: widthFactor,
+ child: Container(
+ height: 5,
+ decoration: BoxDecoration(
+ color: color,
+ borderRadius: BorderRadius.circular(3),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ );
+ }
+}
+
class _EmptyBoards extends StatelessWidget {
const _EmptyBoards({required this.hasQuery});
diff --git a/lib/features/sticky_boards/presentation/sticky_board_frame_save_scheduler.dart b/lib/features/sticky_boards/presentation/sticky_board_frame_save_scheduler.dart
new file mode 100644
index 0000000..08b359b
--- /dev/null
+++ b/lib/features/sticky_boards/presentation/sticky_board_frame_save_scheduler.dart
@@ -0,0 +1,25 @@
+import 'dart:async';
+
+import 'package:flutter/foundation.dart';
+
+class StickyBoardFrameSaveScheduler {
+ StickyBoardFrameSaveScheduler({
+ this.delay = const Duration(milliseconds: 200),
+ });
+
+ final Duration delay;
+ Timer? _timer;
+
+ void schedule(VoidCallback save) {
+ _timer?.cancel();
+ _timer = Timer(delay, () {
+ _timer = null;
+ save();
+ });
+ }
+
+ void cancel() {
+ _timer?.cancel();
+ _timer = null;
+ }
+}
diff --git a/lib/features/sticky_boards/presentation/sticky_board_palette.dart b/lib/features/sticky_boards/presentation/sticky_board_palette.dart
index 4ea19ab..c9d525d 100644
--- a/lib/features/sticky_boards/presentation/sticky_board_palette.dart
+++ b/lib/features/sticky_boards/presentation/sticky_board_palette.dart
@@ -22,4 +22,15 @@ abstract final class StickyBoardPalette {
];
static Color color(int value) => Color(value);
+
+ static Color surfaceColor({
+ required int value,
+ required Color baseColor,
+ required Brightness brightness,
+ bool hovered = false,
+ }) {
+ final isDark = brightness == Brightness.dark;
+ final alpha = isDark ? (hovered ? 0.34 : 0.27) : (hovered ? 0.25 : 0.20);
+ return Color.alphaBlend(color(value).withValues(alpha: alpha), baseColor);
+ }
}
diff --git a/lib/features/sticky_boards/presentation/sticky_board_view_model.dart b/lib/features/sticky_boards/presentation/sticky_board_view_model.dart
index 5a28834..2877230 100644
--- a/lib/features/sticky_boards/presentation/sticky_board_view_model.dart
+++ b/lib/features/sticky_boards/presentation/sticky_board_view_model.dart
@@ -286,6 +286,31 @@ class StickyBoardViewModel extends ChangeNotifier {
});
}
+ Future removeTodoFromAllBoards(String todoId) {
+ return _enqueueMutation(() async {
+ var changed = false;
+ final updatedRelations = >{};
+ for (final entry in _workspace.boardTodoIds.entries) {
+ final remainingTodoIds = entry.value
+ .where((id) => id != todoId)
+ .toList(growable: false);
+ changed = changed || remainingTodoIds.length != entry.value.length;
+ if (remainingTodoIds.isNotEmpty) {
+ updatedRelations[entry.key] = remainingTodoIds;
+ }
+ }
+ if (!changed) {
+ return true;
+ }
+ return _save(
+ StickyBoardWorkspace(
+ boards: _workspace.boards,
+ boardTodoIds: updatedRelations,
+ ),
+ );
+ });
+ }
+
Future setTodoMembership({
required String boardId,
required String todoId,
diff --git a/lib/features/sticky_boards/presentation/sticky_board_window_coordinator.dart b/lib/features/sticky_boards/presentation/sticky_board_window_coordinator.dart
index dd206a5..ef83e16 100644
--- a/lib/features/sticky_boards/presentation/sticky_board_window_coordinator.dart
+++ b/lib/features/sticky_boards/presentation/sticky_board_window_coordinator.dart
@@ -1,19 +1,41 @@
-import 'dart:async';
-
import 'package:flutter/material.dart';
import 'package:multiview_desktop/multiview_desktop.dart';
+import '../../../core/platform/window_bridge.dart';
import '../../todos/presentation/todo_view_model.dart';
import '../domain/sticky_board.dart';
import 'pinned_sticky_board_window.dart';
import 'sticky_board_view_model.dart';
-typedef StickyBoardMainWindowRequest = void Function(String boardId);
+enum StickyBoardMainWindowDestination { board, todoDetails, todoEdit }
+
+class StickyBoardMainWindowRequest {
+ const StickyBoardMainWindowRequest({
+ required this.boardId,
+ this.destination = StickyBoardMainWindowDestination.board,
+ this.todoId,
+ }) : assert(
+ destination == StickyBoardMainWindowDestination.board ||
+ todoId != null,
+ );
+
+ final String boardId;
+ final StickyBoardMainWindowDestination destination;
+ final String? todoId;
+}
+
+typedef StickyBoardMainWindowRequestHandler =
+ void Function(StickyBoardMainWindowRequest request);
+typedef StickyBoardWindowLauncher = Future Function(String boardId);
+typedef StickyBoardWindowHider = Future Function(String boardId);
class StickyBoardWindowCoordinator {
StickyBoardWindowCoordinator({
required StickyBoardViewModel boardController,
required TodoViewModel todoController,
+ required this.windowBridge,
+ this.windowLauncher,
+ this.windowHider,
}) : _boards = boardController,
_todos = todoController;
@@ -23,96 +45,143 @@ class StickyBoardWindowCoordinator {
final StickyBoardViewModel _boards;
final TodoViewModel _todos;
+ final WindowBridge windowBridge;
+ final StickyBoardWindowLauncher? windowLauncher;
+ final StickyBoardWindowHider? windowHider;
final Map _windowIdsByBoardId = {};
+ final Map> _boardWindowOperations =
+ >{};
+ final Set _restoredPinnedBoardIds = {};
- StickyBoardMainWindowRequest? _mainWindowRequest;
+ StickyBoardMainWindowRequestHandler? _mainWindowRequest;
bool _didRestorePinnedBoards = false;
+ Future? _restorePinnedBoardsOperation;
- void setMainWindowRequestHandler(StickyBoardMainWindowRequest? handler) {
+ void setMainWindowRequestHandler(
+ StickyBoardMainWindowRequestHandler? handler,
+ ) {
_mainWindowRequest = handler;
}
- void requestMainWindow(String boardId) {
- _mainWindowRequest?.call(boardId);
+ void requestMainWindow(StickyBoardMainWindowRequest request) {
+ _mainWindowRequest?.call(request);
}
- Future restorePinnedBoards() async {
+ Future restorePinnedBoards() {
if (_didRestorePinnedBoards) {
- return;
- }
- _didRestorePinnedBoards = true;
- for (final board in _boards.boards.where((board) => board.isPinned)) {
- await _openWindow(board.id);
+ return Future.value();
}
+ return _restorePinnedBoardsOperation ??= _restorePinnedBoards()
+ .whenComplete(() => _restorePinnedBoardsOperation = null);
}
- Future togglePin(String boardId) async {
- final board = _boards.boardById(boardId);
- if (board == null) {
- return;
- }
- if (board.isPinned) {
- await unpin(boardId);
- } else {
- await pin(boardId);
+ Future _restorePinnedBoards() async {
+ final pinnedBoardIds = _boards.boards
+ .where((board) => board.isPinned)
+ .map((board) => board.id)
+ .toSet();
+ _restoredPinnedBoardIds.removeWhere(
+ (boardId) => !pinnedBoardIds.contains(boardId),
+ );
+ var hadFailure = false;
+ for (final boardId in pinnedBoardIds.where(
+ (boardId) => !_restoredPinnedBoardIds.contains(boardId),
+ )) {
+ try {
+ await _openWindow(boardId);
+ _restoredPinnedBoardIds.add(boardId);
+ } on Object catch (error, stackTrace) {
+ await _hideRegisteredWindowBestEffort(boardId);
+ hadFailure = true;
+ debugPrint('Floatick could not restore sticky board $boardId: $error');
+ debugPrintStack(stackTrace: stackTrace);
+ }
}
+ _didRestorePinnedBoards =
+ !hadFailure && _restoredPinnedBoardIds.containsAll(pinnedBoardIds);
}
- Future pin(String boardId) async {
+ Future togglePin(String boardId) {
+ return _enqueueBoardWindowOperation(boardId, () async {
+ final board = _boards.boardById(boardId);
+ if (board == null) {
+ return;
+ }
+ if (board.isPinned) {
+ await _unpin(boardId);
+ } else {
+ await _pin(boardId);
+ }
+ });
+ }
+
+ Future pin(String boardId) {
+ return _enqueueBoardWindowOperation(boardId, () => _pin(boardId));
+ }
+
+ Future unpin(String boardId) {
+ return _enqueueBoardWindowOperation(boardId, () => _unpin(boardId));
+ }
+
+ Future _pin(String boardId) async {
final board = _boards.boardById(boardId);
if (board == null) {
return;
}
- if (!board.isPinned && !await _boards.setPinned(boardId, true)) {
- return;
- }
try {
- await _openWindow(boardId);
+ await _openWindow(boardId, positionAdjacentToMainWindow: true);
+ if (!board.isPinned && !await _boards.setPinned(boardId, true)) {
+ await _hideRegisteredWindowBestEffort(boardId);
+ return;
+ }
+ _restoredPinnedBoardIds.add(boardId);
} on Object catch (error, stackTrace) {
- await _boards.setPinned(boardId, false);
+ await _hideRegisteredWindowBestEffort(boardId);
debugPrint('Floatick could not pin sticky board $boardId: $error');
debugPrintStack(stackTrace: stackTrace);
}
}
- Future unpin(String boardId) async {
- final viewId = _windowIdsByBoardId.remove(boardId);
- if (viewId != null) {
- try {
- final window = MultiViewDesktop.fromId(viewId);
- await window.setPreventClose(false);
- await window.closeWindow();
- } on Object catch (error, stackTrace) {
- debugPrint('Floatick could not close sticky board $boardId: $error');
- debugPrintStack(stackTrace: stackTrace);
+ Future _unpin(String boardId) async {
+ _restoredPinnedBoardIds.remove(boardId);
+ _didRestorePinnedBoards = false;
+ if (!await _boards.setPinned(boardId, false)) {
+ return;
+ }
+ try {
+ await _hideRegisteredWindow(boardId);
+ } on Object catch (error, stackTrace) {
+ final restored = await _boards.setPinned(boardId, true);
+ if (restored) {
+ _restoredPinnedBoardIds.add(boardId);
+ } else {
+ debugPrint(
+ 'Floatick could not restore the pin state for sticky board $boardId.',
+ );
}
+ debugPrint('Floatick could not unpin sticky board $boardId: $error');
+ debugPrintStack(stackTrace: stackTrace);
}
- await _boards.setPinned(boardId, false);
}
Future deleteBoard(String boardId) async {
- final viewId = _windowIdsByBoardId.remove(boardId);
- if (viewId != null) {
- try {
- final window = MultiViewDesktop.fromId(viewId);
- await window.setPreventClose(false);
- await window.closeWindow();
- } on Object catch (error, stackTrace) {
- debugPrint(
- 'Floatick could not close deleted sticky board $boardId: $error',
- );
- debugPrintStack(stackTrace: stackTrace);
- }
+ _restoredPinnedBoardIds.remove(boardId);
+ _didRestorePinnedBoards = false;
+ final result = await _boards.deleteBoard(boardId);
+ if (result == StickyBoardMutationResult.success) {
+ await _hideRegisteredWindowBestEffort(boardId);
}
- return _boards.deleteBoard(boardId);
+ return result;
}
void registerWindow({required String boardId, required int viewId}) {
_windowIdsByBoardId[boardId] = viewId;
}
- void forgetWindow(String boardId) {
- _windowIdsByBoardId.remove(boardId);
+ void forgetWindow({required String boardId, required int viewId}) {
+ if (_windowIdsByBoardId[boardId] == viewId) {
+ _windowIdsByBoardId.remove(boardId);
+ }
}
Future saveWindowFrame({
@@ -132,7 +201,15 @@ class StickyBoardWindowCoordinator {
.then((_) {});
}
- Future _openWindow(String boardId) async {
+ Future _openWindow(
+ String boardId, {
+ bool positionAdjacentToMainWindow = false,
+ }) async {
+ final launcher = windowLauncher;
+ if (launcher != null) {
+ await launcher(boardId);
+ return;
+ }
final existingViewId = _windowIdsByBoardId[boardId];
if (existingViewId != null) {
await MultiViewDesktop.fromId(existingViewId).show();
@@ -144,6 +221,8 @@ class StickyBoardWindowCoordinator {
return;
}
final frame = board.windowFrame;
+ final shouldPositionAdjacent =
+ positionAdjacentToMainWindow || frame == null;
final viewId = await openWindow(
(context, id) => PinnedStickyBoardWindow(
boardId: boardId,
@@ -168,10 +247,58 @@ class StickyBoardWindowCoordinator {
);
_windowIdsByBoardId[boardId] = viewId;
final window = MultiViewDesktop.fromId(viewId);
- await window.setHasShadow(true);
+ await window.setHasShadow(false);
+ await windowBridge.configureBorderlessSecondaryWindow(
+ viewId,
+ positionAdjacentToMainWindow: shouldPositionAdjacent,
+ );
await window.setVisibleOnAllWorkspaces(true, visibleOnFullScreen: true);
- if (frame != null) {
+ if (frame != null && !shouldPositionAdjacent) {
await window.setPosition(Offset(frame.left, frame.top));
}
+ // The native window remains fully transparent while it is configured and
+ // positioned. Waiting for Flutter's first completed frame prevents the
+ // default AppKit window surface from flashing before the board is ready.
+ await WidgetsBinding.instance.endOfFrame;
+ await windowBridge.revealBorderlessSecondaryWindow(viewId);
+ }
+
+ Future _enqueueBoardWindowOperation(
+ String boardId,
+ Future Function() operation,
+ ) {
+ final previousOperation =
+ _boardWindowOperations[boardId] ?? Future.value();
+ final nextOperation = previousOperation.then(
+ (_) => operation(),
+ onError: (Object _, StackTrace _) => operation(),
+ );
+ _boardWindowOperations[boardId] = nextOperation;
+ return nextOperation.whenComplete(() {
+ if (identical(_boardWindowOperations[boardId], nextOperation)) {
+ _boardWindowOperations.remove(boardId);
+ }
+ });
+ }
+
+ Future _hideRegisteredWindow(String boardId) async {
+ final hider = windowHider;
+ if (hider != null) {
+ await hider(boardId);
+ return;
+ }
+ final viewId = _windowIdsByBoardId[boardId];
+ if (viewId != null) {
+ await MultiViewDesktop.fromId(viewId).hide();
+ }
+ }
+
+ Future _hideRegisteredWindowBestEffort(String boardId) async {
+ try {
+ await _hideRegisteredWindow(boardId);
+ } on Object catch (error, stackTrace) {
+ debugPrint('Floatick could not hide sticky board $boardId: $error');
+ debugPrintStack(stackTrace: stackTrace);
+ }
}
}
diff --git a/lib/features/sticky_boards/presentation/widgets/sticky_board_management_todo_row.dart b/lib/features/sticky_boards/presentation/widgets/sticky_board_management_todo_row.dart
new file mode 100644
index 0000000..0199987
--- /dev/null
+++ b/lib/features/sticky_boards/presentation/widgets/sticky_board_management_todo_row.dart
@@ -0,0 +1,239 @@
+import 'package:flutter/material.dart';
+
+import '../../../../l10n/l10n.dart';
+import '../../../todos/domain/todo_item.dart';
+import '../../../todos/domain/todo_tag.dart';
+import '../../../todos/presentation/widgets/floatick_tag_chip.dart';
+
+class StickyBoardManagementTodoRow extends StatefulWidget {
+ const StickyBoardManagementTodoRow({
+ required this.item,
+ required this.tags,
+ required this.assignedTagIds,
+ required this.onOpenDetails,
+ required this.onRemove,
+ super.key,
+ });
+
+ final TodoItem item;
+ final List tags;
+ final List assignedTagIds;
+ final VoidCallback onOpenDetails;
+ final VoidCallback onRemove;
+
+ @override
+ State createState() =>
+ _StickyBoardManagementTodoRowState();
+}
+
+class _StickyBoardManagementTodoRowState
+ extends State {
+ final FocusNode _rowFocusNode = FocusNode();
+
+ bool _isHovered = false;
+ bool _hasFocus = false;
+
+ @override
+ void dispose() {
+ _rowFocusNode.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final item = widget.item;
+ final theme = Theme.of(context);
+ final onSurface = theme.colorScheme.onSurface;
+ final reduceMotion = MediaQuery.disableAnimationsOf(context);
+ final showRemoveAction = _isHovered || _hasFocus;
+ final assignedIds = widget.assignedTagIds.toSet();
+ final assignedTags = widget.tags
+ .where((tag) => assignedIds.contains(tag.id))
+ .toList(growable: false);
+
+ return Focus(
+ focusNode: _rowFocusNode,
+ onFocusChange: (hasFocus) {
+ if (_hasFocus != hasFocus) {
+ setState(() => _hasFocus = hasFocus);
+ }
+ },
+ child: Semantics(
+ container: true,
+ label: item.title,
+ value: item.isCompleted
+ ? context.l10n.completedStatus
+ : context.l10n.incompleteStatus,
+ child: MouseRegion(
+ onEnter: (_) => setState(() => _isHovered = true),
+ onExit: (_) => setState(() => _isHovered = false),
+ child: AnimatedContainer(
+ duration: reduceMotion
+ ? Duration.zero
+ : const Duration(milliseconds: 150),
+ margin: const EdgeInsets.symmetric(vertical: 2),
+ padding: const EdgeInsets.fromLTRB(4, 6, 5, 6),
+ decoration: BoxDecoration(
+ color: _isHovered
+ ? (theme.brightness == Brightness.dark
+ ? Colors.white.withValues(alpha: 0.055)
+ : Colors.black.withValues(alpha: 0.035))
+ : Colors.transparent,
+ borderRadius: BorderRadius.circular(11),
+ ),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ Padding(
+ padding: const EdgeInsets.all(4),
+ child: Container(
+ key: ValueKey(
+ 'sticky-board-managed-completion-status-${item.id}',
+ ),
+ width: 21,
+ height: 21,
+ decoration: BoxDecoration(
+ color: item.isCompleted
+ ? theme.colorScheme.primary
+ : Colors.transparent,
+ borderRadius: BorderRadius.circular(7),
+ border: Border.all(
+ color: item.isCompleted
+ ? theme.colorScheme.primary
+ : onSurface.withValues(alpha: 0.28),
+ width: 1.4,
+ ),
+ ),
+ child: item.isCompleted
+ ? const Icon(
+ Icons.check_rounded,
+ size: 15,
+ color: Colors.white,
+ )
+ : null,
+ ),
+ ),
+ const SizedBox(width: 7),
+ Expanded(
+ child: MouseRegion(
+ cursor: SystemMouseCursors.click,
+ child: GestureDetector(
+ key: ValueKey(
+ 'sticky-board-managed-open-details-${item.id}',
+ ),
+ behavior: HitTestBehavior.opaque,
+ onDoubleTap: widget.onOpenDetails,
+ child: SizedBox(
+ height: 30,
+ child: Align(
+ alignment: Alignment.centerLeft,
+ child: Text(
+ item.title,
+ key: ValueKey(
+ 'sticky-board-managed-title-${item.id}',
+ ),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: theme.textTheme.bodyMedium?.copyWith(
+ color: onSurface.withValues(
+ alpha: item.isCompleted ? 0.45 : 0.91,
+ ),
+ fontWeight: FontWeight.w500,
+ decoration: item.isCompleted
+ ? TextDecoration.lineThrough
+ : null,
+ decorationColor: onSurface.withValues(
+ alpha: 0.42,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(width: 3),
+ SizedBox.square(
+ dimension: 30,
+ child: AnimatedOpacity(
+ duration: reduceMotion
+ ? Duration.zero
+ : const Duration(milliseconds: 140),
+ opacity: showRemoveAction ? 1 : 0,
+ child: IgnorePointer(
+ ignoring: !showRemoveAction,
+ child: ExcludeFocus(
+ excluding: !showRemoveAction,
+ child: IconButton(
+ key: ValueKey(
+ 'remove-from-board-${item.id}',
+ ),
+ tooltip:
+ context.l10n.removeFromStickyBoardTooltip,
+ onPressed: widget.onRemove,
+ padding: EdgeInsets.zero,
+ icon: const Icon(
+ Icons.remove_circle_outline_rounded,
+ size: 16,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 3),
+ Row(
+ key: ValueKey(
+ 'sticky-board-managed-metadata-${item.id}',
+ ),
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ const SizedBox(width: 36),
+ Expanded(
+ child: Wrap(
+ spacing: 4,
+ runSpacing: 3,
+ children: [
+ for (final tag in assignedTags)
+ FloatickTagChip(
+ key: ValueKey(
+ 'sticky-board-managed-tag-${item.id}-${tag.id}',
+ ),
+ tag: tag,
+ compact: true,
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(width: 7),
+ Text(
+ _formatTime(context, item.createdAt),
+ key: ValueKey(
+ 'sticky-board-managed-time-${item.id}',
+ ),
+ style: theme.textTheme.labelSmall?.copyWith(
+ color: onSurface.withValues(alpha: 0.35),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+String _formatTime(BuildContext context, DateTime date) {
+ return MaterialLocalizations.of(context).formatTimeOfDay(
+ TimeOfDay.fromDateTime(date.toLocal()),
+ alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context),
+ );
+}
diff --git a/lib/features/sticky_boards/presentation/widgets/sticky_board_read_only_todo_row.dart b/lib/features/sticky_boards/presentation/widgets/sticky_board_read_only_todo_row.dart
new file mode 100644
index 0000000..5a7688c
--- /dev/null
+++ b/lib/features/sticky_boards/presentation/widgets/sticky_board_read_only_todo_row.dart
@@ -0,0 +1,148 @@
+import 'package:flutter/material.dart';
+
+import '../../../../l10n/l10n.dart';
+import '../../../todos/domain/todo_item.dart';
+
+class StickyBoardReadOnlyTodoRow extends StatefulWidget {
+ const StickyBoardReadOnlyTodoRow({
+ required this.item,
+ required this.onToggleCompletion,
+ required this.onOpenDetails,
+ super.key,
+ });
+
+ final TodoItem item;
+ final VoidCallback onToggleCompletion;
+ final VoidCallback onOpenDetails;
+
+ @override
+ State createState() =>
+ _StickyBoardReadOnlyTodoRowState();
+}
+
+class _StickyBoardReadOnlyTodoRowState
+ extends State {
+ bool _isHovered = false;
+
+ @override
+ Widget build(BuildContext context) {
+ final item = widget.item;
+ final theme = Theme.of(context);
+ final onSurface = theme.colorScheme.onSurface;
+ final reduceMotion = MediaQuery.disableAnimationsOf(context);
+
+ return Semantics(
+ container: true,
+ label: item.title,
+ value: item.isCompleted
+ ? context.l10n.completedStatus
+ : context.l10n.incompleteStatus,
+ child: MouseRegion(
+ cursor: SystemMouseCursors.click,
+ onEnter: (_) => setState(() => _isHovered = true),
+ onExit: (_) => setState(() => _isHovered = false),
+ child: AnimatedContainer(
+ duration: reduceMotion
+ ? Duration.zero
+ : const Duration(milliseconds: 150),
+ margin: const EdgeInsets.symmetric(vertical: 2),
+ padding: const EdgeInsets.fromLTRB(4, 5, 8, 5),
+ decoration: BoxDecoration(
+ color: _isHovered
+ ? (theme.brightness == Brightness.dark
+ ? Colors.white.withValues(alpha: 0.055)
+ : Colors.black.withValues(alpha: 0.035))
+ : Colors.transparent,
+ borderRadius: BorderRadius.circular(11),
+ ),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ Semantics(
+ button: true,
+ checked: item.isCompleted,
+ label: item.title,
+ value: item.isCompleted
+ ? context.l10n.completedStatus
+ : context.l10n.incompleteStatus,
+ child: MouseRegion(
+ cursor: SystemMouseCursors.click,
+ child: GestureDetector(
+ key: ValueKey(
+ 'sticky-board-completion-toggle-${item.id}',
+ ),
+ behavior: HitTestBehavior.opaque,
+ onTap: widget.onToggleCompletion,
+ child: Padding(
+ padding: const EdgeInsets.all(4),
+ child: Container(
+ key: ValueKey(
+ 'sticky-board-completion-status-${item.id}',
+ ),
+ width: 21,
+ height: 21,
+ decoration: BoxDecoration(
+ color: item.isCompleted
+ ? theme.colorScheme.primary
+ : Colors.transparent,
+ borderRadius: BorderRadius.circular(7),
+ border: Border.all(
+ color: item.isCompleted
+ ? theme.colorScheme.primary
+ : onSurface.withValues(alpha: 0.28),
+ width: 1.4,
+ ),
+ ),
+ child: item.isCompleted
+ ? const Icon(
+ Icons.check_rounded,
+ size: 15,
+ color: Colors.white,
+ )
+ : null,
+ ),
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(width: 7),
+ Expanded(
+ child: GestureDetector(
+ key: ValueKey(
+ 'sticky-board-open-details-region-${item.id}',
+ ),
+ behavior: HitTestBehavior.opaque,
+ onDoubleTap: widget.onOpenDetails,
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(minHeight: 29),
+ child: Align(
+ alignment: Alignment.centerLeft,
+ child: Text(
+ item.title,
+ key: ValueKey(
+ 'sticky-board-todo-title-${item.id}',
+ ),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: theme.textTheme.bodyMedium?.copyWith(
+ color: onSurface.withValues(
+ alpha: item.isCompleted ? 0.45 : 0.91,
+ ),
+ fontWeight: FontWeight.w500,
+ decoration: item.isCompleted
+ ? TextDecoration.lineThrough
+ : null,
+ decorationColor: onSurface.withValues(alpha: 0.42),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/features/sticky_boards/presentation/widgets/sticky_board_todo_details.dart b/lib/features/sticky_boards/presentation/widgets/sticky_board_todo_details.dart
new file mode 100644
index 0000000..1db8f0b
--- /dev/null
+++ b/lib/features/sticky_boards/presentation/widgets/sticky_board_todo_details.dart
@@ -0,0 +1,131 @@
+import 'package:flutter/material.dart';
+
+import '../../../../l10n/l10n.dart';
+import '../../../todos/domain/todo_item.dart';
+import '../../../todos/domain/todo_tag.dart';
+import '../../../todos/presentation/widgets/floatick_tag_chip.dart';
+import '../../../todos/presentation/widgets/todo_markdown.dart';
+
+class StickyBoardTodoDetails extends StatelessWidget {
+ const StickyBoardTodoDetails({
+ required this.item,
+ required this.tags,
+ required this.onBack,
+ super.key,
+ });
+
+ final TodoItem item;
+ final List tags;
+ final VoidCallback onBack;
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final onSurface = theme.colorScheme.onSurface;
+
+ return Column(
+ key: const Key('sticky-board-todo-details'),
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Padding(
+ padding: const EdgeInsets.fromLTRB(8, 7, 8, 6),
+ child: Row(
+ children: [
+ IconButton(
+ key: const Key('sticky-board-details-back'),
+ tooltip: MaterialLocalizations.of(context).backButtonTooltip,
+ onPressed: onBack,
+ icon: const Icon(Icons.arrow_back_rounded, size: 18),
+ ),
+ const SizedBox(width: 2),
+ Expanded(
+ child: Text(
+ context.l10n.todoDetailsDrawerTitle,
+ style: theme.textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ Divider(height: 1, color: onSurface.withValues(alpha: 0.08)),
+ Padding(
+ padding: const EdgeInsets.fromLTRB(18, 16, 18, 2),
+ child: Text(
+ item.title,
+ key: const Key('sticky-board-details-title'),
+ style: theme.textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ),
+ if (tags.isNotEmpty)
+ Padding(
+ padding: const EdgeInsets.fromLTRB(18, 10, 18, 2),
+ child: Wrap(
+ key: const Key('sticky-board-details-tags'),
+ spacing: 6,
+ runSpacing: 5,
+ children: [
+ for (final tag in tags)
+ FloatickTagChip(
+ key: ValueKey('sticky-board-details-tag-${tag.id}'),
+ tag: tag,
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 8),
+ Expanded(
+ child: item.content.trim().isEmpty
+ ? _EmptyStickyBoardTodoContent(onSurface: onSurface)
+ : TodoMarkdownContent(
+ key: const Key('sticky-board-details-markdown'),
+ content: item.content,
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+class _EmptyStickyBoardTodoContent extends StatelessWidget {
+ const _EmptyStickyBoardTodoContent({required this.onSurface});
+
+ final Color onSurface;
+
+ @override
+ Widget build(BuildContext context) {
+ return Center(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(
+ Icons.notes_rounded,
+ size: 28,
+ color: onSurface.withValues(alpha: 0.28),
+ ),
+ const SizedBox(height: 9),
+ Text(
+ context.l10n.noTodoContentTitle,
+ style: Theme.of(
+ context,
+ ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ context.l10n.noTodoContentMessage,
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: onSurface.withValues(alpha: 0.48),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/features/todos/data/first_run_workspace_seeder.dart b/lib/features/todos/data/first_run_workspace_seeder.dart
new file mode 100644
index 0000000..774068a
--- /dev/null
+++ b/lib/features/todos/data/first_run_workspace_seeder.dart
@@ -0,0 +1,156 @@
+import 'dart:io';
+
+import '../../../core/storage/storage_failure.dart';
+import '../domain/tag_workspace.dart';
+import '../domain/todo_item.dart';
+import '../domain/todo_tag.dart';
+import 'tag_repository.dart';
+import 'todo_repository.dart';
+
+class FirstRunWorkspaceSeeder {
+ FirstRunWorkspaceSeeder({
+ required LocalTodoRepository todoRepository,
+ required LocalTagRepository tagRepository,
+ required String languageCode,
+ DateTime Function()? clock,
+ }) : // Public named parameters cannot use the private field identifiers.
+ // ignore: prefer_initializing_formals
+ _todoRepository = todoRepository,
+ // ignore: prefer_initializing_formals
+ _tagRepository = tagRepository,
+ _copy = _WelcomeCopy.forLanguageCode(languageCode),
+ _clock = clock ?? DateTime.now;
+
+ static const _welcomeTodoId = 'floatick-welcome-todo';
+ static const _tryTodoId = 'floatick-try-todo';
+ static const _welcomeTagId = 'floatick-welcome-tag';
+ static const _tryTagId = 'floatick-try-tag';
+ static const _welcomeTagColor = 0xFF20B8A8;
+ static const _tryTagColor = 0xFF4C8FF5;
+
+ final LocalTodoRepository _todoRepository;
+ final LocalTagRepository _tagRepository;
+ final _WelcomeCopy _copy;
+ final DateTime Function() _clock;
+
+ Future seedIfNeeded() async {
+ final todoStorage = File(_todoRepository.storagePath);
+ final tagStorage = File(_tagRepository.storagePath);
+
+ try {
+ final storageAlreadyExists =
+ await todoStorage.exists() || await tagStorage.exists();
+ if (storageAlreadyExists) {
+ return false;
+ }
+ } on FileSystemException catch (error) {
+ throw StorageFailure(
+ kind: StorageFailureKind.read,
+ path: _todoRepository.rootDirectory.path,
+ cause: error,
+ );
+ }
+
+ final now = _clock();
+ final items = [
+ TodoItem(
+ id: _welcomeTodoId,
+ title: _copy.welcomeTitle,
+ content: _copy.welcomeContent,
+ createdAt: now,
+ ),
+ TodoItem(
+ id: _tryTodoId,
+ title: _copy.tryTitle,
+ content: _copy.tryContent,
+ createdAt: now.subtract(const Duration(minutes: 1)),
+ ),
+ ];
+ final workspace = TagWorkspace(
+ tags: [
+ TodoTag(
+ id: _welcomeTagId,
+ name: _copy.welcomeTag,
+ colorValue: _welcomeTagColor,
+ createdAt: now,
+ ),
+ TodoTag(
+ id: _tryTagId,
+ name: _copy.tryTag,
+ colorValue: _tryTagColor,
+ createdAt: now,
+ ),
+ ],
+ assignments: const >{
+ _welcomeTodoId: [_welcomeTagId],
+ _tryTodoId: [_tryTagId],
+ },
+ );
+
+ try {
+ await _tagRepository.save(workspace);
+ await _todoRepository.save(items);
+ return true;
+ } on StorageFailure catch (error, stackTrace) {
+ await _removePartialSeed(todoStorage, tagStorage);
+ Error.throwWithStackTrace(error, stackTrace);
+ }
+ }
+
+ Future _removePartialSeed(File todoStorage, File tagStorage) async {
+ try {
+ for (final file in [todoStorage, tagStorage]) {
+ if (await file.exists()) {
+ await file.delete();
+ }
+ }
+ } on FileSystemException catch (error) {
+ throw StorageFailure(
+ kind: StorageFailureKind.write,
+ path: _todoRepository.rootDirectory.path,
+ cause: error,
+ );
+ }
+ }
+}
+
+class _WelcomeCopy {
+ const _WelcomeCopy({
+ required this.welcomeTitle,
+ required this.welcomeContent,
+ required this.welcomeTag,
+ required this.tryTitle,
+ required this.tryContent,
+ required this.tryTag,
+ });
+
+ factory _WelcomeCopy.forLanguageCode(String languageCode) {
+ if (languageCode.toLowerCase().startsWith('zh')) {
+ return const _WelcomeCopy(
+ welcomeTitle: '欢迎使用 Floatick',
+ welcomeContent: '点击「+ 新建」创建第一条待办,再用标签把它整理得井井有条。',
+ welcomeTag: '欢迎',
+ tryTitle: '试试完成这条待办',
+ tryContent: '双击待办查看详情;将鼠标悬浮到待办上,可以编辑或归档,完成后试着勾选它。',
+ tryTag: '快速上手',
+ );
+ }
+ return const _WelcomeCopy(
+ welcomeTitle: 'Welcome to Floatick',
+ welcomeContent:
+ 'Choose “+ New” to create your first todo, then use tags to keep it organized.',
+ welcomeTag: 'Welcome',
+ tryTitle: 'Try completing this todo',
+ tryContent:
+ 'Double-click a todo for details. Hover over it to edit or archive it, then check it off.',
+ tryTag: 'Start here',
+ );
+ }
+
+ final String welcomeTitle;
+ final String welcomeContent;
+ final String welcomeTag;
+ final String tryTitle;
+ final String tryContent;
+ final String tryTag;
+}
diff --git a/lib/features/todos/presentation/tag_filter_drawer.dart b/lib/features/todos/presentation/tag_filter_drawer.dart
index c60b1de..c2a63f7 100644
--- a/lib/features/todos/presentation/tag_filter_drawer.dart
+++ b/lib/features/todos/presentation/tag_filter_drawer.dart
@@ -1,25 +1,23 @@
import 'package:flutter/material.dart';
import '../../../l10n/l10n.dart';
-import '../domain/todo_tag.dart';
import 'todo_view_model.dart';
-import 'widgets/tag_palette.dart';
+import 'widgets/tag_selection_row.dart';
enum TagDrawerSelectionMode { filter, assignment }
class TagFilterDrawer extends StatelessWidget {
const TagFilterDrawer.filter({
required this.controller,
- required this.selectedTagId,
+ required this.selectedTagIds,
required this.borderOnLeft,
- required this.onSelected,
+ required this.onToggled,
+ required this.onClear,
required this.onManageTags,
required this.onClose,
required this.closeFocusNode,
super.key,
- }) : mode = TagDrawerSelectionMode.filter,
- selectedTagIds = const {},
- onToggled = null;
+ }) : mode = TagDrawerSelectionMode.filter;
const TagFilterDrawer.assignment({
required this.controller,
@@ -31,16 +29,14 @@ class TagFilterDrawer extends StatelessWidget {
required this.closeFocusNode,
super.key,
}) : mode = TagDrawerSelectionMode.assignment,
- selectedTagId = null,
- onSelected = null;
+ onClear = null;
final TagDrawerSelectionMode mode;
final TodoViewModel controller;
- final String? selectedTagId;
final Set selectedTagIds;
final bool borderOnLeft;
- final ValueChanged? onSelected;
- final ValueChanged