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? onToggled; + final ValueChanged onToggled; + final VoidCallback? onClear; final VoidCallback onManageTags; final VoidCallback onClose; final FocusNode closeFocusNode; @@ -75,12 +71,13 @@ class TagFilterDrawer extends StatelessWidget { animation: controller, builder: (context, _) { final tags = controller.tags; - final effectiveSelectedTagId = - tags.any((tag) => tag.id == selectedTagId) ? selectedTagId : null; final knownTagIds = tags.map((tag) => tag.id).toSet(); final effectiveSelectedTagIds = selectedTagIds .where(knownTagIds.contains) .toSet(); + final usageCounts = controller.tagUsageCountsFor( + tags.map((tag) => tag.id), + ); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -135,48 +132,62 @@ class TagFilterDrawer extends StatelessWidget { : Colors.black.withValues(alpha: 0.06), ), Expanded( - child: ListView( - padding: const EdgeInsets.fromLTRB(10, 10, 10, 14), - children: [ - if (!isAssignment) - _TagFilterRow( - key: const Key('tag-filter-all'), - label: context.l10n.allTagsFilterLabel, - selected: effectiveSelectedTagId == null, - onPressed: () => onSelected!(null), - ), - if (tags.isEmpty) - Padding( - padding: const EdgeInsets.fromLTRB(18, 28, 18, 16), - child: Text( - context.l10n.noTagsToFilterMessage, - textAlign: TextAlign.center, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurface.withValues( - alpha: 0.46, + child: tags.isEmpty + ? ListView( + padding: const EdgeInsets.fromLTRB(10, 10, 10, 14), + children: [ + if (!isAssignment) + SizedBox( + height: tagSelectionRowExtent, + child: TagSelectionRow( + key: const Key('tag-filter-all'), + label: context.l10n.allTagsFilterLabel, + selected: effectiveSelectedTagIds.isEmpty, + onPressed: onClear!, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(18, 28, 18, 16), + child: Text( + context.l10n.noTagsToFilterMessage, + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withValues( + alpha: 0.46, + ), + height: 1.4, + ), ), - height: 1.4, ), - ), + ], ) - else - for (final tag in tags) - _TagFilterRow( - key: ValueKey( - '${isAssignment ? 'tag-assignment' : 'tag-filter'}-${tag.id}', - ), - tag: tag, - label: tag.name, - trailing: '${controller.tagUsageCount(tag.id)}', - selected: isAssignment - ? effectiveSelectedTagIds.contains(tag.id) - : effectiveSelectedTagId == tag.id, - onPressed: isAssignment - ? () => onToggled!(tag.id) - : () => onSelected!(tag.id), - ), - ], - ), + : ListView.builder( + key: const Key('tag-filter-list'), + padding: const EdgeInsets.fromLTRB(10, 10, 10, 14), + itemExtent: tagSelectionRowExtent, + itemCount: tags.length + (isAssignment ? 0 : 1), + itemBuilder: (context, index) { + if (!isAssignment && index == 0) { + return TagSelectionRow( + key: const Key('tag-filter-all'), + label: context.l10n.allTagsFilterLabel, + selected: effectiveSelectedTagIds.isEmpty, + onPressed: onClear!, + ); + } + final tag = tags[index - (isAssignment ? 0 : 1)]; + return TagSelectionRow( + key: ValueKey( + '${isAssignment ? 'tag-assignment' : 'tag-filter'}-${tag.id}', + ), + tag: tag, + label: tag.name, + trailing: '${usageCounts[tag.id] ?? 0}', + selected: effectiveSelectedTagIds.contains(tag.id), + onPressed: () => onToggled(tag.id), + ); + }, + ), ), ], ); @@ -185,108 +196,3 @@ class TagFilterDrawer extends StatelessWidget { ); } } - -class _TagFilterRow extends StatelessWidget { - const _TagFilterRow({ - required this.label, - required this.selected, - required this.onPressed, - this.tag, - this.trailing, - super.key, - }); - - final TodoTag? tag; - final String label; - final String? trailing; - final bool selected; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final tagColor = tag == null ? null : TagPalette.color(tag!.colorValue); - return Semantics( - button: true, - selected: selected, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: InkWell( - onTap: onPressed, - borderRadius: BorderRadius.circular(10), - hoverColor: theme.colorScheme.primary.withValues(alpha: 0.07), - child: AnimatedContainer( - duration: MediaQuery.disableAnimationsOf(context) - ? Duration.zero - : const Duration(milliseconds: 160), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), - decoration: BoxDecoration( - color: selected - ? theme.colorScheme.primary.withValues(alpha: 0.09) - : Colors.transparent, - borderRadius: BorderRadius.circular(10), - ), - child: Row( - children: [ - SizedBox( - width: 18, - child: tagColor == null - ? Icon( - Icons.layers_outlined, - size: 15, - color: theme.colorScheme.onSurface.withValues( - alpha: 0.44, - ), - ) - : Center( - child: Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: tagColor, - shape: BoxShape.circle, - ), - ), - ), - ), - const SizedBox(width: 9), - Expanded( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: selected ? FontWeight.w600 : FontWeight.w500, - ), - ), - ), - if (trailing != null) ...[ - const SizedBox(width: 8), - Text( - trailing!, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurface.withValues( - alpha: 0.40, - ), - ), - ), - ], - const SizedBox(width: 10), - SizedBox( - width: 18, - child: selected - ? Icon( - Icons.check_rounded, - size: 17, - color: theme.colorScheme.primary, - ) - : null, - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/lib/features/todos/presentation/tag_management_drawer.dart b/lib/features/todos/presentation/tag_management_drawer.dart index 3f9158c..89fed1a 100644 --- a/lib/features/todos/presentation/tag_management_drawer.dart +++ b/lib/features/todos/presentation/tag_management_drawer.dart @@ -3,12 +3,15 @@ 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 '../domain/todo_tag.dart'; import 'todo_view_model.dart'; import 'widgets/floatick_tag_chip.dart'; import 'widgets/tag_palette.dart'; +const double _managedTagRowExtent = 44; + class TagManagementDrawer extends StatefulWidget { const TagManagementDrawer({ required this.controller, @@ -210,6 +213,9 @@ class _TagManagementDrawerState extends State { return query.isEmpty || tag.name.toLowerCase().contains(query); }) .toList(growable: false); + final usageCounts = widget.controller.tagUsageCountsFor( + filteredTags.map((tag) => tag.id), + ); final canSubmit = !_isSaving; return Column( @@ -380,28 +386,32 @@ class _TagManagementDrawerState extends State { Expanded( child: filteredTags.isEmpty ? _EmptyTagResults(hasQuery: query.isNotEmpty) - : ListView.separated( + : ListView.builder( + key: const Key('tag-management-list'), padding: const EdgeInsets.fromLTRB(10, 9, 10, 14), + itemExtent: _managedTagRowExtent, itemCount: filteredTags.length, - separatorBuilder: (_, _) => const SizedBox(height: 2), itemBuilder: (context, index) { final tag = filteredTags[index]; - return _ManagedTagRow( - key: ValueKey('managed-tag-${tag.id}'), - tag: tag, - usageCount: widget.controller.tagUsageCount(tag.id), - isEditing: _editingTagId == tag.id, - isConfirmingDelete: _pendingDeleteTagId == tag.id, - enabled: !_isSaving, - onEdit: () => _beginEditing(tag), - onRequestDelete: () { - setState(() => _pendingDeleteTagId = tag.id); - }, - onCancelDelete: () { - setState(() => _pendingDeleteTagId = null); - }, - onConfirmDelete: () => - unawaited(_confirmDelete(tag.id)), + return Padding( + padding: const EdgeInsets.only(bottom: 2), + child: _ManagedTagRow( + key: ValueKey('managed-tag-${tag.id}'), + tag: tag, + usageCount: usageCounts[tag.id] ?? 0, + isEditing: _editingTagId == tag.id, + isConfirmingDelete: _pendingDeleteTagId == tag.id, + enabled: !_isSaving, + onEdit: () => _beginEditing(tag), + onRequestDelete: () { + setState(() => _pendingDeleteTagId = tag.id); + }, + onCancelDelete: () { + setState(() => _pendingDeleteTagId = null); + }, + onConfirmDelete: () => + unawaited(_confirmDelete(tag.id)), + ), ); }, ), @@ -434,8 +444,10 @@ class _ColorButton extends StatelessWidget { button: true, selected: selected, label: context.l10n.tagColorSemanticsLabel, - child: MouseRegion( - cursor: enabled ? SystemMouseCursors.click : SystemMouseCursors.basic, + child: FloatickHoverMotion( + enabled: enabled, + hoverScale: FloatickMotion.swatchHoverScale, + pressedScale: FloatickMotion.swatchPressedScale, child: GestureDetector( onTap: enabled ? onPressed : null, child: AnimatedContainer( diff --git a/lib/features/todos/presentation/todo_editor_drawer.dart b/lib/features/todos/presentation/todo_editor_drawer.dart index 0b18208..e5cfffb 100644 --- a/lib/features/todos/presentation/todo_editor_drawer.dart +++ b/lib/features/todos/presentation/todo_editor_drawer.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import '../../../core/ui/floatick_hover_motion.dart'; import '../../../l10n/l10n.dart'; import '../domain/todo_item.dart'; import '../domain/todo_tag.dart'; @@ -26,6 +27,7 @@ class TodoEditorDrawer extends StatefulWidget { required this.onSave, required this.onSaved, required this.closeFocusNode, + this.canEdit = true, super.key, }); @@ -35,6 +37,7 @@ class TodoEditorDrawer extends StatefulWidget { final List originalAssignedTagIds; final List assignedTagIds; final bool isOpen; + final bool canEdit; final VoidCallback onClose; final VoidCallback onEdit; final VoidCallback onOpenTagAssignment; @@ -81,11 +84,11 @@ class _TodoEditorDrawerState extends State { oldWidget.item?.content != widget.item?.content; final didOpen = !oldWidget.isOpen && widget.isOpen; if (changedContext) { + _formKey.currentState?.reset(); _syncControllers(); _showPreview = false; _isSaving = false; _saveFailed = false; - _formKey.currentState?.reset(); } if (widget.isOpen && (changedContext || didOpen)) { _requestInitialFocus(); @@ -192,13 +195,6 @@ class _TodoEditorDrawerState extends State { : Colors.black.withValues(alpha: 0.07), ), ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: isDark ? 0.28 : 0.12), - blurRadius: 28, - offset: const Offset(0, -8), - ), - ], ), child: ClipRRect( borderRadius: const BorderRadius.vertical(top: Radius.circular(22)), @@ -207,6 +203,7 @@ class _TodoEditorDrawerState extends State { children: [ _DrawerHeader( mode: widget.mode, + canEdit: widget.canEdit, onEdit: widget.onEdit, onClose: widget.onClose, closeFocusNode: widget.closeFocusNode, @@ -231,6 +228,7 @@ class _TodoEditorDrawerState extends State { 'details-${widget.item?.id ?? 'missing'}', ), item: widget.item, + canEdit: widget.canEdit, tags: availableTags .where( (tag) => widget.assignedTagIds.contains(tag.id), @@ -275,12 +273,14 @@ class _TodoEditorDrawerState extends State { class _DrawerHeader extends StatelessWidget { const _DrawerHeader({ required this.mode, + required this.canEdit, required this.onEdit, required this.onClose, required this.closeFocusNode, }); final TodoEditorDrawerMode mode; + final bool canEdit; final VoidCallback onEdit; final VoidCallback onClose; final FocusNode closeFocusNode; @@ -304,19 +304,12 @@ class _DrawerHeader extends StatelessWidget { ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), ), ), - if (mode == TodoEditorDrawerMode.details) - TextButton( + if (mode == TodoEditorDrawerMode.details && canEdit) + IconButton( key: const Key('todo-details-edit'), onPressed: onEdit, - style: TextButton.styleFrom( - minimumSize: const Size(0, 36), - padding: const EdgeInsets.symmetric(horizontal: 10), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - textStyle: Theme.of( - context, - ).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600), - ), - child: Text(context.l10n.editTodoAction), + tooltip: context.l10n.editTodoAction, + icon: const Icon(Icons.edit_outlined, size: 19), ), IconButton( key: const Key('todo-drawer-close'), @@ -659,28 +652,32 @@ class _EditorModeButton extends StatelessWidget { return Semantics( button: true, selected: selected, - child: InkWell( - onTap: onPressed, - borderRadius: BorderRadius.circular(7), - child: AnimatedContainer( - duration: MediaQuery.disableAnimationsOf(context) - ? Duration.zero - : const Duration(milliseconds: 140), - alignment: Alignment.center, - padding: const EdgeInsets.symmetric(horizontal: 9), - decoration: BoxDecoration( - color: selected - ? theme.colorScheme.surface.withValues(alpha: 0.92) - : Colors.transparent, - borderRadius: BorderRadius.circular(7), - ), - child: Text( - label, - style: theme.textTheme.labelSmall?.copyWith( + child: FloatickHoverMotion( + hoverScale: FloatickMotion.controlHoverScale, + pressedScale: FloatickMotion.controlPressedScale, + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(7), + child: AnimatedContainer( + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 140), + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: 9), + decoration: BoxDecoration( color: selected - ? theme.colorScheme.onSurface - : theme.colorScheme.onSurface.withValues(alpha: 0.52), - fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + ? theme.colorScheme.surface.withValues(alpha: 0.92) + : Colors.transparent, + borderRadius: BorderRadius.circular(7), + ), + child: Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: selected + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurface.withValues(alpha: 0.52), + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + ), ), ), ), @@ -690,10 +687,16 @@ class _EditorModeButton extends StatelessWidget { } class _TodoDetails extends StatelessWidget { - const _TodoDetails({required this.item, required this.tags, super.key}); + const _TodoDetails({ + required this.item, + required this.tags, + required this.canEdit, + super.key, + }); final TodoItem? item; final List tags; + final bool canEdit; @override Widget build(BuildContext context) { @@ -731,7 +734,7 @@ class _TodoDetails extends StatelessWidget { const SizedBox(height: 14), Expanded( child: item.content.trim().isEmpty - ? const _EmptyTodoContent() + ? _EmptyTodoContent(canEdit: canEdit) : TodoMarkdownContent( key: const Key('todo-details-markdown'), content: item.content, @@ -744,7 +747,9 @@ class _TodoDetails extends StatelessWidget { } class _EmptyTodoContent extends StatelessWidget { - const _EmptyTodoContent(); + const _EmptyTodoContent({required this.canEdit}); + + final bool canEdit; @override Widget build(BuildContext context) { @@ -769,7 +774,9 @@ class _EmptyTodoContent extends StatelessWidget { ), const SizedBox(height: 5), Text( - context.l10n.noTodoContentMessage, + canEdit + ? context.l10n.noTodoContentMessage + : context.l10n.archivedTodoNoContentMessage, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: onSurface.withValues(alpha: 0.48), diff --git a/lib/features/todos/presentation/todo_panel.dart b/lib/features/todos/presentation/todo_panel.dart index 05c7ddf..782872e 100644 --- a/lib/features/todos/presentation/todo_panel.dart +++ b/lib/features/todos/presentation/todo_panel.dart @@ -3,8 +3,10 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import '../../../app/theme/floatick_theme.dart'; import '../../../core/platform/window_bridge.dart'; import '../../../core/ui/floatick_brand_mark.dart'; +import '../../../core/ui/floatick_surface_metrics.dart'; import '../../../l10n/l10n.dart'; import '../../../l10n/storage_failure_localizations.dart'; import '../../settings/presentation/settings_drawer.dart'; @@ -18,13 +20,9 @@ import 'tag_filter_drawer.dart'; import 'tag_management_drawer.dart'; import 'todo_editor_drawer.dart'; import 'todo_view_model.dart'; -import 'widgets/floatick_tag_chip.dart'; import 'widgets/tag_menus.dart'; import 'widgets/todo_list_row.dart'; -const double _panelWindowInset = 8; -const double _panelOuterRadius = 26; -const double _panelContentRadius = 25; const double _settingsDrawerWidth = 268; const double _tagDrawerWidth = 292; const double _stickyBoardDrawerWidth = 336; @@ -48,6 +46,8 @@ enum _TodoPanelDrawerMode { editTodo, } +enum _TodoPanelDrawerFamily { settings, tags, stickyBoards, todoEditor } + class TodoPanel extends StatefulWidget { const TodoPanel({ required this.controller, @@ -57,7 +57,7 @@ class TodoPanel extends StatefulWidget { required this.stickyBoardWindowCoordinator, required this.windowBridge, required this.expansionAnchor, - required this.requestedStickyBoardId, + required this.stickyBoardRequest, required this.stickyBoardRequestSerial, required this.onCollapse, super.key, @@ -70,7 +70,7 @@ class TodoPanel extends StatefulWidget { final StickyBoardWindowCoordinator stickyBoardWindowCoordinator; final WindowBridge windowBridge; final WindowExpansionAnchor expansionAnchor; - final String? requestedStickyBoardId; + final StickyBoardMainWindowRequest? stickyBoardRequest; final int stickyBoardRequestSerial; final VoidCallback onCollapse; @@ -91,19 +91,24 @@ class _TodoPanelState extends State { TodoListScope _scope = TodoListScope.active; String _query = ''; - String? _selectedTagId; + final Set _selectedTagIds = {}; String? _selectedTodoId; _TodoPanelDrawerMode _drawerMode = _TodoPanelDrawerMode.none; + _TodoPanelDrawerMode? _pendingDrawerMode; _TodoPanelDrawerMode _lastTagDrawerMode = _TodoPanelDrawerMode.tagFilter; _TodoPanelDrawerMode _lastTodoDrawerMode = _TodoPanelDrawerMode.createTodo; _TodoPanelDrawerMode? _tagManagementReturnMode; _TodoPanelDrawerMode? _tagAssignmentReturnMode; _TodoPanelDrawerMode? _todoDrawerReturnMode; Set _todoEditorTagIds = {}; + final Set<_TodoPanelDrawerFamily> _mountedDrawerFamilies = + <_TodoPanelDrawerFamily>{}; String? _selectedStickyBoardId; String? _todoCreationBoardId; + String? _pendingCreatedTodoId; int _todoEditorSession = 0; int _lastHandledStickyBoardRequestSerial = -1; + int _drawerRequestSerial = 0; @override void initState() { @@ -137,24 +142,86 @@ class _TodoPanelState extends State { _showDrawer(_TodoPanelDrawerMode.settings); } + void _toggleArchiveScope() { + setState(() { + _scope = _scope == TodoListScope.active + ? TodoListScope.archived + : TodoListScope.active; + }); + } + void _handleRequestedStickyBoard() { if (_lastHandledStickyBoardRequestSerial == widget.stickyBoardRequestSerial) { return; } _lastHandledStickyBoardRequestSerial = widget.stickyBoardRequestSerial; - final boardId = widget.requestedStickyBoardId; - if (boardId == null || - widget.stickyBoardController.boardById(boardId) == null) { + final request = widget.stickyBoardRequest; + if (request == null || + widget.stickyBoardController.boardById(request.boardId) == null) { return; } WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _openStickyBoard(boardId); + if (!mounted) { + return; } + _openRequestedStickyBoard(request); }); } + void _openRequestedStickyBoard(StickyBoardMainWindowRequest request) { + final boardId = request.boardId; + final todoId = request.todoId; + if (widget.stickyBoardController.boardById(boardId) == null) { + return; + } + if (todoId != null && widget.controller.itemById(todoId) == null) { + return; + } + + _selectedStickyBoardId = boardId; + if (!_mountedDrawerFamilies.contains(_TodoPanelDrawerFamily.stickyBoards)) { + setState( + () => _mountedDrawerFamilies.add(_TodoPanelDrawerFamily.stickyBoards), + ); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _performStickyBoardRequest(request); + } + }); + return; + } + _performStickyBoardRequest(request); + } + + void _performStickyBoardRequest(StickyBoardMainWindowRequest request) { + final todoId = request.todoId; + switch (request.destination) { + case StickyBoardMainWindowDestination.board: + _openStickyBoard(request.boardId); + case StickyBoardMainWindowDestination.todoDetails: + if (todoId == null) { + return; + } + _todoDrawerReturnMode = _TodoPanelDrawerMode.stickyBoardDetail; + _showTodoDrawer( + _TodoPanelDrawerMode.todoDetails, + todoId: todoId, + initialTagIds: widget.controller.tagIdsForTodo(todoId), + ); + case StickyBoardMainWindowDestination.todoEdit: + if (todoId == null) { + return; + } + _todoDrawerReturnMode = _TodoPanelDrawerMode.stickyBoardDetail; + _showTodoDrawer( + _TodoPanelDrawerMode.editTodo, + todoId: todoId, + initialTagIds: widget.controller.tagIdsForTodo(todoId), + ); + } + } + void _openStickyBoards() { _selectedStickyBoardId = null; _showDrawer(_TodoPanelDrawerMode.stickyBoardManagement); @@ -164,11 +231,12 @@ class _TodoPanelState extends State { if (widget.stickyBoardController.boardById(boardId) == null) { return; } - setState(() { - _selectedStickyBoardId = boardId; - _drawerMode = _TodoPanelDrawerMode.stickyBoardDetail; - }); - _requestDrawerFocus(_TodoPanelDrawerMode.stickyBoardDetail); + if (_drawerMode == _TodoPanelDrawerMode.stickyBoardDetail) { + setState(() => _selectedStickyBoardId = boardId); + return; + } + _selectedStickyBoardId = boardId; + _showDrawer(_TodoPanelDrawerMode.stickyBoardDetail); } void _openStickyBoardTodoPicker() { @@ -213,6 +281,7 @@ class _TodoPanelState extends State { void _openTodoCreate({String? stickyBoardId}) { _todoCreationBoardId = stickyBoardId; + _pendingCreatedTodoId = null; _todoDrawerReturnMode = stickyBoardId == null ? null : _TodoPanelDrawerMode.stickyBoardDetail; @@ -246,6 +315,90 @@ class _TodoPanelState extends State { ); } + Future _deleteArchivedTodoPermanently(String todoId) async { + final boardIds = widget.stickyBoardController.boards + .where( + (board) => widget.stickyBoardController + .todoIdsForBoard(board.id) + .contains(todoId), + ) + .map((board) => board.id) + .toList(growable: false); + final removedFromBoards = await widget.stickyBoardController + .removeTodoFromAllBoards(todoId); + if (!removedFromBoards) { + return; + } + final deleted = await widget.controller.deletePermanently(todoId); + if (!deleted) { + if (widget.controller.itemById(todoId) != null) { + for (final boardId in boardIds) { + await widget.stickyBoardController.addTodo( + boardId: boardId, + todoId: todoId, + ); + } + } + return; + } + if (mounted && _selectedTodoId == todoId) { + _closeActiveDrawer(); + } + } + + Future _saveCreatedTodo({ + required String title, + required String content, + required Iterable tagIds, + }) async { + final boardId = _todoCreationBoardId; + var todoId = _pendingCreatedTodoId; + if (todoId == null) { + final todoIdsBeforeSave = widget.controller.items + .map((item) => item.id) + .toSet(); + final item = await widget.controller.create( + title, + content: content, + tagIds: tagIds, + ); + if (item == null) { + final partiallySavedItems = widget.controller.items + .where((item) => !todoIdsBeforeSave.contains(item.id)) + .toList(growable: false); + if (partiallySavedItems.length == 1) { + _pendingCreatedTodoId = partiallySavedItems.single.id; + } + return false; + } + todoId = item.id; + _pendingCreatedTodoId = todoId; + } else { + final updated = await widget.controller.updateDetails( + id: todoId, + title: title, + content: content, + tagIds: tagIds, + ); + if (!updated) { + return false; + } + } + + if (boardId == null) { + _pendingCreatedTodoId = null; + return true; + } + final linked = await widget.stickyBoardController.addTodo( + boardId: boardId, + todoId: todoId, + ); + if (linked) { + _pendingCreatedTodoId = null; + } + return linked; + } + void _openTagAssignmentFromTodo() { if (_drawerMode != _TodoPanelDrawerMode.createTodo && _drawerMode != _TodoPanelDrawerMode.editTodo) { @@ -263,11 +416,6 @@ class _TodoPanelState extends State { _showDrawer(_TodoPanelDrawerMode.tagManagement); } - void _openTagManagementFromStickyBoard() { - _tagManagementReturnMode = _TodoPanelDrawerMode.stickyBoardDetail; - _showDrawer(_TodoPanelDrawerMode.tagManagement); - } - void _toggleTodoEditorTag(String tagId) { if (widget.controller.tagById(tagId) == null) { return; @@ -294,7 +442,30 @@ class _TodoPanelState extends State { } _unfocusDrawerControls(); + final requestSerial = ++_drawerRequestSerial; + final family = _drawerFamilyFor(mode); + if (family != null && !_mountedDrawerFamilies.contains(family)) { + setState(() { + _mountedDrawerFamilies.add(family); + _pendingDrawerMode = mode; + if (family == _TodoPanelDrawerFamily.tags) { + _lastTagDrawerMode = mode; + } + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || requestSerial != _drawerRequestSerial) { + return; + } + _activateDrawer(mode); + }); + return; + } + _activateDrawer(mode); + } + + void _activateDrawer(_TodoPanelDrawerMode mode) { setState(() { + _pendingDrawerMode = null; _drawerMode = mode; if (mode == _TodoPanelDrawerMode.tagFilter || mode == _TodoPanelDrawerMode.tagAssignment || @@ -344,11 +515,21 @@ class _TodoPanelState extends State { } _unfocusDrawerControls(); + final requestSerial = ++_drawerRequestSerial; + final needsMount = !_mountedDrawerFamilies.contains( + _TodoPanelDrawerFamily.todoEditor, + ); setState(() { if (startsNewSession) { _todoEditorSession += 1; } - _drawerMode = mode; + if (needsMount) { + _mountedDrawerFamilies.add(_TodoPanelDrawerFamily.todoEditor); + _pendingDrawerMode = mode; + } else { + _pendingDrawerMode = null; + _drawerMode = mode; + } _lastTodoDrawerMode = mode; _selectedTodoId = todoId; _todoEditorTagIds = initialTagIds.toSet(); @@ -356,22 +537,47 @@ class _TodoPanelState extends State { _scope = TodoListScope.active; } }); + if (!needsMount) { + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || requestSerial != _drawerRequestSerial) { + return; + } + setState(() { + _pendingDrawerMode = null; + _drawerMode = mode; + }); + }); } - void _selectTagFilter(String? tagId) { - _unfocusDrawerControls(); + void _toggleTagFilter(String tagId) { setState(() { - _selectedTagId = tagId; - _drawerMode = _TodoPanelDrawerMode.none; + if (!_selectedTagIds.add(tagId)) { + _selectedTagIds.remove(tagId); + } }); - _restorePanelFocus(); + } + + void _clearTagFilters() { + if (_selectedTagIds.isEmpty) { + return; + } + setState(_selectedTagIds.clear); } void _closeActiveDrawer() { if (_drawerMode == _TodoPanelDrawerMode.none) { + if (_pendingDrawerMode == null) { + return; + } + _drawerRequestSerial += 1; + setState(() => _pendingDrawerMode = null); + _restorePanelFocus(); return; } + _drawerRequestSerial += 1; final closedMode = _drawerMode; final returnMode = switch (closedMode) { _TodoPanelDrawerMode.tagManagement => _tagManagementReturnMode, @@ -393,6 +599,7 @@ class _TodoPanelState extends State { if (_isTodoDrawerMode(closedMode)) { _todoDrawerReturnMode = null; _todoCreationBoardId = null; + _pendingCreatedTodoId = null; } }); if (returnMode != null) { @@ -440,12 +647,30 @@ class _TodoPanelState extends State { mode == _TodoPanelDrawerMode.editTodo; } + _TodoPanelDrawerFamily? _drawerFamilyFor(_TodoPanelDrawerMode mode) { + return switch (mode) { + _TodoPanelDrawerMode.settings => _TodoPanelDrawerFamily.settings, + _TodoPanelDrawerMode.tagFilter || + _TodoPanelDrawerMode.tagAssignment || + _TodoPanelDrawerMode.tagManagement => _TodoPanelDrawerFamily.tags, + _TodoPanelDrawerMode.stickyBoardManagement || + _TodoPanelDrawerMode.stickyBoardDetail || + _TodoPanelDrawerMode.stickyBoardTodoPicker => + _TodoPanelDrawerFamily.stickyBoards, + _TodoPanelDrawerMode.createTodo || + _TodoPanelDrawerMode.todoDetails || + _TodoPanelDrawerMode.editTodo => _TodoPanelDrawerFamily.todoEditor, + _TodoPanelDrawerMode.none => null, + }; + } + @override Widget build(BuildContext context) { final brightness = Theme.of(context).brightness; final isDark = brightness == Brightness.dark; final reduceMotion = MediaQuery.disableAnimationsOf(context); - final isDrawerOpen = _drawerMode != _TodoPanelDrawerMode.none; + final isDrawerOpen = + _drawerMode != _TodoPanelDrawerMode.none || _pendingDrawerMode != null; final isSettingsOpen = _drawerMode == _TodoPanelDrawerMode.settings; final isTagFilterOpen = _drawerMode == _TodoPanelDrawerMode.tagFilter; final isTagAssignmentOpen = @@ -468,6 +693,18 @@ class _TodoPanelState extends State { _drawerMode == _TodoPanelDrawerMode.createTodo || _drawerMode == _TodoPanelDrawerMode.todoDetails || _drawerMode == _TodoPanelDrawerMode.editTodo; + final hasSettingsDrawer = _mountedDrawerFamilies.contains( + _TodoPanelDrawerFamily.settings, + ); + final hasTagDrawer = _mountedDrawerFamilies.contains( + _TodoPanelDrawerFamily.tags, + ); + final hasStickyBoardDrawer = _mountedDrawerFamilies.contains( + _TodoPanelDrawerFamily.stickyBoards, + ); + final hasTodoDrawer = _mountedDrawerFamilies.contains( + _TodoPanelDrawerFamily.todoEditor, + ); final isTodoContextOverlayOpen = isTagAssignmentOpen || (isTagManagementOpen && @@ -475,9 +712,7 @@ class _TodoPanelState extends State { final isTodoDrawerVisible = isTodoDrawerOpen || isTodoContextOverlayOpen; final isStickyBoardContextVisible = (_todoDrawerReturnMode == _TodoPanelDrawerMode.stickyBoardDetail && - (isTodoDrawerOpen || isTodoContextOverlayOpen)) || - (isTagManagementOpen && - _tagManagementReturnMode == _TodoPanelDrawerMode.stickyBoardDetail); + (isTodoDrawerOpen || isTodoContextOverlayOpen)); final isStickyBoardDrawerVisible = isStickyBoardDrawerOpen || isStickyBoardContextVisible; final visibleTagDrawerMode = isTagDrawerOpen @@ -547,14 +782,18 @@ class _TodoPanelState extends State { child: Container( width: 440, height: 700, - padding: const EdgeInsets.all(_panelWindowInset), + padding: const EdgeInsets.all( + FloatickSurfaceMetrics.windowInset, + ), child: DecoratedBox( key: const Key('todo-panel-surface'), decoration: BoxDecoration( color: isDark - ? const Color(0xF2172024) - : const Color(0xF7FAFCFB), - borderRadius: BorderRadius.circular(_panelOuterRadius), + ? FloatickColors.darkSurface + : FloatickColors.lightSurface, + borderRadius: BorderRadius.circular( + FloatickSurfaceMetrics.panelRadius, + ), border: Border.all( color: isDark ? Colors.white.withValues(alpha: 0.12) @@ -562,7 +801,9 @@ class _TodoPanelState extends State { ), ), child: ClipRRect( - borderRadius: BorderRadius.circular(_panelContentRadius), + borderRadius: BorderRadius.circular( + FloatickSurfaceMetrics.panelContentRadius, + ), child: Stack( fit: StackFit.expand, children: [ @@ -571,14 +812,22 @@ class _TodoPanelState extends State { child: AnimatedBuilder( animation: widget.controller, builder: (context, _) { - final selectedTag = _selectedTagId == null - ? null - : widget.controller.tagById(_selectedTagId!); - final effectiveSelectedTagId = selectedTag?.id; + final selectedTags = widget.controller.tags + .where( + (tag) => _selectedTagIds.contains(tag.id), + ) + .toList(growable: false); + final effectiveSelectedTagIds = selectedTags + .map((tag) => tag.id) + .toSet(); return Column( children: [ _PanelHeader( + scope: _scope, activeCount: widget.controller.activeCount, + archivedCount: + widget.controller.archivedCount, + onToggleArchive: _toggleArchiveScope, onOpenStickyBoards: _openStickyBoards, onOpenSettings: _openSettings, onCollapse: widget.onCollapse, @@ -592,18 +841,6 @@ class _TodoPanelState extends State { ), child: Column( children: [ - _ScopePicker( - scope: _scope, - activeCount: - widget.controller.activeCount, - archivedCount: - widget.controller.archivedCount, - reduceMotion: reduceMotion, - onChanged: (scope) { - setState(() => _scope = scope); - }, - ), - const SizedBox(height: 12), Row( children: [ Expanded( @@ -655,53 +892,39 @@ class _TodoPanelState extends State { ), const SizedBox(width: 9), TagFilterButton( - selectedTag: selectedTag, + selectedCount: + effectiveSelectedTagIds + .length, onPressed: _openTagFilter, ), + if (_scope == + TodoListScope.active) ...[ + const SizedBox(width: 9), + SizedBox( + height: 42, + child: FilledButton.tonalIcon( + key: const Key( + 'add-todo-button', + ), + onPressed: _openTodoCreate, + style: FilledButton.styleFrom( + padding: + const EdgeInsets.symmetric( + horizontal: 12, + ), + ), + icon: const Icon( + Icons.add_rounded, + size: 18, + ), + label: Text( + context.l10n.newTodoAction, + ), + ), + ), + ], ], ), - if (_scope == TodoListScope.active) ...[ - const SizedBox(height: 10), - SizedBox( - width: double.infinity, - height: 42, - child: FilledButton.tonalIcon( - key: const Key('add-todo-button'), - onPressed: _openTodoCreate, - style: FilledButton.styleFrom( - alignment: Alignment.centerLeft, - padding: - const EdgeInsets.symmetric( - horizontal: 14, - ), - ), - icon: const Icon( - Icons.add_rounded, - size: 18, - ), - label: Text( - context.l10n.createTodoAction, - ), - ), - ), - ], - if (selectedTag != null) ...[ - const SizedBox(height: 9), - Align( - alignment: Alignment.centerLeft, - child: FloatickTagChip( - key: const Key( - 'active-tag-filter', - ), - tag: selectedTag, - onDeleted: () { - setState( - () => _selectedTagId = null, - ); - }, - ), - ), - ], ], ), ), @@ -713,6 +936,23 @@ class _TodoPanelState extends State { ), onDismiss: widget.controller.dismissError, ), + AnimatedBuilder( + animation: widget.stickyBoardController, + builder: (context, _) { + final error = + widget.stickyBoardController.error; + if (error == null) { + return const SizedBox.shrink(); + } + return _ErrorBanner( + message: context.l10n + .messageForStorageFailure(error), + onDismiss: widget + .stickyBoardController + .dismissError, + ); + }, + ), Divider( height: 1, thickness: 1, @@ -725,10 +965,13 @@ class _TodoPanelState extends State { controller: widget.controller, scope: _scope, query: _query, - selectedTagId: effectiveSelectedTagId, + selectedTagIds: effectiveSelectedTagIds, + onClearTagFilters: _clearTagFilters, onOpenTagManagement: _openTagManagement, onOpenDetails: _openTodoDetails, onEditTodo: _openTodoEdit, + onDeleteTodo: + _deleteArchivedTodoPermanently, ), ), ], @@ -762,337 +1005,330 @@ class _TodoPanelState extends State { ), ), ), - Positioned( - top: 0, - right: 0, - bottom: 0, - width: _settingsDrawerWidth, - child: IgnorePointer( - key: const Key('settings-drawer-pointer'), - ignoring: !isSettingsOpen, - child: ExcludeSemantics( - excluding: !isSettingsOpen, - child: AnimatedSlide( - key: const Key('settings-drawer-slide'), - duration: reduceMotion - ? Duration.zero - : _drawerSlideDuration, - curve: Curves.easeOutCubic, - offset: isSettingsOpen - ? Offset.zero - : const Offset(1, 0), - child: FocusTraversalGroup( - child: SettingsDrawer( - viewModel: widget.settingsController, - updateViewModel: widget.updateController, - workingDirectoryPath: - widget.controller.storageDirectoryPath, - onClose: _closeActiveDrawer, - closeFocusNode: _settingsCloseFocusNode, + if (hasSettingsDrawer) + Positioned( + top: 0, + right: 0, + bottom: 0, + width: _settingsDrawerWidth, + child: IgnorePointer( + key: const Key('settings-drawer-pointer'), + ignoring: !isSettingsOpen, + child: ExcludeSemantics( + excluding: !isSettingsOpen, + child: AnimatedSlide( + key: const Key('settings-drawer-slide'), + duration: reduceMotion + ? Duration.zero + : _drawerSlideDuration, + curve: Curves.easeOutCubic, + offset: isSettingsOpen + ? Offset.zero + : const Offset(1, 0), + child: FocusTraversalGroup( + child: SettingsDrawer( + viewModel: widget.settingsController, + updateViewModel: widget.updateController, + workingDirectoryPath: widget + .controller + .storageDirectoryPath, + onClose: _closeActiveDrawer, + closeFocusNode: _settingsCloseFocusNode, + ), ), ), ), ), ), - ), - Positioned( - top: 0, - left: tagDrawerOnLeft ? 0 : null, - right: tagDrawerOnLeft ? null : 0, - bottom: 0, - width: _stickyBoardDrawerWidth, - child: IgnorePointer( - key: const Key('sticky-board-drawer-pointer'), - ignoring: !isStickyBoardDrawerOpen, - child: ExcludeSemantics( - excluding: !isStickyBoardDrawerOpen, - child: AnimatedSlide( - key: const Key('sticky-board-drawer-slide'), - duration: reduceMotion - ? Duration.zero - : _drawerSlideDuration, - curve: Curves.easeOutCubic, - offset: isStickyBoardDrawerVisible - ? Offset.zero - : Offset(tagDrawerOnLeft ? -1 : 1, 0), - child: FocusTraversalGroup( - child: AnimatedBuilder( - animation: Listenable.merge([ - widget.stickyBoardController, - widget.controller, - ]), - builder: (context, _) { - final board = - _selectedStickyBoardId == null - ? null - : widget.stickyBoardController - .boardById( - _selectedStickyBoardId!, - ); - if (isStickyBoardTodoPickerOpen && - board != null) { - return StickyBoardTodoPickerDrawer( - board: board, - todoController: widget.controller, - boardController: + if (hasStickyBoardDrawer) + Positioned( + top: 0, + left: tagDrawerOnLeft ? 0 : null, + right: tagDrawerOnLeft ? null : 0, + bottom: 0, + width: _stickyBoardDrawerWidth, + child: IgnorePointer( + key: const Key('sticky-board-drawer-pointer'), + ignoring: !isStickyBoardDrawerOpen, + child: ExcludeSemantics( + excluding: !isStickyBoardDrawerOpen, + child: AnimatedSlide( + key: const Key('sticky-board-drawer-slide'), + duration: reduceMotion + ? Duration.zero + : _drawerSlideDuration, + curve: Curves.easeOutCubic, + offset: isStickyBoardDrawerVisible + ? Offset.zero + : Offset(tagDrawerOnLeft ? -1 : 1, 0), + child: FocusTraversalGroup( + child: AnimatedBuilder( + animation: Listenable.merge([ + widget.stickyBoardController, + widget.controller, + ]), + builder: (context, _) { + final board = + _selectedStickyBoardId == null + ? null + : widget.stickyBoardController + .boardById( + _selectedStickyBoardId!, + ); + if (isStickyBoardTodoPickerOpen && + board != null) { + return StickyBoardTodoPickerDrawer( + board: board, + todoController: widget.controller, + boardController: + widget.stickyBoardController, + borderOnLeft: !tagDrawerOnLeft, + onBack: _backToStickyBoardDetail, + onClose: _closeActiveDrawer, + closeFocusNode: + _stickyBoardCloseFocusNode, + ); + } + if ((isStickyBoardDetailOpen || + isStickyBoardContextVisible) && + board != null) { + return StickyBoardDetailDrawer( + board: board, + todoController: widget.controller, + boardController: + widget.stickyBoardController, + borderOnLeft: !tagDrawerOnLeft, + onBack: + _backToStickyBoardManagement, + onClose: _closeActiveDrawer, + onTogglePin: () => + _toggleStickyBoardPin(board.id), + onAddExisting: + _openStickyBoardTodoPicker, + onCreateTodo: () => _openTodoCreate( + stickyBoardId: board.id, + ), + closeFocusNode: + _stickyBoardCloseFocusNode, + ); + } + return StickyBoardManagementDrawer( + controller: widget.stickyBoardController, - borderOnLeft: !tagDrawerOnLeft, - onBack: _backToStickyBoardDetail, - onClose: _closeActiveDrawer, - closeFocusNode: - _stickyBoardCloseFocusNode, - ); - } - if ((isStickyBoardDetailOpen || - isStickyBoardContextVisible) && - board != null) { - return StickyBoardDetailDrawer( - board: board, todoController: widget.controller, - boardController: - widget.stickyBoardController, + isOpen: isStickyBoardManagementOpen, borderOnLeft: !tagDrawerOnLeft, - onBack: _backToStickyBoardManagement, onClose: _closeActiveDrawer, - onTogglePin: () => - _toggleStickyBoardPin(board.id), - onAddExisting: - _openStickyBoardTodoPicker, - onCreateTodo: () => _openTodoCreate( - stickyBoardId: board.id, - ), - onOpenDetails: _openTodoDetails, - onEditTodo: _openTodoEdit, - onOpenTagManagement: - _openTagManagementFromStickyBoard, + onOpenBoard: _openStickyBoard, + onTogglePin: _toggleStickyBoardPin, + onDeleteBoard: _deleteStickyBoard, closeFocusNode: _stickyBoardCloseFocusNode, ); - } - return StickyBoardManagementDrawer( - controller: - widget.stickyBoardController, - isOpen: isStickyBoardManagementOpen, - borderOnLeft: !tagDrawerOnLeft, - onClose: _closeActiveDrawer, - onOpenBoard: _openStickyBoard, - onTogglePin: _toggleStickyBoardPin, - onDeleteBoard: _deleteStickyBoard, - closeFocusNode: - _stickyBoardCloseFocusNode, - ); - }, + }, + ), ), ), ), ), ), - ), - Positioned( - left: 0, - right: 0, - bottom: 0, - height: _todoDrawerHeight, - child: IgnorePointer( - key: const Key('todo-drawer-pointer'), - ignoring: !isTodoDrawerOpen, - child: ExcludeFocus( - excluding: !isTodoDrawerOpen, - child: ExcludeSemantics( + if (hasTodoDrawer) + Positioned( + left: 0, + right: 0, + bottom: 0, + height: _todoDrawerHeight, + child: IgnorePointer( + key: const Key('todo-drawer-pointer'), + ignoring: !isTodoDrawerOpen, + child: ExcludeFocus( excluding: !isTodoDrawerOpen, - child: AnimatedSlide( - key: const Key('todo-drawer-slide'), - duration: reduceMotion - ? Duration.zero - : _drawerSlideDuration, - curve: Curves.easeOutCubic, - offset: isTodoDrawerVisible - ? Offset.zero - : const Offset(0, 1), - child: FocusTraversalGroup( - child: TodoEditorDrawer( - key: ValueKey(_todoEditorSession), - mode: todoEditorMode, - item: selectedTodo, - availableTags: widget.controller.tags, - originalAssignedTagIds: - originalTodoTagIds, - assignedTagIds: todoEditorTagIds, - isOpen: isTodoDrawerOpen, - onClose: _closeActiveDrawer, - onEdit: () { - final todoId = selectedTodo?.id; - if (todoId != null) { - _openTodoEdit(todoId); - } - }, - onOpenTagAssignment: - _openTagAssignmentFromTodo, - onSave: (title, content, tagIds) { - if (todoEditorMode == - TodoEditorDrawerMode.create) { - return () async { - final item = await widget.controller - .create( - title, - content: content, - tagIds: tagIds, - ); - if (item == null) { - return false; - } - final boardId = - _todoCreationBoardId; - if (boardId == null) { - return true; - } - return widget.stickyBoardController - .addTodo( - boardId: boardId, - todoId: item.id, - ); - }(); - } - final todoId = selectedTodo?.id; - if (todoId == null) { - return Future.value(false); - } - return widget.controller.updateDetails( - id: todoId, - title: title, - content: content, - tagIds: tagIds, - ); - }, - onSaved: () { - if (_drawerMode == - _TodoPanelDrawerMode.createTodo) { - _closeActiveDrawer(); - return; - } - if (_drawerMode == - _TodoPanelDrawerMode.editTodo) { - final todoId = _selectedTodoId; + child: ExcludeSemantics( + excluding: !isTodoDrawerOpen, + child: AnimatedSlide( + key: const Key('todo-drawer-slide'), + duration: reduceMotion + ? Duration.zero + : _drawerSlideDuration, + curve: Curves.easeOutCubic, + offset: isTodoDrawerVisible + ? Offset.zero + : const Offset(0, 1), + child: FocusTraversalGroup( + child: TodoEditorDrawer( + key: ValueKey(_todoEditorSession), + mode: todoEditorMode, + item: selectedTodo, + availableTags: widget.controller.tags, + originalAssignedTagIds: + originalTodoTagIds, + assignedTagIds: todoEditorTagIds, + isOpen: isTodoDrawerOpen, + canEdit: + selectedTodo?.isArchived != true, + onClose: _closeActiveDrawer, + onEdit: () { + final todoId = selectedTodo?.id; if (todoId != null) { - _openTodoDetails(todoId); + _openTodoEdit(todoId); } - } - }, - closeFocusNode: _todoDrawerCloseFocusNode, + }, + onOpenTagAssignment: + _openTagAssignmentFromTodo, + onSave: (title, content, tagIds) { + if (todoEditorMode == + TodoEditorDrawerMode.create) { + return _saveCreatedTodo( + title: title, + content: content, + tagIds: tagIds, + ); + } + final todoId = selectedTodo?.id; + if (todoId == null) { + return Future.value(false); + } + return widget.controller + .updateDetails( + id: todoId, + title: title, + content: content, + tagIds: tagIds, + ); + }, + onSaved: () { + if (_drawerMode == + _TodoPanelDrawerMode.createTodo) { + _closeActiveDrawer(); + return; + } + if (_drawerMode == + _TodoPanelDrawerMode.editTodo) { + final todoId = _selectedTodoId; + if (todoId != null) { + _openTodoDetails(todoId); + } + } + }, + closeFocusNode: + _todoDrawerCloseFocusNode, + ), ), ), ), ), ), ), - ), - Positioned.fill( - child: IgnorePointer( - key: const Key('todo-context-scrim-pointer'), - ignoring: !isTodoContextOverlayOpen, - child: ExcludeSemantics( - child: AnimatedOpacity( - key: const Key('todo-context-scrim'), - duration: reduceMotion - ? Duration.zero - : _drawerScrimDuration, - curve: Curves.easeOut, - opacity: isTodoContextOverlayOpen ? 1 : 0, - child: GestureDetector( - key: const Key('todo-context-dismiss'), - behavior: HitTestBehavior.opaque, - onTap: _closeActiveDrawer, - child: ColoredBox( - color: Colors.black.withValues( - alpha: isDark ? 0.18 : 0.10, + if (hasTodoDrawer && hasTagDrawer) + Positioned.fill( + child: IgnorePointer( + key: const Key('todo-context-scrim-pointer'), + ignoring: !isTodoContextOverlayOpen, + child: ExcludeSemantics( + child: AnimatedOpacity( + key: const Key('todo-context-scrim'), + duration: reduceMotion + ? Duration.zero + : _drawerScrimDuration, + curve: Curves.easeOut, + opacity: isTodoContextOverlayOpen ? 1 : 0, + child: GestureDetector( + key: const Key('todo-context-dismiss'), + behavior: HitTestBehavior.opaque, + onTap: _closeActiveDrawer, + child: ColoredBox( + color: Colors.black.withValues( + alpha: isDark ? 0.18 : 0.10, + ), ), ), ), ), ), ), - ), - Positioned( - top: 0, - left: tagDrawerOnLeft ? 0 : null, - right: tagDrawerOnLeft ? null : 0, - bottom: 0, - width: _tagDrawerWidth, - child: IgnorePointer( - key: const Key('tag-drawer-pointer'), - ignoring: !isTagDrawerOpen, - child: ExcludeSemantics( - excluding: !isTagDrawerOpen, - child: AnimatedSlide( - key: const Key('tag-drawer-slide'), - duration: reduceMotion - ? Duration.zero - : _drawerSlideDuration, - curve: Curves.easeOutCubic, - offset: isTagDrawerOpen - ? Offset.zero - : Offset(tagDrawerOnLeft ? -1 : 1, 0), - child: FocusTraversalGroup( - child: AnimatedSwitcher( - duration: reduceMotion - ? Duration.zero - : const Duration(milliseconds: 160), - switchInCurve: Curves.easeOut, - switchOutCurve: Curves.easeIn, - transitionBuilder: (child, animation) { - return FadeTransition( - opacity: animation, - child: child, - ); - }, - child: switch (visibleTagDrawerMode) { - _TodoPanelDrawerMode.tagManagement => - TagManagementDrawer( - key: const ValueKey( - 'tag-management-drawer-content', + if (hasTagDrawer) + Positioned( + top: 0, + left: tagDrawerOnLeft ? 0 : null, + right: tagDrawerOnLeft ? null : 0, + bottom: 0, + width: _tagDrawerWidth, + child: IgnorePointer( + key: const Key('tag-drawer-pointer'), + ignoring: !isTagDrawerOpen, + child: ExcludeSemantics( + excluding: !isTagDrawerOpen, + child: AnimatedSlide( + key: const Key('tag-drawer-slide'), + duration: reduceMotion + ? Duration.zero + : _drawerSlideDuration, + curve: Curves.easeOutCubic, + offset: isTagDrawerOpen + ? Offset.zero + : Offset(tagDrawerOnLeft ? -1 : 1, 0), + child: FocusTraversalGroup( + child: AnimatedSwitcher( + duration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 160), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + transitionBuilder: (child, animation) { + return FadeTransition( + opacity: animation, + child: child, + ); + }, + child: switch (visibleTagDrawerMode) { + _TodoPanelDrawerMode.tagManagement => + TagManagementDrawer( + key: const ValueKey( + 'tag-management-drawer-content', + ), + controller: widget.controller, + isOpen: isTagManagementOpen, + borderOnLeft: !tagDrawerOnLeft, + onClose: _closeActiveDrawer, + closeFocusNode: + _tagManagementCloseFocusNode, ), - controller: widget.controller, - isOpen: isTagManagementOpen, - borderOnLeft: !tagDrawerOnLeft, - onClose: _closeActiveDrawer, - closeFocusNode: - _tagManagementCloseFocusNode, - ), - _TodoPanelDrawerMode.tagAssignment => - TagFilterDrawer.assignment( + _TodoPanelDrawerMode.tagAssignment => + TagFilterDrawer.assignment( + key: const ValueKey( + 'tag-assignment-drawer-content', + ), + controller: widget.controller, + selectedTagIds: _todoEditorTagIds, + borderOnLeft: !tagDrawerOnLeft, + onToggled: _toggleTodoEditorTag, + onManageTags: + _openTagManagementFromTagAssignment, + onClose: _closeActiveDrawer, + closeFocusNode: + _tagAssignmentCloseFocusNode, + ), + _ => TagFilterDrawer.filter( key: const ValueKey( - 'tag-assignment-drawer-content', + 'tag-filter-drawer-content', ), controller: widget.controller, - selectedTagIds: _todoEditorTagIds, + selectedTagIds: _selectedTagIds, borderOnLeft: !tagDrawerOnLeft, - onToggled: _toggleTodoEditorTag, - onManageTags: - _openTagManagementFromTagAssignment, + onToggled: _toggleTagFilter, + onClear: _clearTagFilters, + onManageTags: _openTagManagement, onClose: _closeActiveDrawer, closeFocusNode: - _tagAssignmentCloseFocusNode, + _tagFilterCloseFocusNode, ), - _ => TagFilterDrawer.filter( - key: const ValueKey( - 'tag-filter-drawer-content', - ), - controller: widget.controller, - selectedTagId: _selectedTagId, - borderOnLeft: !tagDrawerOnLeft, - onSelected: _selectTagFilter, - onManageTags: _openTagManagement, - onClose: _closeActiveDrawer, - closeFocusNode: - _tagFilterCloseFocusNode, - ), - }, + }, + ), ), ), ), ), ), - ), ], ), ), @@ -1108,13 +1344,19 @@ class _TodoPanelState extends State { class _PanelHeader extends StatelessWidget { const _PanelHeader({ + required this.scope, required this.activeCount, + required this.archivedCount, + required this.onToggleArchive, required this.onOpenStickyBoards, required this.onOpenSettings, required this.onCollapse, }); + final TodoListScope scope; final int activeCount; + final int archivedCount; + final VoidCallback onToggleArchive; final VoidCallback onOpenStickyBoards; final VoidCallback onOpenSettings; final VoidCallback onCollapse; @@ -1123,6 +1365,11 @@ class _PanelHeader extends StatelessWidget { Widget build(BuildContext context) { final onSurface = Theme.of(context).colorScheme.onSurface; final localizations = context.l10n; + final statusText = scope == TodoListScope.archived + ? '${localizations.archiveScopeLabel} · $archivedCount' + : activeCount == 0 + ? localizations.allClearToday + : localizations.activeTodoCount(activeCount); return Padding( padding: const EdgeInsets.fromLTRB(20, 18, 12, 16), child: Row( @@ -1131,15 +1378,32 @@ class _PanelHeader extends StatelessWidget { const SizedBox(width: 11), Expanded( child: Text( - activeCount == 0 - ? localizations.allClearToday - : localizations.activeTodoCount(activeCount), + statusText, style: TextStyle( color: onSurface.withValues(alpha: 0.53), fontSize: 12, ), ), ), + Semantics( + selected: scope == TodoListScope.archived, + child: IconButton( + key: const Key('archive-scope-button'), + tooltip: scope == TodoListScope.archived + ? localizations.activeScopeLabel + : localizations.archiveScopeLabel, + onPressed: onToggleArchive, + color: scope == TodoListScope.archived + ? Theme.of(context).colorScheme.primary + : null, + icon: Icon( + scope == TodoListScope.archived + ? Icons.archive_rounded + : Icons.archive_outlined, + size: 19, + ), + ), + ), IconButton( key: const Key('sticky-boards-button'), tooltip: localizations.stickyBoardsTooltip, @@ -1173,123 +1437,6 @@ class _MiniMark extends StatelessWidget { } } -class _ScopePicker extends StatelessWidget { - const _ScopePicker({ - required this.scope, - required this.activeCount, - required this.archivedCount, - required this.reduceMotion, - required this.onChanged, - }); - - final TodoListScope scope; - final int activeCount; - final int archivedCount; - final bool reduceMotion; - final ValueChanged onChanged; - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - return Container( - height: 38, - padding: const EdgeInsets.all(3), - decoration: BoxDecoration( - color: isDark - ? Colors.white.withValues(alpha: 0.055) - : Colors.black.withValues(alpha: 0.045), - borderRadius: BorderRadius.circular(11), - ), - child: Row( - children: [ - _ScopeButton( - label: context.l10n.activeScopeLabel, - count: activeCount, - selected: scope == TodoListScope.active, - reduceMotion: reduceMotion, - onPressed: () => onChanged(TodoListScope.active), - ), - _ScopeButton( - label: context.l10n.archiveScopeLabel, - count: archivedCount, - selected: scope == TodoListScope.archived, - reduceMotion: reduceMotion, - onPressed: () => onChanged(TodoListScope.archived), - ), - ], - ), - ); - } -} - -class _ScopeButton extends StatelessWidget { - const _ScopeButton({ - required this.label, - required this.count, - required this.selected, - required this.reduceMotion, - required this.onPressed, - }); - - final String label; - final int count; - final bool selected; - final bool reduceMotion; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final onSurface = Theme.of(context).colorScheme.onSurface; - return Expanded( - child: Semantics( - button: true, - selected: selected, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: onPressed, - child: AnimatedContainer( - duration: reduceMotion - ? Duration.zero - : const Duration(milliseconds: 180), - alignment: Alignment.center, - decoration: BoxDecoration( - color: selected - ? (isDark - ? Colors.white.withValues(alpha: 0.10) - : Colors.white) - : Colors.transparent, - borderRadius: BorderRadius.circular(8), - boxShadow: selected && !isDark - ? [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.07), - blurRadius: 6, - offset: const Offset(0, 1), - ), - ] - : null, - ), - child: Text( - '$label $count', - style: TextStyle( - color: selected - ? onSurface - : onSurface.withValues(alpha: 0.55), - fontSize: 12.5, - fontWeight: selected ? FontWeight.w600 : FontWeight.w500, - ), - ), - ), - ), - ), - ), - ); - } -} - class _ErrorBanner extends StatelessWidget { const _ErrorBanner({required this.message, required this.onDismiss}); @@ -1338,19 +1485,23 @@ class _TodoList extends StatelessWidget { required this.controller, required this.scope, required this.query, - required this.selectedTagId, + required this.selectedTagIds, + required this.onClearTagFilters, required this.onOpenTagManagement, required this.onOpenDetails, required this.onEditTodo, + required this.onDeleteTodo, }); final TodoViewModel controller; final TodoListScope scope; final String query; - final String? selectedTagId; + final Set selectedTagIds; + final VoidCallback onClearTagFilters; final VoidCallback onOpenTagManagement; final ValueChanged onOpenDetails; final ValueChanged onEditTodo; + final ValueChanged onDeleteTodo; @override Widget build(BuildContext context) { @@ -1367,7 +1518,8 @@ class _TodoList extends StatelessWidget { if (entries.isEmpty) { return _EmptyList( scope: scope, - hasQuery: query.isNotEmpty || selectedTagId != null, + hasQuery: query.isNotEmpty || selectedTagIds.isNotEmpty, + onClearTagFilters: selectedTagIds.isEmpty ? null : onClearTagFilters, ); } @@ -1385,16 +1537,25 @@ class _TodoList extends StatelessWidget { onToggle: () => unawaited(controller.toggleCompletion(entry.item.id)), onOpenDetails: () => onOpenDetails(entry.item.id), - onEdit: () => onEditTodo(entry.item.id), + onEdit: scope == TodoListScope.archived + ? null + : () => onEditTodo(entry.item.id), onArchive: () => unawaited(controller.archive(entry.item.id)), onRestore: () => unawaited(controller.restore(entry.item.id)), tags: controller.tags, assignedTagIds: controller.tagIdsForTodo(entry.item.id), - onToggleTag: (tagId) => controller.toggleTagForTodo( - todoId: entry.item.id, - tagId: tagId, - ), - onOpenTagManagement: onOpenTagManagement, + onToggleTag: scope == TodoListScope.archived + ? null + : (tagId) => controller.toggleTagForTodo( + todoId: entry.item.id, + tagId: tagId, + ), + onOpenTagManagement: scope == TodoListScope.archived + ? null + : onOpenTagManagement, + onDeletePermanently: scope == TodoListScope.archived + ? () => onDeleteTodo(entry.item.id) + : null, ), }; }, @@ -1406,7 +1567,7 @@ class _TodoList extends StatelessWidget { final items = controller.itemsForView( archived: archived, query: query, - selectedTagId: selectedTagId, + selectedTagIds: selectedTagIds, ); DateTime relevantDate(TodoItem item) { @@ -1480,10 +1641,15 @@ class _DateDivider extends StatelessWidget { } class _EmptyList extends StatelessWidget { - const _EmptyList({required this.scope, required this.hasQuery}); + const _EmptyList({ + required this.scope, + required this.hasQuery, + this.onClearTagFilters, + }); final TodoListScope scope; final bool hasQuery; + final VoidCallback? onClearTagFilters; @override Widget build(BuildContext context) { @@ -1542,6 +1708,23 @@ class _EmptyList extends StatelessWidget { fontSize: 12, ), ), + if (onClearTagFilters != null) ...[ + const SizedBox(height: 10), + TextButton( + key: const Key('clear-active-tag-filters'), + onPressed: onClearTagFilters, + style: TextButton.styleFrom( + minimumSize: const Size(0, 30), + padding: const EdgeInsets.symmetric(horizontal: 10), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + textStyle: const TextStyle( + fontSize: 11.5, + fontWeight: FontWeight.w600, + ), + ), + child: Text(localizations.clearTagFilterTooltip), + ), + ], ], ), ), diff --git a/lib/features/todos/presentation/todo_view_model.dart b/lib/features/todos/presentation/todo_view_model.dart index aa3c763..b931eb5 100644 --- a/lib/features/todos/presentation/todo_view_model.dart +++ b/lib/features/todos/presentation/todo_view_model.dart @@ -5,6 +5,7 @@ import 'package:characters/characters.dart'; import 'package:flutter/foundation.dart'; import '../../../core/storage/storage_failure.dart'; +import '../data/first_run_workspace_seeder.dart'; import '../data/tag_repository.dart'; import '../data/todo_repository.dart'; import '../domain/tag_workspace.dart'; @@ -32,22 +33,28 @@ class TodoViewModel extends ChangeNotifier { TodoClock? clock, TodoIdGenerator? idGenerator, TagIdGenerator? tagIdGenerator, + FirstRunWorkspaceSeeder? firstRunWorkspaceSeeder, }) : _repository = todoRepository, // The public named parameter cannot use the private field's identifier. // ignore: prefer_initializing_formals _tagRepository = tagRepository, _clock = clock ?? DateTime.now, _idGenerator = idGenerator ?? _generateUuidV4, - _tagIdGenerator = tagIdGenerator ?? _generateUuidV4; + _tagIdGenerator = tagIdGenerator ?? _generateUuidV4, + // The public named parameter cannot use the private field's identifier. + // ignore: prefer_initializing_formals + _firstRunWorkspaceSeeder = firstRunWorkspaceSeeder; final TodoRepository _repository; final TagRepository _tagRepository; final TodoClock _clock; final TodoIdGenerator _idGenerator; final TagIdGenerator _tagIdGenerator; + final FirstRunWorkspaceSeeder? _firstRunWorkspaceSeeder; List _items = []; TagWorkspace _tagWorkspace = TagWorkspace.empty(); + Map _tagUsageCounts = const {}; StorageFailure? _error; bool _isLoading = false; Future _mutationQueue = Future.value(); @@ -94,30 +101,31 @@ class TodoViewModel extends ChangeNotifier { ); } - int tagUsageCount(String tagId) { - return _tagWorkspace.assignments.values - .where((tagIds) => tagIds.contains(tagId)) - .length; + int tagUsageCount(String tagId) => _tagUsageCounts[tagId] ?? 0; + + Map tagUsageCountsFor(Iterable tagIds) { + return Map.unmodifiable({ + for (final tagId in tagIds) tagId: _tagUsageCounts[tagId] ?? 0, + }); } List itemsForView({ required bool archived, required String query, - String? selectedTagId, + Set selectedTagIds = const {}, }) { final normalizedQuery = query.trim().toLowerCase(); final visibleItems = _items.where((item) { final matchesScope = archived ? item.isArchived : !item.isArchived; final assignedTagIds = tagIdsForTodo(item.id); final matchesTag = - selectedTagId == null || assignedTagIds.contains(selectedTagId); + selectedTagIds.isEmpty || selectedTagIds.any(assignedTagIds.contains); final assignedTagNames = _tagWorkspace.tags .where((tag) => assignedTagIds.contains(tag.id)) .map((tag) => tag.name.toLowerCase()); final matchesQuery = normalizedQuery.isEmpty || item.title.toLowerCase().contains(normalizedQuery) || - item.content.toLowerCase().contains(normalizedQuery) || assignedTagNames.any((name) => name.contains(normalizedQuery)); return matchesScope && matchesTag && matchesQuery; }).toList(); @@ -141,6 +149,12 @@ class TodoViewModel extends ChangeNotifier { notifyListeners(); StorageFailure? loadError; + try { + await _firstRunWorkspaceSeeder?.seedIfNeeded(); + } on StorageFailure catch (error) { + loadError = error; + } + try { _items = await _repository.load(); } on StorageFailure catch (error) { @@ -148,7 +162,7 @@ class TodoViewModel extends ChangeNotifier { } try { - _tagWorkspace = await _tagRepository.load(); + _setTagWorkspace(await _tagRepository.load()); } on StorageFailure catch (error) { loadError ??= error; } finally { @@ -208,6 +222,9 @@ class TodoViewModel extends ChangeNotifier { Future toggleCompletion(String id) { return _updateItem(id, (item) { + if (item.isArchived) { + return item; + } return item.withCompletedAt(item.isCompleted ? null : _clock().toUtc()); }); } @@ -223,6 +240,9 @@ class TodoViewModel extends ChangeNotifier { return false; } final existingItem = _items[existingIndex]; + if (existingItem.isArchived) { + return false; + } if (existingItem.title == normalizedTitle) { return true; } @@ -247,6 +267,10 @@ class TodoViewModel extends ChangeNotifier { if (existingIndex == -1) { return false; } + final existingItem = _items[existingIndex]; + if (existingItem.isArchived) { + return false; + } final normalizedTagIds = tagIds == null ? tagIdsForTodo(id) : _normalizeKnownTagIds(tagIds); @@ -254,7 +278,6 @@ class TodoViewModel extends ChangeNotifier { return false; } - final existingItem = _items[existingIndex]; final todoChanged = existingItem.title != normalizedTitle || existingItem.content != content; @@ -290,6 +313,28 @@ class TodoViewModel extends ChangeNotifier { return _updateItem(id, (item) => item.withArchivedAt(null)); } + Future deletePermanently(String id) { + return _enqueueTodoAndTagMutation(() async { + final existingItem = itemById(id); + if (existingItem == null || !existingItem.isArchived) { + return false; + } + + final updatedAssignments = >{ + ..._tagWorkspace.assignments, + }..remove(id); + return _commitTodoAndTags( + updatedItems: _items.where((item) => item.id != id).toList(), + updatedWorkspace: TagWorkspace( + tags: _tagWorkspace.tags, + assignments: updatedAssignments, + ), + todoChanged: true, + tagsChanged: _tagWorkspace.assignments.containsKey(id), + ); + }); + } + Future createTag({ required String name, required int colorValue, @@ -402,14 +447,14 @@ class TodoViewModel extends ChangeNotifier { }); } - Future toggleTagForTodo({ + Future toggleTagForTodo({ required String todoId, required String tagId, }) { return _enqueueTagMutation(() async { - final todoExists = _items.any((item) => item.id == todoId); - if (!todoExists || tagById(tagId) == null) { - return; + final todo = itemById(todoId); + if (todo == null || todo.isArchived || tagById(tagId) == null) { + return false; } final assignedTagIds = tagIdsForTodo(todoId).toSet(); @@ -427,7 +472,7 @@ class TodoViewModel extends ChangeNotifier { .map((tag) => tag.id); } - await _saveTagWorkspace( + return _saveTagWorkspace( TagWorkspace(tags: _tagWorkspace.tags, assignments: updatedAssignments), ); }); @@ -550,7 +595,7 @@ class TodoViewModel extends ChangeNotifier { await _tagRepository.save(updatedWorkspace); } _items = updatedItems; - _tagWorkspace = updatedWorkspace; + _setTagWorkspace(updatedWorkspace); _error = null; notifyListeners(); return true; @@ -558,11 +603,20 @@ class TodoViewModel extends ChangeNotifier { if (todoSaved && tagsChanged) { try { await _repository.save(_items); - } on StorageFailure catch (rollbackError) { - _items = updatedItems; - _error = rollbackError; - notifyListeners(); - return true; + } on StorageFailure { + try { + await _tagRepository.save(updatedWorkspace); + _items = updatedItems; + _setTagWorkspace(updatedWorkspace); + _error = null; + notifyListeners(); + return true; + } on StorageFailure catch (recoveryError) { + _items = updatedItems; + _error = recoveryError; + notifyListeners(); + return false; + } } } _error = error; @@ -574,7 +628,7 @@ class TodoViewModel extends ChangeNotifier { Future _saveTagWorkspace(TagWorkspace workspace) async { try { await _tagRepository.save(workspace); - _tagWorkspace = workspace; + _setTagWorkspace(workspace); _error = null; notifyListeners(); return true; @@ -596,6 +650,22 @@ class TodoViewModel extends ChangeNotifier { return TagMutationResult.success; } + void _setTagWorkspace(TagWorkspace workspace) { + final usageCounts = { + for (final tag in workspace.tags) tag.id: 0, + }; + for (final assignedTagIds in workspace.assignments.values) { + for (final tagId in assignedTagIds) { + final currentCount = usageCounts[tagId]; + if (currentCount != null) { + usageCounts[tagId] = currentCount + 1; + } + } + } + _tagWorkspace = workspace; + _tagUsageCounts = Map.unmodifiable(usageCounts); + } + static bool _sameTagName(String left, String right) { return left.toLowerCase() == right.toLowerCase(); } diff --git a/lib/features/todos/presentation/widgets/floatick_tag_chip.dart b/lib/features/todos/presentation/widgets/floatick_tag_chip.dart index 0f31e13..b59e5c9 100644 --- a/lib/features/todos/presentation/widgets/floatick_tag_chip.dart +++ b/lib/features/todos/presentation/widgets/floatick_tag_chip.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../../../../core/ui/floatick_hover_motion.dart'; import '../../domain/todo_tag.dart'; import 'tag_palette.dart'; @@ -89,8 +90,9 @@ class FloatickTagChip extends StatelessWidget { return Semantics( button: true, label: tag.name, - child: MouseRegion( - cursor: SystemMouseCursors.click, + child: FloatickHoverMotion( + hoverScale: FloatickMotion.chipHoverScale, + pressedScale: FloatickMotion.chipPressedScale, child: GestureDetector(onTap: onPressed, child: chip), ), ); diff --git a/lib/features/todos/presentation/widgets/floating_todo_icon.dart b/lib/features/todos/presentation/widgets/floating_todo_icon.dart index 0a06004..317e568 100644 --- a/lib/features/todos/presentation/widgets/floating_todo_icon.dart +++ b/lib/features/todos/presentation/widgets/floating_todo_icon.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../../../app/theme/floatick_theme.dart'; import '../../../../core/ui/floatick_brand_mark.dart'; +import '../../../../core/ui/floatick_hover_motion.dart'; import '../../../../l10n/l10n.dart'; class FloatingTodoIcon extends StatelessWidget { @@ -24,7 +25,7 @@ class FloatingTodoIcon extends StatelessWidget { button: true, label: context.l10n.openApp, hint: context.l10n.openAppHint, - child: MouseRegion( + child: FloatickHoverMotion( cursor: SystemMouseCursors.grab, child: GestureDetector( behavior: HitTestBehavior.opaque, @@ -43,13 +44,6 @@ class FloatingTodoIcon extends StatelessWidget { child: FloatickBrandMark( size: visualDimension, shape: FloatickBrandMarkShape.circle, - shadows: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.20), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], ), ), if (activeCount > 0) diff --git a/lib/features/todos/presentation/widgets/tag_menus.dart b/lib/features/todos/presentation/widgets/tag_menus.dart index 4576173..0eb99fa 100644 --- a/lib/features/todos/presentation/widgets/tag_menus.dart +++ b/lib/features/todos/presentation/widgets/tag_menus.dart @@ -2,22 +2,23 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import '../../../../core/ui/floatick_modal_bottom_sheet.dart'; +import '../../../../core/ui/floatick_surface_metrics.dart'; import '../../../../l10n/l10n.dart'; import '../../domain/todo_tag.dart'; import 'floatick_tag_chip.dart'; -import 'tag_palette.dart'; +import 'tag_selection_row.dart'; -const double _tagMenuWidth = 238; const double _tagFilterButtonDimension = 42; class TagFilterButton extends StatelessWidget { const TagFilterButton({ - required this.selectedTag, + required this.selectedCount, required this.onPressed, super.key, }); - final TodoTag? selectedTag; + final int selectedCount; final VoidCallback onPressed; @override @@ -29,7 +30,7 @@ class TagFilterButton extends StatelessWidget { child: IconButton( key: const Key('tag-filter-button'), tooltip: context.l10n.filterByTagTooltip, - isSelected: selectedTag != null, + isSelected: selectedCount > 0, style: IconButton.styleFrom( minimumSize: const Size.square(_tagFilterButtonDimension), maximumSize: const Size.square(_tagFilterButtonDimension), @@ -52,19 +53,33 @@ class TagFilterButton extends StatelessWidget { clipBehavior: Clip.none, children: [ const Icon(Icons.sell_outlined, size: 18), - if (selectedTag != null) + if (selectedCount > 0) Positioned( - top: -2, - right: -3, + top: -7, + right: -9, child: Container( - width: 7, - height: 7, + key: const Key('tag-filter-count'), + constraints: const BoxConstraints( + minWidth: 14, + minHeight: 14, + ), + padding: const EdgeInsets.symmetric(horizontal: 3), + alignment: Alignment.center, decoration: BoxDecoration( - color: TagPalette.color(selectedTag!.colorValue), - shape: BoxShape.circle, + color: theme.colorScheme.primary, + borderRadius: BorderRadius.circular(7), border: Border.all( color: theme.colorScheme.surface, - width: 1.2, + width: 1, + ), + ), + child: Text( + selectedCount > 99 ? '99+' : '$selectedCount', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onPrimary, + fontSize: 9, + height: 1, + fontWeight: FontWeight.w700, ), ), ), @@ -79,19 +94,33 @@ class TagFilterButton extends StatelessWidget { size: 18, color: theme.colorScheme.primary, ), - if (selectedTag != null) + if (selectedCount > 0) Positioned( - top: -2, - right: -3, + top: -7, + right: -9, child: Container( - width: 7, - height: 7, + key: const Key('tag-filter-count'), + constraints: const BoxConstraints( + minWidth: 14, + minHeight: 14, + ), + padding: const EdgeInsets.symmetric(horizontal: 3), + alignment: Alignment.center, decoration: BoxDecoration( - color: TagPalette.color(selectedTag!.colorValue), - shape: BoxShape.circle, + color: theme.colorScheme.primary, + borderRadius: BorderRadius.circular(7), border: Border.all( color: theme.colorScheme.surface, - width: 1.2, + width: 1, + ), + ), + child: Text( + selectedCount > 99 ? '99+' : '$selectedCount', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onPrimary, + fontSize: 9, + height: 1, + fontWeight: FontWeight.w700, ), ), ), @@ -116,7 +145,7 @@ class TagAssignmentMenu extends StatefulWidget { final String todoId; final List tags; final List assignedTagIds; - final Future Function(String tagId) onToggle; + final Future Function(String tagId) onToggle; final VoidCallback onManageTags; @override @@ -124,7 +153,22 @@ class TagAssignmentMenu extends StatefulWidget { } class _TagAssignmentMenuState extends State { - final MenuController _menuController = MenuController(); + Future _openBottomSheet() async { + final shouldManageTags = await showFloatickModalBottomSheet( + context: context, + builder: (context) { + return _TagAssignmentBottomSheet( + todoId: widget.todoId, + tags: widget.tags, + assignedTagIds: widget.assignedTagIds, + onToggle: widget.onToggle, + ); + }, + ); + if (shouldManageTags == true && mounted) { + widget.onManageTags(); + } + } @override Widget build(BuildContext context) { @@ -132,222 +176,246 @@ class _TagAssignmentMenuState extends State { final assignedTags = widget.tags .where((tag) => assignedIds.contains(tag.id)) .toList(growable: false); - return MenuAnchor( - controller: _menuController, - consumeOutsideTap: false, - crossAxisUnconstrained: false, - style: _tagMenuStyle(context), - menuChildren: [ - SizedBox( - width: _tagMenuWidth, - child: Padding( - padding: const EdgeInsets.fromLTRB(10, 9, 10, 10), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.only(left: 7), - child: Text( - context.l10n.assignTagsTitle, - style: Theme.of(context).textTheme.labelLarge - ?.copyWith(fontWeight: FontWeight.w600), - ), - ), - ), - IconButton( - tooltip: context.l10n.manageTagsTooltip, - onPressed: () { - _menuController.close(); - WidgetsBinding.instance.addPostFrameCallback((_) { - widget.onManageTags(); - }); - }, - icon: const Icon(Icons.settings_outlined, size: 17), - ), - ], - ), - if (widget.tags.isEmpty) - Padding( - padding: const EdgeInsets.fromLTRB(8, 13, 8, 10), - child: Text( - context.l10n.noTagsYetMessage, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.48), - ), - ), - ) - else - ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 260), - child: SingleChildScrollView( - primary: false, - padding: EdgeInsets.zero, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (final tag in widget.tags) - _TagMenuRow( - key: ValueKey( - 'assign-${widget.todoId}-${tag.id}', - ), - label: tag.name, - color: TagPalette.color(tag.colorValue), - selected: assignedIds.contains(tag.id), - onPressed: () => - unawaited(widget.onToggle(tag.id)), - ), - ], - ), - ), - ), - ], + return Wrap( + spacing: 4, + runSpacing: 3, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + for (final tag in assignedTags) + FloatickTagChip( + key: ValueKey('todo-tag-${widget.todoId}-${tag.id}'), + tag: tag, + compact: true, + ), + SizedBox.square( + dimension: 20, + child: IconButton( + key: ValueKey('assign-tags-${widget.todoId}'), + tooltip: context.l10n.assignTagsTooltip, + onPressed: _openBottomSheet, + padding: EdgeInsets.zero, + icon: Icon( + assignedTags.isEmpty ? Icons.sell_outlined : Icons.sell_rounded, + size: 13, + color: assignedTags.isEmpty + ? null + : Theme.of(context).colorScheme.primary, ), ), ), ], - builder: (context, controller, _) { - return Wrap( - spacing: 4, - runSpacing: 3, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - for (final tag in assignedTags) - FloatickTagChip( - key: ValueKey('todo-tag-${widget.todoId}-${tag.id}'), - tag: tag, - compact: true, - ), - SizedBox.square( - dimension: 20, - child: IconButton( - key: ValueKey('assign-tags-${widget.todoId}'), - tooltip: context.l10n.assignTagsTooltip, - onPressed: () { - controller.isOpen ? controller.close() : controller.open(); - }, - padding: EdgeInsets.zero, - icon: Icon( - assignedTags.isEmpty - ? Icons.sell_outlined - : Icons.sell_rounded, - size: 13, - color: assignedTags.isEmpty - ? null - : Theme.of(context).colorScheme.primary, - ), - ), - ), - ], - ); - }, ); } } -class _TagMenuRow extends StatelessWidget { - const _TagMenuRow({ - required this.label, - required this.selected, - required this.onPressed, - this.color, - super.key, +class _TagAssignmentBottomSheet extends StatefulWidget { + const _TagAssignmentBottomSheet({ + required this.todoId, + required this.tags, + required this.assignedTagIds, + required this.onToggle, }); - final String label; - final bool selected; - final VoidCallback onPressed; - final Color? color; + final String todoId; + final List tags; + final List assignedTagIds; + final Future Function(String tagId) onToggle; + + @override + State<_TagAssignmentBottomSheet> createState() => + _TagAssignmentBottomSheetState(); +} + +class _TagAssignmentBottomSheetState extends State<_TagAssignmentBottomSheet> { + late final Set _selectedTagIds; + final Set _pendingTagIds = {}; + + @override + void initState() { + super.initState(); + final knownTagIds = widget.tags.map((tag) => tag.id).toSet(); + _selectedTagIds = widget.assignedTagIds.where(knownTagIds.contains).toSet(); + } + + Future _toggleTag(String tagId) async { + if (_pendingTagIds.contains(tagId)) { + return; + } + final wasSelected = _selectedTagIds.contains(tagId); + setState(() { + _pendingTagIds.add(tagId); + if (wasSelected) { + _selectedTagIds.remove(tagId); + } else { + _selectedTagIds.add(tagId); + } + }); + final saved = await widget.onToggle(tagId); + if (!mounted) { + return; + } + setState(() { + _pendingTagIds.remove(tagId); + if (!saved) { + if (wasSelected) { + _selectedTagIds.add(tagId); + } else { + _selectedTagIds.remove(tagId); + } + } + }); + } @override Widget build(BuildContext context) { final theme = Theme.of(context); - return Semantics( - button: true, - selected: selected, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: InkWell( - onTap: onPressed, - borderRadius: BorderRadius.circular(8), - hoverColor: theme.colorScheme.primary.withValues(alpha: 0.07), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 7), - child: Row( - children: [ - SizedBox( - width: 16, - child: color == null - ? Icon( - Icons.layers_outlined, - size: 14, - color: theme.colorScheme.onSurface.withValues( - alpha: 0.46, - ), - ) - : Center( - child: Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - ), - ), - ), + final isDark = theme.brightness == Brightness.dark; + final isMacOS = theme.platform == TargetPlatform.macOS; + final mediaSize = MediaQuery.sizeOf(context); + final maxHeight = mediaSize.height * (mediaSize.width < 600 ? 0.72 : 0.52); + final desiredHeight = widget.tags.isEmpty + ? 220.0 + : 112.0 + (widget.tags.length * 48.0); + final minimumHeight = maxHeight < 220 ? maxHeight : 220.0; + final sheetHeight = desiredHeight + .clamp(minimumHeight, maxHeight) + .toDouble(); + final sheetBorderRadius = BorderRadius.only( + topLeft: const Radius.circular( + FloatickSurfaceMetrics.bottomSheetTopRadius, + ), + topRight: const Radius.circular( + FloatickSurfaceMetrics.bottomSheetTopRadius, + ), + bottomLeft: Radius.circular( + isMacOS ? FloatickSurfaceMetrics.panelContentRadius : 0, + ), + bottomRight: Radius.circular( + isMacOS ? FloatickSurfaceMetrics.panelContentRadius : 0, + ), + ); + return SizedBox( + key: const Key('tag-assignment-bottom-sheet'), + width: double.infinity, + height: sheetHeight, + child: DecoratedBox( + key: const Key('tag-assignment-bottom-sheet-surface'), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF202A2E) : const Color(0xFFF9FBFA), + borderRadius: sheetBorderRadius, + border: Border( + top: BorderSide( + color: isDark + ? Colors.white.withValues(alpha: 0.11) + : Colors.black.withValues(alpha: 0.07), + ), + ), + ), + child: ClipRRect( + borderRadius: sheetBorderRadius, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 8), + Center( + child: Container( + key: const Key('tag-assignment-drag-handle'), + width: 34, + height: 4, + decoration: BoxDecoration( + color: theme.colorScheme.onSurface.withValues(alpha: 0.22), + borderRadius: BorderRadius.circular(2), + ), ), - const SizedBox(width: 7), - Expanded( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall?.copyWith( - fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + ), + Padding( + padding: const EdgeInsets.fromLTRB(18, 4, 8, 8), + child: Row( + children: [ + Expanded( + child: Text( + context.l10n.assignTagsTitle, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), ), - ), + TextButton( + key: const Key('tag-assignment-manage'), + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom( + minimumSize: const Size(0, 44), + padding: const EdgeInsets.symmetric(horizontal: 10), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text(context.l10n.manageTagsButtonLabel), + ), + IconButton( + key: const Key('tag-assignment-bottom-sheet-close'), + tooltip: context.l10n.closeTagAssignmentTooltip, + onPressed: () => Navigator.of(context).pop(false), + icon: const Icon(Icons.close_rounded, size: 19), + ), + ], ), - const SizedBox(width: 7), - Icon( - selected ? Icons.check_rounded : null, - size: 16, - color: theme.colorScheme.primary, + ), + Divider( + height: 1, + thickness: 1, + color: isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.06), + ), + Expanded( + child: SafeArea( + key: const Key('tag-assignment-content-safe-area'), + top: false, + left: false, + right: false, + minimum: const EdgeInsets.only( + bottom: + FloatickSurfaceMetrics.bottomSheetContentBottomInset, + ), + child: widget.tags.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 12), + child: Text( + context.l10n.noTagsYetMessage, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withValues( + alpha: 0.48, + ), + height: 1.4, + ), + ), + ), + ) + : ListView.builder( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), + itemExtent: tagSelectionRowExtent, + itemCount: widget.tags.length, + itemBuilder: (context, index) { + final tag = widget.tags[index]; + return TagSelectionRow( + key: ValueKey( + 'assign-${widget.todoId}-${tag.id}', + ), + tag: tag, + label: tag.name, + selected: _selectedTagIds.contains(tag.id), + pending: _pendingTagIds.contains(tag.id), + onPressed: () => unawaited(_toggleTag(tag.id)), + ); + }, + ), ), - ], - ), + ), + ], ), ), ), ); } } - -MenuStyle _tagMenuStyle(BuildContext context) { - final theme = Theme.of(context); - final isDark = theme.brightness == Brightness.dark; - return MenuStyle( - padding: const WidgetStatePropertyAll(EdgeInsets.zero), - elevation: const WidgetStatePropertyAll(0), - backgroundColor: WidgetStatePropertyAll( - isDark ? const Color(0xFF222D31) : const Color(0xFFF9FBFA), - ), - side: WidgetStatePropertyAll( - BorderSide( - color: isDark - ? Colors.white.withValues(alpha: 0.12) - : Colors.black.withValues(alpha: 0.08), - ), - ), - shape: WidgetStatePropertyAll( - RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), - ), - shadowColor: WidgetStatePropertyAll( - Colors.black.withValues(alpha: isDark ? 0.34 : 0.16), - ), - ); -} diff --git a/lib/features/todos/presentation/widgets/tag_selection_row.dart b/lib/features/todos/presentation/widgets/tag_selection_row.dart new file mode 100644 index 0000000..0e49834 --- /dev/null +++ b/lib/features/todos/presentation/widgets/tag_selection_row.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; + +import '../../domain/todo_tag.dart'; +import 'tag_palette.dart'; + +const double tagSelectionRowExtent = 44; + +class TagSelectionRow extends StatelessWidget { + const TagSelectionRow({ + required this.label, + required this.selected, + required this.onPressed, + this.tag, + this.trailing, + this.pending = false, + super.key, + }); + + final TodoTag? tag; + final String label; + final String? trailing; + final bool selected; + final bool pending; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final tagColor = tag == null ? null : TagPalette.color(tag!.colorValue); + return Semantics( + button: true, + enabled: !pending, + selected: selected, + child: MouseRegion( + cursor: pending ? SystemMouseCursors.basic : SystemMouseCursors.click, + child: InkWell( + onTap: pending ? null : onPressed, + borderRadius: BorderRadius.circular(10), + hoverColor: theme.colorScheme.primary.withValues(alpha: 0.07), + child: Container( + constraints: const BoxConstraints(minHeight: tagSelectionRowExtent), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + SizedBox( + width: 18, + child: tagColor == null + ? Icon( + Icons.layers_outlined, + size: 15, + color: theme.colorScheme.onSurface.withValues( + alpha: 0.44, + ), + ) + : Center( + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: tagColor, + shape: BoxShape.circle, + ), + ), + ), + ), + const SizedBox(width: 9), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + if (trailing != null) ...[ + const SizedBox(width: 8), + Text( + trailing!, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurface.withValues( + alpha: 0.40, + ), + ), + ), + ], + const SizedBox(width: 10), + SizedBox( + width: 18, + child: selected + ? Icon( + Icons.check_rounded, + size: 17, + color: theme.colorScheme.primary, + ) + : null, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/todos/presentation/widgets/todo_list_row.dart b/lib/features/todos/presentation/widgets/todo_list_row.dart index 681fbb0..cb47519 100644 --- a/lib/features/todos/presentation/widgets/todo_list_row.dart +++ b/lib/features/todos/presentation/widgets/todo_list_row.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; +import '../../../../core/ui/floatick_hover_motion.dart'; import '../../../../l10n/l10n.dart'; import '../../domain/todo_item.dart'; import '../../domain/todo_tag.dart'; +import 'floatick_tag_chip.dart'; import 'tag_menus.dart'; class TodoListRow extends StatefulWidget { @@ -16,25 +18,39 @@ class TodoListRow extends StatefulWidget { required this.onRestore, required this.tags, required this.assignedTagIds, - required this.onToggleTag, - required this.onOpenTagManagement, + this.onToggleTag, + this.onOpenTagManagement, + this.onOpenTagAssignment, this.onRemoveFromStickyBoard, + this.onDeletePermanently, + this.showArchiveAction = true, this.compact = false, super.key, - }); + }) : assert( + archivedScope || + onOpenTagAssignment != null || + (onToggleTag != null && onOpenTagManagement != null), + ), + assert(!archivedScope || onEdit == null), + assert(archivedScope || onEdit != null), + assert(archivedScope || onDeletePermanently == null), + assert(!archivedScope || onDeletePermanently != null); final TodoItem item; final bool archivedScope; final VoidCallback onToggle; final VoidCallback onOpenDetails; - final VoidCallback onEdit; + final VoidCallback? onEdit; final VoidCallback onArchive; final VoidCallback onRestore; final List tags; final List assignedTagIds; - final Future Function(String tagId) onToggleTag; - final VoidCallback onOpenTagManagement; + final Future Function(String tagId)? onToggleTag; + final VoidCallback? onOpenTagManagement; + final VoidCallback? onOpenTagAssignment; final VoidCallback? onRemoveFromStickyBoard; + final VoidCallback? onDeletePermanently; + final bool showArchiveAction; final bool compact; @override @@ -46,6 +62,7 @@ class _TodoListRowState extends State { bool _isHovered = false; bool _hasFocus = false; + bool _isConfirmingDelete = false; @override void dispose() { @@ -53,6 +70,20 @@ class _TodoListRowState extends State { super.dispose(); } + void _requestPermanentDelete() { + setState(() => _isConfirmingDelete = true); + _rowFocusNode.requestFocus(); + } + + void _cancelPermanentDelete() { + setState(() => _isConfirmingDelete = false); + } + + void _confirmPermanentDelete() { + setState(() => _isConfirmingDelete = false); + widget.onDeletePermanently?.call(); + } + @override Widget build(BuildContext context) { final item = widget.item; @@ -60,9 +91,12 @@ class _TodoListRowState extends State { final isDark = Theme.of(context).brightness == Brightness.dark; final onSurface = Theme.of(context).colorScheme.onSurface; final reduceMotion = MediaQuery.disableAnimationsOf(context); - final showContextActions = _isHovered || _hasFocus; - final trailingActionCount = - 3 + (widget.onRemoveFromStickyBoard == null ? 0 : 1); + final showContextActions = _isHovered || _hasFocus || _isConfirmingDelete; + final trailingActionCount = widget.archivedScope + ? 3 + : 2 + + (widget.showArchiveAction ? 1 : 0) + + (widget.onRemoveFromStickyBoard == null ? 0 : 1); return Focus( focusNode: _rowFocusNode, @@ -99,160 +133,247 @@ class _TodoListRowState extends State { : Colors.transparent, borderRadius: BorderRadius.circular(11), ), - child: Row( + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - if (!widget.archivedScope) - Tooltip( - message: item.isCompleted - ? localizations.markIncompleteTooltip - : localizations.markCompleteTooltip, - child: Semantics( - button: true, - checked: item.isCompleted, + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (!widget.archivedScope) + Tooltip( + message: item.isCompleted + ? localizations.markIncompleteTooltip + : localizations.markCompleteTooltip, + child: Semantics( + button: true, + checked: item.isCompleted, + child: FloatickHoverMotion( + child: GestureDetector( + key: ValueKey( + 'toggle-todo-${widget.item.id}', + ), + behavior: HitTestBehavior.opaque, + onTap: widget.onToggle, + child: Padding( + padding: const EdgeInsets.all(4), + child: AnimatedContainer( + duration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 160), + width: 21, + height: 21, + decoration: BoxDecoration( + color: item.isCompleted + ? Theme.of(context).colorScheme.primary + : Colors.transparent, + borderRadius: BorderRadius.circular(7), + border: Border.all( + color: item.isCompleted + ? Theme.of( + context, + ).colorScheme.primary + : onSurface.withValues(alpha: 0.28), + width: 1.4, + ), + ), + child: item.isCompleted + ? const Icon( + Icons.check_rounded, + size: 15, + color: Colors.white, + ) + : null, + ), + ), + ), + ), + ), + ) + else + Padding( + padding: const EdgeInsets.all(4), + child: Icon( + Icons.inventory_2_outlined, + key: ValueKey( + 'archived-status-${widget.item.id}', + ), + size: 21, + color: onSurface.withValues(alpha: 0.28), + ), + ), + const SizedBox(width: 7), + Expanded( child: MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( + key: ValueKey( + 'todo-open-details-region-${widget.item.id}', + ), behavior: HitTestBehavior.opaque, - onTap: widget.onToggle, - child: Padding( - padding: const EdgeInsets.all(4), - child: AnimatedContainer( - duration: reduceMotion - ? Duration.zero - : const Duration(milliseconds: 160), - width: 21, - height: 21, - decoration: BoxDecoration( - color: item.isCompleted - ? Theme.of(context).colorScheme.primary - : Colors.transparent, - borderRadius: BorderRadius.circular(7), - border: Border.all( - color: item.isCompleted - ? Theme.of(context).colorScheme.primary - : onSurface.withValues(alpha: 0.28), - width: 1.4, + onDoubleTap: widget.onOpenDetails, + child: SizedBox( + height: 30, + child: Align( + alignment: Alignment.centerLeft, + child: Text( + item.title, + key: ValueKey( + 'todo-title-${widget.item.id}', + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: onSurface.withValues( + alpha: item.isCompleted ? 0.45 : 0.91, + ), + fontSize: widget.compact ? 12.5 : 13.5, + height: 1.3, + decoration: item.isCompleted + ? TextDecoration.lineThrough + : null, + decorationColor: onSurface.withValues( + alpha: 0.42, + ), ), ), - child: item.isCompleted - ? const Icon( - Icons.check_rounded, - size: 15, - color: Colors.white, - ) - : null, ), ), ), ), ), - ) - else - Padding( - padding: const EdgeInsets.all(4), - child: Icon( - Icons.inventory_2_outlined, - size: 21, - color: onSurface.withValues(alpha: 0.28), - ), - ), - const SizedBox(width: 7), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.title, - maxLines: widget.compact ? 1 : 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: onSurface.withValues( - alpha: item.isCompleted ? 0.45 : 0.91, - ), - fontSize: widget.compact ? 12.5 : 13.5, - height: 1.3, - decoration: item.isCompleted - ? TextDecoration.lineThrough - : null, - decorationColor: onSurface.withValues(alpha: 0.42), - ), - ), - const SizedBox(height: 4), - Row( - crossAxisAlignment: CrossAxisAlignment.end, + const SizedBox(width: 3), + SizedBox( + width: trailingActionCount * 30, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, children: [ - Expanded( - child: TagAssignmentMenu( - todoId: item.id, - tags: widget.tags, - assignedTagIds: widget.assignedTagIds, - onToggle: widget.onToggleTag, - onManageTags: widget.onOpenTagManagement, + if (!widget.archivedScope) + _HoverAction( + visible: showContextActions, + tooltip: localizations.editTooltip, + onPressed: widget.onEdit!, + icon: Icons.edit_outlined, + key: ValueKey( + 'edit-todo-${widget.item.id}', + ), + ), + _ActionButton( + tooltip: localizations.viewTodoDetailsTooltip, + onPressed: widget.onOpenDetails, + icon: Icons.subject_rounded, + color: item.content.trim().isEmpty + ? onSurface.withValues(alpha: 0.42) + : Theme.of(context).colorScheme.primary, + key: ValueKey( + 'view-todo-${widget.item.id}', ), ), - const SizedBox(width: 7), - Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Text( - _formatTime( - context, - widget.archivedScope - ? (item.archivedAt ?? item.createdAt) - : item.createdAt, + if (widget.archivedScope && _isConfirmingDelete) ...[ + _ActionButton( + key: ValueKey( + 'cancel-delete-todo-${widget.item.id}', ), - style: TextStyle( - color: onSurface.withValues(alpha: 0.35), - fontSize: 10.5, + tooltip: localizations.cancelDeleteTodoTooltip, + onPressed: _cancelPermanentDelete, + icon: Icons.close_rounded, + ), + _ActionButton( + key: ValueKey( + 'confirm-delete-todo-${widget.item.id}', ), + tooltip: localizations.confirmDeleteTodoTooltip, + onPressed: _confirmPermanentDelete, + icon: Icons.delete_forever_outlined, + color: Theme.of(context).colorScheme.error, + ), + ] else if (widget.archivedScope) ...[ + _ActionButton( + key: ValueKey( + 'restore-todo-${widget.item.id}', + ), + tooltip: localizations.restoreTooltip, + onPressed: widget.onRestore, + icon: Icons.unarchive_outlined, + ), + _HoverAction( + key: ValueKey( + 'delete-todo-${widget.item.id}', + ), + visible: showContextActions, + tooltip: + localizations.deleteTodoPermanentlyTooltip, + onPressed: _requestPermanentDelete, + icon: Icons.delete_outline_rounded, + color: Theme.of(context).colorScheme.error, + ), + ] else if (widget.showArchiveAction) + _ActionButton( + key: ValueKey( + 'archive-todo-${widget.item.id}', + ), + tooltip: localizations.archiveTooltip, + onPressed: widget.onArchive, + icon: Icons.archive_outlined, + ), + if (widget.onRemoveFromStickyBoard != null) + _HoverAction( + key: ValueKey( + 'remove-from-board-${widget.item.id}', + ), + visible: showContextActions, + tooltip: + localizations.removeFromStickyBoardTooltip, + onPressed: widget.onRemoveFromStickyBoard!, + icon: Icons.remove_circle_outline_rounded, ), - ), ], ), - ], - ), + ), + ], ), - const SizedBox(width: 3), - SizedBox( - width: trailingActionCount * 30, - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - _HoverAction( - visible: showContextActions, - tooltip: localizations.editTooltip, - onPressed: widget.onEdit, - icon: Icons.edit_outlined, - key: ValueKey('edit-todo-${widget.item.id}'), - ), - _ActionButton( - tooltip: localizations.viewTodoDetailsTooltip, - onPressed: widget.onOpenDetails, - icon: Icons.subject_rounded, - color: item.content.trim().isEmpty - ? onSurface.withValues(alpha: 0.42) - : Theme.of(context).colorScheme.primary, - key: ValueKey('view-todo-${widget.item.id}'), + SizedBox(height: widget.compact ? 3 : 5), + Row( + key: ValueKey('todo-metadata-row-${widget.item.id}'), + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(width: 36), + Expanded( + child: widget.archivedScope + ? _ReadOnlyTodoTags( + todoId: item.id, + tags: widget.tags, + assignedTagIds: widget.assignedTagIds, + ) + : widget.onOpenTagAssignment == null + ? TagAssignmentMenu( + todoId: item.id, + tags: widget.tags, + assignedTagIds: widget.assignedTagIds, + onToggle: widget.onToggleTag!, + onManageTags: widget.onOpenTagManagement!, + ) + : _ExternalTagAssignment( + todoId: item.id, + tags: widget.tags, + assignedTagIds: widget.assignedTagIds, + onPressed: widget.onOpenTagAssignment!, + ), + ), + const SizedBox(width: 7), + Text( + _formatTime( + context, + widget.archivedScope + ? (item.archivedAt ?? item.createdAt) + : item.createdAt, ), - _ActionButton( - tooltip: widget.archivedScope - ? localizations.restoreTooltip - : localizations.archiveTooltip, - onPressed: widget.archivedScope - ? widget.onRestore - : widget.onArchive, - icon: widget.archivedScope - ? Icons.unarchive_outlined - : Icons.archive_outlined, + key: ValueKey('todo-time-${widget.item.id}'), + style: TextStyle( + color: onSurface.withValues(alpha: 0.35), + fontSize: 10.5, ), - if (widget.onRemoveFromStickyBoard != null) - _HoverAction( - visible: showContextActions, - tooltip: localizations.removeFromStickyBoardTooltip, - onPressed: widget.onRemoveFromStickyBoard!, - icon: Icons.remove_circle_outline_rounded, - ), - ], - ), + ), + ], ), ], ), @@ -263,12 +384,97 @@ class _TodoListRowState extends State { } } +class _ExternalTagAssignment extends StatelessWidget { + const _ExternalTagAssignment({ + required this.todoId, + required this.tags, + required this.assignedTagIds, + required this.onPressed, + }); + + final String todoId; + final List tags; + final List assignedTagIds; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final assignedIds = assignedTagIds.toSet(); + final assignedTags = tags + .where((tag) => assignedIds.contains(tag.id)) + .toList(growable: false); + + return Wrap( + spacing: 4, + runSpacing: 3, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + for (final tag in assignedTags) + FloatickTagChip( + key: ValueKey('todo-tag-$todoId-${tag.id}'), + tag: tag, + compact: true, + ), + SizedBox.square( + dimension: 20, + child: IconButton( + key: ValueKey('assign-tags-$todoId'), + tooltip: context.l10n.assignTagsTooltip, + onPressed: onPressed, + padding: EdgeInsets.zero, + icon: Icon( + assignedTags.isEmpty ? Icons.sell_outlined : Icons.sell_rounded, + size: 13, + color: assignedTags.isEmpty + ? null + : Theme.of(context).colorScheme.primary, + ), + ), + ), + ], + ); + } +} + +class _ReadOnlyTodoTags extends StatelessWidget { + const _ReadOnlyTodoTags({ + required this.todoId, + required this.tags, + required this.assignedTagIds, + }); + + final String todoId; + final List tags; + final List assignedTagIds; + + @override + Widget build(BuildContext context) { + final assignedIds = assignedTagIds.toSet(); + final assignedTags = tags + .where((tag) => assignedIds.contains(tag.id)) + .toList(growable: false); + return Wrap( + spacing: 4, + runSpacing: 3, + children: [ + for (final tag in assignedTags) + FloatickTagChip( + key: ValueKey('todo-tag-$todoId-${tag.id}'), + tag: tag, + compact: true, + ), + ], + ); + } +} + class _HoverAction extends StatelessWidget { const _HoverAction({ required this.visible, required this.tooltip, required this.onPressed, required this.icon, + this.color, super.key, }); @@ -276,6 +482,7 @@ class _HoverAction extends StatelessWidget { final String tooltip; final VoidCallback onPressed; final IconData icon; + final Color? color; @override Widget build(BuildContext context) { @@ -293,7 +500,7 @@ class _HoverAction extends StatelessWidget { tooltip: tooltip, onPressed: onPressed, padding: EdgeInsets.zero, - icon: Icon(icon, size: 16), + icon: Icon(icon, size: 16, color: color), ), ), ), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 767a50a..8bb9c15 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -9,6 +9,14 @@ "languageSystemTooltip": "Follow system", "languageSimplifiedChineseTooltip": "Simplified Chinese", "languageEnglishTooltip": "English", + "windowSectionTitle": "Window", + "alwaysOnTopLabel": "Keep above other apps", + "startupSectionTitle": "Startup", + "openAtLoginLabel": "Open at login", + "openAtLoginLoadError": "Couldn't read the login item setting.", + "openAtLoginUpdateError": "Couldn't change the login item setting.", + "openAtLoginApprovalRequired": "Allow Floatick in System Settings → General → Login Items.", + "openAtLoginUnsupported": "Open at login requires macOS 13 or later.", "updatesSectionTitle": "Updates", "currentVersionLabel": "v{version}", "@currentVersionLabel": { @@ -131,7 +139,6 @@ "deleteStickyBoardTitle": "Delete this sticky board?", "deleteStickyBoardMessage": "Its todos will stay safely in All Todos.", "keepStickyBoardAction": "Keep sticky board", - "confirmDeleteStickyBoardAction": "Delete sticky board", "pinStickyBoardTooltip": "Pin to desktop", "unpinStickyBoardTooltip": "Unpin from desktop", "stickyBoardPinnedLabel": "Pinned", @@ -139,9 +146,9 @@ "addExistingTodoTitle": "Add existing todos", "searchTodosToAddHint": "Search todos to add", "noTodosAvailableForBoardMessage": "No todos are available to add.", + "emptyPinnedStickyBoardMessage": "No todos on this board.", "newTodoInStickyBoardAction": "New todo", "removeFromStickyBoardTooltip": "Remove from sticky board", - "openMainListTooltip": "Open main list", "stickyBoardDeleteKeepsTodosHint": "Deleting a sticky board never deletes its todos.", "collapseTooltip": "Collapse (Esc)", "activeScopeLabel": "Todos", @@ -159,7 +166,9 @@ "markdownWriteLabel": "Write", "markdownPreviewLabel": "Preview", "cancelAction": "Cancel", + "confirmAction": "Confirm", "createTodoAction": "Add todo", + "newTodoAction": "New", "saveChangesAction": "Save changes", "saveTodoFailedMessage": "Couldn't save this todo.", "todoNotFoundMessage": "This todo no longer exists.", @@ -178,6 +187,10 @@ "cancelEditTooltip": "Cancel editing", "restoreTooltip": "Restore to todos", "archiveTooltip": "Archive", + "deleteTodoPermanentlyTooltip": "Delete permanently", + "cancelDeleteTodoTooltip": "Keep archived todo", + "confirmDeleteTodoTooltip": "Permanently delete this todo", + "archivedTodoNoContentMessage": "No additional notes were saved.", "noSearchResultsTitle": "No matching results", "emptyArchiveTitle": "Archive is empty", "emptyTodosTitle": "Nothing to do—enjoy the moment", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index a760794..c383276 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -152,6 +152,54 @@ abstract class AppLocalizations { /// **'English'** String get languageEnglishTooltip; + /// No description provided for @windowSectionTitle. + /// + /// In en, this message translates to: + /// **'Window'** + String get windowSectionTitle; + + /// No description provided for @alwaysOnTopLabel. + /// + /// In en, this message translates to: + /// **'Keep above other apps'** + String get alwaysOnTopLabel; + + /// No description provided for @startupSectionTitle. + /// + /// In en, this message translates to: + /// **'Startup'** + String get startupSectionTitle; + + /// No description provided for @openAtLoginLabel. + /// + /// In en, this message translates to: + /// **'Open at login'** + String get openAtLoginLabel; + + /// No description provided for @openAtLoginLoadError. + /// + /// In en, this message translates to: + /// **'Couldn\'t read the login item setting.'** + String get openAtLoginLoadError; + + /// No description provided for @openAtLoginUpdateError. + /// + /// In en, this message translates to: + /// **'Couldn\'t change the login item setting.'** + String get openAtLoginUpdateError; + + /// No description provided for @openAtLoginApprovalRequired. + /// + /// In en, this message translates to: + /// **'Allow Floatick in System Settings → General → Login Items.'** + String get openAtLoginApprovalRequired; + + /// No description provided for @openAtLoginUnsupported. + /// + /// In en, this message translates to: + /// **'Open at login requires macOS 13 or later.'** + String get openAtLoginUnsupported; + /// No description provided for @updatesSectionTitle. /// /// In en, this message translates to: @@ -590,12 +638,6 @@ abstract class AppLocalizations { /// **'Keep sticky board'** String get keepStickyBoardAction; - /// No description provided for @confirmDeleteStickyBoardAction. - /// - /// In en, this message translates to: - /// **'Delete sticky board'** - String get confirmDeleteStickyBoardAction; - /// No description provided for @pinStickyBoardTooltip. /// /// In en, this message translates to: @@ -638,6 +680,12 @@ abstract class AppLocalizations { /// **'No todos are available to add.'** String get noTodosAvailableForBoardMessage; + /// No description provided for @emptyPinnedStickyBoardMessage. + /// + /// In en, this message translates to: + /// **'No todos on this board.'** + String get emptyPinnedStickyBoardMessage; + /// No description provided for @newTodoInStickyBoardAction. /// /// In en, this message translates to: @@ -650,12 +698,6 @@ abstract class AppLocalizations { /// **'Remove from sticky board'** String get removeFromStickyBoardTooltip; - /// No description provided for @openMainListTooltip. - /// - /// In en, this message translates to: - /// **'Open main list'** - String get openMainListTooltip; - /// No description provided for @stickyBoardDeleteKeepsTodosHint. /// /// In en, this message translates to: @@ -758,12 +800,24 @@ abstract class AppLocalizations { /// **'Cancel'** String get cancelAction; + /// No description provided for @confirmAction. + /// + /// In en, this message translates to: + /// **'Confirm'** + String get confirmAction; + /// No description provided for @createTodoAction. /// /// In en, this message translates to: /// **'Add todo'** String get createTodoAction; + /// No description provided for @newTodoAction. + /// + /// In en, this message translates to: + /// **'New'** + String get newTodoAction; + /// No description provided for @saveChangesAction. /// /// In en, this message translates to: @@ -872,6 +926,30 @@ abstract class AppLocalizations { /// **'Archive'** String get archiveTooltip; + /// No description provided for @deleteTodoPermanentlyTooltip. + /// + /// In en, this message translates to: + /// **'Delete permanently'** + String get deleteTodoPermanentlyTooltip; + + /// No description provided for @cancelDeleteTodoTooltip. + /// + /// In en, this message translates to: + /// **'Keep archived todo'** + String get cancelDeleteTodoTooltip; + + /// No description provided for @confirmDeleteTodoTooltip. + /// + /// In en, this message translates to: + /// **'Permanently delete this todo'** + String get confirmDeleteTodoTooltip; + + /// No description provided for @archivedTodoNoContentMessage. + /// + /// In en, this message translates to: + /// **'No additional notes were saved.'** + String get archivedTodoNoContentMessage; + /// No description provided for @noSearchResultsTitle. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index dda07b6..6337081 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -35,6 +35,33 @@ class AppLocalizationsEn extends AppLocalizations { @override String get languageEnglishTooltip => 'English'; + @override + String get windowSectionTitle => 'Window'; + + @override + String get alwaysOnTopLabel => 'Keep above other apps'; + + @override + String get startupSectionTitle => 'Startup'; + + @override + String get openAtLoginLabel => 'Open at login'; + + @override + String get openAtLoginLoadError => 'Couldn\'t read the login item setting.'; + + @override + String get openAtLoginUpdateError => + 'Couldn\'t change the login item setting.'; + + @override + String get openAtLoginApprovalRequired => + 'Allow Floatick in System Settings → General → Login Items.'; + + @override + String get openAtLoginUnsupported => + 'Open at login requires macOS 13 or later.'; + @override String get updatesSectionTitle => 'Updates'; @@ -295,9 +322,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get keepStickyBoardAction => 'Keep sticky board'; - @override - String get confirmDeleteStickyBoardAction => 'Delete sticky board'; - @override String get pinStickyBoardTooltip => 'Pin to desktop'; @@ -321,13 +345,13 @@ class AppLocalizationsEn extends AppLocalizations { 'No todos are available to add.'; @override - String get newTodoInStickyBoardAction => 'New todo'; + String get emptyPinnedStickyBoardMessage => 'No todos on this board.'; @override - String get removeFromStickyBoardTooltip => 'Remove from sticky board'; + String get newTodoInStickyBoardAction => 'New todo'; @override - String get openMainListTooltip => 'Open main list'; + String get removeFromStickyBoardTooltip => 'Remove from sticky board'; @override String get stickyBoardDeleteKeepsTodosHint => @@ -381,9 +405,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get cancelAction => 'Cancel'; + @override + String get confirmAction => 'Confirm'; + @override String get createTodoAction => 'Add todo'; + @override + String get newTodoAction => 'New'; + @override String get saveChangesAction => 'Save changes'; @@ -439,6 +469,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get archiveTooltip => 'Archive'; + @override + String get deleteTodoPermanentlyTooltip => 'Delete permanently'; + + @override + String get cancelDeleteTodoTooltip => 'Keep archived todo'; + + @override + String get confirmDeleteTodoTooltip => 'Permanently delete this todo'; + + @override + String get archivedTodoNoContentMessage => 'No additional notes were saved.'; + @override String get noSearchResultsTitle => 'No matching results'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index fcee62e..99af96c 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -35,6 +35,30 @@ class AppLocalizationsZh extends AppLocalizations { @override String get languageEnglishTooltip => 'English'; + @override + String get windowSectionTitle => '窗口'; + + @override + String get alwaysOnTopLabel => '始终置顶'; + + @override + String get startupSectionTitle => '启动'; + + @override + String get openAtLoginLabel => '登录时打开'; + + @override + String get openAtLoginLoadError => '暂时无法读取登录项设置。'; + + @override + String get openAtLoginUpdateError => '无法修改登录项设置。'; + + @override + String get openAtLoginApprovalRequired => '请前往“系统设置 → 通用 → 登录项”允许 Floatick。'; + + @override + String get openAtLoginUnsupported => '登录时打开需要 macOS 13 或更高版本。'; + @override String get updatesSectionTitle => '更新'; @@ -273,9 +297,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get keepStickyBoardAction => '保留便利板'; - @override - String get confirmDeleteStickyBoardAction => '删除便利板'; - @override String get pinStickyBoardTooltip => '固定到桌面'; @@ -298,13 +319,13 @@ class AppLocalizationsZh extends AppLocalizations { String get noTodosAvailableForBoardMessage => '暂无可添加的待办。'; @override - String get newTodoInStickyBoardAction => '新建待办'; + String get emptyPinnedStickyBoardMessage => '这个便利板还没有待办。'; @override - String get removeFromStickyBoardTooltip => '从便利板移除'; + String get newTodoInStickyBoardAction => '新建待办'; @override - String get openMainListTooltip => '打开主列表'; + String get removeFromStickyBoardTooltip => '从便利板移除'; @override String get stickyBoardDeleteKeepsTodosHint => '删除便利板不会删除其中的待办。'; @@ -357,9 +378,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String get cancelAction => '取消'; + @override + String get confirmAction => '确认'; + @override String get createTodoAction => '添加待办'; + @override + String get newTodoAction => '新建'; + @override String get saveChangesAction => '保存修改'; @@ -414,6 +441,18 @@ class AppLocalizationsZh extends AppLocalizations { @override String get archiveTooltip => '归档'; + @override + String get deleteTodoPermanentlyTooltip => '永久删除'; + + @override + String get cancelDeleteTodoTooltip => '保留归档待办'; + + @override + String get confirmDeleteTodoTooltip => '永久删除这个待办'; + + @override + String get archivedTodoNoContentMessage => '没有保存更多说明。'; + @override String get noSearchResultsTitle => '没有匹配的结果'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index cd83af9..58ae5dd 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -9,6 +9,14 @@ "languageSystemTooltip": "跟随系统", "languageSimplifiedChineseTooltip": "简体中文", "languageEnglishTooltip": "English", + "windowSectionTitle": "窗口", + "alwaysOnTopLabel": "始终置顶", + "startupSectionTitle": "启动", + "openAtLoginLabel": "登录时打开", + "openAtLoginLoadError": "暂时无法读取登录项设置。", + "openAtLoginUpdateError": "无法修改登录项设置。", + "openAtLoginApprovalRequired": "请前往“系统设置 → 通用 → 登录项”允许 Floatick。", + "openAtLoginUnsupported": "登录时打开需要 macOS 13 或更高版本。", "updatesSectionTitle": "更新", "currentVersionLabel": "v{version}", "automaticUpdateChecksLabel": "自动检查", @@ -82,7 +90,6 @@ "deleteStickyBoardTitle": "删除这个便利板?", "deleteStickyBoardMessage": "其中的待办仍会安全保留在全部待办中。", "keepStickyBoardAction": "保留便利板", - "confirmDeleteStickyBoardAction": "删除便利板", "pinStickyBoardTooltip": "固定到桌面", "unpinStickyBoardTooltip": "取消桌面固定", "stickyBoardPinnedLabel": "已固定", @@ -90,9 +97,9 @@ "addExistingTodoTitle": "添加现有待办", "searchTodosToAddHint": "搜索可添加的待办", "noTodosAvailableForBoardMessage": "暂无可添加的待办。", + "emptyPinnedStickyBoardMessage": "这个便利板还没有待办。", "newTodoInStickyBoardAction": "新建待办", "removeFromStickyBoardTooltip": "从便利板移除", - "openMainListTooltip": "打开主列表", "stickyBoardDeleteKeepsTodosHint": "删除便利板不会删除其中的待办。", "collapseTooltip": "收起(Esc)", "activeScopeLabel": "待办", @@ -110,7 +117,9 @@ "markdownWriteLabel": "编辑", "markdownPreviewLabel": "预览", "cancelAction": "取消", + "confirmAction": "确认", "createTodoAction": "添加待办", + "newTodoAction": "新建", "saveChangesAction": "保存修改", "saveTodoFailedMessage": "无法保存这个待办。", "todoNotFoundMessage": "这个待办已不存在。", @@ -129,6 +138,10 @@ "cancelEditTooltip": "取消编辑", "restoreTooltip": "恢复到待办", "archiveTooltip": "归档", + "deleteTodoPermanentlyTooltip": "永久删除", + "cancelDeleteTodoTooltip": "保留归档待办", + "confirmDeleteTodoTooltip": "永久删除这个待办", + "archivedTodoNoContentMessage": "没有保存更多说明。", "noSearchResultsTitle": "没有匹配的结果", "emptyArchiveTitle": "归档还是空的", "emptyTodosTitle": "没有待办,享受此刻", diff --git a/lib/main.dart b/lib/main.dart index d5bb4ee..de33615 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,13 +1,17 @@ +import 'dart:ui'; + import 'package:flutter/widgets.dart'; import 'package:multiview_desktop/multiview_desktop.dart'; import 'app/floatick_app.dart'; import 'core/platform/window_bridge.dart'; +import 'features/settings/data/login_item_repository.dart'; import 'features/settings/data/settings_repository.dart'; import 'features/settings/presentation/settings_view_model.dart'; import 'features/sticky_boards/data/sticky_board_repository.dart'; import 'features/sticky_boards/presentation/sticky_board_view_model.dart'; import 'features/sticky_boards/presentation/sticky_board_window_coordinator.dart'; +import 'features/todos/data/first_run_workspace_seeder.dart'; import 'features/todos/data/tag_repository.dart'; import 'features/todos/data/todo_repository.dart'; import 'features/todos/presentation/todo_view_model.dart'; @@ -17,12 +21,20 @@ import 'features/updates/presentation/update_view_model.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); + final todoRepository = LocalTodoRepository(); + final tagRepository = LocalTagRepository(); final controller = TodoViewModel( - todoRepository: LocalTodoRepository(), - tagRepository: LocalTagRepository(), + todoRepository: todoRepository, + tagRepository: tagRepository, + firstRunWorkspaceSeeder: FirstRunWorkspaceSeeder( + todoRepository: todoRepository, + tagRepository: tagRepository, + languageCode: PlatformDispatcher.instance.locale.languageCode, + ), ); final settingsController = SettingsViewModel( settingsRepository: LocalSettingsRepository(), + loginItemRepository: MethodChannelLoginItemRepository(), ); final updateController = UpdateViewModel( updateRepository: MethodChannelUpdateRepository(), @@ -36,9 +48,11 @@ Future main() async { updateController.load(), stickyBoardController.load(), ]); + final windowBridge = MethodChannelWindowBridge(); final stickyBoardWindowCoordinator = StickyBoardWindowCoordinator( boardController: stickyBoardController, todoController: controller, + windowBridge: windowBridge, ); runMultiApp( @@ -48,7 +62,7 @@ Future main() async { updateController: updateController, stickyBoardController: stickyBoardController, stickyBoardWindowCoordinator: stickyBoardWindowCoordinator, - windowBridge: MethodChannelWindowBridge(), + windowBridge: windowBridge, ), ); } diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index b7726fb..0648797 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -30,6 +30,7 @@ 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; F10A00012F21000100F10A01 /* UpdateService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10A00022F21000100F10A01 /* UpdateService.swift */; }; F10A00032F21000100F10A01 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = F10A00042F21000100F10A01 /* Sparkle */; }; + F10A00072F21000100F10A01 /* LoginItemService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10A00062F21000100F10A01 /* LoginItemService.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -83,6 +84,7 @@ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; F10A00022F21000100F10A01 /* UpdateService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateService.swift; sourceTree = ""; }; + F10A00062F21000100F10A01 /* LoginItemService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginItemService.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -173,6 +175,7 @@ 33CC10F02044A3C60003C045 /* AppDelegate.swift */, 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, F10A00022F21000100F10A01 /* UpdateService.swift */, + F10A00062F21000100F10A01 /* LoginItemService.swift */, 33E51913231747F40026EE4D /* DebugProfile.entitlements */, 33E51914231749380026EE4D /* Release.entitlements */, 33CC11242044D66E0003C045 /* Resources */, @@ -360,9 +363,10 @@ 33CC10E92044A3C60003C045 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; - files = ( + files = ( 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, F10A00012F21000100F10A01 /* UpdateService.swift in Sources */, + F10A00072F21000100F10A01 /* LoginItemService.swift in Sources */, 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, ); diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/macos/Runner/Base.lproj/MainMenu.xib index 80e867a..48d5ace 100644 --- a/macos/Runner/Base.lproj/MainMenu.xib +++ b/macos/Runner/Base.lproj/MainMenu.xib @@ -331,7 +331,7 @@ - + diff --git a/macos/Runner/LoginItemService.swift b/macos/Runner/LoginItemService.swift new file mode 100644 index 0000000..c180a98 --- /dev/null +++ b/macos/Runner/LoginItemService.swift @@ -0,0 +1,111 @@ +import FlutterMacOS +import ServiceManagement + +final class LoginItemService { + private enum Status: String { + case disabled + case enabled + case requiresApproval + case unsupported + } + + private var channel: FlutterMethodChannel? + + func configure(binaryMessenger: FlutterBinaryMessenger) { + let channel = FlutterMethodChannel( + name: "floatick/login_item", + binaryMessenger: binaryMessenger + ) + channel.setMethodCallHandler { [weak self] call, result in + guard let self else { + result( + FlutterError( + code: "login_item_unavailable", + message: "The Floatick login item service is unavailable.", + details: nil + ) + ) + return + } + + switch call.method { + case "loadStatus": + result(self.currentStatus().rawValue) + case "setEnabled": + guard let enabled = call.arguments as? Bool else { + result( + FlutterError( + code: "invalid_argument", + message: "setEnabled expects a Boolean argument.", + details: nil + ) + ) + return + } + do { + result(try self.setEnabled(enabled).rawValue) + } catch { + result( + FlutterError( + code: "login_item_update_failed", + message: "Floatick could not update its login item.", + details: nil + ) + ) + } + default: + result(FlutterMethodNotImplemented) + } + } + self.channel = channel + } + + private func currentStatus() -> Status { + guard #available(macOS 13.0, *) else { + return .unsupported + } + return status(for: SMAppService.mainApp) + } + + @available(macOS 13.0, *) + private func status(for service: SMAppService) -> Status { + switch service.status { + case .enabled: + return .enabled + case .requiresApproval: + return .requiresApproval + case .notFound, .notRegistered: + return .disabled + @unknown default: + return .disabled + } + } + + private func setEnabled(_ enabled: Bool) throws -> Status { + guard #available(macOS 13.0, *) else { + return .unsupported + } + + let service = SMAppService.mainApp + if enabled { + switch service.status { + case .enabled, .requiresApproval: + break + case .notFound, .notRegistered: + try service.register() + @unknown default: + try service.register() + } + } else { + switch service.status { + case .enabled, .requiresApproval: + try service.unregister() + case .notFound, .notRegistered: + break + @unknown default: + try service.unregister() + } + } + return status(for: service) + } +} diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index d3b9394..3046f67 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -17,6 +17,23 @@ final class MainFlutterWindow: NSWindow { case bottomRight } + private enum PreferredAppearance: String { + case system + case light + case dark + + var nativeAppearance: NSAppearance? { + switch self { + case .system: + return nil + case .light: + return NSAppearance(named: .aqua) + case .dark: + return NSAppearance(named: .darkAqua) + } + } + } + private enum DefaultsKey { static let collapsedOriginX = "floatick.collapsedOrigin.x" static let collapsedOriginY = "floatick.collapsedOrigin.y" @@ -27,13 +44,52 @@ final class MainFlutterWindow: NSWindow { private var isExpanded = false private var collapsedOrigin = NSPoint.zero private var pendingExpansionAnchor: ExpansionAnchor? + private var collapsedIconPanel: NSPanel? + private var collapsedIconView: FloatingTodoIconView? private var collapsedDragOverlay: CollapsedDragOverlayView? + private weak var flutterContentView: NSView? private var windowChannel: FlutterMethodChannel? private var updateService: UpdateService? + private var loginItemService: LoginItemService? + private var appliedAlwaysOnTop: Bool? + private var preferredAppearance = PreferredAppearance.system + private var secondaryWindowKeyObserver: NSObjectProtocol? + private let configuredSecondaryWindows = NSHashTable.weakObjects() override var canBecomeKey: Bool { true } override var canBecomeMain: Bool { true } + override func makeKeyAndOrderFront(_ sender: Any?) { + guard isExpanded else { + orderOut(nil) + collapsedIconPanel?.orderFrontRegardless() + return + } + super.makeKeyAndOrderFront(sender) + } + + override func orderFront(_ sender: Any?) { + guard isExpanded else { + orderOut(nil) + collapsedIconPanel?.orderFrontRegardless() + return + } + super.orderFront(sender) + } + + override func sendEvent(_ event: NSEvent) { + if + isExpanded, + event.type == .leftMouseDown, + !isKeyWindow + { + NSApp.activate(ignoringOtherApps: true) + makeKey() + _ = focusFlutterContent() + } + super.sendEvent(event) + } + override func awakeFromNib() { let engine = FlutterEngine( name: "floatick_main_engine", @@ -50,10 +106,16 @@ final class MainFlutterWindow: NSWindow { configureWindow() contentViewController = flutterViewController + flutterContentView = flutterViewController.view + configureRoundedFlutterSurface( + in: flutterViewController, + cornerRadius: 26 + ) RegisterGeneratedPlugins(registry: flutterViewController) configureWindowChannel(for: flutterViewController) configureUpdateService(for: flutterViewController) - configureDragOverlay(for: flutterViewController.view) + configureLoginItemService(for: flutterViewController) + observeInitialSecondaryWindowPresentation() let origin = restoredCollapsedOrigin() ?? defaultCollapsedOrigin() collapsedOrigin = clampedOrigin( @@ -61,13 +123,23 @@ final class MainFlutterWindow: NSWindow { for: Layout.collapsedSize, on: screen(containing: origin) ) + let initialAnchor = preferredExpansionAnchor() setFrame( - NSRect(origin: collapsedOrigin, size: Layout.collapsedSize), - display: true + expandedFrame(for: initialAnchor), + display: false ) - orderFrontRegardless() + lockMainWindowSize() + orderOut(nil) + configureCollapsedIconWindow() super.awakeFromNib() + DispatchQueue.main.async { [weak self] in + guard let self, !self.isExpanded else { + return + } + self.orderOut(nil) + self.collapsedIconPanel?.orderFrontRegardless() + } } private func configureWindow() { @@ -75,7 +147,7 @@ final class MainFlutterWindow: NSWindow { backgroundColor = .clear isOpaque = false hasShadow = false - level = .floating + level = .statusBar collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] animationBehavior = .none isMovable = false @@ -84,6 +156,8 @@ final class MainFlutterWindow: NSWindow { hidesOnDeactivate = false isRestorable = false title = "Floatick" + alphaValue = 1 + lockMainWindowSize() } private func configureWindowChannel( @@ -111,17 +185,43 @@ final class MainFlutterWindow: NSWindow { self.pendingExpansionAnchor = anchor result(anchor.rawValue) case "setExpanded": - guard let expanded = call.arguments as? Bool else { + guard + let arguments = call.arguments as? [String: Any], + let expanded = arguments["expanded"] as? Bool, + let animated = arguments["animated"] as? Bool + else { + result( + FlutterError( + code: "invalid_argument", + message: + "setExpanded expects expanded and animated Boolean values.", + details: nil + ) + ) + return + } + self.setExpanded( + expanded, + animated: animated, + completion: { result(nil) } + ) + case "setFloatingIconCount": + guard + let activeCount = (call.arguments as? NSNumber)?.intValue, + activeCount >= 0 + else { result( FlutterError( code: "invalid_argument", - message: "setExpanded expects a Boolean argument.", + message: + "setFloatingIconCount expects a non-negative count.", details: nil ) ) return } - self.setExpanded(expanded, completion: { result(nil) }) + self.collapsedIconView?.setActiveCount(activeCount) + result(nil) case "setPreferredLanguage": let languageCode: String? if call.arguments == nil || call.arguments is NSNull { @@ -144,6 +244,95 @@ final class MainFlutterWindow: NSWindow { NativeCopy.preferredLanguageCode = languageCode self.collapsedDragOverlay?.refreshLocalizedContent() result(nil) + case "setPreferredTheme": + guard + let rawPreference = call.arguments as? String, + let preference = PreferredAppearance(rawValue: rawPreference) + else { + result( + FlutterError( + code: "invalid_argument", + message: + "setPreferredTheme expects \"system\", \"light\", or \"dark\".", + details: nil + ) + ) + return + } + self.setPreferredAppearance(preference) + result(nil) + case "setAlwaysOnTop": + guard let alwaysOnTop = call.arguments as? Bool else { + result( + FlutterError( + code: "invalid_argument", + message: "setAlwaysOnTop expects a Boolean argument.", + details: nil + ) + ) + return + } + self.setAlwaysOnTop(alwaysOnTop) + result(nil) + case "configureBorderlessSecondaryWindow": + guard + let arguments = call.arguments as? [String: Any], + let viewIdentifier = (arguments["viewId"] as? NSNumber)?.int64Value, + let positionAdjacentToMainWindow = + arguments["positionAdjacentToMainWindow"] as? Bool + else { + result( + FlutterError( + code: "invalid_argument", + message: + "configureBorderlessSecondaryWindow expects a view ID and positioning preference.", + details: nil + ) + ) + return + } + guard self.configureBorderlessSecondaryWindow( + viewIdentifier: viewIdentifier, + positionAdjacentToMainWindow: positionAdjacentToMainWindow + ) else { + result( + FlutterError( + code: "window_unavailable", + message: "The secondary Flutter window could not be found.", + details: viewIdentifier + ) + ) + return + } + result(nil) + case "revealBorderlessSecondaryWindow": + guard + let viewIdentifier = (call.arguments as? NSNumber)?.int64Value + else { + result( + FlutterError( + code: "invalid_argument", + message: + "revealBorderlessSecondaryWindow expects a view ID.", + details: nil + ) + ) + return + } + guard self.revealBorderlessSecondaryWindow( + viewIdentifier: viewIdentifier + ) else { + result( + FlutterError( + code: "window_unavailable", + message: + "The configured secondary Flutter window could not be found.", + details: viewIdentifier + ) + ) + return + } + result(nil) default: result(FlutterMethodNotImplemented) } @@ -151,6 +340,243 @@ final class MainFlutterWindow: NSWindow { windowChannel = channel } + private func configureBorderlessSecondaryWindow( + viewIdentifier: Int64, + positionAdjacentToMainWindow: Bool + ) -> Bool { + guard + let targetWindow = NSApp.windows.first(where: { window in + guard + window !== self, + let controller = self.flutterViewController(in: window) + else { + return false + } + return controller.viewIdentifier == viewIdentifier + }), + let flutterViewController = flutterViewController(in: targetWindow) + else { + return false + } + + targetWindow.alphaValue = 0 + configureTransparentRoundedWindow( + targetWindow, + flutterViewController: flutterViewController + ) + let existingFrame = targetWindow.frame + targetWindow.styleMask = [.borderless, .resizable] + targetWindow.setFrame(existingFrame, display: false) + targetWindow.preservesContentDuringLiveResize = true + targetWindow.contentView?.layerContentsRedrawPolicy = .onSetNeedsDisplay + targetWindow.contentView?.layerContentsPlacement = .scaleAxesIndependently + targetWindow.appearance = preferredAppearance.nativeAppearance + if positionAdjacentToMainWindow { + positionSecondaryWindowAdjacentToMainWindow(targetWindow) + } + if isExpanded { + DispatchQueue.main.async { [weak self] in + guard let self, self.isExpanded else { + return + } + self.activateAndFocusFlutterContent() + } + } + configuredSecondaryWindows.add(targetWindow) + return true + } + + private func revealBorderlessSecondaryWindow( + viewIdentifier: Int64 + ) -> Bool { + guard + let targetWindow = NSApp.windows.first(where: { window in + guard + window !== self, + let controller = self.flutterViewController(in: window) + else { + return false + } + return controller.viewIdentifier == viewIdentifier + }), + configuredSecondaryWindows.contains(targetWindow) + else { + return false + } + + targetWindow.displayIfNeeded() + targetWindow.alphaValue = 1 + targetWindow.orderFrontRegardless() + return true + } + + private func observeInitialSecondaryWindowPresentation() { + secondaryWindowKeyObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didBecomeKeyNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard + let self, + let targetWindow = notification.object as? NSWindow, + targetWindow !== self, + !self.configuredSecondaryWindows.contains(targetWindow), + let flutterViewController = self.flutterViewController( + in: targetWindow + ) + else { + return + } + + // multiview_desktop orders a new NSWindow on screen before Dart can + // apply its WindowOptions. Keep that initial native surface invisible; + // the coordinator reveals it only after configuration, positioning and + // Flutter's first completed frame. + targetWindow.alphaValue = 0 + self.configureTransparentRoundedWindow( + targetWindow, + flutterViewController: flutterViewController + ) + } + } + + private func configureTransparentRoundedWindow( + _ targetWindow: NSWindow, + flutterViewController: FlutterViewController + ) { + targetWindow.backgroundColor = .clear + targetWindow.isOpaque = false + targetWindow.hasShadow = false + targetWindow.invalidateShadow() + targetWindow.contentView?.wantsLayer = true + targetWindow.contentView?.layer?.backgroundColor = NSColor.clear.cgColor + targetWindow.contentView?.layer?.isOpaque = false + configureRoundedFlutterSurface( + in: flutterViewController, + cornerRadius: 22 + ) + } + + private func positionSecondaryWindowAdjacentToMainWindow( + _ targetWindow: NSWindow + ) { + let mainFrame = frame + let targetSize = targetWindow.frame.size + let targetScreen = screen( + containing: NSPoint(x: mainFrame.midX, y: mainFrame.midY) + ) + let visibleFrame = targetScreen.visibleFrame.insetBy( + dx: Layout.screenPadding, + dy: Layout.screenPadding + ) + let gap: CGFloat = 12 + let rightOriginX = mainFrame.maxX + gap + let leftOriginX = mainFrame.minX - targetSize.width - gap + let fitsOnRight = rightOriginX + targetSize.width <= visibleFrame.maxX + let fitsOnLeft = leftOriginX >= visibleFrame.minX + + let originX: CGFloat + if fitsOnRight && !fitsOnLeft { + originX = rightOriginX + } else if fitsOnLeft && !fitsOnRight { + originX = leftOriginX + } else if visibleFrame.maxX - mainFrame.maxX >= + mainFrame.minX - visibleFrame.minX + { + originX = rightOriginX + } else { + originX = leftOriginX + } + + let centeredOriginY = mainFrame.midY - targetSize.height / 2 + let maximumX = max( + visibleFrame.minX, + visibleFrame.maxX - targetSize.width + ) + let maximumY = max( + visibleFrame.minY, + visibleFrame.maxY - targetSize.height + ) + targetWindow.setFrameOrigin( + NSPoint( + x: min(max(originX, visibleFrame.minX), maximumX), + y: min(max(centeredOriginY, visibleFrame.minY), maximumY) + ) + ) + } + + private func flutterViewController( + in window: NSWindow + ) -> FlutterViewController? { + return flutterViewController(in: window.contentViewController) + } + + private func flutterViewController( + in controller: NSViewController? + ) -> FlutterViewController? { + guard let controller else { + return nil + } + if let flutterViewController = controller as? FlutterViewController { + return flutterViewController + } + for child in controller.children { + if let flutterViewController = flutterViewController(in: child) { + return flutterViewController + } + } + return nil + } + + private func configureRoundedFlutterSurface( + in flutterViewController: FlutterViewController, + cornerRadius: CGFloat + ) { + let rootView = flutterViewController.view + rootView.wantsLayer = true + rootView.layer?.backgroundColor = NSColor.clear.cgColor + rootView.layer?.isOpaque = false + rootView.layer?.cornerRadius = cornerRadius + rootView.layer?.cornerCurve = .continuous + rootView.layer?.masksToBounds = true + } + + private func setAlwaysOnTop(_ alwaysOnTop: Bool) { + let targetLevel: NSWindow.Level = alwaysOnTop ? .statusBar : .normal + guard + appliedAlwaysOnTop != alwaysOnTop || + level != targetLevel + else { + return + } + appliedAlwaysOnTop = alwaysOnTop + level = targetLevel + collapsedIconPanel?.level = targetLevel + if alwaysOnTop { + if isExpanded { + orderFrontRegardless() + } else { + collapsedIconPanel?.orderFrontRegardless() + } + } + } + + private func setPreferredAppearance(_ preference: PreferredAppearance) { + guard preferredAppearance != preference else { + return + } + preferredAppearance = preference + let nativeAppearance = preference.nativeAppearance + appearance = nativeAppearance + collapsedIconPanel?.appearance = nativeAppearance + for window in NSApp.windows where window !== self { + guard flutterViewController(in: window) != nil else { + continue + } + window.appearance = nativeAppearance + } + } + private func configureUpdateService( for flutterViewController: FlutterViewController ) { @@ -161,8 +587,45 @@ final class MainFlutterWindow: NSWindow { self.updateService = updateService } - private func configureDragOverlay(for view: NSView) { - let overlay = CollapsedDragOverlayView(frame: view.bounds) + private func configureLoginItemService( + for flutterViewController: FlutterViewController + ) { + let loginItemService = LoginItemService() + loginItemService.configure( + binaryMessenger: flutterViewController.engine.binaryMessenger + ) + self.loginItemService = loginItemService + } + + private func configureCollapsedIconWindow() { + let iconPanel = NSPanel( + contentRect: NSRect(origin: collapsedOrigin, size: Layout.collapsedSize), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + iconPanel.backgroundColor = .clear + iconPanel.isOpaque = false + iconPanel.hasShadow = false + iconPanel.hidesOnDeactivate = false + iconPanel.isReleasedWhenClosed = false + iconPanel.collectionBehavior = collectionBehavior + iconPanel.level = level + iconPanel.animationBehavior = .none + + let iconView = FloatingTodoIconView( + frame: NSRect(origin: .zero, size: Layout.collapsedSize), + activeCount: 0 + ) + iconPanel.contentView = iconView + collapsedIconPanel = iconPanel + collapsedIconView = iconView + configureDragOverlay(for: iconView) + iconPanel.orderFrontRegardless() + } + + private func configureDragOverlay(for iconView: NSView) { + let overlay = CollapsedDragOverlayView(frame: iconView.bounds) overlay.autoresizingMask = [.width, .height] overlay.onClick = { [weak self] in guard let self else { @@ -190,7 +653,7 @@ final class MainFlutterWindow: NSWindow { for: Layout.collapsedSize, on: targetScreen ) - self.setFrameOrigin(origin) + self.collapsedIconPanel?.setFrameOrigin(origin) self.collapsedOrigin = origin self.pendingExpansionAnchor = nil } @@ -198,35 +661,43 @@ final class MainFlutterWindow: NSWindow { guard let self else { return } - self.collapsedOrigin = self.frame.origin + if let iconOrigin = self.collapsedIconPanel?.frame.origin { + self.collapsedOrigin = iconOrigin + } self.persistCollapsedOrigin() } - view.addSubview(overlay) + iconView.addSubview(overlay) collapsedDragOverlay = overlay } private func setExpanded( _ expanded: Bool, + animated: Bool, completion: @escaping () -> Void ) { guard expanded != isExpanded else { + if expanded { + activateAndFocusFlutterContent() + } completion() return } - if expanded { - collapsedOrigin = frame.origin - persistCollapsedOrigin() - } isExpanded = expanded - collapsedDragOverlay?.isHidden = expanded if expanded { let anchor = pendingExpansionAnchor ?? preferredExpansionAnchor() pendingExpansionAnchor = nil - setFrame(expandedFrame(for: anchor), display: true) - NSApp.activate(ignoringOtherApps: true) - makeKeyAndOrderFront(nil) + lockMainWindowSize() + setFrame(expandedFrame(for: anchor), display: false) + alphaValue = animated ? 0 : 1 + activateAndFocusFlutterContent() + collapsedIconPanel?.orderFrontRegardless() + transitionWindows( + showMainWindow: true, + animated: animated, + completion: completion + ) } else { let targetScreen = screen(containing: collapsedOrigin) collapsedOrigin = clampedOrigin( @@ -234,14 +705,97 @@ final class MainFlutterWindow: NSWindow { for: Layout.collapsedSize, on: targetScreen ) - setFrame( + collapsedIconPanel?.setFrame( NSRect(origin: collapsedOrigin, size: Layout.collapsedSize), - display: true + display: false + ) + collapsedIconPanel?.alphaValue = animated ? 0 : 1 + collapsedIconPanel?.orderFrontRegardless() + transitionWindows( + showMainWindow: false, + animated: animated, + completion: completion ) - orderFrontRegardless() - resignKey() } - completion() + } + + private func lockMainWindowSize() { + styleMask = [.borderless] + minSize = Layout.expandedSize + maxSize = Layout.expandedSize + contentMinSize = Layout.expandedSize + contentMaxSize = Layout.expandedSize + } + + private func transitionWindows( + showMainWindow: Bool, + animated: Bool, + completion: @escaping () -> Void + ) { + let changes = { [weak self] in + guard let self else { + return + } + self.alphaValue = showMainWindow ? 1 : 0 + self.collapsedIconPanel?.alphaValue = showMainWindow ? 0 : 1 + } + let finished = { [weak self] in + guard let self else { + completion() + return + } + if showMainWindow { + self.collapsedIconPanel?.orderOut(nil) + self.collapsedIconPanel?.alphaValue = 1 + self.activateAndFocusFlutterContent() + } else { + self.orderOut(nil) + self.alphaValue = 1 + self.resignKey() + } + completion() + } + + guard animated else { + changes() + finished() + return + } + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.12 + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + animator().alphaValue = showMainWindow ? 1 : 0 + collapsedIconPanel?.animator().alphaValue = showMainWindow ? 0 : 1 + } completionHandler: { + finished() + } + } + + private func activateAndFocusFlutterContent() { + NSApp.activate(ignoringOtherApps: true) + makeKeyAndOrderFront(nil) + _ = focusFlutterContent() + + // Expansion begins from acceptsFirstMouse on the collapsed overlay, so + // activation can finish on the next AppKit run-loop turn. Reassert the + // Flutter view afterwards to keep keyboard input off the overlay/window. + DispatchQueue.main.async { [weak self] in + guard let self, self.isExpanded else { + return + } + self.makeKeyAndOrderFront(nil) + if !self.focusFlutterContent() { + NSLog("Floatick could not focus the Flutter content view.") + } + } + } + + @discardableResult + private func focusFlutterContent() -> Bool { + guard let flutterContentView else { + return false + } + return makeFirstResponder(flutterContentView) } private func preferredExpansionAnchor() -> ExpansionAnchor { @@ -408,6 +962,197 @@ final class MainFlutterWindow: NSWindow { } } +private final class FloatingTodoIconView: NSView { + private enum Metrics { + static let brandFrame = NSRect(x: 10, y: 10, width: 52, height: 52) + static let badgeHeight: CGFloat = 20 + static let badgeRightEdge: CGFloat = 65 + static let badgeTop: CGFloat = 7 + } + + private var activeCount: Int + + override var isFlipped: Bool { true } + override var isOpaque: Bool { false } + + init(frame frameRect: NSRect, activeCount: Int) { + self.activeCount = activeCount + super.init(frame: frameRect) + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("FloatingTodoIconView is created programmatically.") + } + + func setActiveCount(_ activeCount: Int) { + guard self.activeCount != activeCount else { + return + } + self.activeCount = activeCount + needsDisplay = true + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + drawBrandMark() + if activeCount > 0 { + drawBadge() + } + } + + private func drawBrandMark() { + let brandPath = NSBezierPath(ovalIn: Metrics.brandFrame) + NSGradient( + starting: NSColor( + calibratedRed: 36 / 255, + green: 56 / 255, + blue: 60 / 255, + alpha: 1 + ), + ending: NSColor( + calibratedRed: 23 / 255, + green: 35 / 255, + blue: 38 / 255, + alpha: 1 + ) + )?.draw(in: brandPath, angle: -45) + + NSColor( + calibratedRed: 64 / 255, + green: 87 / 255, + blue: 90 / 255, + alpha: 0.92 + ).setStroke() + brandPath.lineWidth = 1.2 + brandPath.stroke() + + drawCheck( + start: point(x: 0.22, y: 0.50), + firstControl: point(x: 0.27, y: 0.54), + secondControl: point(x: 0.31, y: 0.59), + middle: point(x: 0.36, y: 0.64), + thirdControl: point(x: 0.41, y: 0.59), + fourthControl: point(x: 0.47, y: 0.52), + end: point(x: 0.53, y: 0.46), + color: NSColor( + calibratedRed: 29 / 255, + green: 179 / 255, + blue: 168 / 255, + alpha: 1 + ) + ) + drawCheck( + start: point(x: 0.38, y: 0.50), + firstControl: point(x: 0.43, y: 0.55), + secondControl: point(x: 0.47, y: 0.60), + middle: point(x: 0.52, y: 0.64), + thirdControl: point(x: 0.60, y: 0.55), + fourthControl: point(x: 0.68, y: 0.46), + end: point(x: 0.77, y: 0.37), + color: NSColor( + calibratedRed: 44 / 255, + green: 204 / 255, + blue: 189 / 255, + alpha: 1 + ) + ) + } + + private func point(x: CGFloat, y: CGFloat) -> NSPoint { + NSPoint( + x: Metrics.brandFrame.minX + (Metrics.brandFrame.width * x), + y: Metrics.brandFrame.minY + (Metrics.brandFrame.height * y) + ) + } + + private func drawCheck( + start: NSPoint, + firstControl: NSPoint, + secondControl: NSPoint, + middle: NSPoint, + thirdControl: NSPoint, + fourthControl: NSPoint, + end: NSPoint, + color: NSColor + ) { + let path = NSBezierPath() + path.move(to: start) + path.curve( + to: middle, + controlPoint1: firstControl, + controlPoint2: secondControl + ) + path.curve( + to: end, + controlPoint1: thirdControl, + controlPoint2: fourthControl + ) + path.lineWidth = Metrics.brandFrame.width * 0.07 + path.lineCapStyle = .round + path.lineJoinStyle = .round + color.setStroke() + path.stroke() + } + + private func drawBadge() { + let label = activeCount > 99 ? "99+" : "\(activeCount)" + let attributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 9, weight: .bold), + .foregroundColor: NSColor.white, + ] + let labelSize = (label as NSString).size(withAttributes: attributes) + let badgeWidth = max(20, labelSize.width + 9) + let badgeFrame = NSRect( + x: Metrics.badgeRightEdge - badgeWidth, + y: Metrics.badgeTop, + width: badgeWidth, + height: Metrics.badgeHeight + ) + + NSGraphicsContext.saveGraphicsState() + let shadow = NSShadow() + shadow.shadowColor = NSColor.black.withAlphaComponent(0.22) + shadow.shadowBlurRadius = 5 + shadow.shadowOffset = NSSize(width: 0, height: -2) + shadow.set() + NSColor( + calibratedRed: 241 / 255, + green: 120 / 255, + blue: 66 / 255, + alpha: 1 + ).setFill() + NSBezierPath( + roundedRect: badgeFrame, + xRadius: Metrics.badgeHeight / 2, + yRadius: Metrics.badgeHeight / 2 + ).fill() + NSGraphicsContext.restoreGraphicsState() + + let labelFrame = NSRect( + x: badgeFrame.minX, + y: badgeFrame.midY - (labelSize.height / 2), + width: badgeFrame.width, + height: labelSize.height + ) + (label as NSString).draw( + in: labelFrame, + withAttributes: attributes.merging( + [.paragraphStyle: centeredParagraphStyle], + uniquingKeysWith: { current, _ in current } + ) + ) + } + + private var centeredParagraphStyle: NSParagraphStyle { + let style = NSMutableParagraphStyle() + style.alignment = .center + return style + } +} + private enum NativeCopy { static var preferredLanguageCode: String? @@ -430,7 +1175,7 @@ private enum NativeCopy { } } -private final class CollapsedDragOverlayView: NSView { +final class CollapsedDragOverlayView: NSView { private static let dragThreshold: CGFloat = 4 var onClick: (() -> Void)? diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift index 61f3bd1..268b9c3 100644 --- a/macos/RunnerTests/RunnerTests.swift +++ b/macos/RunnerTests/RunnerTests.swift @@ -1,12 +1,29 @@ import Cocoa -import FlutterMacOS import XCTest +@testable import Floatick class RunnerTests: XCTestCase { - func testExample() { - // If you add code to the Runner application, consider adding tests here. - // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + func testCollapsedIconIsAnAccessibleButton() { + let overlay = CollapsedDragOverlayView( + frame: NSRect(x: 0, y: 0, width: 72, height: 72) + ) + + XCTAssertTrue(overlay.isAccessibilityElement()) + XCTAssertEqual(overlay.accessibilityRole(), .button) + XCTAssertFalse((overlay.accessibilityLabel() ?? "").isEmpty) } + func testAccessibilityPressExpandsTheApp() { + let overlay = CollapsedDragOverlayView( + frame: NSRect(x: 0, y: 0, width: 72, height: 72) + ) + var pressCount = 0 + overlay.onClick = { + pressCount += 1 + } + + XCTAssertTrue(overlay.accessibilityPerformPress()) + XCTAssertEqual(pressCount, 1) + } } diff --git a/pubspec.lock b/pubspec.lock index d0c3a84..3632dbf 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,11 +57,24 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_lints: dependency: "direct dev" description: @@ -88,6 +101,16 @@ packages: description: flutter source: sdk version: "0.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" intl: dependency: "direct main" description: @@ -176,6 +199,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + process: + dependency: transitive + description: + name: process + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 + url: "https://pub.dev" + source: hosted + version: "5.0.5" sky_engine: dependency: transitive description: flutter @@ -213,6 +252,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" term_glyph: dependency: transitive description: @@ -245,6 +292,14 @@ packages: url: "https://pub.dev" source: hosted version: "15.2.0" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + url: "https://pub.dev" + source: hosted + version: "3.1.0" sdks: dart: ">=3.12.2 <4.0.0" flutter: ">=3.38.2" diff --git a/pubspec.yaml b/pubspec.yaml index 705d52e..82c18a7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: floatick description: A focused, local-first floating todo list for macOS. publish_to: 'none' -version: 0.2.0+2 +version: 0.2.0+6 environment: sdk: ^3.12.2 @@ -17,6 +17,8 @@ dependencies: multiview_desktop: ^1.2.0 dev_dependencies: + integration_test: + sdk: flutter flutter_test: sdk: flutter flutter_lints: ^6.0.0 diff --git a/test/app/floatick_app_test.dart b/test/app/floatick_app_test.dart index c276075..6ac3b22 100644 --- a/test/app/floatick_app_test.dart +++ b/test/app/floatick_app_test.dart @@ -1,10 +1,13 @@ import 'package:floatick/app/floatick_app.dart'; import 'package:floatick/core/platform/window_bridge.dart'; -import 'package:floatick/core/ui/floatick_brand_mark.dart'; +import 'package:floatick/core/storage/storage_failure.dart'; +import 'package:floatick/features/settings/data/login_item_repository.dart'; import 'package:floatick/features/settings/data/settings_repository.dart'; import 'package:floatick/features/settings/domain/app_settings.dart'; +import 'package:floatick/features/settings/domain/login_item_status.dart'; import 'package:floatick/features/settings/presentation/settings_view_model.dart'; import 'package:floatick/features/sticky_boards/data/sticky_board_repository.dart'; +import 'package:floatick/features/sticky_boards/domain/sticky_board.dart'; import 'package:floatick/features/sticky_boards/domain/sticky_board_workspace.dart'; import 'package:floatick/features/sticky_boards/presentation/sticky_board_view_model.dart'; import 'package:floatick/features/sticky_boards/presentation/sticky_board_window_coordinator.dart'; @@ -12,8 +15,10 @@ import 'package:floatick/features/todos/data/tag_repository.dart'; import 'package:floatick/features/todos/data/todo_repository.dart'; import 'package:floatick/features/todos/domain/tag_workspace.dart'; import 'package:floatick/features/todos/domain/todo_item.dart'; +import 'package:floatick/features/todos/domain/todo_tag.dart'; +import 'package:floatick/features/todos/presentation/todo_editor_drawer.dart'; +import 'package:floatick/features/todos/presentation/todo_panel.dart'; import 'package:floatick/features/todos/presentation/todo_view_model.dart'; -import 'package:floatick/features/todos/presentation/widgets/floating_todo_icon.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'; @@ -41,8 +46,10 @@ void main() { ); final windowBridge = _WidgetTestWindowBridge(); final settingsRepository = _WidgetTestSettingsRepository(); + final loginItemRepository = _WidgetTestLoginItemRepository(); final settingsController = SettingsViewModel( settingsRepository: settingsRepository, + loginItemRepository: loginItemRepository, ); final updateRepository = _WidgetTestUpdateRepository(); final updateController = UpdateViewModel( @@ -54,6 +61,7 @@ void main() { final stickyBoardWindowCoordinator = StickyBoardWindowCoordinator( boardController: stickyBoardController, todoController: controller, + windowBridge: windowBridge, ); await controller.load(); await settingsController.load(); @@ -71,15 +79,49 @@ void main() { locale: const Locale('zh'), ), ); - expect(find.byKey(const ValueKey('floating-todo-icon')), findsOneWidget); - expect(find.byType(FloatickBrandMark), findsOneWidget); - expect( - tester.getSize(find.byKey(const ValueKey('floating-todo-icon'))), - const Size.square(FloatingTodoIcon.canvasDimension), + await tester.pump(); + expect(windowBridge.floatingIconCounts, [0]); + expect(windowBridge.preferredThemeValues, ['system']); + final tooltipMouse = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await tooltipMouse.addPointer( + location: tester.getCenter(find.byKey(const Key('collapse-button'))), ); windowBridge.expandRequestHandler?.call(WindowExpansionAnchor.topRight); await tester.pumpAndSettle(); + expect(windowBridge.expandedValues, [true]); + expect(windowBridge.expandedAnimatedValues, [true]); + expect( + tester + .widget( + find.byKey(const Key('panel-tooltip-visibility')), + ) + .visible, + isFalse, + ); + await tester.pump(const Duration(seconds: 1)); + expect(find.text('收起(Esc)'), findsNothing); + + await tooltipMouse.moveTo(Offset.zero); + await tester.pump(); + expect( + tester + .widget( + find.byKey(const Key('panel-tooltip-visibility')), + ) + .visible, + isTrue, + ); + await tooltipMouse.moveTo( + tester.getCenter(find.byKey(const Key('collapse-button'))), + ); + await tester.pump(const Duration(seconds: 1)); + expect(find.text('收起(Esc)'), findsOneWidget); + await tooltipMouse.moveTo(Offset.zero); + await tester.pumpAndSettle(); + await tooltipMouse.removePointer(); expect(windowBridge.expandedValues, [true]); expect(find.text('Floatick'), findsNothing); @@ -88,30 +130,46 @@ void main() { expect(find.byKey(const Key('search-field')), findsOneWidget); expect(find.byKey(const Key('tag-filter-button')), findsOneWidget); expect(find.byKey(const Key('add-todo-button')), findsOneWidget); + expect(find.byKey(const Key('archive-scope-button')), findsOneWidget); + expect(find.text('待办 0'), findsNothing); + expect(find.text('归档 0'), findsNothing); + + await tester.tap(find.byKey(const Key('archive-scope-button'))); + await tester.pumpAndSettle(); + expect(find.text('归档 · 0'), findsOneWidget); + expect(find.text('搜索归档'), findsOneWidget); + expect(find.byKey(const Key('add-todo-button')), findsNothing); + + await tester.tap(find.byKey(const Key('archive-scope-button'))); + await tester.pumpAndSettle(); + expect(find.text('今天已经清空'), findsOneWidget); + expect(find.text('搜索待办'), findsOneWidget); + expect(find.byKey(const Key('add-todo-button')), findsOneWidget); + final searchRect = tester.getRect(find.byKey(const Key('search-field'))); final tagFilterRect = tester.getRect( find.byKey(const Key('tag-filter-button')), ); + final newTodoRect = tester.getRect( + find.byKey(const Key('add-todo-button')), + ); expect(tagFilterRect.left, greaterThan(searchRect.right)); + expect(newTodoRect.left, greaterThan(tagFilterRect.right)); expect((tagFilterRect.center.dy - searchRect.center.dy).abs(), lessThan(1)); + expect((newTodoRect.center.dy - searchRect.center.dy).abs(), lessThan(1)); expect(tagFilterRect.size, const Size.square(42)); + expect(newTodoRect.height, 42); + expect(find.text('新建'), findsOneWidget); final panelSurface = tester.widget( find.byKey(const Key('todo-panel-surface')), ); final panelDecoration = panelSurface.decoration as BoxDecoration; expect(panelDecoration.boxShadow, isNull); - expect( - tester - .widget(find.byKey(const Key('settings-drawer-slide'))) - .offset, - const Offset(1, 0), - ); - expect( - tester - .widget(find.byKey(const Key('todo-drawer-slide'))) - .offset, - const Offset(0, 1), - ); + expect(find.byKey(const Key('settings-drawer-slide')), findsNothing); + expect(find.byKey(const Key('tag-drawer-slide')), findsNothing); + expect(find.byKey(const Key('sticky-board-drawer-slide')), findsNothing); + expect(find.byKey(const Key('todo-drawer-slide')), findsNothing); + expect(find.byKey(const Key('todo-context-scrim')), findsNothing); await tester.tap(find.byKey(const Key('settings-button'))); await tester.pumpAndSettle(); @@ -120,6 +178,10 @@ void main() { expect(find.byType(Dialog), findsNothing); expect(find.text('设置'), findsOneWidget); expect(find.text('语言'), findsOneWidget); + expect(find.text('窗口'), findsOneWidget); + expect(find.text('始终置顶'), findsOneWidget); + expect(find.text('启动'), findsOneWidget); + expect(find.text('登录时打开'), findsOneWidget); expect(find.text('更新'), findsOneWidget); expect(find.text('v0.1.0'), findsOneWidget); expect(find.text('工作目录'), findsOneWidget); @@ -146,6 +208,25 @@ void main() { tester.getSize(find.byKey(const Key('automatic-update-toggle'))), const Size(32, 18), ); + expect( + tester.getSize(find.byKey(const Key('always-on-top-toggle'))), + const Size(32, 18), + ); + expect( + tester.getSize(find.byKey(const Key('open-at-login-toggle'))), + const Size(32, 18), + ); + expect(settingsController.openAtLogin, isFalse); + await tester.tap(find.byKey(const Key('open-at-login-setting'))); + await tester.pumpAndSettle(); + expect(settingsController.openAtLogin, isTrue); + expect(loginItemRepository.setEnabledValues, [true]); + expect(windowBridge.alwaysOnTopValues, [true]); + await tester.tap(find.byKey(const Key('always-on-top-setting'))); + await tester.pumpAndSettle(); + expect(settingsController.alwaysOnTop, isFalse); + expect(settingsRepository.savedSettings.alwaysOnTop, isFalse); + expect(windowBridge.alwaysOnTopValues, [true, false]); expect( tester.getSize(find.byKey(const Key('update-settings-section'))).height, lessThan(105), @@ -217,6 +298,11 @@ void main() { settingsRepository.savedSettings.themePreference, AppThemePreference.light, ); + expect(windowBridge.preferredThemeValues, [ + 'system', + 'dark', + 'light', + ]); await tester.tap(find.byKey(const Key('settings-close'))); await tester.pumpAndSettle(); @@ -357,7 +443,15 @@ void main() { await tester.pumpAndSettle(); expect(windowBridge.expandedValues, [true, false]); - expect(find.byKey(const ValueKey('floating-todo-icon')), findsOneWidget); + expect(windowBridge.expandedAnimatedValues, [true, true]); + expect( + tester + .widget( + find.byKey(const Key('panel-tooltip-visibility')), + ) + .visible, + isFalse, + ); }); testWidgets('tags can be created, assigned, and used as a filter', ( @@ -383,6 +477,7 @@ void main() { ); final settingsController = SettingsViewModel( settingsRepository: _WidgetTestSettingsRepository(), + loginItemRepository: _WidgetTestLoginItemRepository(), ); final updateController = UpdateViewModel( updateRepository: _WidgetTestUpdateRepository(), @@ -390,11 +485,12 @@ void main() { final stickyBoardController = StickyBoardViewModel( repository: _WidgetTestStickyBoardRepository(), ); + final windowBridge = _WidgetTestWindowBridge(); final stickyBoardWindowCoordinator = StickyBoardWindowCoordinator( boardController: stickyBoardController, todoController: controller, + windowBridge: windowBridge, ); - final windowBridge = _WidgetTestWindowBridge(); await controller.load(); await settingsController.load(); await updateController.load(); @@ -459,6 +555,12 @@ void main() { expect(controller.tags.single.name, 'Work'); expect(tagRepository.savedWorkspace.tags.single.name, 'Work'); expect(find.byKey(const Key('managed-tag-tag-work')), findsOneWidget); + expect( + tester + .widget(find.byKey(const Key('tag-management-list'))) + .itemExtent, + 44, + ); await tester.tap(find.byKey(const Key('tag-management-close'))); await tester.pumpAndSettle(); @@ -614,6 +716,11 @@ void main() { .icon, Icons.sell_rounded, ); + await tester.tap( + find.byKey(const Key('tag-assignment-bottom-sheet-close')), + ); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('tag-assignment-bottom-sheet')), findsNothing); await tester.tap(find.byKey(const Key('add-todo-button'))); await tester.pumpAndSettle(); @@ -625,20 +732,67 @@ void main() { await tester.tap(find.byKey(const Key('save-todo-details'))); await tester.pumpAndSettle(); + expect( + await controller.create( + 'Personal task', + tagIds: const ['tag-personal'], + ), + isNotNull, + ); + expect( + await controller.createTag(name: 'Unused', colorValue: 0xFF20B8A8), + TagMutationResult.success, + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tag-filter-button'))); await tester.pumpAndSettle(); + expect( + tester + .widget(find.byKey(const Key('tag-filter-list'))) + .itemExtent, + 44, + ); await tester.tap(find.byKey(const Key('tag-filter-tag-work'))); await tester.pumpAndSettle(); + expect( + find.descendant( + of: find.byKey(const Key('tag-filter-tag-work')), + matching: find.byIcon(Icons.check_rounded), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byKey(const Key('tag-filter-tag-work')), + matching: find.byType(AnimatedContainer), + ), + findsNothing, + ); + expect(find.byKey(const Key('tag-filter-drawer')), findsOneWidget); + await tester.tap(find.byKey(const Key('tag-filter-tag-personal'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('tag-filter-drawer')), findsOneWidget); + 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.byKey(const Key('active-tag-filter')), findsOneWidget); + expect(find.byKey(const Key('active-tag-filter')), findsNothing); + expect(find.byKey(const Key('tag-filter-count')), findsOneWidget); expect(find.text('Tagged task').hitTestable(), findsOneWidget); + expect(find.text('Personal task').hitTestable(), findsOneWidget); expect(find.text('Other task').hitTestable(), findsNothing); expect(tester.takeException(), isNull); await tester.tap(find.byKey(const Key('collapse-button'))); await tester.pumpAndSettle(); - await tester.pump(const Duration(milliseconds: 300)); - expect(find.byKey(const ValueKey('floating-todo-icon')), findsOneWidget); + expect(windowBridge.expandedValues.last, isFalse); windowBridge.expandRequestHandler?.call(WindowExpansionAnchor.topLeft); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tag-filter-button'))); @@ -647,6 +801,29 @@ void main() { tester.getTopRight(find.byKey(const Key('tag-filter-drawer'))).dx, tester.getTopRight(find.byKey(const Key('todo-panel-surface'))).dx, ); + 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-all'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('tag-filter-count')), findsNothing); + await tester.tap(find.byKey(const Key('tag-filter-tag-3'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tag-filter-close'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('clear-active-tag-filters')), findsOneWidget); + await tester.tap(find.byKey(const Key('clear-active-tag-filters'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('tag-filter-count')), findsNothing); + expect(find.text('Tagged task').hitTestable(), findsOneWidget); + expect(find.text('Personal task').hitTestable(), findsOneWidget); + expect(find.text('Other task').hitTestable(), findsOneWidget); + 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(); @@ -659,7 +836,7 @@ void main() { testWidgets('sticky boards create virtual groups and reuse the todo editor', ( WidgetTester tester, ) async { - tester.view.physicalSize = const Size(500, 760); + tester.view.physicalSize = const Size(440, 700); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); @@ -671,19 +848,41 @@ void main() { title: 'Review the launch checklist', createdAt: DateTime.utc(2026, 7, 26, 9), ), + TodoItem( + id: 'content-only-todo', + title: 'Plan the next iteration', + content: 'Private launch phrase', + createdAt: DateTime.utc(2026, 7, 26, 8), + ), ]; + final tagRepository = _WidgetTestTagRepository() + ..savedWorkspace = TagWorkspace( + tags: [ + TodoTag( + id: 'tag-focus', + name: 'Focus', + colorValue: 0xFF4C8FF5, + createdAt: DateTime.utc(2026, 7, 26, 7), + ), + ], + assignments: const >{ + 'existing-todo': ['tag-focus'], + }, + ); var todoSequence = 0; final todoController = TodoViewModel( todoRepository: todoRepository, - tagRepository: _WidgetTestTagRepository(), + tagRepository: tagRepository, idGenerator: () => 'created-todo-${++todoSequence}', ); + final stickyBoardRepository = _WidgetTestStickyBoardRepository(); final stickyBoardController = StickyBoardViewModel( - repository: _WidgetTestStickyBoardRepository(), + repository: stickyBoardRepository, idGenerator: () => 'board-launch', ); final settingsController = SettingsViewModel( settingsRepository: _WidgetTestSettingsRepository(), + loginItemRepository: _WidgetTestLoginItemRepository(), ); final updateController = UpdateViewModel( updateRepository: _WidgetTestUpdateRepository(), @@ -692,6 +891,8 @@ void main() { final stickyBoardWindowCoordinator = StickyBoardWindowCoordinator( boardController: stickyBoardController, todoController: todoController, + windowBridge: windowBridge, + windowLauncher: (_) async {}, ); await Future.wait(>[ todoController.load(), @@ -735,18 +936,147 @@ void main() { ); await tester.tap(find.byKey(const Key('submit-sticky-board'))); await tester.pumpAndSettle(); + expect( + find.byKey(const Key('sticky-board-thumbnail-grid')), + findsOneWidget, + ); expect(find.byKey(const Key('sticky-board-board-launch')), findsOneWidget); + expect( + find.byKey(const Key('sticky-board-thumbnail-board-launch')), + findsOneWidget, + ); + final pinButton = find.byKey( + const Key('toggle-sticky-board-pin-board-launch'), + ); + expect(pinButton, findsOneWidget); + await tester.tap(pinButton); + await tester.pumpAndSettle(); + expect(stickyBoardController.boardById('board-launch')?.isPinned, isTrue); + expect( + find.descendant( + of: pinButton, + matching: find.byIcon(Icons.push_pin_rounded), + ), + findsOneWidget, + ); + await tester.tap(pinButton); + await tester.pumpAndSettle(); + expect(stickyBoardController.boardById('board-launch')?.isPinned, isFalse); + + final boardMouse = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + addTearDown(boardMouse.removePointer); + await boardMouse.addPointer(); + await boardMouse.moveTo( + tester.getCenter( + find.byKey(const Key('sticky-board-thumbnail-board-launch')), + ), + ); + await tester.pumpAndSettle(); + final boardRectBeforeConfirmation = tester.getRect( + find.byKey(const Key('sticky-board-thumbnail-board-launch')), + ); + await tester.tap(find.byKey(const Key('delete-sticky-board-board-launch'))); + await tester.pumpAndSettle(); + + final deleteConfirmation = find.byKey( + const Key('sticky-board-delete-confirmation-board-launch'), + ); + final cancelDeleteBoard = find.byKey( + const Key('cancel-delete-sticky-board-board-launch'), + ); + final confirmDeleteBoard = find.byKey( + const Key('confirm-delete-sticky-board-board-launch'), + ); + expect(deleteConfirmation, findsOneWidget); + expect(find.byType(AlertDialog), findsOneWidget); + expect(cancelDeleteBoard, findsOneWidget); + expect(confirmDeleteBoard, findsOneWidget); + expect( + find.descendant(of: confirmDeleteBoard, matching: find.text('Confirm')), + findsOneWidget, + ); + expect(find.text('Delete sticky board'), findsNothing); + expect( + tester.getRect( + find.byKey(const Key('sticky-board-thumbnail-board-launch')), + ), + boardRectBeforeConfirmation, + ); + expect(tester.takeException(), isNull); + + await tester.tap(cancelDeleteBoard); + await tester.pumpAndSettle(); + expect(deleteConfirmation, findsNothing); + expect(stickyBoardController.boardById('board-launch'), isNotNull); + await boardMouse.moveTo(Offset.zero); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('sticky-board-board-launch'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('sticky-board-detail-drawer')), findsOneWidget); + tester.widget(find.byType(TodoPanel)).onCollapse(); + await tester.pumpAndSettle(); + expect(windowBridge.expandedValues, [true, false]); + windowBridge.expandRequestHandler?.call(WindowExpansionAnchor.topRight); + await tester.pumpAndSettle(); + expect(windowBridge.expandedValues, [true, false, true]); + expect(find.byKey(const Key('sticky-board-detail-drawer')), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('sticky-board-drawer-slide')), + ) + .offset, + Offset.zero, + ); + await tester.tap(find.byKey(const Key('sticky-board-add-existing'))); await tester.pumpAndSettle(); expect( find.byKey(const Key('sticky-board-todo-picker-drawer')), findsOneWidget, ); + final pickerTile = tester.widget( + find.byKey(const Key('sticky-board-picker-existing-todo')), + ); + final pickerShape = pickerTile.shape as RoundedRectangleBorder; + expect( + pickerShape.borderRadius, + const BorderRadius.all(Radius.circular(11)), + ); + expect( + find.ancestor( + of: find.byKey(const Key('sticky-board-picker-existing-todo')), + matching: find.byWidgetPredicate( + (widget) => + widget is Material && + widget.clipBehavior == Clip.antiAlias && + widget.shape == pickerShape, + ), + ), + findsOneWidget, + ); + await tester.enterText( + find.byKey(const Key('sticky-board-todo-search')), + 'Private launch phrase', + ); + await tester.pump(); + expect( + find.byKey(const Key('sticky-board-picker-content-only-todo')), + findsNothing, + ); + await tester.enterText( + find.byKey(const Key('sticky-board-todo-search')), + 'Focus', + ); + await tester.pump(); + expect( + find.byKey(const Key('sticky-board-picker-existing-todo')), + findsOneWidget, + ); await tester.tap( find.byKey(const Key('sticky-board-picker-existing-todo')), ); @@ -760,6 +1090,92 @@ void main() { find.byKey(const Key('sticky-board-todo-existing-todo')), findsOneWidget, ); + final stickyBoardDetail = find.byKey( + const Key('sticky-board-detail-drawer'), + ); + expect( + find.descendant( + of: stickyBoardDetail, + matching: find.byKey( + const Key('sticky-board-managed-tag-existing-todo-tag-focus'), + ), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: stickyBoardDetail, + matching: find.byKey( + const Key('sticky-board-managed-time-existing-todo'), + ), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: stickyBoardDetail, + matching: find.byKey(const Key('toggle-todo-existing-todo')), + ), + findsNothing, + ); + expect( + find.descendant( + of: stickyBoardDetail, + matching: find.byKey(const Key('edit-todo-existing-todo')), + ), + findsNothing, + ); + expect( + find.descendant( + of: stickyBoardDetail, + matching: find.byKey(const Key('view-todo-existing-todo')), + ), + findsNothing, + ); + expect( + find.descendant( + of: stickyBoardDetail, + matching: find.byKey(const Key('archive-todo-existing-todo')), + ), + findsNothing, + ); + expect( + find.descendant( + of: stickyBoardDetail, + matching: find.byKey(const Key('assign-tags-existing-todo')), + ), + findsNothing, + ); + expect( + find.descendant( + of: stickyBoardDetail, + matching: find.byKey(const Key('remove-from-board-existing-todo')), + ), + findsOneWidget, + ); + + await tester.tap( + find.descendant( + of: stickyBoardDetail, + matching: find.byKey( + const Key('sticky-board-managed-open-details-existing-todo'), + ), + ), + ); + await tester.pump(kDoubleTapMinTime); + await tester.tap( + find.descendant( + of: stickyBoardDetail, + matching: find.byKey( + const Key('sticky-board-managed-open-details-existing-todo'), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('sticky-board-todo-details')), findsOneWidget); + expect(find.byKey(const Key('sticky-board-details-edit')), findsNothing); + await tester.tap(find.byKey(const Key('sticky-board-details-back'))); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('sticky-board-new-todo'))); await tester.pumpAndSettle(); @@ -767,6 +1183,7 @@ void main() { find.byKey(const Key('todo-title-field')), 'Share the release notes', ); + stickyBoardRepository.failNextSave = true; await tester.pump(); expect( tester @@ -777,6 +1194,18 @@ void main() { await tester.tap(find.byKey(const Key('save-todo-details'))); await tester.pumpAndSettle(); + expect( + todoController.items.where( + (item) => item.title == 'Share the release notes', + ), + hasLength(1), + ); + expect(stickyBoardController.todoCountForBoard('board-launch'), 1); + expect(find.byKey(const Key('todo-title-field')), findsOneWidget); + + await tester.tap(find.byKey(const Key('save-todo-details'))); + await tester.pumpAndSettle(); + expect( todoController.items.map((item) => item.id), contains('created-todo-1'), @@ -787,9 +1216,162 @@ void main() { 'created-todo-1', ]); expect(find.text('Share the release notes'), findsWidgets); + + stickyBoardWindowCoordinator.requestMainWindow( + const StickyBoardMainWindowRequest( + boardId: 'board-launch', + destination: StickyBoardMainWindowDestination.todoEdit, + todoId: 'existing-todo', + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Edit todo'), findsOneWidget); + expect( + tester.widget(find.byType(TodoEditorDrawer)).item?.id, + 'existing-todo', + ); + expect( + tester + .widget(find.byKey(const Key('todo-title-field'))) + .controller + ?.text, + 'Review the launch checklist', + ); + await tester.tap(find.byKey(const Key('todo-drawer-close'))); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Close Sticky Boards')); + await tester.pumpAndSettle(); + + tester.widget(find.byType(TodoPanel)).onCollapse(); + await tester.pumpAndSettle(); + windowBridge.expandRequestHandler?.call(WindowExpansionAnchor.topRight); + await tester.pumpAndSettle(); + expect( + tester + .widget( + find.byKey(const Key('sticky-board-drawer-slide')), + ) + .offset, + isNot(Offset.zero), + ); + expect(find.byKey(const Key('search-field')).hitTestable(), findsOneWidget); + + await tester.tap(find.byKey(const Key('sticky-boards-button'))); + await tester.pumpAndSettle(); + await boardMouse.moveTo( + tester.getCenter( + find.byKey(const Key('sticky-board-thumbnail-board-launch')), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('delete-sticky-board-board-launch'))); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const Key('confirm-delete-sticky-board-board-launch')), + ); + await tester.pumpAndSettle(); + + expect(stickyBoardController.boardById('board-launch'), isNull); + expect(todoController.itemById('existing-todo'), isNotNull); + expect(todoController.itemById('created-todo-1'), isNotNull); expect(tester.takeException(), isNull); }); + testWidgets('permanent deletion waits for sticky board cleanup', ( + WidgetTester tester, + ) async { + tester.view.physicalSize = const Size(500, 760); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final archivedTodo = TodoItem( + id: 'archived-linked', + title: 'Archived linked todo', + createdAt: DateTime.utc(2026, 7, 26, 8), + archivedAt: DateTime.utc(2026, 7, 26, 9), + ); + final todoRepository = _WidgetTestRepository() + ..savedItems = [archivedTodo]; + final boardRepository = _WidgetTestStickyBoardRepository() + ..savedWorkspace = StickyBoardWorkspace( + boards: [ + StickyBoard( + id: 'board-linked', + name: 'Linked', + colorValue: 0xFF20B8A8, + createdAt: DateTime.utc(2026, 7, 26, 7), + ), + ], + boardTodoIds: const >{ + 'board-linked': ['archived-linked'], + }, + ); + final todoController = TodoViewModel( + todoRepository: todoRepository, + tagRepository: _WidgetTestTagRepository(), + ); + final boardController = StickyBoardViewModel(repository: boardRepository); + final settingsController = SettingsViewModel( + settingsRepository: _WidgetTestSettingsRepository(), + loginItemRepository: _WidgetTestLoginItemRepository(), + ); + final updateController = UpdateViewModel( + updateRepository: _WidgetTestUpdateRepository(), + ); + final windowBridge = _WidgetTestWindowBridge(); + final coordinator = StickyBoardWindowCoordinator( + boardController: boardController, + todoController: todoController, + windowBridge: windowBridge, + ); + await Future.wait(>[ + todoController.load(), + boardController.load(), + settingsController.load(), + updateController.load(), + ]); + + await tester.pumpWidget( + FloatickApp( + controller: todoController, + settingsController: settingsController, + updateController: updateController, + stickyBoardController: boardController, + stickyBoardWindowCoordinator: coordinator, + windowBridge: windowBridge, + locale: const Locale('en'), + ), + ); + windowBridge.expandRequestHandler?.call(WindowExpansionAnchor.topRight); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('archive-scope-button'))); + await tester.pumpAndSettle(); + expect(find.text('Archive · 1'), findsOneWidget); + expect(find.text('Archive 1'), findsNothing); + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(mouse.removePointer); + await mouse.addPointer(); + await mouse.moveTo(tester.getCenter(find.text('Archived linked todo'))); + await tester.pumpAndSettle(); + + boardRepository.failNextSave = true; + await tester.tap(find.byKey(const Key('delete-todo-archived-linked'))); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const Key('confirm-delete-todo-archived-linked')), + ); + await tester.pumpAndSettle(); + + expect(todoController.itemById('archived-linked'), archivedTodo); + expect(todoRepository.savedItems, [archivedTodo]); + expect(boardController.todoIdsForBoard('board-linked'), [ + 'archived-linked', + ]); + expect(find.text("Floatick couldn't save to .floatick."), findsOneWidget); + }); + testWidgets('English locale translates the primary todo experience', ( WidgetTester tester, ) async { @@ -804,6 +1386,7 @@ void main() { ); final settingsController = SettingsViewModel( settingsRepository: _WidgetTestSettingsRepository(), + loginItemRepository: _WidgetTestLoginItemRepository(), ); final updateController = UpdateViewModel( updateRepository: _WidgetTestUpdateRepository(), @@ -811,11 +1394,12 @@ void main() { final stickyBoardController = StickyBoardViewModel( repository: _WidgetTestStickyBoardRepository(), ); + final windowBridge = _WidgetTestWindowBridge(); final stickyBoardWindowCoordinator = StickyBoardWindowCoordinator( boardController: stickyBoardController, todoController: controller, + windowBridge: windowBridge, ); - final windowBridge = _WidgetTestWindowBridge(); await controller.load(); await settingsController.load(); await updateController.load(); @@ -841,7 +1425,7 @@ void main() { expect( find.descendant( of: find.byKey(const Key('add-todo-button')), - matching: find.text('Add todo'), + matching: find.text('New'), ), findsOneWidget, ); @@ -857,6 +1441,10 @@ void main() { expect(find.text('Settings'), findsOneWidget); expect(find.text('Appearance'), findsOneWidget); expect(find.text('Language'), findsOneWidget); + expect(find.text('Window'), findsOneWidget); + expect(find.text('Keep above other apps'), findsOneWidget); + expect(find.text('Startup'), findsOneWidget); + expect(find.text('Open at login'), findsOneWidget); expect(find.text('Updates'), findsOneWidget); expect(find.text('v0.1.0'), findsOneWidget); expect(find.text('Automatic checks'), findsOneWidget); @@ -880,6 +1468,7 @@ void main() { final settingsRepository = _WidgetTestSettingsRepository(); final settingsController = SettingsViewModel( settingsRepository: settingsRepository, + loginItemRepository: _WidgetTestLoginItemRepository(), ); final updateController = UpdateViewModel( updateRepository: _WidgetTestUpdateRepository(), @@ -887,11 +1476,12 @@ void main() { final stickyBoardController = StickyBoardViewModel( repository: _WidgetTestStickyBoardRepository(), ); + final windowBridge = _WidgetTestWindowBridge(); final stickyBoardWindowCoordinator = StickyBoardWindowCoordinator( boardController: stickyBoardController, todoController: controller, + windowBridge: windowBridge, ); - final windowBridge = _WidgetTestWindowBridge(); await controller.load(); await settingsController.load(); await updateController.load(); @@ -964,6 +1554,30 @@ class _WidgetTestSettingsRepository implements SettingsRepository { } } +class _WidgetTestLoginItemRepository implements LoginItemRepository { + LoginItemStatus status = LoginItemStatus.disabled; + LoginItemStatus? nextStatus; + bool failNextUpdate = false; + final List setEnabledValues = []; + + @override + Future loadStatus() async => status; + + @override + Future setEnabled(bool enabled) async { + setEnabledValues.add(enabled); + if (failNextUpdate) { + failNextUpdate = false; + throw const LoginItemFailure(kind: LoginItemFailureKind.update); + } + status = + nextStatus ?? + (enabled ? LoginItemStatus.enabled : LoginItemStatus.disabled); + nextStatus = null; + return status; + } +} + class _WidgetTestRepository implements TodoRepository { List savedItems = []; @@ -998,6 +1612,7 @@ class _WidgetTestTagRepository implements TagRepository { class _WidgetTestStickyBoardRepository implements StickyBoardRepository { StickyBoardWorkspace savedWorkspace = StickyBoardWorkspace.empty(); + bool failNextSave = false; @override String get storagePath => '/tmp/floatick-widget-test/sticky_boards.json'; @@ -1007,6 +1622,10 @@ class _WidgetTestStickyBoardRepository implements StickyBoardRepository { @override Future save(StickyBoardWorkspace workspace) async { + if (failNextSave) { + failNextSave = false; + throw const StorageFailure(kind: StorageFailureKind.write); + } savedWorkspace = workspace; } } @@ -1040,7 +1659,11 @@ class _WidgetTestUpdateRepository implements UpdateRepository { class _WidgetTestWindowBridge implements WindowBridge { final List expandedValues = []; + final List expandedAnimatedValues = []; + final List floatingIconCounts = []; final List preferredLanguageValues = []; + final List preferredThemeValues = []; + final List alwaysOnTopValues = []; ExpandRequestHandler? expandRequestHandler; @override @@ -1054,12 +1677,37 @@ class _WidgetTestWindowBridge implements WindowBridge { } @override - Future setExpanded(bool expanded) async { + Future setExpanded(bool expanded, {bool animated = true}) async { expandedValues.add(expanded); + expandedAnimatedValues.add(animated); + } + + @override + Future setFloatingIconCount(int activeCount) async { + floatingIconCounts.add(activeCount); } @override Future setPreferredLanguage(String? languageCode) async { preferredLanguageValues.add(languageCode); } + + @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 {} } diff --git a/test/app/theme/floatick_theme_test.dart b/test/app/theme/floatick_theme_test.dart new file mode 100644 index 0000000..f63aab2 --- /dev/null +++ b/test/app/theme/floatick_theme_test.dart @@ -0,0 +1,147 @@ +import 'package:floatick/app/theme/floatick_theme.dart'; +import 'package:floatick/core/ui/floatick_hover_motion.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('visible application surfaces are fully opaque', () { + expect(FloatickColors.darkSurface.a, 1); + expect(FloatickColors.lightSurface.a, 1); + expect(buildFloatickTheme(Brightness.dark).colorScheme.surface.a, 1); + expect(buildFloatickTheme(Brightness.light).colorScheme.surface.a, 1); + }); + + for (final brightness in Brightness.values) { + test( + '$brightness icon buttons use color feedback without a state fill', + () { + final theme = buildFloatickTheme(brightness); + final style = theme.iconButtonTheme.style!; + + expect( + style.overlayColor!.resolve(const {WidgetState.hovered}), + Colors.transparent, + ); + expect( + style.overlayColor!.resolve(const {WidgetState.focused}), + Colors.transparent, + ); + expect( + style.overlayColor!.resolve(const {WidgetState.pressed}), + Colors.transparent, + ); + expect( + style.foregroundColor!.resolve(const { + WidgetState.hovered, + }), + isNot(style.foregroundColor!.resolve(const {})), + ); + expect( + style.foregroundColor!.resolve(const { + WidgetState.selected, + }), + theme.colorScheme.primary, + ); + expect(style.foregroundBuilder, isNotNull); + expect(theme.textButtonTheme.style!.foregroundBuilder, isNotNull); + expect(theme.filledButtonTheme.style!.foregroundBuilder, isNotNull); + expect(theme.outlinedButtonTheme.style!.foregroundBuilder, isNotNull); + expect(theme.elevatedButtonTheme.style!.foregroundBuilder, isNotNull); + }, + ); + + testWidgets('$brightness icon buttons scale on hover', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: buildFloatickTheme(brightness), + home: Scaffold( + body: Center( + child: IconButton( + key: const Key('themed-icon-button'), + onPressed: () {}, + icon: const Icon(Icons.settings_rounded), + ), + ), + ), + ), + ); + + final button = find.byKey(const Key('themed-icon-button')); + expect(_buttonMotion(tester, button).scale, 1); + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo(tester.getCenter(button)); + await tester.pump(); + + expect( + _buttonMotion(tester, button).scale, + FloatickMotion.iconHoverScale, + ); + }); + + testWidgets('$brightness material buttons share the control motion', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: buildFloatickTheme(brightness), + home: Scaffold( + body: Row( + children: [ + TextButton( + key: const Key('text-button'), + onPressed: () {}, + child: const Text('Text'), + ), + FilledButton( + key: const Key('filled-button'), + onPressed: () {}, + child: const Text('Filled'), + ), + OutlinedButton( + key: const Key('outlined-button'), + onPressed: () {}, + child: const Text('Outlined'), + ), + ElevatedButton( + key: const Key('elevated-button'), + onPressed: () {}, + child: const Text('Elevated'), + ), + ], + ), + ), + ), + ); + + for (final key in const [ + 'text-button', + 'filled-button', + 'outlined-button', + 'elevated-button', + ]) { + final button = find.byKey(Key(key)); + expect(_buttonMotion(tester, button).scale, 1); + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo(tester.getCenter(button)); + await tester.pump(); + + expect( + _buttonMotion(tester, button).scale, + FloatickMotion.controlHoverScale, + ); + await mouse.removePointer(); + } + }); + } +} + +AnimatedScale _buttonMotion(WidgetTester tester, Finder button) { + return tester.widget( + find.descendant(of: button, matching: find.byType(AnimatedScale)), + ); +} diff --git a/test/core/platform/window_bridge_test.dart b/test/core/platform/window_bridge_test.dart new file mode 100644 index 0000000..1537ad5 --- /dev/null +++ b/test/core/platform/window_bridge_test.dart @@ -0,0 +1,78 @@ +import 'package:floatick/core/platform/window_bridge.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('floatick/window'); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('configures the requested secondary window as borderless', () async { + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return null; + }); + final bridge = MethodChannelWindowBridge(); + + await bridge.configureBorderlessSecondaryWindow( + 42, + positionAdjacentToMainWindow: true, + ); + + expect(calls, hasLength(1)); + expect(calls.single.method, 'configureBorderlessSecondaryWindow'); + expect(calls.single.arguments, { + 'viewId': 42, + 'positionAdjacentToMainWindow': true, + }); + }); + + test('reveals a configured secondary window', () async { + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return null; + }); + final bridge = MethodChannelWindowBridge(); + + await bridge.revealBorderlessSecondaryWindow(42); + + expect(calls, hasLength(1)); + expect(calls.single.method, 'revealBorderlessSecondaryWindow'); + expect(calls.single.arguments, 42); + }); + + test('coordinates the fixed main window and native floating icon', () async { + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return null; + }); + final bridge = MethodChannelWindowBridge(); + + await bridge.setFloatingIconCount(7); + await bridge.setPreferredTheme('dark'); + await bridge.setExpanded(true, animated: false); + + expect(calls.map((call) => call.method), [ + 'setFloatingIconCount', + 'setPreferredTheme', + 'setExpanded', + ]); + expect(calls.first.arguments, 7); + expect(calls[1].arguments, 'dark'); + expect(calls.last.arguments, { + 'expanded': true, + 'animated': false, + }); + }); +} diff --git a/test/core/ui/floatick_hover_motion_test.dart b/test/core/ui/floatick_hover_motion_test.dart new file mode 100644 index 0000000..fd4e296 --- /dev/null +++ b/test/core/ui/floatick_hover_motion_test.dart @@ -0,0 +1,150 @@ +import 'package:floatick/core/ui/floatick_hover_motion.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('animates hover and press without changing layout', ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: FloatickHoverMotion( + child: SizedBox.square(key: Key('motion-target'), dimension: 40), + ), + ), + ), + ); + + expect( + tester.getSize(find.byKey(const Key('motion-target'))), + const Size.square(40), + ); + expect(_animatedScale(tester).scale, 1); + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo( + tester.getCenter(find.byKey(const Key('motion-target'))), + ); + await tester.pump(); + + expect(_animatedScale(tester).scale, FloatickMotion.iconHoverScale); + expect( + tester.getSize(find.byKey(const Key('motion-target'))), + const Size.square(40), + ); + + await mouse.down(tester.getCenter(find.byKey(const Key('motion-target')))); + await tester.pump(); + expect(_animatedScale(tester).scale, FloatickMotion.iconPressedScale); + + await mouse.up(); + await mouse.moveTo(Offset.zero); + await tester.pump(); + expect(_animatedScale(tester).scale, 1); + }); + + testWidgets('disables transforms when reduced motion is enabled', ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: MediaQuery( + data: MediaQueryData(disableAnimations: true), + child: Center( + child: FloatickHoverMotion( + hoverTurns: FloatickMotion.emphasisHoverTurns, + child: SizedBox.square(key: Key('motion-target'), dimension: 40), + ), + ), + ), + ), + ); + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo( + tester.getCenter(find.byKey(const Key('motion-target'))), + ); + await tester.pump(); + + expect(find.byType(AnimatedScale), findsNothing); + expect(find.byType(AnimatedRotation), findsNothing); + expect( + tester.getSize(find.byKey(const Key('motion-target'))), + const Size.square(40), + ); + }); + + testWidgets('supports emphasized tilt without changing layout', ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: FloatickHoverMotion( + hoverScale: FloatickMotion.emphasisHoverScale, + pressedScale: FloatickMotion.emphasisPressedScale, + hoverTurns: FloatickMotion.emphasisHoverTurns, + child: SizedBox.square(key: Key('motion-target'), dimension: 40), + ), + ), + ), + ); + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo( + tester.getCenter(find.byKey(const Key('motion-target'))), + ); + await tester.pump(); + + expect( + tester.widget(find.byType(AnimatedRotation)).turns, + FloatickMotion.emphasisHoverTurns, + ); + expect( + tester.getSize(find.byKey(const Key('motion-target'))), + const Size.square(40), + ); + }); + + testWidgets('receives hover over a nested icon button', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: FloatickHoverMotion( + hoverScale: FloatickMotion.emphasisHoverScale, + pressedScale: FloatickMotion.emphasisPressedScale, + hoverTurns: FloatickMotion.emphasisHoverTurns, + child: IconButton( + key: const Key('pin-button'), + style: const ButtonStyle( + foregroundBuilder: FloatickMotion.passthroughForegroundBuilder, + ), + onPressed: () {}, + icon: const Icon(Icons.push_pin_rounded), + ), + ), + ), + ), + ); + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo(tester.getCenter(find.byKey(const Key('pin-button')))); + await tester.pump(); + + expect( + tester.widget(find.byType(AnimatedRotation)).turns, + FloatickMotion.emphasisHoverTurns, + ); + expect(_animatedScale(tester).scale, FloatickMotion.emphasisHoverScale); + }); +} + +AnimatedScale _animatedScale(WidgetTester tester) { + return tester.widget(find.byType(AnimatedScale)); +} diff --git a/test/features/settings/data/login_item_repository_test.dart b/test/features/settings/data/login_item_repository_test.dart new file mode 100644 index 0000000..6040f8c --- /dev/null +++ b/test/features/settings/data/login_item_repository_test.dart @@ -0,0 +1,85 @@ +import 'package:floatick/features/settings/data/login_item_repository.dart'; +import 'package:floatick/features/settings/domain/login_item_status.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('floatick/login_item'); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('loads the native login item status', () async { + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return 'enabled'; + }); + final repository = MethodChannelLoginItemRepository(); + + final status = await repository.loadStatus(); + + expect(status, LoginItemStatus.enabled); + expect(calls, hasLength(1)); + expect(calls.single.method, 'loadStatus'); + expect(calls.single.arguments, isNull); + }); + + test('updates the native login item and returns its actual status', () async { + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return 'requiresApproval'; + }); + final repository = MethodChannelLoginItemRepository(); + + final status = await repository.setEnabled(true); + + expect(status, LoginItemStatus.requiresApproval); + expect(calls, hasLength(1)); + expect(calls.single.method, 'setEnabled'); + expect(calls.single.arguments, isTrue); + }); + + test('rejects an unknown native login item status', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async => 'pending'); + final repository = MethodChannelLoginItemRepository(); + + await expectLater( + repository.loadStatus(), + throwsA( + isA().having( + (failure) => failure.kind, + 'kind', + LoginItemFailureKind.invalidResponse, + ), + ), + ); + }); + + test('wraps native update failures', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async { + throw PlatformException(code: 'login_item_update_failed'); + }); + final repository = MethodChannelLoginItemRepository(); + + await expectLater( + repository.setEnabled(true), + throwsA( + isA().having( + (failure) => failure.kind, + 'kind', + LoginItemFailureKind.update, + ), + ), + ); + }); +} diff --git a/test/features/settings/data/settings_repository_test.dart b/test/features/settings/data/settings_repository_test.dart index ea8ec6b..0c5cdca 100644 --- a/test/features/settings/data/settings_repository_test.dart +++ b/test/features/settings/data/settings_repository_test.dart @@ -33,6 +33,7 @@ void main() { expect(settings, const AppSettings()); expect(settings.themePreference, AppThemePreference.system); expect(settings.languagePreference, AppLanguagePreference.system); + expect(settings.alwaysOnTop, isTrue); expect(await repository.rootDirectory.exists(), isTrue); }, ); @@ -41,6 +42,7 @@ void main() { const settings = AppSettings( themePreference: AppThemePreference.light, languagePreference: AppLanguagePreference.simplifiedChinese, + alwaysOnTop: false, ); await repository.save(settings); @@ -49,9 +51,10 @@ void main() { expect(loadedSettings, settings); expect(json, { - 'version': 2, + 'version': 3, 'theme': 'light', 'language': 'zh', + 'alwaysOnTop': false, }); }); @@ -65,6 +68,19 @@ void main() { expect(settings.themePreference, AppThemePreference.dark); expect(settings.languagePreference, AppLanguagePreference.system); + expect(settings.alwaysOnTop, isTrue); + }); + + test('version 2 settings default to keeping the window on top', () async { + await repository.rootDirectory.create(recursive: true); + await File( + repository.storagePath, + ).writeAsString('{"version": 2, "theme": "system", "language": "en"}'); + + final settings = await repository.load(); + + expect(settings.languagePreference, AppLanguagePreference.english); + expect(settings.alwaysOnTop, isTrue); }); test('damaged storage is reported and left unchanged', () async { @@ -105,4 +121,25 @@ void main() { ); expect(await file.readAsString(), damagedContent); }); + + test('invalid window level setting is reported and left unchanged', () async { + await repository.rootDirectory.create(recursive: true); + final file = File(repository.storagePath); + const damagedContent = + '{"version": 3, "theme": "system", "language": "en",' + '"alwaysOnTop": "yes"}'; + await file.writeAsString(damagedContent); + + await expectLater( + repository.load(), + throwsA( + isA().having( + (error) => error.kind, + 'kind', + StorageFailureKind.invalidData, + ), + ), + ); + expect(await file.readAsString(), damagedContent); + }); } diff --git a/test/features/settings/presentation/settings_view_model_test.dart b/test/features/settings/presentation/settings_view_model_test.dart index 54f7c49..79f1474 100644 --- a/test/features/settings/presentation/settings_view_model_test.dart +++ b/test/features/settings/presentation/settings_view_model_test.dart @@ -1,31 +1,43 @@ import 'dart:async'; import 'package:floatick/core/storage/storage_failure.dart'; +import 'package:floatick/features/settings/data/login_item_repository.dart'; import 'package:floatick/features/settings/data/settings_repository.dart'; import 'package:floatick/features/settings/domain/app_settings.dart'; +import 'package:floatick/features/settings/domain/login_item_status.dart'; import 'package:floatick/features/settings/presentation/settings_view_model.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { late _MemorySettingsRepository repository; + late _MemoryLoginItemRepository loginItemRepository; late SettingsViewModel controller; setUp(() { repository = _MemorySettingsRepository(); - controller = SettingsViewModel(settingsRepository: repository); + loginItemRepository = _MemoryLoginItemRepository(); + controller = SettingsViewModel( + settingsRepository: repository, + loginItemRepository: loginItemRepository, + ); }); test('load exposes persisted appearance preferences', () async { repository.savedSettings = const AppSettings( themePreference: AppThemePreference.dark, languagePreference: AppLanguagePreference.english, + alwaysOnTop: false, ); + loginItemRepository.status = LoginItemStatus.enabled; await controller.load(); expect(controller.themePreference, AppThemePreference.dark); expect(controller.languagePreference, AppLanguagePreference.english); + expect(controller.alwaysOnTop, isFalse); + expect(controller.openAtLogin, isTrue); expect(controller.error, isNull); + expect(controller.loginItemError, isNull); }); test('theme changes immediately and persists the new preference', () async { @@ -104,6 +116,76 @@ void main() { expect(controller.error?.kind, StorageFailureKind.write); expect(controller.isSaving, isFalse); }); + + test('window level changes immediately and persists', () async { + await controller.load(); + + await controller.setAlwaysOnTop(false); + + expect(controller.alwaysOnTop, isFalse); + expect(repository.savedSettings.alwaysOnTop, isFalse); + expect(controller.error, isNull); + }); + + test( + 'a failed window level save rolls the visible preference back', + () async { + await controller.load(); + repository.failNextSave = true; + + await controller.setAlwaysOnTop(false); + + expect(controller.alwaysOnTop, isTrue); + expect(controller.error?.kind, StorageFailureKind.write); + }, + ); + + test( + 'login item changes immediately and synchronizes native state', + () async { + await controller.load(); + final updateCompleter = Completer(); + loginItemRepository.pendingUpdate = updateCompleter; + + final operation = controller.setOpenAtLogin(true); + + expect(controller.openAtLogin, isTrue); + expect(controller.isUpdatingLoginItem, isTrue); + + updateCompleter.complete(); + await operation; + + expect(loginItemRepository.setEnabledValues, [true]); + expect(loginItemRepository.status, LoginItemStatus.enabled); + expect(controller.openAtLogin, isTrue); + expect(controller.isUpdatingLoginItem, isFalse); + expect(controller.loginItemError, isNull); + }, + ); + + test('a failed login item update rolls the visible state back', () async { + await controller.load(); + loginItemRepository.failNextUpdate = true; + + await controller.setOpenAtLogin(true); + + expect(controller.openAtLogin, isFalse); + expect(controller.loginItemError?.kind, LoginItemFailureKind.update); + expect(controller.isUpdatingLoginItem, isFalse); + }); + + test('login item approval requirements are exposed to the UI', () async { + await controller.load(); + loginItemRepository.nextStatus = LoginItemStatus.requiresApproval; + + await controller.setOpenAtLogin(true); + + expect(controller.openAtLogin, isFalse); + expect( + controller.loginItemError?.kind, + LoginItemFailureKind.requiresApproval, + ); + }); } class _MemorySettingsRepository implements SettingsRepository { @@ -131,3 +213,33 @@ class _MemorySettingsRepository implements SettingsRepository { savedSettings = settings; } } + +class _MemoryLoginItemRepository implements LoginItemRepository { + LoginItemStatus status = LoginItemStatus.disabled; + LoginItemStatus? nextStatus; + Completer? pendingUpdate; + bool failNextUpdate = false; + final List setEnabledValues = []; + + @override + Future loadStatus() async => status; + + @override + Future setEnabled(bool enabled) async { + setEnabledValues.add(enabled); + final pendingUpdate = this.pendingUpdate; + if (pendingUpdate != null) { + await pendingUpdate.future; + this.pendingUpdate = null; + } + if (failNextUpdate) { + failNextUpdate = false; + throw const LoginItemFailure(kind: LoginItemFailureKind.update); + } + status = + nextStatus ?? + (enabled ? LoginItemStatus.enabled : LoginItemStatus.disabled); + nextStatus = null; + return status; + } +} diff --git a/test/features/sticky_boards/presentation/sticky_board_frame_save_scheduler_test.dart b/test/features/sticky_boards/presentation/sticky_board_frame_save_scheduler_test.dart new file mode 100644 index 0000000..ba1de8b --- /dev/null +++ b/test/features/sticky_boards/presentation/sticky_board_frame_save_scheduler_test.dart @@ -0,0 +1,35 @@ +import 'package:floatick/features/sticky_boards/presentation/sticky_board_frame_save_scheduler.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('coalesces rapid frame changes into the latest save', ( + WidgetTester tester, + ) async { + final scheduler = StickyBoardFrameSaveScheduler(); + addTearDown(scheduler.cancel); + var saveCount = 0; + + scheduler.schedule(() => saveCount += 1); + scheduler.schedule(() => saveCount += 1); + scheduler.schedule(() => saveCount += 1); + + await tester.pump(const Duration(milliseconds: 199)); + expect(saveCount, 0); + + await tester.pump(const Duration(milliseconds: 1)); + expect(saveCount, 1); + }); + + testWidgets('cancel prevents a pending frame save', ( + WidgetTester tester, + ) async { + final scheduler = StickyBoardFrameSaveScheduler(); + var saveCount = 0; + + scheduler.schedule(() => saveCount += 1); + scheduler.cancel(); + await tester.pump(const Duration(milliseconds: 200)); + + expect(saveCount, 0); + }); +} diff --git a/test/features/sticky_boards/presentation/sticky_board_palette_test.dart b/test/features/sticky_boards/presentation/sticky_board_palette_test.dart new file mode 100644 index 0000000..d880599 --- /dev/null +++ b/test/features/sticky_boards/presentation/sticky_board_palette_test.dart @@ -0,0 +1,42 @@ +import 'package:floatick/features/sticky_boards/presentation/sticky_board_palette.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('uses the board color across the complete themed surface', () { + const baseColor = Color(0xFF20282C); + + final blueSurface = StickyBoardPalette.surfaceColor( + value: StickyBoardPalette.blue, + baseColor: baseColor, + brightness: Brightness.dark, + ); + final orangeSurface = StickyBoardPalette.surfaceColor( + value: StickyBoardPalette.orange, + baseColor: baseColor, + brightness: Brightness.dark, + ); + + expect(blueSurface, isNot(baseColor)); + expect(orangeSurface, isNot(baseColor)); + expect(blueSurface, isNot(orangeSurface)); + }); + + test('strengthens the complete board surface on hover', () { + const baseColor = Color(0xFFF4F6F5); + + final restingSurface = StickyBoardPalette.surfaceColor( + value: StickyBoardPalette.purple, + baseColor: baseColor, + brightness: Brightness.light, + ); + final hoveredSurface = StickyBoardPalette.surfaceColor( + value: StickyBoardPalette.purple, + baseColor: baseColor, + brightness: Brightness.light, + hovered: true, + ); + + expect(hoveredSurface, isNot(restingSurface)); + }); +} diff --git a/test/features/sticky_boards/presentation/sticky_board_view_model_test.dart b/test/features/sticky_boards/presentation/sticky_board_view_model_test.dart index a292b60..295c434 100644 --- a/test/features/sticky_boards/presentation/sticky_board_view_model_test.dart +++ b/test/features/sticky_boards/presentation/sticky_board_view_model_test.dart @@ -82,6 +82,30 @@ void main() { expect(controller.todoIdsForBoard('board-1'), ['todo-1']); }, ); + + test('removing a deleted todo cleans every board relation', () async { + var idSequence = 0; + final repository = _MemoryStickyBoardRepository(); + final controller = StickyBoardViewModel( + repository: repository, + idGenerator: () => 'board-${++idSequence}', + ); + await controller.load(); + await controller.createBoard(name: 'Work', colorValue: 0xFF20B8A8); + await controller.createBoard(name: 'Later', colorValue: 0xFF4C8FF5); + await controller.addTodo(boardId: 'board-1', todoId: 'todo-1'); + await controller.addTodo(boardId: 'board-1', todoId: 'todo-2'); + await controller.addTodo(boardId: 'board-2', todoId: 'todo-1'); + + expect(await controller.removeTodoFromAllBoards('todo-1'), isTrue); + + expect(controller.todoIdsForBoard('board-1'), ['todo-2']); + expect(controller.todoIdsForBoard('board-2'), isEmpty); + expect(controller.boards.length, 2); + expect(repository.savedWorkspace.boardTodoIds, >{ + 'board-1': ['todo-2'], + }); + }); } class _MemoryStickyBoardRepository implements StickyBoardRepository { diff --git a/test/features/sticky_boards/presentation/sticky_board_window_coordinator_test.dart b/test/features/sticky_boards/presentation/sticky_board_window_coordinator_test.dart new file mode 100644 index 0000000..33aa25b --- /dev/null +++ b/test/features/sticky_boards/presentation/sticky_board_window_coordinator_test.dart @@ -0,0 +1,279 @@ +import 'package:floatick/core/platform/window_bridge.dart'; +import 'package:floatick/features/sticky_boards/data/sticky_board_repository.dart'; +import 'package:floatick/features/sticky_boards/domain/sticky_board.dart'; +import 'package:floatick/features/sticky_boards/domain/sticky_board_workspace.dart'; +import 'package:floatick/features/sticky_boards/presentation/sticky_board_view_model.dart'; +import 'package:floatick/features/sticky_boards/presentation/sticky_board_window_coordinator.dart'; +import 'package:floatick/features/todos/data/tag_repository.dart'; +import 'package:floatick/features/todos/data/todo_repository.dart'; +import 'package:floatick/features/todos/domain/tag_workspace.dart'; +import 'package:floatick/features/todos/domain/todo_item.dart'; +import 'package:floatick/features/todos/presentation/todo_view_model.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('forwards a typed main-window navigation request', () { + final coordinator = StickyBoardWindowCoordinator( + boardController: StickyBoardViewModel( + repository: _MemoryStickyBoardRepository(), + ), + todoController: TodoViewModel( + todoRepository: _MemoryTodoRepository(), + tagRepository: _MemoryTagRepository(), + ), + windowBridge: _MemoryWindowBridge(), + ); + StickyBoardMainWindowRequest? receivedRequest; + coordinator.setMainWindowRequestHandler((request) { + receivedRequest = request; + }); + + coordinator.requestMainWindow( + const StickyBoardMainWindowRequest( + boardId: 'board-1', + destination: StickyBoardMainWindowDestination.todoEdit, + todoId: 'todo-1', + ), + ); + + expect(receivedRequest?.boardId, 'board-1'); + expect( + receivedRequest?.destination, + StickyBoardMainWindowDestination.todoEdit, + ); + expect(receivedRequest?.todoId, 'todo-1'); + }); + + test('continues restoring boards and retries only failed windows', () async { + final boardController = StickyBoardViewModel( + repository: _MemoryStickyBoardRepository( + workspace: StickyBoardWorkspace( + boards: [ + StickyBoard( + id: 'board-retry', + name: 'Retry', + colorValue: 0xFF20B8A8, + createdAt: DateTime.utc(2026, 7, 27), + isPinned: true, + ), + StickyBoard( + id: 'board-ready', + name: 'Ready', + colorValue: 0xFF4C8FF5, + createdAt: DateTime.utc(2026, 7, 27), + isPinned: true, + ), + ], + boardTodoIds: const >{}, + ), + ), + ); + await boardController.load(); + final launchCounts = {}; + final coordinator = StickyBoardWindowCoordinator( + boardController: boardController, + todoController: TodoViewModel( + todoRepository: _MemoryTodoRepository(), + tagRepository: _MemoryTagRepository(), + ), + windowBridge: _MemoryWindowBridge(), + windowLauncher: (boardId) async { + final attempt = (launchCounts[boardId] ?? 0) + 1; + launchCounts[boardId] = attempt; + if (boardId == 'board-retry' && attempt == 1) { + throw StateError('first launch failed'); + } + }, + ); + + await coordinator.restorePinnedBoards(); + expect(launchCounts, {'board-retry': 1, 'board-ready': 1}); + + await coordinator.restorePinnedBoards(); + expect(launchCounts, {'board-retry': 2, 'board-ready': 1}); + + await coordinator.restorePinnedBoards(); + expect(launchCounts, {'board-retry': 2, 'board-ready': 1}); + }); + + test('does not persist pinned state when the window cannot open', () async { + final boardController = StickyBoardViewModel( + repository: _MemoryStickyBoardRepository( + workspace: StickyBoardWorkspace( + boards: [ + StickyBoard( + id: 'board-failed', + name: 'Failed', + colorValue: 0xFF20B8A8, + createdAt: DateTime.utc(2026, 7, 27), + ), + ], + boardTodoIds: const >{}, + ), + ), + ); + await boardController.load(); + final coordinator = StickyBoardWindowCoordinator( + boardController: boardController, + todoController: TodoViewModel( + todoRepository: _MemoryTodoRepository(), + tagRepository: _MemoryTagRepository(), + ), + windowBridge: _MemoryWindowBridge(), + windowLauncher: (_) => throw StateError('window unavailable'), + ); + + await coordinator.pin('board-failed'); + + expect(boardController.boardById('board-failed')?.isPinned, isFalse); + }); + + test('a pinned board can always be toggled back to unpinned', () async { + final boardController = StickyBoardViewModel( + repository: _MemoryStickyBoardRepository( + workspace: StickyBoardWorkspace( + boards: [ + StickyBoard( + id: 'board-toggle', + name: 'Toggle', + colorValue: 0xFF20B8A8, + createdAt: DateTime.utc(2026, 7, 27), + ), + ], + boardTodoIds: const >{}, + ), + ), + ); + final hiddenBoardIds = []; + await boardController.load(); + final coordinator = StickyBoardWindowCoordinator( + boardController: boardController, + todoController: TodoViewModel( + todoRepository: _MemoryTodoRepository(), + tagRepository: _MemoryTagRepository(), + ), + windowBridge: _MemoryWindowBridge(), + windowLauncher: (_) async {}, + windowHider: (boardId) async => hiddenBoardIds.add(boardId), + ); + + await coordinator.togglePin('board-toggle'); + expect(boardController.boardById('board-toggle')?.isPinned, isTrue); + + await coordinator.togglePin('board-toggle'); + expect(boardController.boardById('board-toggle')?.isPinned, isFalse); + expect(hiddenBoardIds, ['board-toggle']); + + await coordinator.togglePin('board-toggle'); + expect(boardController.boardById('board-toggle')?.isPinned, isTrue); + + await coordinator.togglePin('board-toggle'); + expect(boardController.boardById('board-toggle')?.isPinned, isFalse); + expect(hiddenBoardIds, ['board-toggle', 'board-toggle']); + }); + + test('restores pinned state when hiding the board window fails', () async { + final boardController = StickyBoardViewModel( + repository: _MemoryStickyBoardRepository( + workspace: StickyBoardWorkspace( + boards: [ + StickyBoard( + id: 'board-hide-failure', + name: 'Hide failure', + colorValue: 0xFF20B8A8, + createdAt: DateTime.utc(2026, 7, 27), + isPinned: true, + ), + ], + boardTodoIds: const >{}, + ), + ), + ); + await boardController.load(); + final coordinator = StickyBoardWindowCoordinator( + boardController: boardController, + todoController: TodoViewModel( + todoRepository: _MemoryTodoRepository(), + tagRepository: _MemoryTagRepository(), + ), + windowBridge: _MemoryWindowBridge(), + windowHider: (_) => throw StateError('window unavailable'), + ); + + await coordinator.unpin('board-hide-failure'); + + expect(boardController.boardById('board-hide-failure')?.isPinned, isTrue); + }); +} + +class _MemoryWindowBridge implements WindowBridge { + @override + Future configureBorderlessSecondaryWindow( + int viewId, { + bool positionAdjacentToMainWindow = false, + }) async {} + + @override + Future revealBorderlessSecondaryWindow(int viewId) async {} + + @override + Future preferredExpansionAnchor() async { + return WindowExpansionAnchor.topRight; + } + + @override + void setExpandRequestHandler(ExpandRequestHandler? handler) {} + + @override + Future setExpanded(bool expanded, {bool animated = true}) async {} + + @override + Future setFloatingIconCount(int activeCount) async {} + + @override + Future setPreferredLanguage(String? languageCode) async {} + + @override + Future setPreferredTheme(String themePreference) async {} + + @override + Future setAlwaysOnTop(bool alwaysOnTop) async {} +} + +class _MemoryStickyBoardRepository implements StickyBoardRepository { + _MemoryStickyBoardRepository({StickyBoardWorkspace? workspace}) + : _workspace = workspace ?? StickyBoardWorkspace.empty(); + + final StickyBoardWorkspace _workspace; + + @override + String get storagePath => '/tmp/floatick-sticky-board-coordinator-test.json'; + + @override + Future load() async => _workspace; + + @override + Future save(StickyBoardWorkspace workspace) async {} +} + +class _MemoryTodoRepository implements TodoRepository { + @override + String get storagePath => '/tmp/floatick-sticky-board-todos-test.json'; + + @override + Future> load() async => const []; + + @override + Future save(List items) async {} +} + +class _MemoryTagRepository implements TagRepository { + @override + String get storagePath => '/tmp/floatick-sticky-board-tags-test.json'; + + @override + Future load() async => TagWorkspace.empty(); + + @override + Future save(TagWorkspace workspace) async {} +} diff --git a/test/features/sticky_boards/presentation/widgets/sticky_board_management_todo_row_test.dart b/test/features/sticky_boards/presentation/widgets/sticky_board_management_todo_row_test.dart new file mode 100644 index 0000000..5d3133b --- /dev/null +++ b/test/features/sticky_boards/presentation/widgets/sticky_board_management_todo_row_test.dart @@ -0,0 +1,92 @@ +import 'package:floatick/features/sticky_boards/presentation/widgets/sticky_board_management_todo_row.dart'; +import 'package:floatick/features/todos/domain/todo_item.dart'; +import 'package:floatick/features/todos/domain/todo_tag.dart'; +import 'package:floatick/l10n/app_localizations.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'shows read-only todo metadata and only exposes details and removal', + (tester) async { + var detailsCount = 0; + var removeCount = 0; + final item = TodoItem( + id: 'todo-1', + title: 'Review candidate', + content: 'Read-only content', + createdAt: DateTime.utc(2026, 7, 27, 2), + completedAt: DateTime.utc(2026, 7, 27, 3), + ); + final tag = TodoTag( + id: 'tag-1', + name: 'Release', + colorValue: 0xFF20BFAF, + createdAt: DateTime.utc(2026, 7, 27, 1), + ); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SizedBox( + width: 380, + child: StickyBoardManagementTodoRow( + item: item, + tags: [tag], + assignedTagIds: const ['tag-1'], + onOpenDetails: () => detailsCount += 1, + onRemove: () => removeCount += 1, + ), + ), + ), + ), + ); + + expect(find.text('Review candidate'), findsOneWidget); + expect(find.text('Release'), findsOneWidget); + expect( + find.byKey(const Key('sticky-board-managed-tag-todo-1-tag-1')), + findsOneWidget, + ); + expect( + find.byKey(const Key('sticky-board-managed-time-todo-1')), + findsOneWidget, + ); + expect(find.byKey(const Key('toggle-todo-todo-1')), findsNothing); + expect(find.byKey(const Key('edit-todo-todo-1')), findsNothing); + expect(find.byKey(const Key('view-todo-todo-1')), findsNothing); + expect(find.byKey(const Key('archive-todo-todo-1')), findsNothing); + expect(find.byKey(const Key('assign-tags-todo-1')), findsNothing); + + await tester.tap( + find.byKey(const Key('sticky-board-managed-completion-status-todo-1')), + ); + await tester.pump(); + expect(detailsCount, 0); + expect(removeCount, 0); + + await tester.tap( + find.byKey(const Key('sticky-board-managed-open-details-todo-1')), + ); + await tester.pump(kDoubleTapMinTime); + await tester.tap( + find.byKey(const Key('sticky-board-managed-open-details-todo-1')), + ); + await tester.pumpAndSettle(); + expect(detailsCount, 1); + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(); + await mouse.moveTo( + tester.getCenter(find.byType(StickyBoardManagementTodoRow)), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('remove-from-board-todo-1'))); + await tester.pump(); + expect(removeCount, 1); + }, + ); +} diff --git a/test/features/sticky_boards/presentation/widgets/sticky_board_read_only_todo_row_test.dart b/test/features/sticky_boards/presentation/widgets/sticky_board_read_only_todo_row_test.dart new file mode 100644 index 0000000..d837a63 --- /dev/null +++ b/test/features/sticky_boards/presentation/widgets/sticky_board_read_only_todo_row_test.dart @@ -0,0 +1,76 @@ +import 'package:floatick/features/sticky_boards/presentation/widgets/sticky_board_read_only_todo_row.dart'; +import 'package:floatick/features/todos/domain/todo_item.dart'; +import 'package:floatick/l10n/app_localizations.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('toggles completion and opens local details on double tap', ( + tester, + ) async { + var detailsCount = 0; + var completionToggleCount = 0; + final item = TodoItem( + id: 'todo-1', + title: 'Review candidate', + content: 'Read-only content', + createdAt: DateTime.utc(2026, 7, 27, 2), + ); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SizedBox( + width: 380, + child: StickyBoardReadOnlyTodoRow( + item: item, + onToggleCompletion: () => completionToggleCount += 1, + onOpenDetails: () => detailsCount += 1, + ), + ), + ), + ), + ); + + expect(find.text('Review candidate'), findsOneWidget); + expect(find.byType(IconButton), findsNothing); + expect(find.byKey(const Key('assign-tags-todo-1')), findsNothing); + expect(find.byKey(const Key('edit-todo-todo-1')), findsNothing); + expect( + find.byKey(const Key('sticky-board-todo-time-todo-1')), + findsNothing, + ); + expect( + find.byKey(const Key('sticky-board-todo-tag-todo-1-tag-1')), + findsNothing, + ); + + await tester.tap( + find.byKey(const Key('sticky-board-completion-toggle-todo-1')), + ); + await tester.pump(); + + expect(completionToggleCount, 1); + expect(detailsCount, 0); + + await tester.tap( + find.byKey(const Key('sticky-board-open-details-region-todo-1')), + ); + await tester.pump(kDoubleTapTimeout); + expect(detailsCount, 0); + + await tester.tap( + find.byKey(const Key('sticky-board-open-details-region-todo-1')), + ); + await tester.pump(kDoubleTapMinTime); + await tester.tap( + find.byKey(const Key('sticky-board-open-details-region-todo-1')), + ); + await tester.pumpAndSettle(); + + expect(detailsCount, 1); + }); +} diff --git a/test/features/sticky_boards/presentation/widgets/sticky_board_todo_details_test.dart b/test/features/sticky_boards/presentation/widgets/sticky_board_todo_details_test.dart new file mode 100644 index 0000000..e96b028 --- /dev/null +++ b/test/features/sticky_boards/presentation/widgets/sticky_board_todo_details_test.dart @@ -0,0 +1,55 @@ +import 'package:floatick/features/sticky_boards/presentation/widgets/sticky_board_todo_details.dart'; +import 'package:floatick/features/todos/domain/todo_item.dart'; +import 'package:floatick/features/todos/domain/todo_tag.dart'; +import 'package:floatick/l10n/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('shows todo content locally without edit actions', ( + tester, + ) async { + var backCount = 0; + final item = TodoItem( + id: 'todo-1', + title: 'Prepare release', + content: '## Checklist\n\n- Verify the DMG', + createdAt: DateTime.utc(2026, 7, 27, 2), + ); + final tag = TodoTag( + id: 'tag-1', + name: 'Release', + colorValue: 0xFF20BFAF, + createdAt: DateTime.utc(2026, 7, 27, 1), + ); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SizedBox( + width: 380, + height: 460, + child: StickyBoardTodoDetails( + item: item, + tags: [tag], + onBack: () => backCount += 1, + ), + ), + ), + ), + ); + + expect(find.byKey(const Key('sticky-board-details-title')), findsOneWidget); + expect(find.text('Prepare release'), findsOneWidget); + expect(find.text('Checklist'), findsOneWidget); + expect(find.text('Verify the DMG'), findsOneWidget); + expect(find.text('Release'), findsOneWidget); + expect(find.byKey(const Key('sticky-board-details-edit')), findsNothing); + + await tester.tap(find.byKey(const Key('sticky-board-details-back'))); + + expect(backCount, 1); + }); +} diff --git a/test/features/todos/data/first_run_workspace_seeder_test.dart b/test/features/todos/data/first_run_workspace_seeder_test.dart new file mode 100644 index 0000000..3cd4eaa --- /dev/null +++ b/test/features/todos/data/first_run_workspace_seeder_test.dart @@ -0,0 +1,108 @@ +import 'dart:convert'; +import 'dart:io'; + +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:flutter_test/flutter_test.dart'; + +void main() { + late Directory temporaryDirectory; + late Directory storageDirectory; + late LocalTodoRepository todoRepository; + late LocalTagRepository tagRepository; + + setUp(() async { + temporaryDirectory = await Directory.systemTemp.createTemp( + 'floatick-first-run-seeder-test-', + ); + storageDirectory = Directory('${temporaryDirectory.path}/.floatick'); + todoRepository = LocalTodoRepository(rootDirectory: storageDirectory); + tagRepository = LocalTagRepository(rootDirectory: storageDirectory); + }); + + tearDown(() async { + if (await temporaryDirectory.exists()) { + await temporaryDirectory.delete(recursive: true); + } + }); + + test( + 'seeds two localized todos with one tag each on first install', + () async { + final seeded = await FirstRunWorkspaceSeeder( + todoRepository: todoRepository, + tagRepository: tagRepository, + languageCode: 'zh-CN', + clock: () => DateTime.utc(2026, 7, 28, 8), + ).seedIfNeeded(); + + final todos = await todoRepository.load(); + final workspace = await tagRepository.load(); + + expect(seeded, isTrue); + expect(todos, hasLength(2)); + expect(todos.map((todo) => todo.title), [ + '欢迎使用 Floatick', + '试试完成这条待办', + ]); + expect(todos.every((todo) => todo.content.isNotEmpty), isTrue); + expect(workspace.tags.map((tag) => tag.name), ['欢迎', '快速上手']); + expect( + todos.map((todo) => workspace.tagIdsForTodo(todo.id).length), + everyElement(1), + ); + }, + ); + + test('uses English copy for non-Chinese system languages', () async { + await FirstRunWorkspaceSeeder( + todoRepository: todoRepository, + tagRepository: tagRepository, + languageCode: 'en-US', + clock: () => DateTime.utc(2026, 7, 28, 8), + ).seedIfNeeded(); + + final todos = await todoRepository.load(); + final workspace = await tagRepository.load(); + + expect(todos.map((todo) => todo.title), [ + 'Welcome to Floatick', + 'Try completing this todo', + ]); + expect(workspace.tags.map((tag) => tag.name), [ + 'Welcome', + 'Start here', + ]); + }); + + test('does not reseed after a user deliberately clears all todos', () async { + await storageDirectory.create(recursive: true); + await File( + todoRepository.storagePath, + ).writeAsString(jsonEncode([])); + + final seeded = await FirstRunWorkspaceSeeder( + todoRepository: todoRepository, + tagRepository: tagRepository, + languageCode: 'zh-CN', + ).seedIfNeeded(); + + expect(seeded, isFalse); + expect(await todoRepository.load(), isEmpty); + expect(await File(tagRepository.storagePath).exists(), isFalse); + }); + + test('does not seed over an existing tag workspace', () async { + await tagRepository.save(await tagRepository.load()); + + final seeded = await FirstRunWorkspaceSeeder( + todoRepository: todoRepository, + tagRepository: tagRepository, + languageCode: 'zh-CN', + ).seedIfNeeded(); + + expect(seeded, isFalse); + expect(await File(todoRepository.storagePath).exists(), isFalse); + }); +} diff --git a/test/features/todos/presentation/todo_editor_drawer_test.dart b/test/features/todos/presentation/todo_editor_drawer_test.dart index b6dcad1..0cc5ee6 100644 --- a/test/features/todos/presentation/todo_editor_drawer_test.dart +++ b/test/features/todos/presentation/todo_editor_drawer_test.dart @@ -170,9 +170,66 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('todo-details-tags')), findsOneWidget); + expect( + find.descendant( + of: find.byKey(const Key('todo-details-edit')), + matching: find.byIcon(Icons.edit_outlined), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byKey(const Key('todo-details-edit')), + matching: find.text('Edit'), + ), + findsNothing, + ); expect(find.text('Work'), findsOneWidget); }); + testWidgets('archived details are read-only', (WidgetTester tester) async { + final closeFocusNode = FocusNode(); + addTearDown(closeFocusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SizedBox( + width: 440, + height: 520, + child: TodoEditorDrawer( + mode: TodoEditorDrawerMode.details, + item: TodoItem( + id: 'archived', + title: 'Archived todo', + createdAt: DateTime.utc(2026, 7, 25), + archivedAt: DateTime.utc(2026, 7, 26), + ), + availableTags: const [], + originalAssignedTagIds: const [], + assignedTagIds: const [], + isOpen: true, + canEdit: false, + onClose: () {}, + onEdit: () {}, + onOpenTagAssignment: () {}, + onSave: (title, content, tagIds) async => true, + onSaved: () {}, + closeFocusNode: closeFocusNode, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('todo-details-edit')), findsNothing); + expect(find.text('No additional notes were saved.'), findsOneWidget); + }); + testWidgets('create drawer opens the shared tag assignment surface', ( WidgetTester tester, ) async { diff --git a/test/features/todos/presentation/todo_list_row_test.dart b/test/features/todos/presentation/todo_list_row_test.dart new file mode 100644 index 0000000..4d0d195 --- /dev/null +++ b/test/features/todos/presentation/todo_list_row_test.dart @@ -0,0 +1,441 @@ +import 'dart:async'; + +import 'package:floatick/features/todos/domain/todo_item.dart'; +import 'package:floatick/features/todos/domain/todo_tag.dart'; +import 'package:floatick/features/todos/presentation/widgets/todo_list_row.dart'; +import 'package:floatick/l10n/app_localizations.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'primary controls align and double-clicking the title opens details', + (tester) async { + var toggleCount = 0; + var detailsCount = 0; + final item = TodoItem( + id: 'aligned', + title: 'Review the aligned row', + createdAt: DateTime.utc(2026, 7, 27, 8), + ); + final tags = [ + TodoTag( + id: 'tag-work', + name: 'Work', + colorValue: 0xFF20BFB2, + createdAt: DateTime.utc(2026, 7, 27, 7), + ), + ]; + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Center( + child: SizedBox( + width: 420, + child: TodoListRow( + item: item, + archivedScope: false, + onToggle: () => toggleCount += 1, + onOpenDetails: () => detailsCount += 1, + onEdit: () {}, + onArchive: () {}, + onRestore: () {}, + tags: tags, + assignedTagIds: const ['tag-work'], + onOpenTagAssignment: () {}, + ), + ), + ), + ), + ), + ); + + final primaryCenterY = tester + .getCenter(find.byKey(const Key('todo-title-aligned'))) + .dy; + for (final key in [ + 'toggle-todo-aligned', + 'edit-todo-aligned', + 'view-todo-aligned', + 'archive-todo-aligned', + ]) { + expect( + tester.getCenter(find.byKey(Key(key))).dy, + closeTo(primaryCenterY, 0.5), + ); + } + + final tagCenterY = tester + .getCenter(find.byKey(const Key('todo-tag-aligned-tag-work'))) + .dy; + final timeCenterY = tester + .getCenter(find.byKey(const Key('todo-time-aligned'))) + .dy; + expect(timeCenterY, closeTo(tagCenterY, 0.5)); + expect(tagCenterY, greaterThan(primaryCenterY + 10)); + + final detailsRegion = find.byKey( + const Key('todo-open-details-region-aligned'), + ); + await tester.tap(detailsRegion); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tap(detailsRegion); + await tester.pump(); + + expect(detailsCount, 1); + + await tester.tap(find.byKey(const Key('toggle-todo-aligned'))); + await tester.pump(const Duration(milliseconds: 350)); + expect(toggleCount, 1); + expect(detailsCount, 1); + }, + ); + + testWidgets('external tag action bypasses the inline assignment menu', ( + tester, + ) async { + var openCount = 0; + final item = TodoItem( + id: 'todo-1', + title: 'Review the draft', + createdAt: DateTime.utc(2026, 7, 27, 8), + ); + final tags = [ + TodoTag( + id: 'tag-1', + name: 'Work', + colorValue: 0xFF20BFB2, + createdAt: DateTime.utc(2026, 7, 27, 7), + ), + ]; + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: TodoListRow( + item: item, + archivedScope: false, + onToggle: () {}, + onOpenDetails: () {}, + onEdit: () {}, + onArchive: () {}, + onRestore: () {}, + tags: tags, + assignedTagIds: const ['tag-1'], + onOpenTagAssignment: () => openCount += 1, + showArchiveAction: false, + ), + ), + ), + ); + + expect(find.text('Work'), findsOneWidget); + expect(find.byType(MenuAnchor), findsNothing); + expect(find.byIcon(Icons.archive_outlined), findsNothing); + + await tester.tap(find.byKey(const Key('assign-tags-todo-1'))); + + expect(openCount, 1); + }); + + testWidgets( + 'inline tag action opens a responsive bottom sheet and reflects saved state', + (tester) async { + await tester.binding.setSurfaceSize(const Size(390, 844)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + var saveSucceeds = false; + var toggleCount = 0; + var manageCount = 0; + final item = TodoItem( + id: 'bottom-sheet', + title: 'Plan mobile tag flow', + createdAt: DateTime.utc(2026, 7, 27, 8), + ); + final tags = [ + TodoTag( + id: 'tag-work', + name: 'Work', + colorValue: 0xFF20BFB2, + createdAt: DateTime.utc(2026, 7, 27, 7), + ), + ]; + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: ThemeData(platform: TargetPlatform.android), + home: Scaffold( + body: TodoListRow( + item: item, + archivedScope: false, + onToggle: () {}, + onOpenDetails: () {}, + onEdit: () {}, + onArchive: () {}, + onRestore: () {}, + tags: tags, + assignedTagIds: const [], + onToggleTag: (_) async { + toggleCount += 1; + return saveSucceeds; + }, + onOpenTagManagement: () => manageCount += 1, + ), + ), + ), + ); + + await tester.tap(find.byKey(const Key('assign-tags-bottom-sheet'))); + await tester.pumpAndSettle(); + + final sheet = find.byKey(const Key('tag-assignment-bottom-sheet')); + final tagRow = find.byKey(const Key('assign-bottom-sheet-tag-work')); + expect(sheet, findsOneWidget); + expect(find.byType(MenuAnchor), findsNothing); + expect(tester.getSize(sheet).width, closeTo(390, 0.5)); + expect(tester.getSize(sheet).height, lessThanOrEqualTo(844 * 0.72)); + expect(tester.getSize(tagRow).height, greaterThanOrEqualTo(44)); + + await tester.tap(tagRow); + await tester.pumpAndSettle(); + expect(toggleCount, 1); + expect( + find.descendant(of: tagRow, matching: find.byIcon(Icons.check_rounded)), + findsNothing, + ); + + saveSucceeds = true; + await tester.tap(tagRow); + await tester.pumpAndSettle(); + expect(toggleCount, 2); + expect( + find.descendant(of: tagRow, matching: find.byIcon(Icons.check_rounded)), + findsOneWidget, + ); + final selectedRowInkWell = tester.widget( + find.descendant(of: tagRow, matching: find.byType(InkWell)), + ); + expect( + (selectedRowInkWell.child! as Container).decoration, + isNull, + reason: 'Selected tags should use only a checkmark, without row fill.', + ); + + await tester.tap(find.byKey(const Key('tag-assignment-manage'))); + await tester.pumpAndSettle(); + expect(sheet, findsNothing); + expect(manageCount, 1); + }, + ); + + testWidgets( + 'macOS tag sheet stays inside the panel and keeps selection geometry stable', + (tester) async { + await tester.binding.setSurfaceSize(const Size(440, 700)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final pendingSaves = >[]; + final item = TodoItem( + id: 'mac-sheet', + title: 'Verify stable tags', + createdAt: DateTime.utc(2026, 7, 27, 8), + ); + final tags = [ + TodoTag( + id: 'tag-work', + name: 'Work', + colorValue: 0xFF20BFB2, + createdAt: DateTime.utc(2026, 7, 27, 7), + ), + ]; + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: ThemeData(platform: TargetPlatform.macOS), + home: Scaffold( + backgroundColor: Colors.transparent, + body: Padding( + padding: const EdgeInsets.all(8), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(26), + ), + child: TodoListRow( + item: item, + archivedScope: false, + onToggle: () {}, + onOpenDetails: () {}, + onEdit: () {}, + onArchive: () {}, + onRestore: () {}, + tags: tags, + assignedTagIds: const [], + onToggleTag: (_) { + final completer = Completer(); + pendingSaves.add(completer); + return completer.future; + }, + onOpenTagManagement: () {}, + ), + ), + ), + ), + ), + ); + + await tester.tap(find.byKey(const Key('assign-tags-mac-sheet'))); + await tester.pumpAndSettle(); + + final boundary = find.byKey(const Key('floatick-modal-surface-boundary')); + final sheet = find.byKey(const Key('tag-assignment-bottom-sheet')); + final sheetSurface = find.byKey( + const Key('tag-assignment-bottom-sheet-surface'), + ); + final tagRow = find.byKey(const Key('assign-mac-sheet-tag-work')); + expect(tester.getRect(boundary), const Rect.fromLTWH(0, 0, 440, 700)); + expect(tester.getRect(sheet).left, tester.getRect(boundary).left); + expect(tester.getRect(sheet).right, tester.getRect(boundary).right); + expect(tester.getRect(sheet).bottom, tester.getRect(boundary).bottom); + final sheetDecoration = + tester.widget(sheetSurface).decoration as BoxDecoration; + final sheetRadius = sheetDecoration.borderRadius! as BorderRadius; + expect(sheetRadius.bottomLeft.x, 25); + expect(sheetRadius.bottomRight.x, 25); + final contentSafeArea = tester.widget( + find.byKey(const Key('tag-assignment-content-safe-area')), + ); + expect(contentSafeArea.minimum.bottom, 16); + final sheetList = find.descendant( + of: sheet, + matching: find.byType(ListView), + ); + expect( + tester.getRect(sheet).bottom - tester.getRect(sheetList).bottom, + greaterThanOrEqualTo(16), + ); + + final initialRect = tester.getRect(tagRow); + await tester.tap(tagRow); + await tester.pump(); + expect(pendingSaves, hasLength(1)); + expect( + find.descendant(of: tagRow, matching: find.byIcon(Icons.check_rounded)), + findsOneWidget, + ); + expect( + find.descendant( + of: tagRow, + matching: find.byType(CircularProgressIndicator), + ), + findsNothing, + ); + expect(tester.getRect(tagRow), initialRect); + + pendingSaves.first.complete(true); + await tester.pumpAndSettle(); + expect(tester.getRect(tagRow), initialRect); + + await tester.tap(tagRow); + await tester.pump(); + expect(pendingSaves, hasLength(2)); + expect( + find.descendant(of: tagRow, matching: find.byIcon(Icons.check_rounded)), + findsNothing, + ); + expect(tester.getRect(tagRow), initialRect); + + pendingSaves.last.complete(true); + await tester.pumpAndSettle(); + expect(tester.getRect(tagRow), initialRect); + }, + ); + + testWidgets( + 'archived row only offers view, restore, and confirmed deletion', + (tester) async { + var viewCount = 0; + var restoreCount = 0; + var deleteCount = 0; + final item = TodoItem( + id: 'archived', + title: 'Archived todo', + createdAt: DateTime.utc(2026, 7, 27, 8), + archivedAt: DateTime.utc(2026, 7, 27, 9), + ); + final tags = [ + TodoTag( + id: 'tag-1', + name: 'Work', + colorValue: 0xFF20BFB2, + createdAt: DateTime.utc(2026, 7, 27, 7), + ), + ]; + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: TodoListRow( + item: item, + archivedScope: true, + onToggle: () {}, + onOpenDetails: () => viewCount += 1, + onEdit: null, + onArchive: () {}, + onRestore: () => restoreCount += 1, + tags: tags, + assignedTagIds: const ['tag-1'], + onDeletePermanently: () => deleteCount += 1, + ), + ), + ), + ); + + expect(find.text('Work'), findsOneWidget); + expect(find.byKey(const Key('edit-todo-archived')), findsNothing); + expect(find.byKey(const Key('assign-tags-archived')), findsNothing); + + await tester.tap(find.byKey(const Key('view-todo-archived'))); + await tester.tap(find.byKey(const Key('restore-todo-archived'))); + expect(viewCount, 1); + expect(restoreCount, 1); + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(mouse.removePointer); + await mouse.addPointer(); + await mouse.moveTo(tester.getCenter(find.byType(TodoListRow))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('delete-todo-archived'))); + await tester.pumpAndSettle(); + + expect( + find.byKey(const Key('cancel-delete-todo-archived')), + findsOneWidget, + ); + expect( + find.byKey(const Key('confirm-delete-todo-archived')), + findsOneWidget, + ); + expect(deleteCount, 0); + + await tester.tap(find.byKey(const Key('confirm-delete-todo-archived'))); + expect(deleteCount, 1); + }, + ); +} diff --git a/test/features/todos/presentation/todo_view_model_test.dart b/test/features/todos/presentation/todo_view_model_test.dart index 7adc045..7e5bec8 100644 --- a/test/features/todos/presentation/todo_view_model_test.dart +++ b/test/features/todos/presentation/todo_view_model_test.dart @@ -68,7 +68,7 @@ void main() { }); test( - 'itemsForView filters scope and query, then sorts newest first', + 'itemsForView searches visible titles, ignores content, and sorts', () async { repository.savedItems = [ TodoItem( @@ -107,7 +107,7 @@ void main() { controller .itemsForView(archived: false, query: 'storage format') .map((item) => item.id), - ['older-active'], + isEmpty, ); expect( controller.itemsForView(archived: true, query: '').single.id, @@ -241,6 +241,197 @@ void main() { }, ); + test('archived todos reject detail and tag edits', () async { + repository.savedItems = [ + TodoItem( + id: 'archived', + title: 'Archived todo', + content: 'Original notes', + createdAt: DateTime.parse(firstDate), + archivedAt: DateTime.parse('2026-07-24T12:00:00.000Z'), + ), + ]; + tagRepository.savedWorkspace = TagWorkspace( + tags: [ + TodoTag( + id: 'tag-focus', + name: 'Focus', + colorValue: 0xFF4C8FF5, + createdAt: DateTime.parse(firstDate), + ), + ], + assignments: const >{}, + ); + await controller.load(); + + expect(await controller.rename('archived', 'Changed'), isFalse); + expect( + await controller.updateDetails( + id: 'archived', + title: 'Changed', + content: 'Changed notes', + tagIds: const ['tag-focus'], + ), + isFalse, + ); + expect( + await controller.toggleTagForTodo(todoId: 'archived', tagId: 'tag-focus'), + isFalse, + ); + await controller.toggleCompletion('archived'); + + expect(controller.items.single.title, 'Archived todo'); + expect(controller.items.single.content, 'Original notes'); + expect(controller.items.single.isCompleted, isFalse); + expect(controller.tagIdsForTodo('archived'), isEmpty); + expect(repository.saveCount, 0); + expect(tagRepository.saveCount, 0); + }); + + test('permanent delete only removes archived todo and its tags', () async { + repository.savedItems = [ + TodoItem( + id: 'active', + title: 'Active todo', + createdAt: DateTime.parse(firstDate), + ), + TodoItem( + id: 'archived', + title: 'Archived todo', + createdAt: DateTime.parse(firstDate), + archivedAt: DateTime.parse('2026-07-24T12:00:00.000Z'), + ), + ]; + tagRepository.savedWorkspace = TagWorkspace( + tags: [ + TodoTag( + id: 'tag-focus', + name: 'Focus', + colorValue: 0xFF4C8FF5, + createdAt: DateTime.parse(firstDate), + ), + ], + assignments: const >{ + 'active': ['tag-focus'], + 'archived': ['tag-focus'], + }, + ); + await controller.load(); + + expect(await controller.deletePermanently('active'), isFalse); + expect(await controller.deletePermanently('archived'), isTrue); + + expect(controller.items.map((item) => item.id), ['active']); + expect(controller.tagIdsForTodo('active'), ['tag-focus']); + expect(controller.tagIdsForTodo('archived'), isEmpty); + expect(repository.savedItems.map((item) => item.id), ['active']); + expect(tagRepository.savedWorkspace.assignments, >{ + 'active': ['tag-focus'], + }); + expect(repository.saveCount, 1); + expect(tagRepository.saveCount, 1); + }); + + test('failed tag cleanup rolls back permanent deletion', () async { + final archivedItem = TodoItem( + id: 'archived', + title: 'Archived todo', + createdAt: DateTime.parse(firstDate), + archivedAt: DateTime.parse('2026-07-24T12:00:00.000Z'), + ); + repository.savedItems = [archivedItem]; + tagRepository.savedWorkspace = TagWorkspace( + tags: [ + TodoTag( + id: 'tag-focus', + name: 'Focus', + colorValue: 0xFF4C8FF5, + createdAt: DateTime.parse(firstDate), + ), + ], + assignments: const >{ + 'archived': ['tag-focus'], + }, + ); + await controller.load(); + tagRepository.failNextSave = true; + + expect(await controller.deletePermanently('archived'), isFalse); + + expect(controller.items, [archivedItem]); + expect(repository.savedItems, [archivedItem]); + expect(controller.tagIdsForTodo('archived'), ['tag-focus']); + expect(controller.error?.kind, StorageFailureKind.write); + expect(repository.saveCount, 2); + expect(tagRepository.saveCount, 1); + }); + + test('a failed rollback completes the original save when possible', () async { + tagRepository.savedWorkspace = TagWorkspace( + tags: [ + TodoTag( + id: 'tag-focus', + name: 'Focus', + colorValue: 0xFF4C8FF5, + createdAt: DateTime.parse(firstDate), + ), + ], + assignments: const >{}, + ); + await controller.load(); + repository.saveCallsToFail.add(2); + tagRepository.failNextSave = true; + + final didAdd = await controller.add( + 'Recovered todo', + tagIds: const ['tag-focus'], + ); + + expect(didAdd, isTrue); + expect(controller.items.single.title, 'Recovered todo'); + expect(controller.tagIdsForTodo(controller.items.single.id), [ + 'tag-focus', + ]); + expect(repository.saveCount, 2); + expect(tagRepository.saveCount, 2); + expect(controller.error, isNull); + }); + + test( + 'an unrecoverable partial save never reports a successful add', + () async { + tagRepository.savedWorkspace = TagWorkspace( + tags: [ + TodoTag( + id: 'tag-focus', + name: 'Focus', + colorValue: 0xFF4C8FF5, + createdAt: DateTime.parse(firstDate), + ), + ], + assignments: const >{}, + ); + await controller.load(); + repository.saveCallsToFail.add(2); + tagRepository.saveCallsToFail.addAll({1, 2}); + + final didAdd = await controller.add( + 'Partially persisted todo', + tagIds: const ['tag-focus'], + ); + + expect(didAdd, isFalse); + expect(repository.savedItems.single.title, 'Partially persisted todo'); + expect(controller.items, repository.savedItems); + expect( + controller.tagIdsForTodo(repository.savedItems.single.id), + isEmpty, + ); + expect(controller.error?.kind, StorageFailureKind.write); + expect(repository.saveCount, 2); + }, + ); + test( 'a failed save keeps visible state unchanged and queue usable', () async { @@ -318,19 +509,40 @@ void main() { colorValue: 0xFF20B8A8, createdAt: DateTime.parse(firstDate), ), + TodoTag( + id: 'tag-personal', + name: 'Personal', + colorValue: 0xFF4D8DF7, + createdAt: DateTime.parse(firstDate), + ), ], assignments: const >{ 'work-item': ['tag-work'], + 'personal-item': ['tag-personal'], }, ); await controller.load(); expect( controller - .itemsForView(archived: false, query: '', selectedTagId: 'tag-work') + .itemsForView( + archived: false, + query: '', + selectedTagIds: const {'tag-work'}, + ) .map((item) => item.id), ['work-item'], ); + expect( + controller + .itemsForView( + archived: false, + query: '', + selectedTagIds: const {'tag-work', 'tag-personal'}, + ) + .map((item) => item.id), + ['work-item', 'personal-item'], + ); expect( controller .itemsForView(archived: false, query: 'work') @@ -338,17 +550,29 @@ void main() { ['work-item'], ); - await controller.toggleTagForTodo( - todoId: 'personal-item', - tagId: 'tag-work', + expect( + await controller.toggleTagForTodo( + todoId: 'personal-item', + tagId: 'tag-work', + ), + isTrue, ); - expect(controller.tagIdsForTodo('personal-item'), ['tag-work']); + expect(controller.tagIdsForTodo('personal-item'), [ + 'tag-work', + 'tag-personal', + ]); expect(controller.tagUsageCount('tag-work'), 2); + expect( + controller.tagUsageCountsFor(const ['tag-work', 'tag-missing']), + const {'tag-work': 2, 'tag-missing': 0}, + ); await controller.deleteTag('tag-work'); - expect(controller.tags, isEmpty); + expect(controller.tags.map((tag) => tag.id), ['tag-personal']); expect(controller.tagIdsForTodo('work-item'), isEmpty); - expect(controller.tagIdsForTodo('personal-item'), isEmpty); + expect(controller.tagIdsForTodo('personal-item'), [ + 'tag-personal', + ]); }, ); @@ -366,6 +590,39 @@ void main() { expect(controller.error?.kind, StorageFailureKind.write); }); + test( + 'failed tag assignment reports failure and keeps state unchanged', + () async { + repository.savedItems = [ + TodoItem( + id: 'todo-1', + title: 'Keep assignment stable', + createdAt: DateTime.parse(firstDate), + ), + ]; + tagRepository.savedWorkspace = TagWorkspace( + tags: [ + TodoTag( + id: 'tag-work', + name: 'Work', + colorValue: 0xFF20B8A8, + createdAt: DateTime.parse(firstDate), + ), + ], + assignments: const >{}, + ); + await controller.load(); + tagRepository.failNextSave = true; + + expect( + await controller.toggleTagForTodo(todoId: 'todo-1', tagId: 'tag-work'), + isFalse, + ); + expect(controller.tagIdsForTodo('todo-1'), isEmpty); + expect(controller.error?.kind, StorageFailureKind.write); + }, + ); + test('add persists selected tags with the new todo', () async { tagRepository.savedWorkspace = TagWorkspace( tags: [ @@ -458,6 +715,7 @@ class _MemoryTodoRepository implements TodoRepository { List savedItems = []; int saveCount = 0; bool failNextSave = false; + final Set saveCallsToFail = {}; @override String get storagePath => '/tmp/floatick-test/todos.json'; @@ -470,7 +728,7 @@ class _MemoryTodoRepository implements TodoRepository { @override Future save(List items) async { saveCount += 1; - if (failNextSave) { + if (failNextSave || saveCallsToFail.remove(saveCount)) { failNextSave = false; throw const StorageFailure(kind: StorageFailureKind.write); } @@ -482,6 +740,7 @@ class _MemoryTagRepository implements TagRepository { TagWorkspace savedWorkspace = TagWorkspace.empty(); int saveCount = 0; bool failNextSave = false; + final Set saveCallsToFail = {}; @override String get storagePath => '/tmp/floatick-test/tags.json'; @@ -492,7 +751,7 @@ class _MemoryTagRepository implements TagRepository { @override Future save(TagWorkspace workspace) async { saveCount += 1; - if (failNextSave) { + if (failNextSave || saveCallsToFail.remove(saveCount)) { failNextSave = false; throw const StorageFailure(kind: StorageFailureKind.write); } diff --git a/tool/release/prepare_unsigned_app.sh b/tool/release/prepare_unsigned_app.sh new file mode 100755 index 0000000..917e18a --- /dev/null +++ b/tool/release/prepare_unsigned_app.sh @@ -0,0 +1,70 @@ +#!/bin/bash + +set -euo pipefail + +readonly expected_argument_count=1 + +if [[ $# -ne $expected_argument_count ]]; then + echo "Usage: $0 " >&2 + exit 64 +fi + +readonly app_path=$1 + +if [[ ! -d "$app_path" || "$app_path" != *.app ]]; then + echo "Expected an existing .app bundle: $app_path" >&2 + exit 66 +fi + +script_directory=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +readonly script_directory +readonly entitlements_path="$script_directory/unsigned_release.entitlements" +readonly frameworks_path="$app_path/Contents/Frameworks" +readonly sparkle_framework="$frameworks_path/Sparkle.framework" +readonly sparkle_version="$sparkle_framework/Versions/Current" +readonly -a signing_options=( + --force + --sign - + --options runtime + --timestamp=none +) + +if [[ ! -f "$entitlements_path" ]]; then + echo "Unsigned release entitlements are missing: $entitlements_path" >&2 + exit 66 +fi + +if [[ ! -d "$sparkle_version" ]]; then + echo "Sparkle framework is missing from the app bundle." >&2 + exit 66 +fi + +# Sparkle's helpers must be signed before the framework that seals them. +# Downloader.xpc can carry version-specific entitlements, so preserve them. +codesign "${signing_options[@]}" \ + "$sparkle_version/XPCServices/Installer.xpc" +codesign "${signing_options[@]}" \ + --preserve-metadata=entitlements \ + "$sparkle_version/XPCServices/Downloader.xpc" +codesign "${signing_options[@]}" \ + "$sparkle_version/Autoupdate" +codesign "${signing_options[@]}" \ + "$sparkle_version/Updater.app" +codesign "${signing_options[@]}" \ + "$sparkle_framework" + +for framework in "$frameworks_path"/*.framework; do + if [[ "$framework" == "$sparkle_framework" ]]; then + continue + fi + codesign "${signing_options[@]}" "$framework" +done + +# An ad-hoc identity has no Team ID. Disable library validation only for these +# unsigned candidate builds so macOS can load their consistently ad-hoc-signed +# embedded frameworks. Developer ID distributions must use the signed pipeline. +codesign "${signing_options[@]}" \ + --entitlements "$entitlements_path" \ + "$app_path" + +codesign --verify --deep --strict --verbose=2 "$app_path" diff --git a/tool/release/smoke_test_app.sh b/tool/release/smoke_test_app.sh new file mode 100755 index 0000000..ef6fea7 --- /dev/null +++ b/tool/release/smoke_test_app.sh @@ -0,0 +1,88 @@ +#!/bin/bash + +set -euo pipefail + +readonly expected_argument_count=1 +readonly startup_seconds=5 + +if [[ $# -ne $expected_argument_count ]]; then + echo "Usage: $0 " >&2 + exit 64 +fi + +readonly app_path=$1 + +if [[ ! -d "$app_path" || "$app_path" != *.app ]]; then + echo "Expected an existing .app bundle: $app_path" >&2 + exit 66 +fi + +if ! command -v ruby >/dev/null 2>&1; then + echo "Ruby is required to validate the first-run JSON workspace." >&2 + exit 69 +fi + +readonly info_plist="$app_path/Contents/Info.plist" +if [[ ! -f "$info_plist" ]]; then + echo "App Info.plist is missing: $info_plist" >&2 + exit 66 +fi + +executable_name=$(/usr/libexec/PlistBuddy \ + -c 'Print :CFBundleExecutable' \ + "$info_plist") +readonly executable_path="$app_path/Contents/MacOS/$executable_name" + +if [[ ! -x "$executable_path" ]]; then + echo "App executable is missing or not executable: $executable_path" >&2 + exit 66 +fi + +log_path=$(mktemp "${TMPDIR:-/tmp}/floatick-smoke.XXXXXX") +readonly log_path +test_home=$(mktemp -d "${TMPDIR:-/tmp}/floatick-smoke-home.XXXXXX") +readonly test_home +readonly workspace_path="$test_home/.floatick" +readonly todos_path="$workspace_path/todos.json" +readonly tags_path="$workspace_path/tags.json" +app_pid= + +cleanup() { + if [[ -n "$app_pid" ]] && kill -0 "$app_pid" >/dev/null 2>&1; then + kill -TERM "$app_pid" >/dev/null 2>&1 || true + wait "$app_pid" >/dev/null 2>&1 || true + fi + rm -f "$log_path" + rm -rf "$test_home" +} +trap cleanup EXIT + +HOME="$test_home" "$executable_path" >"$log_path" 2>&1 & +app_pid=$! + +sleep "$startup_seconds" + +if ! kill -0 "$app_pid" >/dev/null 2>&1; then + exit_status=0 + wait "$app_pid" || exit_status=$? + echo "App exited during the ${startup_seconds}s startup smoke test (status $exit_status)." >&2 + if [[ -s "$log_path" ]]; then + echo "Application output:" >&2 + sed 's/^/ /' "$log_path" >&2 + fi + exit 1 +fi + +for workspace_file in "$todos_path" "$tags_path"; do + if [[ ! -s "$workspace_file" ]]; then + echo "First-run workspace file was not created: $workspace_file" >&2 + exit 1 + fi + + if ! ruby -rjson -e 'JSON.parse(File.read(ARGV.fetch(0)))' "$workspace_file"; then + echo "First-run workspace file is not valid JSON: $workspace_file" >&2 + exit 1 + fi +done + +echo "App remained running and created a valid isolated first-run workspace." diff --git a/tool/release/unsigned_release.entitlements b/tool/release/unsigned_release.entitlements new file mode 100644 index 0000000..8cc185a --- /dev/null +++ b/tool/release/unsigned_release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.cs.disable-library-validation + + + diff --git a/tool/test/run_ui_tests.sh b/tool/test/run_ui_tests.sh new file mode 100755 index 0000000..5713808 --- /dev/null +++ b/tool/test/run_ui_tests.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +set -euo pipefail + +readonly script_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly repository_root="$(cd "$script_directory/../.." && pwd)" + +cd "$repository_root" + +echo "Running Floatick user journeys on the real macOS Flutter engine..." +flutter test integration_test/floatick_ui_test.dart -d macos + +echo "Running native macOS accessibility boundary tests..." +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 \ + -quiet