From bf7b1d873bb35be99e991c09e8e5e41fb659124b Mon Sep 17 00:00:00 2001 From: lucaslushuo Date: Sun, 26 Jul 2026 22:36:32 +0800 Subject: [PATCH 01/12] fix(release): normalize unsigned macOS signatures --- .github/workflows/candidate.yml | 10 ++++ .github/workflows/ci.yml | 10 ++++ docs/RELEASING.md | 10 +++- tool/release/prepare_unsigned_app.sh | 70 ++++++++++++++++++++++ tool/release/smoke_test_app.sh | 65 ++++++++++++++++++++ tool/release/unsigned_release.entitlements | 8 +++ 6 files changed, 171 insertions(+), 2 deletions(-) create mode 100755 tool/release/prepare_unsigned_app.sh create mode 100755 tool/release/smoke_test_app.sh create mode 100644 tool/release/unsigned_release.entitlements 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..6f4ac27 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,11 @@ jobs: - 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 +65,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/docs/RELEASING.md b/docs/RELEASING.md index 6fb61c3..ef7cb74 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -61,8 +61,10 @@ Every push to `release/0.1.0` 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 +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/v0.1.0`. Only users with push access can list Draft Releases through the GitHub API. @@ -182,6 +184,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/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..9a9117b --- /dev/null +++ b/tool/release/smoke_test_app.sh @@ -0,0 +1,65 @@ +#!/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 + +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 +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" +} +trap cleanup EXIT + +"$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 + +echo "App remained running for the ${startup_seconds}s startup smoke test." 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 + + + From 5c01e646230e947ceefceba66dbc65ced00e0efe Mon Sep 17 00:00:00 2001 From: lucaslushuo Date: Mon, 27 Jul 2026 00:11:34 +0800 Subject: [PATCH 02/12] fix(macos): smooth startup and restore text input --- lib/app/floatick_app.dart | 125 +++- .../todos/presentation/todo_panel.dart | 690 ++++++++++-------- .../widgets/floating_todo_icon.dart | 7 - macos/Runner/MainFlutterWindow.swift | 32 +- pubspec.yaml | 2 +- test/app/floatick_app_test.dart | 25 +- 6 files changed, 551 insertions(+), 330 deletions(-) diff --git a/lib/app/floatick_app.dart b/lib/app/floatick_app.dart index 552837e..cb484a8 100644 --- a/lib/app/floatick_app.dart +++ b/lib/app/floatick_app.dart @@ -103,6 +103,7 @@ class _FloatickShellState extends State<_FloatickShell> { WindowExpansionAnchor _expansionAnchor = WindowExpansionAnchor.topRight; String? _requestedStickyBoardId; int _stickyBoardRequestSerial = 0; + Future? _rendererWarmUpFuture; @override void initState() { @@ -114,7 +115,8 @@ class _FloatickShellState extends State<_FloatickShell> { ); unawaited(_syncPreferredLanguage()); WidgetsBinding.instance.addPostFrameCallback((_) { - unawaited(widget.stickyBoardWindowCoordinator.restorePinnedBoards()); + _startRendererWarmUp(); + unawaited(_restorePinnedBoardsAfterWarmUp()); }); } @@ -163,6 +165,28 @@ class _FloatickShellState extends State<_FloatickShell> { unawaited(_syncPreferredLanguage()); } + 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 _restorePinnedBoardsAfterWarmUp() async { + _startRendererWarmUp(); + await _rendererWarmUpFuture; + if (!mounted) { + return; + } + await widget.stickyBoardWindowCoordinator.restorePinnedBoards(); + } + Future _syncPreferredLanguage() async { final languageCode = switch (widget.settingsController.languagePreference) { AppLanguagePreference.system => null, @@ -206,6 +230,11 @@ class _FloatickShellState extends State<_FloatickShell> { try { if (expanded) { + _startRendererWarmUp(); + await _rendererWarmUpFuture; + if (!mounted) { + return; + } final expansionAnchor = requestedAnchor ?? await widget.windowBridge.preferredExpansionAnchor(); @@ -268,7 +297,7 @@ class _FloatickShellState extends State<_FloatickShell> { ); final isPanel = child.key == const ValueKey('todo-panel'); final scaleAnimation = Tween( - begin: isPanel ? 0.80 : 0.92, + begin: isPanel ? 0.95 : 0.92, end: 1, ).animate(curvedAnimation); return FadeTransition( @@ -281,19 +310,21 @@ class _FloatickShellState extends State<_FloatickShell> { ); }, child: _isExpanded - ? TodoPanel( + ? RepaintBoundary( 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)), + child: TodoPanel( + 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'), @@ -309,3 +340,69 @@ class _FloatickShellState extends State<_FloatickShell> { ); } } + +class _FloatickShaderWarmUp extends ShaderWarmUp { + const _FloatickShaderWarmUp(); + + @override + Size get size => const Size.square(120); + + @override + Future warmUpOnCanvas(Canvas canvas) { + final panelBounds = Rect.fromLTWH(8, 8, size.width - 16, size.height - 16); + final panelShape = RRect.fromRectAndRadius( + panelBounds, + const Radius.circular(26), + ); + 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(); + + final shadowPath = Path()..addRRect(panelShape); + canvas.drawShadow(shadowPath, Colors.black, 6, false); + 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/features/todos/presentation/todo_panel.dart b/lib/features/todos/presentation/todo_panel.dart index 05c7ddf..b7b1bef 100644 --- a/lib/features/todos/presentation/todo_panel.dart +++ b/lib/features/todos/presentation/todo_panel.dart @@ -48,6 +48,8 @@ enum _TodoPanelDrawerMode { editTodo, } +enum _TodoPanelDrawerFamily { settings, tags, stickyBoards, todoEditor } + class TodoPanel extends StatefulWidget { const TodoPanel({ required this.controller, @@ -94,16 +96,20 @@ class _TodoPanelState extends State { String? _selectedTagId; 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; int _todoEditorSession = 0; int _lastHandledStickyBoardRequestSerial = -1; + int _drawerRequestSerial = 0; @override void initState() { @@ -164,11 +170,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() { @@ -294,7 +301,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 +374,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,11 +396,25 @@ 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(); + _drawerRequestSerial += 1; setState(() { + _pendingDrawerMode = null; _selectedTagId = tagId; _drawerMode = _TodoPanelDrawerMode.none; }); @@ -369,9 +423,16 @@ class _TodoPanelState extends State { 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, @@ -440,12 +501,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 +547,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 && @@ -762,337 +853,348 @@ 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, - ), - ), - ), - ), - ), - ), - 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, - ), - onOpenDetails: _openTodoDetails, - onEditTodo: _openTodoEdit, - onOpenTagManagement: - _openTagManagementFromStickyBoard, - closeFocusNode: - _stickyBoardCloseFocusNode, - ); - } - return StickyBoardManagementDrawer( - controller: - widget.stickyBoardController, - isOpen: isStickyBoardManagementOpen, - borderOnLeft: !tagDrawerOnLeft, - onClose: _closeActiveDrawer, - onOpenBoard: _openStickyBoard, - onTogglePin: _toggleStickyBoardPin, - onDeleteBoard: _deleteStickyBoard, - closeFocusNode: - _stickyBoardCloseFocusNode, - ); - }, + 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( - left: 0, - right: 0, - bottom: 0, - height: _todoDrawerHeight, - child: IgnorePointer( - key: const Key('todo-drawer-pointer'), - ignoring: !isTodoDrawerOpen, - child: ExcludeFocus( - excluding: !isTodoDrawerOpen, + 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: !isTodoDrawerOpen, + excluding: !isStickyBoardDrawerOpen, child: AnimatedSlide( - key: const Key('todo-drawer-slide'), + key: const Key('sticky-board-drawer-slide'), duration: reduceMotion ? Duration.zero : _drawerSlideDuration, curve: Curves.easeOutCubic, - offset: isTodoDrawerVisible + offset: isStickyBoardDrawerVisible ? Offset.zero - : const Offset(0, 1), + : Offset(tagDrawerOnLeft ? -1 : 1, 0), 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, - ); - }(); + 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, + ); } - final todoId = selectedTodo?.id; - if (todoId == null) { - return Future.value(false); + 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, + ), + onOpenDetails: _openTodoDetails, + onEditTodo: _openTodoEdit, + onOpenTagManagement: + _openTagManagementFromStickyBoard, + closeFocusNode: + _stickyBoardCloseFocusNode, + ); } - return widget.controller.updateDetails( - id: todoId, - title: title, - content: content, - tagIds: tagIds, + return StickyBoardManagementDrawer( + controller: + widget.stickyBoardController, + isOpen: isStickyBoardManagementOpen, + borderOnLeft: !tagDrawerOnLeft, + onClose: _closeActiveDrawer, + onOpenBoard: _openStickyBoard, + onTogglePin: _toggleStickyBoardPin, + onDeleteBoard: _deleteStickyBoard, + closeFocusNode: + _stickyBoardCloseFocusNode, ); }, - onSaved: () { - if (_drawerMode == - _TodoPanelDrawerMode.createTodo) { - _closeActiveDrawer(); - return; - } - if (_drawerMode == - _TodoPanelDrawerMode.editTodo) { - final todoId = _selectedTodoId; + ), + ), + ), + ), + ), + ), + 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: 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, + 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 () 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; + 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, + selectedTagId: _selectedTagId, borderOnLeft: !tagDrawerOnLeft, - onToggled: _toggleTodoEditorTag, - onManageTags: - _openTagManagementFromTagAssignment, + onSelected: _selectTagFilter, + 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, - ), - }, + }, + ), ), ), ), ), ), - ), ], ), ), diff --git a/lib/features/todos/presentation/widgets/floating_todo_icon.dart b/lib/features/todos/presentation/widgets/floating_todo_icon.dart index 0a06004..23d95c0 100644 --- a/lib/features/todos/presentation/widgets/floating_todo_icon.dart +++ b/lib/features/todos/presentation/widgets/floating_todo_icon.dart @@ -43,13 +43,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/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index d3b9394..f9b87a9 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -28,6 +28,7 @@ final class MainFlutterWindow: NSWindow { private var collapsedOrigin = NSPoint.zero private var pendingExpansionAnchor: ExpansionAnchor? private var collapsedDragOverlay: CollapsedDragOverlayView? + private weak var flutterContentView: NSView? private var windowChannel: FlutterMethodChannel? private var updateService: UpdateService? @@ -50,6 +51,7 @@ final class MainFlutterWindow: NSWindow { configureWindow() contentViewController = flutterViewController + flutterContentView = flutterViewController.view RegisterGeneratedPlugins(registry: flutterViewController) configureWindowChannel(for: flutterViewController) configureUpdateService(for: flutterViewController) @@ -225,8 +227,7 @@ final class MainFlutterWindow: NSWindow { let anchor = pendingExpansionAnchor ?? preferredExpansionAnchor() pendingExpansionAnchor = nil setFrame(expandedFrame(for: anchor), display: true) - NSApp.activate(ignoringOtherApps: true) - makeKeyAndOrderFront(nil) + activateAndFocusFlutterContent() } else { let targetScreen = screen(containing: collapsedOrigin) collapsedOrigin = clampedOrigin( @@ -244,6 +245,33 @@ final class MainFlutterWindow: NSWindow { completion() } + 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 { let collapsedFrame = NSRect( origin: collapsedOrigin, diff --git a/pubspec.yaml b/pubspec.yaml index 705d52e..711740a 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+3 environment: sdk: ^3.12.2 diff --git a/test/app/floatick_app_test.dart b/test/app/floatick_app_test.dart index c276075..e43c880 100644 --- a/test/app/floatick_app_test.dart +++ b/test/app/floatick_app_test.dart @@ -77,6 +77,14 @@ void main() { tester.getSize(find.byKey(const ValueKey('floating-todo-icon'))), const Size.square(FloatingTodoIcon.canvasDimension), ); + final floatingMark = tester.widget( + find.descendant( + of: find.byKey(const ValueKey('floating-todo-icon')), + matching: find.byType(FloatickBrandMark), + ), + ); + expect(floatingMark.shape, FloatickBrandMarkShape.circle); + expect(floatingMark.shadows, isEmpty); windowBridge.expandRequestHandler?.call(WindowExpansionAnchor.topRight); await tester.pumpAndSettle(); @@ -100,18 +108,11 @@ void main() { ); 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(); From ea052b2e9eea6c0203de64f612ad53c9899b520d Mon Sep 17 00:00:00 2001 From: lucaslushuo Date: Mon, 27 Jul 2026 10:55:31 +0800 Subject: [PATCH 03/12] fix(macos): refine sticky boards and window behavior --- lib/app/floatick_app.dart | 236 +++++++++----- lib/core/platform/window_bridge.dart | 7 + .../settings/domain/app_settings.dart | 19 +- .../presentation/settings_drawer.dart | 74 +++++ .../presentation/settings_view_model.dart | 9 + .../widgets/compact_settings_toggle.dart | 57 ++++ .../widgets/update_settings_section.dart | 50 +-- .../pinned_sticky_board_window.dart | 288 +++--------------- .../sticky_board_window_coordinator.dart | 32 +- .../presentation/todo_editor_drawer.dart | 2 +- .../todos/presentation/todo_panel.dart | 68 ++++- .../presentation/widgets/todo_list_row.dart | 118 +++++-- lib/l10n/app_en.arb | 3 + lib/l10n/app_localizations.dart | 18 ++ lib/l10n/app_localizations_en.dart | 9 + lib/l10n/app_localizations_zh.dart | 9 + lib/l10n/app_zh.arb | 3 + macos/Runner/MainFlutterWindow.swift | 30 +- pubspec.yaml | 2 +- test/app/floatick_app_test.dart | 77 +++++ .../data/settings_repository_test.dart | 39 ++- .../settings_view_model_test.dart | 25 ++ .../sticky_board_window_coordinator_test.dart | 76 +++++ .../presentation/todo_list_row_test.dart | 58 ++++ 24 files changed, 900 insertions(+), 409 deletions(-) create mode 100644 lib/features/settings/presentation/widgets/compact_settings_toggle.dart create mode 100644 test/features/sticky_boards/presentation/sticky_board_window_coordinator_test.dart create mode 100644 test/features/todos/presentation/todo_list_row_test.dart diff --git a/lib/app/floatick_app.dart b/lib/app/floatick_app.dart index cb484a8..26de56d 100644 --- a/lib/app/floatick_app.dart +++ b/lib/app/floatick_app.dart @@ -93,30 +93,52 @@ class _FloatickShell extends StatefulWidget { State<_FloatickShell> createState() => _FloatickShellState(); } -class _FloatickShellState extends State<_FloatickShell> { - static const _motionDuration = Duration(milliseconds: 220); +class _FloatickShellState extends State<_FloatickShell> + with SingleTickerProviderStateMixin { + static const _expandedPanelSize = Size(440, 700); + static const _expandDuration = Duration(milliseconds: 180); + static const _collapseDuration = Duration(milliseconds: 150); bool _isExpanded = false; bool _isChangingWindow = false; + bool _isPanelPrepared = false; bool _hasSyncedPreferredLanguage = false; + bool _hasSyncedAlwaysOnTop = false; String? _lastSyncedLanguageCode; + bool? _lastSyncedAlwaysOnTop; WindowExpansionAnchor _expansionAnchor = WindowExpansionAnchor.topRight; - String? _requestedStickyBoardId; + StickyBoardMainWindowRequest? _stickyBoardRequest; int _stickyBoardRequestSerial = 0; Future? _rendererWarmUpFuture; + Future? _panelPreparationFuture; + late final AnimationController _panelAnimationController; + late final Animation _panelOpacity; + late final Animation _panelScale; @override void initState() { super.initState(); + _panelAnimationController = AnimationController( + vsync: this, + duration: _expandDuration, + reverseDuration: _collapseDuration, + ); + final panelCurve = CurvedAnimation( + parent: _panelAnimationController, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + _panelOpacity = panelCurve; + _panelScale = Tween(begin: 0.97, end: 1).animate(panelCurve); widget.windowBridge.setExpandRequestHandler(_handleNativeExpandRequest); widget.settingsController.addListener(_handleSettingsChanged); widget.stickyBoardWindowCoordinator.setMainWindowRequestHandler( _handleStickyBoardWindowRequest, ); unawaited(_syncPreferredLanguage()); + unawaited(_syncAlwaysOnTop()); WidgetsBinding.instance.addPostFrameCallback((_) { - _startRendererWarmUp(); - unawaited(_restorePinnedBoardsAfterWarmUp()); + unawaited(_preparePanelAndRestorePinnedBoards()); }); } @@ -127,11 +149,13 @@ class _FloatickShellState extends State<_FloatickShell> { oldWidget.windowBridge.setExpandRequestHandler(null); widget.windowBridge.setExpandRequestHandler(_handleNativeExpandRequest); _hasSyncedPreferredLanguage = false; + _hasSyncedAlwaysOnTop = false; } if (oldWidget.settingsController != widget.settingsController) { oldWidget.settingsController.removeListener(_handleSettingsChanged); widget.settingsController.addListener(_handleSettingsChanged); _hasSyncedPreferredLanguage = false; + _hasSyncedAlwaysOnTop = false; } if (oldWidget.stickyBoardWindowCoordinator != widget.stickyBoardWindowCoordinator) { @@ -143,6 +167,9 @@ class _FloatickShellState extends State<_FloatickShell> { if (!_hasSyncedPreferredLanguage) { unawaited(_syncPreferredLanguage()); } + if (!_hasSyncedAlwaysOnTop) { + unawaited(_syncAlwaysOnTop()); + } } @override @@ -150,12 +177,13 @@ class _FloatickShellState extends State<_FloatickShell> { widget.windowBridge.setExpandRequestHandler(null); widget.settingsController.removeListener(_handleSettingsChanged); widget.stickyBoardWindowCoordinator.setMainWindowRequestHandler(null); + _panelAnimationController.dispose(); super.dispose(); } - void _handleStickyBoardWindowRequest(String boardId) { + void _handleStickyBoardWindowRequest(StickyBoardMainWindowRequest request) { setState(() { - _requestedStickyBoardId = boardId; + _stickyBoardRequest = request; _stickyBoardRequestSerial += 1; }); unawaited(_setExpanded(true)); @@ -163,6 +191,7 @@ class _FloatickShellState extends State<_FloatickShell> { void _handleSettingsChanged() { unawaited(_syncPreferredLanguage()); + unawaited(_syncAlwaysOnTop()); } void _startRendererWarmUp() { @@ -178,15 +207,27 @@ class _FloatickShellState extends State<_FloatickShell> { } } - Future _restorePinnedBoardsAfterWarmUp() async { - _startRendererWarmUp(); - await _rendererWarmUpFuture; + 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 { final languageCode = switch (widget.settingsController.languagePreference) { AppLanguagePreference.system => null, @@ -211,6 +252,25 @@ 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); + } + } + void _handleNativeExpandRequest(WindowExpansionAnchor expansionAnchor) { unawaited(_setExpanded(true, requestedAnchor: expansionAnchor)); } @@ -219,19 +279,29 @@ class _FloatickShellState extends State<_FloatickShell> { bool expanded, { WindowExpansionAnchor? requestedAnchor, }) async { - if (_isChangingWindow || _isExpanded == expanded) { + if (_isChangingWindow) { + return; + } + if (_isExpanded == expanded) { + if (expanded) { + try { + await widget.windowBridge.setExpanded(true); + } 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; + final previousExpanded = _isExpanded; setState(() => _isChangingWindow = true); try { if (expanded) { - _startRendererWarmUp(); - await _rendererWarmUpFuture; + await _ensurePanelPrepared(); if (!mounted) { return; } @@ -244,16 +314,21 @@ class _FloatickShellState extends State<_FloatickShell> { setState(() => _expansionAnchor = expansionAnchor); await WidgetsBinding.instance.endOfFrame; await widget.windowBridge.setExpanded(true); - await WidgetsBinding.instance.endOfFrame; if (!mounted) { return; } setState(() => _isExpanded = true); + if (reduceMotion) { + _panelAnimationController.value = 1; + } else { + await _panelAnimationController.forward().orCancel; + } } else { setState(() => _isExpanded = false); - await WidgetsBinding.instance.endOfFrame; - if (motionDuration > Duration.zero) { - await Future.delayed(motionDuration); + if (reduceMotion) { + _panelAnimationController.value = 0; + } else { + await _panelAnimationController.reverse().orCancel; } await widget.windowBridge.setExpanded(false); } @@ -261,7 +336,8 @@ class _FloatickShellState extends State<_FloatickShell> { debugPrint('Floatick could not change the native window: $error'); debugPrintStack(stackTrace: stackTrace); if (mounted) { - setState(() => _isExpanded = !expanded); + setState(() => _isExpanded = previousExpanded); + _panelAnimationController.value = previousExpanded ? 1 : 0; } } finally { if (mounted) { @@ -272,8 +348,6 @@ 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, @@ -283,59 +357,81 @@ class _FloatickShellState extends State<_FloatickShell> { 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.95 : 0.92, - end: 1, - ).animate(curvedAnimation); - return FadeTransition( - opacity: curvedAnimation, - child: ScaleTransition( - scale: scaleAnimation, + body: LayoutBuilder( + builder: (context, constraints) { + final panelSize = Size( + constraints.maxWidth < _expandedPanelSize.width + ? _expandedPanelSize.width + : constraints.maxWidth, + constraints.maxHeight < _expandedPanelSize.height + ? _expandedPanelSize.height + : constraints.maxHeight, + ); + return Stack( + clipBehavior: Clip.none, + children: [ + Align( alignment: expansionAlignment, - child: child, - ), - ); - }, - child: _isExpanded - ? RepaintBoundary( - key: const ValueKey('todo-panel'), - child: TodoPanel( - 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)), + child: FadeTransition( + opacity: ReverseAnimation(_panelOpacity), + child: IgnorePointer( + ignoring: _isExpanded || _isChangingWindow, + child: FloatingTodoIcon( + key: const ValueKey('floating-todo-icon'), + activeCount: widget.controller.activeCount, + onOpen: () => unawaited(_setExpanded(true)), + ), ), - ) - : 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)), + ), + ), + if (_isPanelPrepared) + Positioned.fill( + child: OverflowBox( + alignment: expansionAlignment, + minWidth: panelSize.width, + maxWidth: panelSize.width, + minHeight: panelSize.height, + maxHeight: panelSize.height, + child: SizedBox.fromSize( + size: panelSize, + child: IgnorePointer( + ignoring: !_isExpanded, + child: TickerMode( + enabled: _isExpanded, + child: FadeTransition( + opacity: _panelOpacity, + child: ScaleTransition( + scale: _panelScale, + alignment: expansionAlignment, + child: RepaintBoundary( + key: const ValueKey('todo-panel'), + 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)), + ), + ), + ), + ), + ), + ), + ), ), ), - ), + ], + ); + }, ), ); } diff --git a/lib/core/platform/window_bridge.dart b/lib/core/platform/window_bridge.dart index 87f174e..257737a 100644 --- a/lib/core/platform/window_bridge.dart +++ b/lib/core/platform/window_bridge.dart @@ -25,6 +25,8 @@ abstract interface class WindowBridge { Future setExpanded(bool expanded); Future setPreferredLanguage(String? languageCode); + + Future setAlwaysOnTop(bool alwaysOnTop); } class MethodChannelWindowBridge implements WindowBridge { @@ -58,6 +60,11 @@ class MethodChannelWindowBridge implements WindowBridge { return _channel.invokeMethod('setPreferredLanguage', languageCode); } + @override + Future setAlwaysOnTop(bool alwaysOnTop) { + return _channel.invokeMethod('setAlwaysOnTop', alwaysOnTop); + } + Future _handleNativeMethod(MethodCall call) async { if (call.method == 'requestExpand') { _expandRequestHandler?.call( 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/presentation/settings_drawer.dart b/lib/features/settings/presentation/settings_drawer.dart index 6564e95..f10e9a8 100644 --- a/lib/features/settings/presentation/settings_drawer.dart +++ b/lib/features/settings/presentation/settings_drawer.dart @@ -7,6 +7,7 @@ import '../../../l10n/storage_failure_localizations.dart'; import '../../updates/presentation/update_view_model.dart'; import '../domain/app_settings.dart'; import 'settings_view_model.dart'; +import 'widgets/compact_settings_toggle.dart'; import 'widgets/update_settings_section.dart'; class SettingsDrawer extends StatelessWidget { @@ -80,6 +81,15 @@ 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: 28), UpdateSettingsSection(viewModel: updateViewModel), const SizedBox(height: 28), Text( @@ -124,6 +134,70 @@ class SettingsDrawer extends StatelessWidget { } } +class _AlwaysOnTopSetting extends StatelessWidget { + const _AlwaysOnTopSetting({required this.viewModel}); + + final SettingsViewModel viewModel; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final enabled = !viewModel.isSaving; + return Semantics( + label: context.l10n.alwaysOnTopLabel, + toggled: viewModel.alwaysOnTop, + enabled: enabled, + child: ExcludeSemantics( + child: Material( + color: Colors.transparent, + child: InkWell( + key: const Key('always-on-top-setting'), + borderRadius: BorderRadius.circular(8), + hoverColor: theme.colorScheme.primary.withValues(alpha: 0.06), + highlightColor: theme.colorScheme.primary.withValues(alpha: 0.10), + onTap: enabled + ? () { + unawaited(viewModel.setAlwaysOnTop(!viewModel.alwaysOnTop)); + } + : null, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 34), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Row( + children: [ + Expanded( + child: Text( + context.l10n.alwaysOnTopLabel, + 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: const Key('always-on-top-toggle'), + value: viewModel.alwaysOnTop, + enabled: enabled, + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + 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..503b744 100644 --- a/lib/features/settings/presentation/settings_view_model.dart +++ b/lib/features/settings/presentation/settings_view_model.dart @@ -18,6 +18,7 @@ class SettingsViewModel extends ChangeNotifier { AppSettings get settings => _settings; AppThemePreference get themePreference => _settings.themePreference; AppLanguagePreference get languagePreference => _settings.languagePreference; + bool get alwaysOnTop => _settings.alwaysOnTop; StorageFailure? get error => _error; bool get isLoading => _isLoading; bool get isSaving => _isSaving; @@ -55,6 +56,14 @@ 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 _save(AppSettings nextSettings) async { final previousSettings = _settings; _settings = nextSettings; 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..dfd4757 100644 --- a/lib/features/sticky_boards/presentation/pinned_sticky_board_window.dart +++ b/lib/features/sticky_boards/presentation/pinned_sticky_board_window.dart @@ -5,16 +5,12 @@ import 'package:multiview_desktop/multiview_desktop.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_view_model.dart'; import 'sticky_board_window_coordinator.dart'; -enum _PinnedDrawerMode { none, create, details, edit, tagAssignment } - class PinnedStickyBoardWindow extends StatefulWidget { const PinnedStickyBoardWindow({ required this.boardId, @@ -38,14 +34,6 @@ 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; @override @@ -69,8 +57,6 @@ class _PinnedStickyBoardWindowState extends State void dispose() { widget.boardController.removeListener(_handleModelChanged); widget.todoController.removeListener(_handleModelChanged); - _todoDrawerCloseFocusNode.dispose(); - _tagDrawerCloseFocusNode.dispose(); widget.coordinator.forgetWindow(widget.boardId); super.dispose(); } @@ -117,57 +103,18 @@ class _PinnedStickyBoardWindowState extends State 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; - }); - } - - void _openTagAssignment() { - if (_drawerMode == _PinnedDrawerMode.create || - _drawerMode == _PinnedDrawerMode.edit) { - setState(() => _drawerMode = _PinnedDrawerMode.tagAssignment); - } - } - - void _closeDrawer() { - setState(() { - if (_drawerMode == _PinnedDrawerMode.tagAssignment) { - _drawerMode = _todoDrawerMode; - } else { - _drawerMode = _PinnedDrawerMode.none; - } - }); - } - - void _toggleEditorTag(String tagId) { - setState(() { - if (!_todoEditorTagIds.add(tagId)) { - _todoEditorTagIds.remove(tagId); - } - }); + void _openMain({ + StickyBoardMainWindowDestination destination = + StickyBoardMainWindowDestination.board, + String? todoId, + }) { + widget.coordinator.requestMainWindow( + StickyBoardMainWindowRequest( + boardId: widget.boardId, + destination: destination, + todoId: todoId, + ), + ); } @override @@ -182,19 +129,6 @@ 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 - ? null - : widget.todoController.itemById(_selectedTodoId!); - final editorMode = switch (_todoDrawerMode) { - _PinnedDrawerMode.details => TodoEditorDrawerMode.details, - _PinnedDrawerMode.edit => TodoEditorDrawerMode.edit, - _ => TodoEditorDrawerMode.create, - }; return Material( type: MaterialType.transparency, @@ -209,145 +143,20 @@ class _PinnedStickyBoardWindowState extends State ? Colors.white.withValues(alpha: 0.12) : Colors.white.withValues(alpha: 0.90), ), - 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, + child: Column( 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, - ), - ), - ), + _PinnedHeader(board: board, onUnpin: () => unawaited(_unpin())), + Divider( + height: 1, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.08), ), + Expanded(child: _buildTodoList(board)), + _PinnedFooter(onOpenMain: _openMain), ], ), ), @@ -366,9 +175,9 @@ class _PinnedStickyBoardWindowState extends State if (items.isEmpty) { return Center( child: TextButton.icon( - onPressed: _openCreate, - icon: const Icon(Icons.add_rounded, size: 17), - label: Text(context.l10n.newTodoInStickyBoardAction), + onPressed: _openMain, + icon: const Icon(Icons.open_in_new_rounded, size: 17), + label: Text(context.l10n.openMainListAction), ), ); } @@ -383,25 +192,23 @@ class _PinnedStickyBoardWindowState extends State archivedScope: false, onToggle: () => unawaited(widget.todoController.toggleCompletion(item.id)), - onOpenDetails: () => _openDetails(item.id), - onEdit: () => _openEdit(item.id), + onOpenDetails: () => _openMain( + destination: StickyBoardMainWindowDestination.todoDetails, + todoId: item.id, + ), + onEdit: () => _openMain( + destination: StickyBoardMainWindowDestination.todoEdit, + todoId: 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( + onOpenTagAssignment: () => _openMain( + destination: StickyBoardMainWindowDestination.todoEdit, todoId: item.id, - tagId: tagId, - ), - onOpenTagManagement: () { - widget.coordinator.requestMainWindow(board.id); - }, - onRemoveFromStickyBoard: () => unawaited( - widget.boardController.removeTodo( - boardId: board.id, - todoId: item.id, - ), ), + showArchiveAction: false, compact: true, ); }, @@ -459,32 +266,19 @@ class _PinnedHeader extends StatelessWidget { } class _PinnedFooter extends StatelessWidget { - const _PinnedFooter({required this.onAddTodo, required this.onOpenMain}); + const _PinnedFooter({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), - ), - ], + child: OutlinedButton.icon( + key: const Key('pinned-sticky-board-open-main'), + onPressed: onOpenMain, + icon: const Icon(Icons.open_in_new_rounded, size: 16), + label: Text(context.l10n.openMainListAction), ), ); } 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..110c198 100644 --- a/lib/features/sticky_boards/presentation/sticky_board_window_coordinator.dart +++ b/lib/features/sticky_boards/presentation/sticky_board_window_coordinator.dart @@ -8,7 +8,25 @@ 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); class StickyBoardWindowCoordinator { StickyBoardWindowCoordinator({ @@ -25,15 +43,17 @@ class StickyBoardWindowCoordinator { final TodoViewModel _todos; final Map _windowIdsByBoardId = {}; - StickyBoardMainWindowRequest? _mainWindowRequest; + StickyBoardMainWindowRequestHandler? _mainWindowRequest; bool _didRestorePinnedBoards = false; - 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 { @@ -168,7 +188,7 @@ class StickyBoardWindowCoordinator { ); _windowIdsByBoardId[boardId] = viewId; final window = MultiViewDesktop.fromId(viewId); - await window.setHasShadow(true); + await window.setHasShadow(false); await window.setVisibleOnAllWorkspaces(true, visibleOnFullScreen: true); if (frame != null) { await window.setPosition(Offset(frame.left, frame.top)); diff --git a/lib/features/todos/presentation/todo_editor_drawer.dart b/lib/features/todos/presentation/todo_editor_drawer.dart index 0b18208..b34e49e 100644 --- a/lib/features/todos/presentation/todo_editor_drawer.dart +++ b/lib/features/todos/presentation/todo_editor_drawer.dart @@ -81,11 +81,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(); diff --git a/lib/features/todos/presentation/todo_panel.dart b/lib/features/todos/presentation/todo_panel.dart index b7b1bef..259bd51 100644 --- a/lib/features/todos/presentation/todo_panel.dart +++ b/lib/features/todos/presentation/todo_panel.dart @@ -59,7 +59,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, @@ -72,7 +72,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; @@ -149,18 +149,72 @@ class _TodoPanelState extends State { 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); diff --git a/lib/features/todos/presentation/widgets/todo_list_row.dart b/lib/features/todos/presentation/widgets/todo_list_row.dart index 681fbb0..9f3d9b5 100644 --- a/lib/features/todos/presentation/widgets/todo_list_row.dart +++ b/lib/features/todos/presentation/widgets/todo_list_row.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.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,12 +17,17 @@ 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.showArchiveAction = true, this.compact = false, super.key, - }); + }) : assert( + onOpenTagAssignment != null || + (onToggleTag != null && onOpenTagManagement != null), + ); final TodoItem item; final bool archivedScope; @@ -32,9 +38,11 @@ class TodoListRow extends StatefulWidget { 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 bool showArchiveAction; final bool compact; @override @@ -62,7 +70,9 @@ class _TodoListRowState extends State { final reduceMotion = MediaQuery.disableAnimationsOf(context); final showContextActions = _isHovered || _hasFocus; final trailingActionCount = - 3 + (widget.onRemoveFromStickyBoard == null ? 0 : 1); + 2 + + (widget.showArchiveAction ? 1 : 0) + + (widget.onRemoveFromStickyBoard == null ? 0 : 1); return Focus( focusNode: _rowFocusNode, @@ -182,13 +192,20 @@ class _TodoListRowState extends State { crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( - child: TagAssignmentMenu( - todoId: item.id, - tags: widget.tags, - assignedTagIds: widget.assignedTagIds, - onToggle: widget.onToggleTag, - onManageTags: widget.onOpenTagManagement, - ), + child: 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), Padding( @@ -233,17 +250,18 @@ class _TodoListRowState extends State { : Theme.of(context).colorScheme.primary, key: ValueKey('view-todo-${widget.item.id}'), ), - _ActionButton( - tooltip: widget.archivedScope - ? localizations.restoreTooltip - : localizations.archiveTooltip, - onPressed: widget.archivedScope - ? widget.onRestore - : widget.onArchive, - icon: widget.archivedScope - ? Icons.unarchive_outlined - : Icons.archive_outlined, - ), + if (widget.showArchiveAction) + _ActionButton( + tooltip: widget.archivedScope + ? localizations.restoreTooltip + : localizations.archiveTooltip, + onPressed: widget.archivedScope + ? widget.onRestore + : widget.onArchive, + icon: widget.archivedScope + ? Icons.unarchive_outlined + : Icons.archive_outlined, + ), if (widget.onRemoveFromStickyBoard != null) _HoverAction( visible: showContextActions, @@ -263,6 +281,58 @@ 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 _HoverAction extends StatelessWidget { const _HoverAction({ required this.visible, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 767a50a..0e5a0a7 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -9,6 +9,8 @@ "languageSystemTooltip": "Follow system", "languageSimplifiedChineseTooltip": "Simplified Chinese", "languageEnglishTooltip": "English", + "windowSectionTitle": "Window", + "alwaysOnTopLabel": "Keep above other apps", "updatesSectionTitle": "Updates", "currentVersionLabel": "v{version}", "@currentVersionLabel": { @@ -142,6 +144,7 @@ "newTodoInStickyBoardAction": "New todo", "removeFromStickyBoardTooltip": "Remove from sticky board", "openMainListTooltip": "Open main list", + "openMainListAction": "Open in Floatick", "stickyBoardDeleteKeepsTodosHint": "Deleting a sticky board never deletes its todos.", "collapseTooltip": "Collapse (Esc)", "activeScopeLabel": "Todos", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index a760794..be7ae8b 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -152,6 +152,18 @@ 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 @updatesSectionTitle. /// /// In en, this message translates to: @@ -656,6 +668,12 @@ abstract class AppLocalizations { /// **'Open main list'** String get openMainListTooltip; + /// No description provided for @openMainListAction. + /// + /// In en, this message translates to: + /// **'Open in Floatick'** + String get openMainListAction; + /// No description provided for @stickyBoardDeleteKeepsTodosHint. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index dda07b6..b2f81b7 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -35,6 +35,12 @@ 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 updatesSectionTitle => 'Updates'; @@ -329,6 +335,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get openMainListTooltip => 'Open main list'; + @override + String get openMainListAction => 'Open in Floatick'; + @override String get stickyBoardDeleteKeepsTodosHint => 'Deleting a sticky board never deletes its todos.'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index fcee62e..7724144 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -35,6 +35,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get languageEnglishTooltip => 'English'; + @override + String get windowSectionTitle => '窗口'; + + @override + String get alwaysOnTopLabel => '始终置顶'; + @override String get updatesSectionTitle => '更新'; @@ -306,6 +312,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get openMainListTooltip => '打开主列表'; + @override + String get openMainListAction => '在 Floatick 中打开'; + @override String get stickyBoardDeleteKeepsTodosHint => '删除便利板不会删除其中的待办。'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index cd83af9..0043f8c 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -9,6 +9,8 @@ "languageSystemTooltip": "跟随系统", "languageSimplifiedChineseTooltip": "简体中文", "languageEnglishTooltip": "English", + "windowSectionTitle": "窗口", + "alwaysOnTopLabel": "始终置顶", "updatesSectionTitle": "更新", "currentVersionLabel": "v{version}", "automaticUpdateChecksLabel": "自动检查", @@ -93,6 +95,7 @@ "newTodoInStickyBoardAction": "新建待办", "removeFromStickyBoardTooltip": "从便利板移除", "openMainListTooltip": "打开主列表", + "openMainListAction": "在 Floatick 中打开", "stickyBoardDeleteKeepsTodosHint": "删除便利板不会删除其中的待办。", "collapseTooltip": "收起(Esc)", "activeScopeLabel": "待办", diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index f9b87a9..ab3378a 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -31,6 +31,7 @@ final class MainFlutterWindow: NSWindow { private weak var flutterContentView: NSView? private var windowChannel: FlutterMethodChannel? private var updateService: UpdateService? + private var alwaysOnTop = true override var canBecomeKey: Bool { true } override var canBecomeMain: Bool { true } @@ -77,7 +78,7 @@ final class MainFlutterWindow: NSWindow { backgroundColor = .clear isOpaque = false hasShadow = false - level = .floating + level = .statusBar collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] animationBehavior = .none isMovable = false @@ -146,6 +147,19 @@ final class MainFlutterWindow: NSWindow { NativeCopy.preferredLanguageCode = languageCode self.collapsedDragOverlay?.refreshLocalizedContent() 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) default: result(FlutterMethodNotImplemented) } @@ -153,6 +167,17 @@ final class MainFlutterWindow: NSWindow { windowChannel = channel } + private func setAlwaysOnTop(_ alwaysOnTop: Bool) { + guard self.alwaysOnTop != alwaysOnTop else { + return + } + self.alwaysOnTop = alwaysOnTop + level = alwaysOnTop ? .statusBar : .normal + if alwaysOnTop { + orderFrontRegardless() + } + } + private func configureUpdateService( for flutterViewController: FlutterViewController ) { @@ -212,6 +237,9 @@ final class MainFlutterWindow: NSWindow { completion: @escaping () -> Void ) { guard expanded != isExpanded else { + if expanded { + activateAndFocusFlutterContent() + } completion() return } diff --git a/pubspec.yaml b/pubspec.yaml index 711740a..e4ab574 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+3 +version: 0.2.0+4 environment: sdk: ^3.12.2 diff --git a/test/app/floatick_app_test.dart b/test/app/floatick_app_test.dart index e43c880..d5a584d 100644 --- a/test/app/floatick_app_test.dart +++ b/test/app/floatick_app_test.dart @@ -12,6 +12,8 @@ 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_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'; @@ -121,6 +123,8 @@ 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('v0.1.0'), findsOneWidget); expect(find.text('工作目录'), findsOneWidget); @@ -147,6 +151,16 @@ 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(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), @@ -742,6 +756,22 @@ void main() { 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( @@ -788,6 +818,45 @@ 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); expect(tester.takeException(), isNull); }); @@ -858,6 +927,8 @@ 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('Updates'), findsOneWidget); expect(find.text('v0.1.0'), findsOneWidget); expect(find.text('Automatic checks'), findsOneWidget); @@ -1042,6 +1113,7 @@ class _WidgetTestUpdateRepository implements UpdateRepository { class _WidgetTestWindowBridge implements WindowBridge { final List expandedValues = []; final List preferredLanguageValues = []; + final List alwaysOnTopValues = []; ExpandRequestHandler? expandRequestHandler; @override @@ -1063,4 +1135,9 @@ class _WidgetTestWindowBridge implements WindowBridge { Future setPreferredLanguage(String? languageCode) async { preferredLanguageValues.add(languageCode); } + + @override + Future setAlwaysOnTop(bool alwaysOnTop) async { + alwaysOnTopValues.add(alwaysOnTop); + } } 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..30e009b 100644 --- a/test/features/settings/presentation/settings_view_model_test.dart +++ b/test/features/settings/presentation/settings_view_model_test.dart @@ -19,12 +19,14 @@ void main() { repository.savedSettings = const AppSettings( themePreference: AppThemePreference.dark, languagePreference: AppLanguagePreference.english, + alwaysOnTop: false, ); await controller.load(); expect(controller.themePreference, AppThemePreference.dark); expect(controller.languagePreference, AppLanguagePreference.english); + expect(controller.alwaysOnTop, isFalse); expect(controller.error, isNull); }); @@ -104,6 +106,29 @@ 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); + }, + ); } class _MemorySettingsRepository implements SettingsRepository { 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..1ae4ba3 --- /dev/null +++ b/test/features/sticky_boards/presentation/sticky_board_window_coordinator_test.dart @@ -0,0 +1,76 @@ +import 'package:floatick/features/sticky_boards/data/sticky_board_repository.dart'; +import 'package:floatick/features/sticky_boards/domain/sticky_board_workspace.dart'; +import 'package:floatick/features/sticky_boards/presentation/sticky_board_view_model.dart'; +import 'package:floatick/features/sticky_boards/presentation/sticky_board_window_coordinator.dart'; +import 'package:floatick/features/todos/data/tag_repository.dart'; +import 'package:floatick/features/todos/data/todo_repository.dart'; +import 'package:floatick/features/todos/domain/tag_workspace.dart'; +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(), + ), + ); + 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'); + }); +} + +class _MemoryStickyBoardRepository implements StickyBoardRepository { + @override + String get storagePath => '/tmp/floatick-sticky-board-coordinator-test.json'; + + @override + Future load() async => StickyBoardWorkspace.empty(); + + @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/todos/presentation/todo_list_row_test.dart b/test/features/todos/presentation/todo_list_row_test.dart new file mode 100644 index 0000000..dfe8f56 --- /dev/null +++ b/test/features/todos/presentation/todo_list_row_test.dart @@ -0,0 +1,58 @@ +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/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + 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); + }); +} From ea974ff61769d091e7e970e251291856e2bcde51 Mon Sep 17 00:00:00 2001 From: lucaslushuo Date: Mon, 27 Jul 2026 11:14:55 +0800 Subject: [PATCH 04/12] fix(macos): remove sticky board window backing --- lib/core/platform/window_bridge.dart | 10 ++++ .../sticky_board_window_coordinator.dart | 7 ++- lib/main.dart | 4 +- macos/Runner/MainFlutterWindow.swift | 55 +++++++++++++++++++ pubspec.yaml | 2 +- test/app/floatick_app_test.dart | 14 ++++- test/core/platform/window_bridge_test.dart | 30 ++++++++++ .../sticky_board_window_coordinator_test.dart | 24 ++++++++ 8 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 test/core/platform/window_bridge_test.dart diff --git a/lib/core/platform/window_bridge.dart b/lib/core/platform/window_bridge.dart index 257737a..65646e7 100644 --- a/lib/core/platform/window_bridge.dart +++ b/lib/core/platform/window_bridge.dart @@ -27,6 +27,8 @@ abstract interface class WindowBridge { Future setPreferredLanguage(String? languageCode); Future setAlwaysOnTop(bool alwaysOnTop); + + Future configureTransparentSecondaryWindow(int viewId); } class MethodChannelWindowBridge implements WindowBridge { @@ -65,6 +67,14 @@ class MethodChannelWindowBridge implements WindowBridge { return _channel.invokeMethod('setAlwaysOnTop', alwaysOnTop); } + @override + Future configureTransparentSecondaryWindow(int viewId) { + return _channel.invokeMethod( + 'configureTransparentSecondaryWindow', + viewId, + ); + } + Future _handleNativeMethod(MethodCall call) async { if (call.method == 'requestExpand') { _expandRequestHandler?.call( 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 110c198..169c6b2 100644 --- a/lib/features/sticky_boards/presentation/sticky_board_window_coordinator.dart +++ b/lib/features/sticky_boards/presentation/sticky_board_window_coordinator.dart @@ -3,6 +3,7 @@ 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'; @@ -32,8 +33,10 @@ class StickyBoardWindowCoordinator { StickyBoardWindowCoordinator({ required StickyBoardViewModel boardController, required TodoViewModel todoController, + required WindowBridge windowBridge, }) : _boards = boardController, - _todos = todoController; + _todos = todoController, + _windowBridge = windowBridge; static const Size defaultWindowSize = Size(380, 460); static const Size minimumWindowSize = Size(320, 300); @@ -41,6 +44,7 @@ class StickyBoardWindowCoordinator { final StickyBoardViewModel _boards; final TodoViewModel _todos; + final WindowBridge _windowBridge; final Map _windowIdsByBoardId = {}; StickyBoardMainWindowRequestHandler? _mainWindowRequest; @@ -189,6 +193,7 @@ class StickyBoardWindowCoordinator { _windowIdsByBoardId[boardId] = viewId; final window = MultiViewDesktop.fromId(viewId); await window.setHasShadow(false); + await _windowBridge.configureTransparentSecondaryWindow(viewId); await window.setVisibleOnAllWorkspaces(true, visibleOnFullScreen: true); if (frame != null) { await window.setPosition(Offset(frame.left, frame.top)); diff --git a/lib/main.dart b/lib/main.dart index d5bb4ee..1d199bc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -36,9 +36,11 @@ Future main() async { updateController.load(), stickyBoardController.load(), ]); + final windowBridge = MethodChannelWindowBridge(); final stickyBoardWindowCoordinator = StickyBoardWindowCoordinator( boardController: stickyBoardController, todoController: controller, + windowBridge: windowBridge, ); runMultiApp( @@ -48,7 +50,7 @@ Future main() async { updateController: updateController, stickyBoardController: stickyBoardController, stickyBoardWindowCoordinator: stickyBoardWindowCoordinator, - windowBridge: MethodChannelWindowBridge(), + windowBridge: windowBridge, ), ); } diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index ab3378a..8b2b241 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -160,6 +160,32 @@ final class MainFlutterWindow: NSWindow { } self.setAlwaysOnTop(alwaysOnTop) result(nil) + case "configureTransparentSecondaryWindow": + guard + let viewIdentifier = (call.arguments as? NSNumber)?.int64Value + else { + result( + FlutterError( + code: "invalid_argument", + message: "configureTransparentSecondaryWindow expects a view ID.", + details: nil + ) + ) + return + } + guard self.configureTransparentSecondaryWindow( + viewIdentifier: viewIdentifier + ) else { + result( + FlutterError( + code: "window_unavailable", + message: "The secondary Flutter window could not be found.", + details: viewIdentifier + ) + ) + return + } + result(nil) default: result(FlutterMethodNotImplemented) } @@ -167,6 +193,35 @@ final class MainFlutterWindow: NSWindow { windowChannel = channel } + private func configureTransparentSecondaryWindow( + viewIdentifier: Int64 + ) -> Bool { + guard + let targetWindow = NSApp.windows.first(where: { window in + guard + window !== self, + let controller = window.contentViewController + as? FlutterViewController + else { + return false + } + return controller.viewIdentifier == viewIdentifier + }), + let flutterViewController = targetWindow.contentViewController + as? FlutterViewController + else { + return false + } + + flutterViewController.backgroundColor = .clear + targetWindow.backgroundColor = .clear + targetWindow.isOpaque = false + targetWindow.hasShadow = false + targetWindow.contentView?.wantsLayer = true + targetWindow.contentView?.layer?.backgroundColor = NSColor.clear.cgColor + return true + } + private func setAlwaysOnTop(_ alwaysOnTop: Bool) { guard self.alwaysOnTop != alwaysOnTop else { return diff --git a/pubspec.yaml b/pubspec.yaml index e4ab574..ae3762c 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+4 +version: 0.2.0+5 environment: sdk: ^3.12.2 diff --git a/test/app/floatick_app_test.dart b/test/app/floatick_app_test.dart index d5a584d..8c5852d 100644 --- a/test/app/floatick_app_test.dart +++ b/test/app/floatick_app_test.dart @@ -56,6 +56,7 @@ void main() { final stickyBoardWindowCoordinator = StickyBoardWindowCoordinator( boardController: stickyBoardController, todoController: controller, + windowBridge: windowBridge, ); await controller.load(); await settingsController.load(); @@ -405,11 +406,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(); @@ -707,6 +709,7 @@ void main() { final stickyBoardWindowCoordinator = StickyBoardWindowCoordinator( boardController: stickyBoardController, todoController: todoController, + windowBridge: windowBridge, ); await Future.wait(>[ todoController.load(), @@ -881,11 +884,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(); @@ -959,11 +963,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(); @@ -1140,4 +1145,7 @@ class _WidgetTestWindowBridge implements WindowBridge { Future setAlwaysOnTop(bool alwaysOnTop) async { alwaysOnTopValues.add(alwaysOnTop); } + + @override + Future configureTransparentSecondaryWindow(int viewId) async {} } diff --git a/test/core/platform/window_bridge_test.dart b/test/core/platform/window_bridge_test.dart new file mode 100644 index 0000000..82dcf53 --- /dev/null +++ b/test/core/platform/window_bridge_test.dart @@ -0,0 +1,30 @@ +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 for transparency', () async { + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return null; + }); + final bridge = MethodChannelWindowBridge(); + + await bridge.configureTransparentSecondaryWindow(42); + + expect(calls, hasLength(1)); + expect(calls.single.method, 'configureTransparentSecondaryWindow'); + expect(calls.single.arguments, 42); + }); +} 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 index 1ae4ba3..ea07480 100644 --- a/test/features/sticky_boards/presentation/sticky_board_window_coordinator_test.dart +++ b/test/features/sticky_boards/presentation/sticky_board_window_coordinator_test.dart @@ -1,3 +1,4 @@ +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_workspace.dart'; import 'package:floatick/features/sticky_boards/presentation/sticky_board_view_model.dart'; @@ -19,6 +20,7 @@ void main() { todoRepository: _MemoryTodoRepository(), tagRepository: _MemoryTagRepository(), ), + windowBridge: _MemoryWindowBridge(), ); StickyBoardMainWindowRequest? receivedRequest; coordinator.setMainWindowRequestHandler((request) { @@ -42,6 +44,28 @@ void main() { }); } +class _MemoryWindowBridge implements WindowBridge { + @override + Future configureTransparentSecondaryWindow(int viewId) async {} + + @override + Future preferredExpansionAnchor() async { + return WindowExpansionAnchor.topRight; + } + + @override + void setExpandRequestHandler(ExpandRequestHandler? handler) {} + + @override + Future setExpanded(bool expanded) async {} + + @override + Future setPreferredLanguage(String? languageCode) async {} + + @override + Future setAlwaysOnTop(bool alwaysOnTop) async {} +} + class _MemoryStickyBoardRepository implements StickyBoardRepository { @override String get storagePath => '/tmp/floatick-sticky-board-coordinator-test.json'; From 7229fb1892cce20a4651a8750563d4fd30e91d43 Mon Sep 17 00:00:00 2001 From: lucaslushuo Date: Mon, 27 Jul 2026 11:17:01 +0800 Subject: [PATCH 05/12] fix(ci): satisfy window coordinator lint --- .../presentation/sticky_board_window_coordinator.dart | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) 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 169c6b2..934de8f 100644 --- a/lib/features/sticky_boards/presentation/sticky_board_window_coordinator.dart +++ b/lib/features/sticky_boards/presentation/sticky_board_window_coordinator.dart @@ -33,10 +33,9 @@ class StickyBoardWindowCoordinator { StickyBoardWindowCoordinator({ required StickyBoardViewModel boardController, required TodoViewModel todoController, - required WindowBridge windowBridge, + required this.windowBridge, }) : _boards = boardController, - _todos = todoController, - _windowBridge = windowBridge; + _todos = todoController; static const Size defaultWindowSize = Size(380, 460); static const Size minimumWindowSize = Size(320, 300); @@ -44,7 +43,7 @@ class StickyBoardWindowCoordinator { final StickyBoardViewModel _boards; final TodoViewModel _todos; - final WindowBridge _windowBridge; + final WindowBridge windowBridge; final Map _windowIdsByBoardId = {}; StickyBoardMainWindowRequestHandler? _mainWindowRequest; @@ -193,7 +192,7 @@ class StickyBoardWindowCoordinator { _windowIdsByBoardId[boardId] = viewId; final window = MultiViewDesktop.fromId(viewId); await window.setHasShadow(false); - await _windowBridge.configureTransparentSecondaryWindow(viewId); + await windowBridge.configureTransparentSecondaryWindow(viewId); await window.setVisibleOnAllWorkspaces(true, visibleOnFullScreen: true); if (frame != null) { await window.setPosition(Offset(frame.left, frame.top)); From 36aac5429ff4ddd5f800b61a8afc0be1d5de04b0 Mon Sep 17 00:00:00 2001 From: lucaslushuo Date: Mon, 27 Jul 2026 16:44:13 +0800 Subject: [PATCH 06/12] fix(app): refine todo and sticky board interactions Stabilize borderless sticky board windows and in-board details. Unify archive, tag filtering, search, focus, and high-refresh UI behavior. Bump the 0.2.0 candidate build to 6. --- lib/app/floatick_app.dart | 73 ++- lib/app/theme/floatick_theme.dart | 22 + lib/core/platform/window_bridge.dart | 6 +- .../pinned_sticky_board_window.dart | 127 +++-- .../presentation/sticky_board_view_model.dart | 25 + .../sticky_board_window_coordinator.dart | 2 +- .../widgets/sticky_board_todo_details.dart | 139 +++++ .../todos/presentation/tag_filter_drawer.dart | 132 ++--- .../presentation/tag_management_drawer.dart | 45 +- .../presentation/todo_editor_drawer.dart | 26 +- .../todos/presentation/todo_panel.dart | 135 +++-- .../todos/presentation/todo_view_model.dart | 81 ++- .../todos/presentation/widgets/tag_menus.dart | 494 +++++++++++------- .../presentation/widgets/todo_list_row.dart | 437 ++++++++++------ lib/l10n/app_en.arb | 4 + lib/l10n/app_localizations.dart | 24 + lib/l10n/app_localizations_en.dart | 12 + lib/l10n/app_localizations_zh.dart | 12 + lib/l10n/app_zh.arb | 4 + macos/Runner/MainFlutterWindow.swift | 47 +- pubspec.yaml | 2 +- test/app/floatick_app_test.dart | 131 ++++- test/app/theme/floatick_theme_test.dart | 40 ++ test/core/platform/window_bridge_test.dart | 6 +- .../sticky_board_view_model_test.dart | 24 + .../sticky_board_window_coordinator_test.dart | 2 +- .../sticky_board_todo_details_test.dart | 58 ++ .../presentation/todo_editor_drawer_test.dart | 43 ++ .../presentation/todo_list_row_test.dart | 247 +++++++++ .../presentation/todo_view_model_test.dart | 209 +++++++- 30 files changed, 2034 insertions(+), 575 deletions(-) create mode 100644 lib/features/sticky_boards/presentation/widgets/sticky_board_todo_details.dart create mode 100644 test/app/theme/floatick_theme_test.dart create mode 100644 test/features/sticky_boards/presentation/widgets/sticky_board_todo_details_test.dart diff --git a/lib/app/floatick_app.dart b/lib/app/floatick_app.dart index 26de56d..108ca9d 100644 --- a/lib/app/floatick_app.dart +++ b/lib/app/floatick_app.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../core/platform/window_bridge.dart'; import '../features/settings/domain/app_settings.dart'; @@ -102,6 +103,7 @@ class _FloatickShellState extends State<_FloatickShell> bool _isExpanded = false; bool _isChangingWindow = false; bool _isPanelPrepared = false; + bool _panelTooltipsEnabled = false; bool _hasSyncedPreferredLanguage = false; bool _hasSyncedAlwaysOnTop = false; String? _lastSyncedLanguageCode; @@ -275,6 +277,20 @@ class _FloatickShellState extends State<_FloatickShell> 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, @@ -297,7 +313,10 @@ class _FloatickShellState extends State<_FloatickShell> final reduceMotion = MediaQuery.maybeOf(context)?.disableAnimations ?? false; final previousExpanded = _isExpanded; - setState(() => _isChangingWindow = true); + setState(() { + _isChangingWindow = true; + _panelTooltipsEnabled = false; + }); try { if (expanded) { @@ -405,21 +424,43 @@ class _FloatickShellState extends State<_FloatickShell> alignment: expansionAlignment, child: RepaintBoundary( key: const ValueKey('todo-panel'), - 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)), + 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)), + ), + ), + ), ), ), ), diff --git a/lib/app/theme/floatick_theme.dart b/lib/app/theme/floatick_theme.dart index f3c8565..060ac74 100644 --- a/lib/app/theme/floatick_theme.dart +++ b/lib/app/theme/floatick_theme.dart @@ -10,6 +10,8 @@ abstract final class FloatickColors { static const darkSurfaceElevated = Color(0xFF222D31); } +const _iconButtonStateDuration = Duration(milliseconds: 120); + ThemeData buildFloatickTheme(Brightness brightness) { final isDark = brightness == Brightness.dark; final colorScheme = @@ -33,6 +35,26 @@ ThemeData buildFloatickTheme(Brightness brightness) { canvasColor: Colors.transparent, splashFactory: NoSplash.splashFactory, visualDensity: VisualDensity.standard, + iconButtonTheme: IconButtonThemeData( + style: ButtonStyle( + animationDuration: _iconButtonStateDuration, + 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); + }), + ), + ), 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 65646e7..72fb396 100644 --- a/lib/core/platform/window_bridge.dart +++ b/lib/core/platform/window_bridge.dart @@ -28,7 +28,7 @@ abstract interface class WindowBridge { Future setAlwaysOnTop(bool alwaysOnTop); - Future configureTransparentSecondaryWindow(int viewId); + Future configureBorderlessSecondaryWindow(int viewId); } class MethodChannelWindowBridge implements WindowBridge { @@ -68,9 +68,9 @@ class MethodChannelWindowBridge implements WindowBridge { } @override - Future configureTransparentSecondaryWindow(int viewId) { + Future configureBorderlessSecondaryWindow(int viewId) { return _channel.invokeMethod( - 'configureTransparentSecondaryWindow', + 'configureBorderlessSecondaryWindow', viewId, ); } 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 dfd4757..159d6ef 100644 --- a/lib/features/sticky_boards/presentation/pinned_sticky_board_window.dart +++ b/lib/features/sticky_boards/presentation/pinned_sticky_board_window.dart @@ -10,6 +10,7 @@ import '../../todos/presentation/widgets/todo_list_row.dart'; import '../domain/sticky_board.dart'; import 'sticky_board_view_model.dart'; import 'sticky_board_window_coordinator.dart'; +import 'widgets/sticky_board_todo_details.dart'; class PinnedStickyBoardWindow extends StatefulWidget { const PinnedStickyBoardWindow({ @@ -35,6 +36,7 @@ class PinnedStickyBoardWindow extends StatefulWidget { class _PinnedStickyBoardWindowState extends State with WindowListener { bool _isClosing = false; + String? _detailsTodoId; @override void initState() { @@ -62,9 +64,20 @@ class _PinnedStickyBoardWindowState extends State } 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 @@ -117,6 +130,14 @@ class _PinnedStickyBoardWindowState extends State ); } + void _showTodoDetails(String todoId) { + setState(() => _detailsTodoId = todoId); + } + + void _closeTodoDetails() { + setState(() => _detailsTodoId = null); + } + @override Widget build(BuildContext context) { final board = widget.boardController.boardById(widget.boardId); @@ -130,36 +151,62 @@ class _PinnedStickyBoardWindowState extends State } final isDark = Theme.of(context).brightness == Brightness.dark; + final detailsItem = _detailsTodoId == null + ? null + : widget.todoController.itemById(_detailsTodoId!); + return Material( type: MaterialType.transparency, - child: Padding( - padding: const EdgeInsets.all(8), - child: DecoratedBox( - decoration: BoxDecoration( - color: isDark ? const Color(0xF7182226) : const Color(0xFAFAFCFB), - borderRadius: BorderRadius.circular(22), - border: Border.all( - color: isDark - ? Colors.white.withValues(alpha: 0.12) - : Colors.white.withValues(alpha: 0.90), - ), + child: DecoratedBox( + decoration: BoxDecoration( + color: isDark ? const Color(0xFF182226) : const Color(0xFFFAFCFB), + borderRadius: BorderRadius.circular(22), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.12) + : Colors.white.withValues(alpha: 0.90), ), - child: ClipRRect( - borderRadius: BorderRadius.circular(21), - child: 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(onOpenMain: _openMain), - ], + ), + 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, + onEdit: () => _openMain( + destination: + StickyBoardMainWindowDestination.todoEdit, + todoId: detailsItem.id, + ), + ), + ), + ), + ], ), ), ); @@ -192,10 +239,7 @@ class _PinnedStickyBoardWindowState extends State archivedScope: false, onToggle: () => unawaited(widget.todoController.toggleCompletion(item.id)), - onOpenDetails: () => _openMain( - destination: StickyBoardMainWindowDestination.todoDetails, - todoId: item.id, - ), + onOpenDetails: () => _showTodoDetails(item.id), onEdit: () => _openMain( destination: StickyBoardMainWindowDestination.todoEdit, todoId: item.id, @@ -264,22 +308,3 @@ class _PinnedHeader extends StatelessWidget { ); } } - -class _PinnedFooter extends StatelessWidget { - const _PinnedFooter({required this.onOpenMain}); - - final VoidCallback onOpenMain; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB(12, 8, 8, 11), - child: OutlinedButton.icon( - key: const Key('pinned-sticky-board-open-main'), - onPressed: onOpenMain, - icon: const Icon(Icons.open_in_new_rounded, size: 16), - label: Text(context.l10n.openMainListAction), - ), - ); - } -} 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 934de8f..9fa615b 100644 --- a/lib/features/sticky_boards/presentation/sticky_board_window_coordinator.dart +++ b/lib/features/sticky_boards/presentation/sticky_board_window_coordinator.dart @@ -192,7 +192,7 @@ class StickyBoardWindowCoordinator { _windowIdsByBoardId[boardId] = viewId; final window = MultiViewDesktop.fromId(viewId); await window.setHasShadow(false); - await windowBridge.configureTransparentSecondaryWindow(viewId); + await windowBridge.configureBorderlessSecondaryWindow(viewId); await window.setVisibleOnAllWorkspaces(true, visibleOnFullScreen: true); if (frame != null) { await window.setPosition(Offset(frame.left, frame.top)); 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..a057c4d --- /dev/null +++ b/lib/features/sticky_boards/presentation/widgets/sticky_board_todo_details.dart @@ -0,0 +1,139 @@ +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, + required this.onEdit, + super.key, + }); + + final TodoItem item; + final List tags; + final VoidCallback onBack; + final VoidCallback onEdit; + + @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, + ), + ), + ), + IconButton( + key: const Key('sticky-board-details-edit'), + tooltip: context.l10n.editTooltip, + onPressed: onEdit, + icon: const Icon(Icons.edit_outlined, size: 18), + ), + ], + ), + ), + Divider(height: 1, color: onSurface.withValues(alpha: 0.08)), + Padding( + padding: const EdgeInsets.fromLTRB(18, 16, 18, 2), + child: Text( + item.title, + key: const Key('sticky-board-details-title'), + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + if (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/presentation/tag_filter_drawer.dart b/lib/features/todos/presentation/tag_filter_drawer.dart index c60b1de..b2cea8f 100644 --- a/lib/features/todos/presentation/tag_filter_drawer.dart +++ b/lib/features/todos/presentation/tag_filter_drawer.dart @@ -5,21 +5,22 @@ import '../domain/todo_tag.dart'; import 'todo_view_model.dart'; import 'widgets/tag_palette.dart'; +const double _tagFilterRowExtent = 44; + 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 +32,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 +74,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 +135,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: _tagFilterRowExtent, + child: _TagFilterRow( + 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: _tagFilterRowExtent, + itemCount: tags.length + (isAssignment ? 0 : 1), + itemBuilder: (context, index) { + if (!isAssignment && index == 0) { + return _TagFilterRow( + key: const Key('tag-filter-all'), + label: context.l10n.allTagsFilterLabel, + selected: effectiveSelectedTagIds.isEmpty, + onPressed: onClear!, + ); + } + final tag = tags[index - (isAssignment ? 0 : 1)]; + return _TagFilterRow( + 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), + ); + }, + ), ), ], ); @@ -215,17 +229,9 @@ class _TagFilterRow extends StatelessWidget { 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: Container( + constraints: const BoxConstraints(minHeight: _tagFilterRowExtent), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), child: Row( children: [ SizedBox( @@ -256,7 +262,7 @@ class _TagFilterRow extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + fontWeight: FontWeight.w500, ), ), ), diff --git a/lib/features/todos/presentation/tag_management_drawer.dart b/lib/features/todos/presentation/tag_management_drawer.dart index 3f9158c..ba73e84 100644 --- a/lib/features/todos/presentation/tag_management_drawer.dart +++ b/lib/features/todos/presentation/tag_management_drawer.dart @@ -9,6 +9,8 @@ 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 +212,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 +385,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)), + ), ); }, ), diff --git a/lib/features/todos/presentation/todo_editor_drawer.dart b/lib/features/todos/presentation/todo_editor_drawer.dart index b34e49e..2a2c626 100644 --- a/lib/features/todos/presentation/todo_editor_drawer.dart +++ b/lib/features/todos/presentation/todo_editor_drawer.dart @@ -26,6 +26,7 @@ class TodoEditorDrawer extends StatefulWidget { required this.onSave, required this.onSaved, required this.closeFocusNode, + this.canEdit = true, super.key, }); @@ -35,6 +36,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; @@ -207,6 +209,7 @@ class _TodoEditorDrawerState extends State { children: [ _DrawerHeader( mode: widget.mode, + canEdit: widget.canEdit, onEdit: widget.onEdit, onClose: widget.onClose, closeFocusNode: widget.closeFocusNode, @@ -231,6 +234,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 +279,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,7 +310,7 @@ class _DrawerHeader extends StatelessWidget { ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), ), ), - if (mode == TodoEditorDrawerMode.details) + if (mode == TodoEditorDrawerMode.details && canEdit) TextButton( key: const Key('todo-details-edit'), onPressed: onEdit, @@ -690,10 +696,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 +743,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 +756,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 +783,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 259bd51..a33241b 100644 --- a/lib/features/todos/presentation/todo_panel.dart +++ b/lib/features/todos/presentation/todo_panel.dart @@ -18,7 +18,6 @@ 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'; @@ -93,7 +92,7 @@ class _TodoPanelState extends State { TodoListScope _scope = TodoListScope.active; String _query = ''; - String? _selectedTagId; + final Set _selectedTagIds = {}; String? _selectedTodoId; _TodoPanelDrawerMode _drawerMode = _TodoPanelDrawerMode.none; _TodoPanelDrawerMode? _pendingDrawerMode; @@ -307,6 +306,17 @@ class _TodoPanelState extends State { ); } + Future _deleteArchivedTodoPermanently(String todoId) async { + final deleted = await widget.controller.deletePermanently(todoId); + if (!deleted) { + return; + } + await widget.stickyBoardController.removeTodoFromAllBoards(todoId); + if (mounted && _selectedTodoId == todoId) { + _closeActiveDrawer(); + } + } + void _openTagAssignmentFromTodo() { if (_drawerMode != _TodoPanelDrawerMode.createTodo && _drawerMode != _TodoPanelDrawerMode.editTodo) { @@ -464,15 +474,19 @@ class _TodoPanelState extends State { }); } - void _selectTagFilter(String? tagId) { - _unfocusDrawerControls(); - _drawerRequestSerial += 1; + void _toggleTagFilter(String tagId) { setState(() { - _pendingDrawerMode = null; - _selectedTagId = tagId; - _drawerMode = _TodoPanelDrawerMode.none; + if (!_selectedTagIds.add(tagId)) { + _selectedTagIds.remove(tagId); + } }); - _restorePanelFocus(); + } + + void _clearTagFilters() { + if (_selectedTagIds.isEmpty) { + return; + } + setState(_selectedTagIds.clear); } void _closeActiveDrawer() { @@ -716,10 +730,14 @@ 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( @@ -800,7 +818,9 @@ class _TodoPanelState extends State { ), const SizedBox(width: 9), TagFilterButton( - selectedTag: selectedTag, + selectedCount: + effectiveSelectedTagIds + .length, onPressed: _openTagFilter, ), ], @@ -830,23 +850,6 @@ class _TodoPanelState extends State { ), ), ], - 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, - ); - }, - ), - ), - ], ], ), ), @@ -870,10 +873,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, ), ), ], @@ -1069,6 +1075,8 @@ class _TodoPanelState extends State { originalTodoTagIds, assignedTagIds: todoEditorTagIds, isOpen: isTodoDrawerOpen, + canEdit: + selectedTodo?.isArchived != true, onClose: _closeActiveDrawer, onEdit: () { final todoId = selectedTodo?.id; @@ -1234,9 +1242,10 @@ class _TodoPanelState extends State { 'tag-filter-drawer-content', ), controller: widget.controller, - selectedTagId: _selectedTagId, + selectedTagIds: _selectedTagIds, borderOnLeft: !tagDrawerOnLeft, - onSelected: _selectTagFilter, + onToggled: _toggleTagFilter, + onClear: _clearTagFilters, onManageTags: _openTagManagement, onClose: _closeActiveDrawer, closeFocusNode: @@ -1494,19 +1503,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) { @@ -1523,7 +1536,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, ); } @@ -1541,16 +1555,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, ), }; }, @@ -1562,7 +1585,7 @@ class _TodoList extends StatelessWidget { final items = controller.itemsForView( archived: archived, query: query, - selectedTagId: selectedTagId, + selectedTagIds: selectedTagIds, ); DateTime relevantDate(TodoItem item) { @@ -1636,10 +1659,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) { @@ -1698,6 +1726,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..f901e78 100644 --- a/lib/features/todos/presentation/todo_view_model.dart +++ b/lib/features/todos/presentation/todo_view_model.dart @@ -48,6 +48,7 @@ class TodoViewModel extends ChangeNotifier { List _items = []; TagWorkspace _tagWorkspace = TagWorkspace.empty(); + Map _tagUsageCounts = const {}; StorageFailure? _error; bool _isLoading = false; Future _mutationQueue = Future.value(); @@ -94,30 +95,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(); @@ -148,7 +150,7 @@ class TodoViewModel extends ChangeNotifier { } try { - _tagWorkspace = await _tagRepository.load(); + _setTagWorkspace(await _tagRepository.load()); } on StorageFailure catch (error) { loadError ??= error; } finally { @@ -208,6 +210,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 +228,9 @@ class TodoViewModel extends ChangeNotifier { return false; } final existingItem = _items[existingIndex]; + if (existingItem.isArchived) { + return false; + } if (existingItem.title == normalizedTitle) { return true; } @@ -247,6 +255,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 +266,6 @@ class TodoViewModel extends ChangeNotifier { return false; } - final existingItem = _items[existingIndex]; final todoChanged = existingItem.title != normalizedTitle || existingItem.content != content; @@ -290,6 +301,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 +435,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 +460,7 @@ class TodoViewModel extends ChangeNotifier { .map((tag) => tag.id); } - await _saveTagWorkspace( + return _saveTagWorkspace( TagWorkspace(tags: _tagWorkspace.tags, assignments: updatedAssignments), ); }); @@ -550,7 +583,7 @@ class TodoViewModel extends ChangeNotifier { await _tagRepository.save(updatedWorkspace); } _items = updatedItems; - _tagWorkspace = updatedWorkspace; + _setTagWorkspace(updatedWorkspace); _error = null; notifyListeners(); return true; @@ -574,7 +607,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 +629,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/tag_menus.dart b/lib/features/todos/presentation/widgets/tag_menus.dart index 4576173..5b87eb1 100644 --- a/lib/features/todos/presentation/widgets/tag_menus.dart +++ b/lib/features/todos/presentation/widgets/tag_menus.dart @@ -7,17 +7,16 @@ import '../../domain/todo_tag.dart'; import 'floatick_tag_chip.dart'; import 'tag_palette.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 +28,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 +51,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 +92,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 +143,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 +151,33 @@ class TagAssignmentMenu extends StatefulWidget { } class _TagAssignmentMenuState extends State { - final MenuController _menuController = MenuController(); + Future _openBottomSheet() async { + final theme = Theme.of(context); + final shouldManageTags = await showModalBottomSheet( + context: context, + useSafeArea: true, + isScrollControlled: true, + isDismissible: true, + enableDrag: true, + showDragHandle: false, + backgroundColor: Colors.transparent, + barrierColor: Colors.black.withValues( + alpha: theme.brightness == Brightness.dark ? 0.38 : 0.22, + ), + constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width), + 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,137 +185,225 @@ 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( + 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, + ), + ), + ), + ], + ); + } +} + +class _TagAssignmentBottomSheet extends StatefulWidget { + const _TagAssignmentBottomSheet({ + required this.todoId, + required this.tags, + required this.assignedTagIds, + required this.onToggle, + }); + + 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; + } + setState(() => _pendingTagIds.add(tagId)); + final saved = await widget.onToggle(tagId); + if (!mounted) { + return; + } + setState(() { + _pendingTagIds.remove(tagId); + if (saved && !_selectedTagIds.add(tagId)) { + _selectedTagIds.remove(tagId); + } + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + 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(); + return SizedBox( + key: const Key('tag-assignment-bottom-sheet'), + height: sheetHeight, + child: DecoratedBox( + decoration: BoxDecoration( + color: isDark ? const Color(0xFF202A2E) : const Color(0xFFF9FBFA), + borderRadius: const BorderRadius.vertical(top: Radius.circular(22)), + border: Border( + top: BorderSide( + color: isDark + ? Colors.white.withValues(alpha: 0.11) + : Colors.black.withValues(alpha: 0.07), + ), + ), + ), + child: ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(22)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + 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), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(18, 4, 8, 8), + child: 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), + 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( - tooltip: context.l10n.manageTagsTooltip, - onPressed: () { - _menuController.close(); - WidgetsBinding.instance.addPostFrameCallback((_) { - widget.onManageTags(); - }); - }, - icon: const Icon(Icons.settings_outlined, size: 17), + 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), ), ], ), - 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}', + ), + Divider( + height: 1, + thickness: 1, + color: isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.06), + ), + Expanded( + child: widget.tags.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 28), + child: Text( + context.l10n.noTagsYetMessage, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withValues( + alpha: 0.48, ), - label: tag.name, - color: TagPalette.color(tag.colorValue), - selected: assignedIds.contains(tag.id), - onPressed: () => - unawaited(widget.onToggle(tag.id)), + height: 1.4, + ), + ), + ), + ) + : ListView.builder( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 18), + itemCount: widget.tags.length, + itemBuilder: (context, index) { + final tag = widget.tags[index]; + return _TagBottomSheetRow( + key: ValueKey( + 'assign-${widget.todoId}-${tag.id}', ), - ], + label: tag.name, + color: TagPalette.color(tag.colorValue), + selected: _selectedTagIds.contains(tag.id), + pending: _pendingTagIds.contains(tag.id), + onPressed: () => unawaited(_toggleTag(tag.id)), + ); + }, ), - ), - ), - ], - ), + ), + ], ), ), - ], - 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({ +class _TagBottomSheetRow extends StatelessWidget { + const _TagBottomSheetRow({ required this.label, + required this.color, required this.selected, + required this.pending, required this.onPressed, - this.color, super.key, }); final String label; + final Color color; final bool selected; + final bool pending; final VoidCallback onPressed; - final Color? color; @override Widget build(BuildContext context) { @@ -271,52 +412,64 @@ class _TagMenuRow extends StatelessWidget { button: true, selected: selected, child: MouseRegion( - cursor: SystemMouseCursors.click, + cursor: pending ? SystemMouseCursors.basic : SystemMouseCursors.click, child: InkWell( - onTap: onPressed, - borderRadius: BorderRadius.circular(8), + onTap: pending ? null : onPressed, + borderRadius: BorderRadius.circular(10), hoverColor: theme.colorScheme.primary.withValues(alpha: 0.07), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 7), + child: AnimatedContainer( + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 160), + constraints: const BoxConstraints(minHeight: 44), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), + decoration: BoxDecoration( + color: selected + ? theme.colorScheme.primary.withValues(alpha: 0.09) + : Colors.transparent, + borderRadius: BorderRadius.circular(10), + ), 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, - ), - ), - ), + SizedBox.square( + dimension: 18, + child: Center( + child: Container( + width: 9, + height: 9, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + ), + ), ), - const SizedBox(width: 7), + const SizedBox(width: 10), Expanded( child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall?.copyWith( + style: theme.textTheme.bodyMedium?.copyWith( fontWeight: selected ? FontWeight.w600 : FontWeight.w500, ), ), ), - const SizedBox(width: 7), - Icon( - selected ? Icons.check_rounded : null, - size: 16, - color: theme.colorScheme.primary, + const SizedBox(width: 10), + SizedBox.square( + dimension: 18, + child: pending + ? const Padding( + padding: EdgeInsets.all(2), + child: CircularProgressIndicator(strokeWidth: 1.6), + ) + : selected + ? Icon( + Icons.check_rounded, + size: 18, + color: theme.colorScheme.primary, + ) + : null, ), ], ), @@ -326,28 +479,3 @@ class _TagMenuRow extends StatelessWidget { ); } } - -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/todo_list_row.dart b/lib/features/todos/presentation/widgets/todo_list_row.dart index 9f3d9b5..6a4a35c 100644 --- a/lib/features/todos/presentation/widgets/todo_list_row.dart +++ b/lib/features/todos/presentation/widgets/todo_list_row.dart @@ -21,27 +21,34 @@ class TodoListRow extends StatefulWidget { this.onOpenTagManagement, this.onOpenTagAssignment, this.onRemoveFromStickyBoard, + this.onDeletePermanently, this.showArchiveAction = true, this.compact = false, super.key, }) : assert( - onOpenTagAssignment != null || + 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 Future Function(String tagId)? onToggleTag; final VoidCallback? onOpenTagManagement; final VoidCallback? onOpenTagAssignment; final VoidCallback? onRemoveFromStickyBoard; + final VoidCallback? onDeletePermanently; final bool showArchiveAction; final bool compact; @@ -54,6 +61,7 @@ class _TodoListRowState extends State { bool _isHovered = false; bool _hasFocus = false; + bool _isConfirmingDelete = false; @override void dispose() { @@ -61,6 +69,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; @@ -68,11 +90,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 = - 2 + - (widget.showArchiveAction ? 1 : 0) + - (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, @@ -109,168 +132,248 @@ 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: MouseRegion( + cursor: SystemMouseCursors.click, + 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: 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!, - ), + 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}'), + 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: 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}'), + key: ValueKey('todo-time-${widget.item.id}'), + style: TextStyle( + color: onSurface.withValues(alpha: 0.35), + fontSize: 10.5, ), - if (widget.showArchiveAction) - _ActionButton( - tooltip: widget.archivedScope - ? localizations.restoreTooltip - : localizations.archiveTooltip, - onPressed: widget.archivedScope - ? widget.onRestore - : widget.onArchive, - icon: widget.archivedScope - ? Icons.unarchive_outlined - : Icons.archive_outlined, - ), - if (widget.onRemoveFromStickyBoard != null) - _HoverAction( - visible: showContextActions, - tooltip: localizations.removeFromStickyBoardTooltip, - onPressed: widget.onRemoveFromStickyBoard!, - icon: Icons.remove_circle_outline_rounded, - ), - ], - ), + ), + ], ), ], ), @@ -333,12 +436,45 @@ class _ExternalTagAssignment extends StatelessWidget { } } +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, }); @@ -346,6 +482,7 @@ class _HoverAction extends StatelessWidget { final String tooltip; final VoidCallback onPressed; final IconData icon; + final Color? color; @override Widget build(BuildContext context) { @@ -363,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 0e5a0a7..41d170c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -181,6 +181,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 be7ae8b..f47cdb9 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -890,6 +890,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 b2f81b7..df607f0 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -448,6 +448,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 7724144..52553c2 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -423,6 +423,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 0043f8c..da6dbe0 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -132,6 +132,10 @@ "cancelEditTooltip": "取消编辑", "restoreTooltip": "恢复到待办", "archiveTooltip": "归档", + "deleteTodoPermanentlyTooltip": "永久删除", + "cancelDeleteTodoTooltip": "保留归档待办", + "confirmDeleteTodoTooltip": "永久删除这个待办", + "archivedTodoNoContentMessage": "没有保存更多说明。", "noSearchResultsTitle": "没有匹配的结果", "emptyArchiveTitle": "归档还是空的", "emptyTodosTitle": "没有待办,享受此刻", diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index 8b2b241..017c51c 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -31,11 +31,24 @@ final class MainFlutterWindow: NSWindow { private weak var flutterContentView: NSView? private var windowChannel: FlutterMethodChannel? private var updateService: UpdateService? - private var alwaysOnTop = true + private var appliedAlwaysOnTop: Bool? override var canBecomeKey: Bool { true } override var canBecomeMain: Bool { true } + 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", @@ -160,20 +173,20 @@ final class MainFlutterWindow: NSWindow { } self.setAlwaysOnTop(alwaysOnTop) result(nil) - case "configureTransparentSecondaryWindow": + case "configureBorderlessSecondaryWindow": guard let viewIdentifier = (call.arguments as? NSNumber)?.int64Value else { result( FlutterError( code: "invalid_argument", - message: "configureTransparentSecondaryWindow expects a view ID.", + message: "configureBorderlessSecondaryWindow expects a view ID.", details: nil ) ) return } - guard self.configureTransparentSecondaryWindow( + guard self.configureBorderlessSecondaryWindow( viewIdentifier: viewIdentifier ) else { result( @@ -193,7 +206,7 @@ final class MainFlutterWindow: NSWindow { windowChannel = channel } - private func configureTransparentSecondaryWindow( + private func configureBorderlessSecondaryWindow( viewIdentifier: Int64 ) -> Bool { guard @@ -214,20 +227,38 @@ final class MainFlutterWindow: NSWindow { } flutterViewController.backgroundColor = .clear + let existingFrame = targetWindow.frame + targetWindow.styleMask = [.borderless, .resizable] + targetWindow.setFrame(existingFrame, display: true) targetWindow.backgroundColor = .clear targetWindow.isOpaque = false targetWindow.hasShadow = false + targetWindow.preservesContentDuringLiveResize = true targetWindow.contentView?.wantsLayer = true targetWindow.contentView?.layer?.backgroundColor = NSColor.clear.cgColor + targetWindow.contentView?.layerContentsRedrawPolicy = .onSetNeedsDisplay + targetWindow.contentView?.layerContentsPlacement = .scaleAxesIndependently + if isExpanded { + DispatchQueue.main.async { [weak self] in + guard let self, self.isExpanded else { + return + } + self.activateAndFocusFlutterContent() + } + } return true } private func setAlwaysOnTop(_ alwaysOnTop: Bool) { - guard self.alwaysOnTop != alwaysOnTop else { + let targetLevel: NSWindow.Level = alwaysOnTop ? .statusBar : .normal + guard + appliedAlwaysOnTop != alwaysOnTop || + level != targetLevel + else { return } - self.alwaysOnTop = alwaysOnTop - level = alwaysOnTop ? .statusBar : .normal + appliedAlwaysOnTop = alwaysOnTop + level = targetLevel if alwaysOnTop { orderFrontRegardless() } diff --git a/pubspec.yaml b/pubspec.yaml index ae3762c..bcc9b65 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+5 +version: 0.2.0+6 environment: sdk: ^3.12.2 diff --git a/test/app/floatick_app_test.dart b/test/app/floatick_app_test.dart index 8c5852d..e5143fd 100644 --- a/test/app/floatick_app_test.dart +++ b/test/app/floatick_app_test.dart @@ -89,8 +89,45 @@ void main() { expect(floatingMark.shape, FloatickBrandMarkShape.circle); expect(floatingMark.shadows, isEmpty); + await tester.pump(); + 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( + 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); @@ -374,6 +411,14 @@ void main() { expect(windowBridge.expandedValues, [true, false]); expect(find.byKey(const ValueKey('floating-todo-icon')), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('panel-tooltip-visibility')), + ) + .visible, + isFalse, + ); }); testWidgets('tags can be created, assigned, and used as a filter', ( @@ -476,6 +521,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(); @@ -631,6 +682,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(); @@ -642,13 +698,61 @@ 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); @@ -664,6 +768,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(); @@ -1147,5 +1274,5 @@ class _WidgetTestWindowBridge implements WindowBridge { } @override - Future configureTransparentSecondaryWindow(int viewId) async {} + Future configureBorderlessSecondaryWindow(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..f27dc2a --- /dev/null +++ b/test/app/theme/floatick_theme_test.dart @@ -0,0 +1,40 @@ +import 'package:floatick/app/theme/floatick_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + 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, + ); + }, + ); + } +} diff --git a/test/core/platform/window_bridge_test.dart b/test/core/platform/window_bridge_test.dart index 82dcf53..bb6e2e5 100644 --- a/test/core/platform/window_bridge_test.dart +++ b/test/core/platform/window_bridge_test.dart @@ -12,7 +12,7 @@ void main() { .setMockMethodCallHandler(channel, null); }); - test('configures the requested secondary window for transparency', () async { + test('configures the requested secondary window as borderless', () async { final calls = []; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (call) async { @@ -21,10 +21,10 @@ void main() { }); final bridge = MethodChannelWindowBridge(); - await bridge.configureTransparentSecondaryWindow(42); + await bridge.configureBorderlessSecondaryWindow(42); expect(calls, hasLength(1)); - expect(calls.single.method, 'configureTransparentSecondaryWindow'); + expect(calls.single.method, 'configureBorderlessSecondaryWindow'); expect(calls.single.arguments, 42); }); } 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 index ea07480..d0b0346 100644 --- a/test/features/sticky_boards/presentation/sticky_board_window_coordinator_test.dart +++ b/test/features/sticky_boards/presentation/sticky_board_window_coordinator_test.dart @@ -46,7 +46,7 @@ void main() { class _MemoryWindowBridge implements WindowBridge { @override - Future configureTransparentSecondaryWindow(int viewId) async {} + Future configureBorderlessSecondaryWindow(int viewId) async {} @override Future preferredExpansionAnchor() async { 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..3df5399 --- /dev/null +++ b/test/features/sticky_boards/presentation/widgets/sticky_board_todo_details_test.dart @@ -0,0 +1,58 @@ +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 and exposes edit separately', ( + tester, + ) async { + var backCount = 0; + var editCount = 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, + onEdit: () => editCount += 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); + + await tester.tap(find.byKey(const Key('sticky-board-details-back'))); + await tester.tap(find.byKey(const Key('sticky-board-details-edit'))); + + expect(backCount, 1); + expect(editCount, 1); + }); +} diff --git a/test/features/todos/presentation/todo_editor_drawer_test.dart b/test/features/todos/presentation/todo_editor_drawer_test.dart index b6dcad1..5cc51d4 100644 --- a/test/features/todos/presentation/todo_editor_drawer_test.dart +++ b/test/features/todos/presentation/todo_editor_drawer_test.dart @@ -173,6 +173,49 @@ void main() { 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 index dfe8f56..6236077 100644 --- a/test/features/todos/presentation/todo_list_row_test.dart +++ b/test/features/todos/presentation/todo_list_row_test.dart @@ -2,10 +2,98 @@ 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 { @@ -55,4 +143,163 @@ void main() { 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, + 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, + ); + + await tester.tap(find.byKey(const Key('tag-assignment-manage'))); + await tester.pumpAndSettle(); + expect(sheet, findsNothing); + expect(manageCount, 1); + }, + ); + + 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..2f69df5 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,131 @@ 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 save keeps visible state unchanged and queue usable', () async { @@ -318,19 +443,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 +484,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 +524,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: [ From 23a7bae34ed8319f8746434defacfbecaad508db Mon Sep 17 00:00:00 2001 From: lucaslushuo Date: Mon, 27 Jul 2026 20:03:55 +0800 Subject: [PATCH 07/12] fix(app): stabilize windows and unify interactions Keep the floating icon and fixed main window in sync across startup and expansion. Make pinned sticky boards reliable and position new boards beside the main panel. Unify bottom-sheet, tag, todo, archive, search, and hover interactions with regression coverage. --- docs/DEVELOPMENT_WORKFLOW.md | 24 +- docs/RELEASING.md | 29 +- lib/app/floatick_app.dart | 269 ++++---- lib/app/theme/floatick_theme.dart | 2 + lib/core/platform/window_bridge.dart | 38 +- lib/core/ui/floatick_modal_bottom_sheet.dart | 88 +++ lib/core/ui/floatick_surface_metrics.dart | 7 + .../pinned_sticky_board_window.dart | 76 ++- .../presentation/sticky_board_drawers.dart | 117 ++-- .../sticky_board_frame_save_scheduler.dart | 25 + .../sticky_board_window_coordinator.dart | 187 ++++-- .../todos/presentation/tag_filter_drawer.dart | 112 +--- .../presentation/todo_editor_drawer.dart | 20 +- .../todos/presentation/todo_panel.dart | 144 ++++- .../todos/presentation/todo_view_model.dart | 19 +- .../todos/presentation/widgets/tag_menus.dart | 216 +++---- .../widgets/tag_selection_row.dart | 106 ++++ lib/l10n/app_en.arb | 3 +- lib/l10n/app_localizations.dart | 18 +- lib/l10n/app_localizations_en.dart | 9 +- lib/l10n/app_localizations_zh.dart | 9 +- lib/l10n/app_zh.arb | 3 +- macos/Runner.xcodeproj/project.pbxproj | 6 +- macos/Runner/Base.lproj/MainMenu.xib | 2 +- macos/Runner/MainFlutterWindow.swift | 573 +++++++++++++++++- test/app/floatick_app_test.dart | 246 +++++++- test/core/platform/window_bridge_test.dart | 36 +- ...ticky_board_frame_save_scheduler_test.dart | 35 ++ .../sticky_board_window_coordinator_test.dart | 139 ++++- .../presentation/todo_editor_drawer_test.dart | 14 + .../presentation/todo_list_row_test.dart | 136 +++++ .../presentation/todo_view_model_test.dart | 72 ++- 32 files changed, 2095 insertions(+), 685 deletions(-) create mode 100644 lib/core/ui/floatick_modal_bottom_sheet.dart create mode 100644 lib/core/ui/floatick_surface_metrics.dart create mode 100644 lib/features/sticky_boards/presentation/sticky_board_frame_save_scheduler.dart create mode 100644 lib/features/todos/presentation/widgets/tag_selection_row.dart create mode 100644 test/features/sticky_boards/presentation/sticky_board_frame_save_scheduler_test.dart diff --git a/docs/DEVELOPMENT_WORKFLOW.md b/docs/DEVELOPMENT_WORKFLOW.md index 68e7fe5..f811333 100644 --- a/docs/DEVELOPMENT_WORKFLOW.md +++ b/docs/DEVELOPMENT_WORKFLOW.md @@ -93,25 +93,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 +145,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 +187,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 +198,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 ef7cb74..0948231 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -38,25 +38,26 @@ 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; @@ -65,7 +66,7 @@ Every push to `release/0.1.0` runs the Release Candidate workflow. It: 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/v0.1.0`. + `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, @@ -104,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. @@ -112,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 @@ -145,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 @@ -155,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 diff --git a/lib/app/floatick_app.dart b/lib/app/floatick_app.dart index 108ca9d..fbe2b29 100644 --- a/lib/app/floatick_app.dart +++ b/lib/app/floatick_app.dart @@ -4,13 +4,13 @@ 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,51 +94,39 @@ class _FloatickShell extends StatefulWidget { State<_FloatickShell> createState() => _FloatickShellState(); } -class _FloatickShellState extends State<_FloatickShell> - with SingleTickerProviderStateMixin { +class _FloatickShellState extends State<_FloatickShell> { static const _expandedPanelSize = Size(440, 700); - static const _expandDuration = Duration(milliseconds: 180); - static const _collapseDuration = Duration(milliseconds: 150); 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; StickyBoardMainWindowRequest? _stickyBoardRequest; int _stickyBoardRequestSerial = 0; Future? _rendererWarmUpFuture; Future? _panelPreparationFuture; - late final AnimationController _panelAnimationController; - late final Animation _panelOpacity; - late final Animation _panelScale; @override void initState() { super.initState(); - _panelAnimationController = AnimationController( - vsync: this, - duration: _expandDuration, - reverseDuration: _collapseDuration, - ); - final panelCurve = CurvedAnimation( - parent: _panelAnimationController, - curve: Curves.easeOutCubic, - reverseCurve: Curves.easeInCubic, - ); - _panelOpacity = panelCurve; - _panelScale = Tween(begin: 0.97, end: 1).animate(panelCurve); 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(_preparePanelAndRestorePinnedBoards()); }); @@ -151,7 +139,14 @@ 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); @@ -169,17 +164,23 @@ 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); - _panelAnimationController.dispose(); super.dispose(); } @@ -193,9 +194,14 @@ class _FloatickShellState extends State<_FloatickShell> void _handleSettingsChanged() { unawaited(_syncPreferredLanguage()); + unawaited(_syncPreferredTheme()); unawaited(_syncAlwaysOnTop()); } + void _handleTodoStateChanged() { + unawaited(_syncFloatingIconCount()); + } + void _startRendererWarmUp() { _rendererWarmUpFuture ??= _warmUpRenderer(); } @@ -273,6 +279,43 @@ class _FloatickShellState extends State<_FloatickShell> } } + 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)); } @@ -300,8 +343,9 @@ class _FloatickShellState extends State<_FloatickShell> } if (_isExpanded == expanded) { if (expanded) { + unawaited(widget.stickyBoardWindowCoordinator.restorePinnedBoards()); try { - await widget.windowBridge.setExpanded(true); + await widget.windowBridge.setExpanded(true, animated: false); } on Object catch (error, stackTrace) { debugPrint('Floatick could not focus the native window: $error'); debugPrintStack(stackTrace: stackTrace); @@ -324,39 +368,42 @@ class _FloatickShellState extends State<_FloatickShell> if (!mounted) { return; } + unawaited(widget.stickyBoardWindowCoordinator.restorePinnedBoards()); final expansionAnchor = requestedAnchor ?? await widget.windowBridge.preferredExpansionAnchor(); if (!mounted) { return; } - setState(() => _expansionAnchor = expansionAnchor); + setState(() { + _expansionAnchor = expansionAnchor; + _isExpanded = true; + }); await WidgetsBinding.instance.endOfFrame; - await widget.windowBridge.setExpanded(true); + await widget.windowBridge.setExpanded(true, animated: !reduceMotion); + } else { + await widget.windowBridge.setExpanded(false, animated: !reduceMotion); if (!mounted) { return; } - setState(() => _isExpanded = true); - if (reduceMotion) { - _panelAnimationController.value = 1; - } else { - await _panelAnimationController.forward().orCancel; - } - } else { setState(() => _isExpanded = false); - if (reduceMotion) { - _panelAnimationController.value = 0; - } else { - await _panelAnimationController.reverse().orCancel; - } - await widget.windowBridge.setExpanded(false); } } on Object catch (error, stackTrace) { debugPrint('Floatick could not change the native window: $error'); debugPrintStack(stackTrace: stackTrace); if (mounted) { + 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); - _panelAnimationController.value = previousExpanded ? 1 : 0; } } finally { if (mounted) { @@ -367,112 +414,51 @@ class _FloatickShellState extends State<_FloatickShell> @override Widget build(BuildContext context) { - 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: LayoutBuilder( - builder: (context, constraints) { - final panelSize = Size( - constraints.maxWidth < _expandedPanelSize.width - ? _expandedPanelSize.width - : constraints.maxWidth, - constraints.maxHeight < _expandedPanelSize.height - ? _expandedPanelSize.height - : constraints.maxHeight, - ); - return Stack( - clipBehavior: Clip.none, - children: [ - Align( - alignment: expansionAlignment, - child: FadeTransition( - opacity: ReverseAnimation(_panelOpacity), - child: IgnorePointer( - ignoring: _isExpanded || _isChangingWindow, - child: FloatingTodoIcon( - key: const ValueKey('floating-todo-icon'), - activeCount: widget.controller.activeCount, - onOpen: () => unawaited(_setExpanded(true)), - ), - ), - ), - ), - if (_isPanelPrepared) - Positioned.fill( - child: OverflowBox( - alignment: expansionAlignment, - minWidth: panelSize.width, - maxWidth: panelSize.width, - minHeight: panelSize.height, - maxHeight: panelSize.height, - child: SizedBox.fromSize( - size: panelSize, - child: IgnorePointer( - ignoring: !_isExpanded, - child: TickerMode( - enabled: _isExpanded, - child: FadeTransition( - opacity: _panelOpacity, - child: ScaleTransition( - scale: _panelScale, - alignment: expansionAlignment, - 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)), - ), - ), - ), - ), - ), - ), + 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(), ), ); } @@ -486,10 +472,15 @@ class _FloatickShaderWarmUp extends ShaderWarmUp { @override Future warmUpOnCanvas(Canvas canvas) { - final panelBounds = Rect.fromLTWH(8, 8, size.width - 16, size.height - 16); + 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(26), + const Radius.circular(FloatickSurfaceMetrics.panelRadius), ); final gradientPaint = Paint() ..shader = const LinearGradient( @@ -505,8 +496,6 @@ class _FloatickShaderWarmUp extends ShaderWarmUp { canvas.drawRRect(panelShape, gradientPaint); canvas.restore(); - final shadowPath = Path()..addRRect(panelShape); - canvas.drawShadow(shadowPath, Colors.black, 6, false); canvas.drawCircle( const Offset(34, 34), 16, diff --git a/lib/app/theme/floatick_theme.dart b/lib/app/theme/floatick_theme.dart index 060ac74..d1c4ed2 100644 --- a/lib/app/theme/floatick_theme.dart +++ b/lib/app/theme/floatick_theme.dart @@ -8,6 +8,8 @@ abstract final class FloatickColors { static const mutedInk = Color(0xFF657178); static const darkSurface = Color(0xFF182125); static const darkSurfaceElevated = Color(0xFF222D31); + static const darkGlassSurface = Color(0xDE182125); + static const lightGlassSurface = Color(0xEBF9FBFA); } const _iconButtonStateDuration = Duration(milliseconds: 120); diff --git a/lib/core/platform/window_bridge.dart b/lib/core/platform/window_bridge.dart index 72fb396..51e6c12 100644 --- a/lib/core/platform/window_bridge.dart +++ b/lib/core/platform/window_bridge.dart @@ -22,13 +22,20 @@ 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); + Future configureBorderlessSecondaryWindow( + int viewId, { + bool positionAdjacentToMainWindow = false, + }); } class MethodChannelWindowBridge implements WindowBridge { @@ -53,8 +60,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 @@ -62,16 +77,27 @@ 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) { + Future configureBorderlessSecondaryWindow( + int viewId, { + bool positionAdjacentToMainWindow = false, + }) { return _channel.invokeMethod( 'configureBorderlessSecondaryWindow', - viewId, + { + 'viewId': viewId, + 'positionAdjacentToMainWindow': positionAdjacentToMainWindow, + }, ); } 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/sticky_boards/presentation/pinned_sticky_board_window.dart b/lib/features/sticky_boards/presentation/pinned_sticky_board_window.dart index 159d6ef..c496af0 100644 --- a/lib/features/sticky_boards/presentation/pinned_sticky_board_window.dart +++ b/lib/features/sticky_boards/presentation/pinned_sticky_board_window.dart @@ -3,11 +3,13 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:multiview_desktop/multiview_desktop.dart'; +import '../../../app/theme/floatick_theme.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_frame_save_scheduler.dart'; import 'sticky_board_view_model.dart'; import 'sticky_board_window_coordinator.dart'; import 'widgets/sticky_board_todo_details.dart'; @@ -35,6 +37,8 @@ class PinnedStickyBoardWindow extends StatefulWidget { class _PinnedStickyBoardWindowState extends State with WindowListener { + final StickyBoardFrameSaveScheduler _frameSaveScheduler = + StickyBoardFrameSaveScheduler(); bool _isClosing = false; String? _detailsTodoId; @@ -57,9 +61,13 @@ class _PinnedStickyBoardWindowState extends State @override void dispose() { + _frameSaveScheduler.cancel(); widget.boardController.removeListener(_handleModelChanged); widget.todoController.removeListener(_handleModelChanged); - widget.coordinator.forgetWindow(widget.boardId); + widget.coordinator.forgetWindow( + boardId: widget.boardId, + viewId: widget.viewId, + ); super.dispose(); } @@ -83,42 +91,64 @@ class _PinnedStickyBoardWindowState extends State @override void onWindowClose() { if (!_isClosing) { - unawaited(widget.coordinator.unpin(widget.boardId)); + _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 || (_isClosing && !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) { return; } + _frameSaveScheduler.cancel(); _isClosing = true; await widget.coordinator.unpin(widget.boardId); } + Future _persistBoundsAndUnpin() async { + if (_isClosing) { + return; + } + _isClosing = true; + await _persistBounds(allowClosing: true); + await widget.coordinator.unpin(widget.boardId); + } + void _openMain({ - StickyBoardMainWindowDestination destination = - StickyBoardMainWindowDestination.board, + required StickyBoardMainWindowDestination destination, String? todoId, }) { widget.coordinator.requestMainWindow( @@ -159,7 +189,9 @@ class _PinnedStickyBoardWindowState extends State type: MaterialType.transparency, child: DecoratedBox( decoration: BoxDecoration( - color: isDark ? const Color(0xFF182226) : const Color(0xFFFAFCFB), + color: isDark + ? FloatickColors.darkGlassSurface + : FloatickColors.lightGlassSurface, borderRadius: BorderRadius.circular(22), border: Border.all( color: isDark @@ -221,10 +253,18 @@ class _PinnedStickyBoardWindowState extends State .toList(growable: false); if (items.isEmpty) { return Center( - child: TextButton.icon( - onPressed: _openMain, - icon: const Icon(Icons.open_in_new_rounded, size: 17), - label: Text(context.l10n.openMainListAction), + 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), + ), + ), ), ); } diff --git a/lib/features/sticky_boards/presentation/sticky_board_drawers.dart b/lib/features/sticky_boards/presentation/sticky_board_drawers.dart index f286cab..d4df4c4 100644 --- a/lib/features/sticky_boards/presentation/sticky_board_drawers.dart +++ b/lib/features/sticky_boards/presentation/sticky_board_drawers.dart @@ -11,6 +11,8 @@ import '../domain/sticky_board.dart'; import 'sticky_board_palette.dart'; import 'sticky_board_view_model.dart'; +const BorderRadius _selectionRowRadius = BorderRadius.all(Radius.circular(11)); + class StickyBoardManagementDrawer extends StatefulWidget { const StickyBoardManagementDrawer({ required this.controller, @@ -588,16 +590,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 +665,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, + ), ), ), ); @@ -918,15 +928,24 @@ class _ManagedBoardRowState extends State<_ManagedBoardRow> { ], ), ), - if (widget.board.isPinned) - Tooltip( - message: context.l10n.stickyBoardPinnedLabel, - child: Icon( - Icons.push_pin_rounded, - size: 14, - color: theme.colorScheme.primary, - ), + IconButton( + key: ValueKey( + 'toggle-sticky-board-pin-${widget.board.id}', ), + 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, + color: widget.board.isPinned + ? theme.colorScheme.primary + : null, + ), + ), AnimatedOpacity( duration: MediaQuery.disableAnimationsOf(context) ? Duration.zero @@ -942,18 +961,6 @@ class _ManagedBoardRowState extends State<_ManagedBoardRow> { 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, - ), - ), IconButton( tooltip: context.l10n.deleteStickyBoardTooltip, onPressed: widget.onDelete, 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_window_coordinator.dart b/lib/features/sticky_boards/presentation/sticky_board_window_coordinator.dart index 9fa615b..f889d26 100644 --- a/lib/features/sticky_boards/presentation/sticky_board_window_coordinator.dart +++ b/lib/features/sticky_boards/presentation/sticky_board_window_coordinator.dart @@ -28,12 +28,14 @@ class StickyBoardMainWindowRequest { typedef StickyBoardMainWindowRequestHandler = void Function(StickyBoardMainWindowRequest request); +typedef StickyBoardWindowLauncher = Future Function(String boardId); class StickyBoardWindowCoordinator { StickyBoardWindowCoordinator({ required StickyBoardViewModel boardController, required TodoViewModel todoController, required this.windowBridge, + this.windowLauncher, }) : _boards = boardController, _todos = todoController; @@ -44,10 +46,15 @@ class StickyBoardWindowCoordinator { final StickyBoardViewModel _boards; final TodoViewModel _todos; final WindowBridge windowBridge; + final StickyBoardWindowLauncher? windowLauncher; final Map _windowIdsByBoardId = {}; + final Map> _boardWindowOperations = + >{}; + final Set _restoredPinnedBoardIds = {}; StickyBoardMainWindowRequestHandler? _mainWindowRequest; bool _didRestorePinnedBoards = false; + Future? _restorePinnedBoardsOperation; void setMainWindowRequestHandler( StickyBoardMainWindowRequestHandler? handler, @@ -59,83 +66,108 @@ class StickyBoardWindowCoordinator { _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) { + _closeRegisteredWindowWithoutWaiting(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)) { + _closeRegisteredWindowWithoutWaiting(boardId); + return; + } + _restoredPinnedBoardIds.add(boardId); } on Object catch (error, stackTrace) { - await _boards.setPinned(boardId, false); + _closeRegisteredWindowWithoutWaiting(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; } - await _boards.setPinned(boardId, false); + _closeRegisteredWindowWithoutWaiting(boardId); } 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) { + _closeRegisteredWindowWithoutWaiting(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({ @@ -155,7 +187,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(); @@ -167,6 +207,8 @@ class StickyBoardWindowCoordinator { return; } final frame = board.windowFrame; + final shouldPositionAdjacent = + positionAdjacentToMainWindow || frame == null; final viewId = await openWindow( (context, id) => PinnedStickyBoardWindow( boardId: boardId, @@ -192,10 +234,53 @@ class StickyBoardWindowCoordinator { _windowIdsByBoardId[boardId] = viewId; final window = MultiViewDesktop.fromId(viewId); await window.setHasShadow(false); - await windowBridge.configureBorderlessSecondaryWindow(viewId); + 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)); } } + + 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); + } + }); + } + + void _closeRegisteredWindowWithoutWaiting(String boardId) { + final viewId = _windowIdsByBoardId.remove(boardId); + if (viewId == null) { + return; + } + unawaited(_closeWindow(boardId: boardId, viewId: viewId)); + } + + Future _closeWindow({ + required String boardId, + required int viewId, + }) async { + 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); + } + } } diff --git a/lib/features/todos/presentation/tag_filter_drawer.dart b/lib/features/todos/presentation/tag_filter_drawer.dart index b2cea8f..c2a63f7 100644 --- a/lib/features/todos/presentation/tag_filter_drawer.dart +++ b/lib/features/todos/presentation/tag_filter_drawer.dart @@ -1,11 +1,8 @@ import 'package:flutter/material.dart'; import '../../../l10n/l10n.dart'; -import '../domain/todo_tag.dart'; import 'todo_view_model.dart'; -import 'widgets/tag_palette.dart'; - -const double _tagFilterRowExtent = 44; +import 'widgets/tag_selection_row.dart'; enum TagDrawerSelectionMode { filter, assignment } @@ -141,8 +138,8 @@ class TagFilterDrawer extends StatelessWidget { children: [ if (!isAssignment) SizedBox( - height: _tagFilterRowExtent, - child: _TagFilterRow( + height: tagSelectionRowExtent, + child: TagSelectionRow( key: const Key('tag-filter-all'), label: context.l10n.allTagsFilterLabel, selected: effectiveSelectedTagIds.isEmpty, @@ -167,11 +164,11 @@ class TagFilterDrawer extends StatelessWidget { : ListView.builder( key: const Key('tag-filter-list'), padding: const EdgeInsets.fromLTRB(10, 10, 10, 14), - itemExtent: _tagFilterRowExtent, + itemExtent: tagSelectionRowExtent, itemCount: tags.length + (isAssignment ? 0 : 1), itemBuilder: (context, index) { if (!isAssignment && index == 0) { - return _TagFilterRow( + return TagSelectionRow( key: const Key('tag-filter-all'), label: context.l10n.allTagsFilterLabel, selected: effectiveSelectedTagIds.isEmpty, @@ -179,7 +176,7 @@ class TagFilterDrawer extends StatelessWidget { ); } final tag = tags[index - (isAssignment ? 0 : 1)]; - return _TagFilterRow( + return TagSelectionRow( key: ValueKey( '${isAssignment ? 'tag-assignment' : 'tag-filter'}-${tag.id}', ), @@ -199,100 +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: Container( - constraints: const BoxConstraints(minHeight: _tagFilterRowExtent), - 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/todo_editor_drawer.dart b/lib/features/todos/presentation/todo_editor_drawer.dart index 2a2c626..0f0736a 100644 --- a/lib/features/todos/presentation/todo_editor_drawer.dart +++ b/lib/features/todos/presentation/todo_editor_drawer.dart @@ -194,13 +194,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)), @@ -311,18 +304,11 @@ class _DrawerHeader extends StatelessWidget { ), ), if (mode == TodoEditorDrawerMode.details && canEdit) - TextButton( + 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'), diff --git a/lib/features/todos/presentation/todo_panel.dart b/lib/features/todos/presentation/todo_panel.dart index a33241b..0360f59 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'; @@ -21,9 +23,6 @@ import 'todo_view_model.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; @@ -106,6 +105,7 @@ class _TodoPanelState extends State { <_TodoPanelDrawerFamily>{}; String? _selectedStickyBoardId; String? _todoCreationBoardId; + String? _pendingCreatedTodoId; int _todoEditorSession = 0; int _lastHandledStickyBoardRequestSerial = -1; int _drawerRequestSerial = 0; @@ -273,6 +273,7 @@ class _TodoPanelState extends State { void _openTodoCreate({String? stickyBoardId}) { _todoCreationBoardId = stickyBoardId; + _pendingCreatedTodoId = null; _todoDrawerReturnMode = stickyBoardId == null ? null : _TodoPanelDrawerMode.stickyBoardDetail; @@ -307,16 +308,89 @@ 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; } - await widget.stickyBoardController.removeTodoFromAllBoards(todoId); 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) { @@ -522,6 +596,7 @@ class _TodoPanelState extends State { if (_isTodoDrawerMode(closedMode)) { _todoDrawerReturnMode = null; _todoCreationBoardId = null; + _pendingCreatedTodoId = null; } }); if (returnMode != null) { @@ -706,14 +781,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.darkGlassSurface + : FloatickColors.lightGlassSurface, + borderRadius: BorderRadius.circular( + FloatickSurfaceMetrics.panelRadius, + ), border: Border.all( color: isDark ? Colors.white.withValues(alpha: 0.12) @@ -721,7 +800,9 @@ class _TodoPanelState extends State { ), ), child: ClipRRect( - borderRadius: BorderRadius.circular(_panelContentRadius), + borderRadius: BorderRadius.circular( + FloatickSurfaceMetrics.panelContentRadius, + ), child: Stack( fit: StackFit.expand, children: [ @@ -861,6 +942,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, @@ -1089,29 +1187,11 @@ class _TodoPanelState extends State { 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, - ); - }(); + return _saveCreatedTodo( + title: title, + content: content, + tagIds: tagIds, + ); } final todoId = selectedTodo?.id; if (todoId == null) { diff --git a/lib/features/todos/presentation/todo_view_model.dart b/lib/features/todos/presentation/todo_view_model.dart index f901e78..87ef230 100644 --- a/lib/features/todos/presentation/todo_view_model.dart +++ b/lib/features/todos/presentation/todo_view_model.dart @@ -591,11 +591,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; diff --git a/lib/features/todos/presentation/widgets/tag_menus.dart b/lib/features/todos/presentation/widgets/tag_menus.dart index 5b87eb1..0eb99fa 100644 --- a/lib/features/todos/presentation/widgets/tag_menus.dart +++ b/lib/features/todos/presentation/widgets/tag_menus.dart @@ -2,10 +2,12 @@ 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 _tagFilterButtonDimension = 42; @@ -152,19 +154,8 @@ class TagAssignmentMenu extends StatefulWidget { class _TagAssignmentMenuState extends State { Future _openBottomSheet() async { - final theme = Theme.of(context); - final shouldManageTags = await showModalBottomSheet( + final shouldManageTags = await showFloatickModalBottomSheet( context: context, - useSafeArea: true, - isScrollControlled: true, - isDismissible: true, - enableDrag: true, - showDragHandle: false, - backgroundColor: Colors.transparent, - barrierColor: Colors.black.withValues( - alpha: theme.brightness == Brightness.dark ? 0.38 : 0.22, - ), - constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width), builder: (context) { return _TagAssignmentBottomSheet( todoId: widget.todoId, @@ -250,15 +241,27 @@ class _TagAssignmentBottomSheetState extends State<_TagAssignmentBottomSheet> { if (_pendingTagIds.contains(tagId)) { return; } - setState(() => _pendingTagIds.add(tagId)); + 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 && !_selectedTagIds.add(tagId)) { - _selectedTagIds.remove(tagId); + if (!saved) { + if (wasSelected) { + _selectedTagIds.add(tagId); + } else { + _selectedTagIds.remove(tagId); + } } }); } @@ -267,6 +270,7 @@ class _TagAssignmentBottomSheetState extends State<_TagAssignmentBottomSheet> { Widget build(BuildContext context) { final theme = Theme.of(context); 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 @@ -276,13 +280,29 @@ class _TagAssignmentBottomSheetState extends State<_TagAssignmentBottomSheet> { 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: const BorderRadius.vertical(top: Radius.circular(22)), + borderRadius: sheetBorderRadius, border: Border( top: BorderSide( color: isDark @@ -292,7 +312,7 @@ class _TagAssignmentBottomSheetState extends State<_TagAssignmentBottomSheet> { ), ), child: ClipRRect( - borderRadius: const BorderRadius.vertical(top: Radius.circular(22)), + borderRadius: sheetBorderRadius, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -347,39 +367,50 @@ class _TagAssignmentBottomSheetState extends State<_TagAssignmentBottomSheet> { : Colors.black.withValues(alpha: 0.06), ), Expanded( - child: widget.tags.isEmpty - ? Center( - child: Padding( - padding: const EdgeInsets.fromLTRB(24, 12, 24, 28), - child: Text( - context.l10n.noTagsYetMessage, - textAlign: TextAlign.center, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurface.withValues( - alpha: 0.48, + 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, ), - 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)), + ); + }, ), - ) - : ListView.builder( - padding: const EdgeInsets.fromLTRB(12, 8, 12, 18), - itemCount: widget.tags.length, - itemBuilder: (context, index) { - final tag = widget.tags[index]; - return _TagBottomSheetRow( - key: ValueKey( - 'assign-${widget.todoId}-${tag.id}', - ), - label: tag.name, - color: TagPalette.color(tag.colorValue), - selected: _selectedTagIds.contains(tag.id), - pending: _pendingTagIds.contains(tag.id), - onPressed: () => unawaited(_toggleTag(tag.id)), - ); - }, - ), + ), ), ], ), @@ -388,94 +419,3 @@ class _TagAssignmentBottomSheetState extends State<_TagAssignmentBottomSheet> { ); } } - -class _TagBottomSheetRow extends StatelessWidget { - const _TagBottomSheetRow({ - required this.label, - required this.color, - required this.selected, - required this.pending, - required this.onPressed, - super.key, - }); - - final String label; - final Color color; - final bool selected; - final bool pending; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Semantics( - button: true, - 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: AnimatedContainer( - duration: MediaQuery.disableAnimationsOf(context) - ? Duration.zero - : const Duration(milliseconds: 160), - constraints: const BoxConstraints(minHeight: 44), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), - decoration: BoxDecoration( - color: selected - ? theme.colorScheme.primary.withValues(alpha: 0.09) - : Colors.transparent, - borderRadius: BorderRadius.circular(10), - ), - child: Row( - children: [ - SizedBox.square( - dimension: 18, - child: Center( - child: Container( - width: 9, - height: 9, - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - ), - ), - ), - ), - const SizedBox(width: 10), - Expanded( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: selected ? FontWeight.w600 : FontWeight.w500, - ), - ), - ), - const SizedBox(width: 10), - SizedBox.square( - dimension: 18, - child: pending - ? const Padding( - padding: EdgeInsets.all(2), - child: CircularProgressIndicator(strokeWidth: 1.6), - ) - : selected - ? Icon( - Icons.check_rounded, - size: 18, - color: theme.colorScheme.primary, - ) - : null, - ), - ], - ), - ), - ), - ), - ); - } -} 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/l10n/app_en.arb b/lib/l10n/app_en.arb index 41d170c..90ccfc2 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -141,10 +141,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", - "openMainListAction": "Open in Floatick", "stickyBoardDeleteKeepsTodosHint": "Deleting a sticky board never deletes its todos.", "collapseTooltip": "Collapse (Esc)", "activeScopeLabel": "Todos", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index f47cdb9..24b7dd5 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -650,6 +650,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: @@ -662,18 +668,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 @openMainListAction. - /// - /// In en, this message translates to: - /// **'Open in Floatick'** - String get openMainListAction; - /// No description provided for @stickyBoardDeleteKeepsTodosHint. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index df607f0..fac6aae 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -327,16 +327,13 @@ class AppLocalizationsEn extends AppLocalizations { 'No todos are available to add.'; @override - String get newTodoInStickyBoardAction => 'New todo'; - - @override - String get removeFromStickyBoardTooltip => 'Remove from sticky board'; + String get emptyPinnedStickyBoardMessage => 'No todos on this board.'; @override - String get openMainListTooltip => 'Open main list'; + String get newTodoInStickyBoardAction => 'New todo'; @override - String get openMainListAction => 'Open in Floatick'; + String get removeFromStickyBoardTooltip => 'Remove from sticky board'; @override String get stickyBoardDeleteKeepsTodosHint => diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 52553c2..fe086ad 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -304,16 +304,13 @@ class AppLocalizationsZh extends AppLocalizations { String get noTodosAvailableForBoardMessage => '暂无可添加的待办。'; @override - String get newTodoInStickyBoardAction => '新建待办'; - - @override - String get removeFromStickyBoardTooltip => '从便利板移除'; + String get emptyPinnedStickyBoardMessage => '这个便利板还没有待办。'; @override - String get openMainListTooltip => '打开主列表'; + String get newTodoInStickyBoardAction => '新建待办'; @override - String get openMainListAction => '在 Floatick 中打开'; + String get removeFromStickyBoardTooltip => '从便利板移除'; @override String get stickyBoardDeleteKeepsTodosHint => '删除便利板不会删除其中的待办。'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index da6dbe0..3586523 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -92,10 +92,9 @@ "addExistingTodoTitle": "添加现有待办", "searchTodosToAddHint": "搜索可添加的待办", "noTodosAvailableForBoardMessage": "暂无可添加的待办。", + "emptyPinnedStickyBoardMessage": "这个便利板还没有待办。", "newTodoInStickyBoardAction": "新建待办", "removeFromStickyBoardTooltip": "从便利板移除", - "openMainListTooltip": "打开主列表", - "openMainListAction": "在 Floatick 中打开", "stickyBoardDeleteKeepsTodosHint": "删除便利板不会删除其中的待办。", "collapseTooltip": "收起(Esc)", "activeScopeLabel": "待办", diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index b7726fb..b2b125f 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -360,9 +360,9 @@ 33CC10E92044A3C60003C045 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; - files = ( - 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, - F10A00012F21000100F10A01 /* UpdateService.swift in Sources */, + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + F10A00012F21000100F10A01 /* UpdateService.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/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index 017c51c..7647664 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,15 +44,36 @@ 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 appliedAlwaysOnTop: Bool? + private var preferredAppearance = PreferredAppearance.system 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, @@ -66,10 +104,13 @@ final class MainFlutterWindow: NSWindow { configureWindow() contentViewController = flutterViewController flutterContentView = flutterViewController.view + installFrostedBackground( + in: flutterViewController, + cornerRadius: 26 + ) RegisterGeneratedPlugins(registry: flutterViewController) configureWindowChannel(for: flutterViewController) configureUpdateService(for: flutterViewController) - configureDragOverlay(for: flutterViewController.view) let origin = restoredCollapsedOrigin() ?? defaultCollapsedOrigin() collapsedOrigin = clampedOrigin( @@ -77,13 +118,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() { @@ -100,6 +151,8 @@ final class MainFlutterWindow: NSWindow { hidesOnDeactivate = false isRestorable = false title = "Floatick" + alphaValue = 1 + lockMainWindowSize() } private func configureWindowChannel( @@ -127,17 +180,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 a Boolean argument.", + message: + "setExpanded expects expanded and animated Boolean values.", details: nil ) ) return } - self.setExpanded(expanded, completion: { result(nil) }) + 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: + "setFloatingIconCount expects a non-negative count.", + details: nil + ) + ) + return + } + self.collapsedIconView?.setActiveCount(activeCount) + result(nil) case "setPreferredLanguage": let languageCode: String? if call.arguments == nil || call.arguments is NSNull { @@ -160,6 +239,23 @@ 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( @@ -175,19 +271,24 @@ final class MainFlutterWindow: NSWindow { result(nil) case "configureBorderlessSecondaryWindow": guard - let viewIdentifier = (call.arguments as? NSNumber)?.int64Value + 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.", + message: + "configureBorderlessSecondaryWindow expects a view ID and positioning preference.", details: nil ) ) return } guard self.configureBorderlessSecondaryWindow( - viewIdentifier: viewIdentifier + viewIdentifier: viewIdentifier, + positionAdjacentToMainWindow: positionAdjacentToMainWindow ) else { result( FlutterError( @@ -207,26 +308,29 @@ final class MainFlutterWindow: NSWindow { } private func configureBorderlessSecondaryWindow( - viewIdentifier: Int64 + viewIdentifier: Int64, + positionAdjacentToMainWindow: Bool ) -> Bool { guard let targetWindow = NSApp.windows.first(where: { window in guard window !== self, - let controller = window.contentViewController - as? FlutterViewController + let controller = self.flutterViewController(in: window) else { return false } return controller.viewIdentifier == viewIdentifier }), - let flutterViewController = targetWindow.contentViewController - as? FlutterViewController + let flutterViewController = flutterViewController(in: targetWindow) else { return false } flutterViewController.backgroundColor = .clear + installFrostedBackground( + in: flutterViewController, + cornerRadius: 22 + ) let existingFrame = targetWindow.frame targetWindow.styleMask = [.borderless, .resizable] targetWindow.setFrame(existingFrame, display: true) @@ -238,6 +342,10 @@ final class MainFlutterWindow: NSWindow { targetWindow.contentView?.layer?.backgroundColor = NSColor.clear.cgColor 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 { @@ -249,6 +357,106 @@ final class MainFlutterWindow: NSWindow { return true } + 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 installFrostedBackground( + in flutterViewController: FlutterViewController, + cornerRadius: CGFloat + ) { + let rootView = flutterViewController.view + rootView.wantsLayer = true + rootView.layer?.backgroundColor = NSColor.clear.cgColor + rootView.layer?.cornerRadius = cornerRadius + rootView.layer?.masksToBounds = true + + let effectIdentifier = NSUserInterfaceItemIdentifier( + "floatick.frosted-background" + ) + if rootView.subviews.contains(where: { + $0.identifier == effectIdentifier + }) { + return + } + + let effectView = NSVisualEffectView(frame: rootView.bounds) + effectView.identifier = effectIdentifier + effectView.autoresizingMask = [.width, .height] + effectView.blendingMode = .behindWindow + effectView.material = .underWindowBackground + effectView.state = .active + effectView.isEmphasized = true + rootView.addSubview(effectView, positioned: .below, relativeTo: nil) + } + private func setAlwaysOnTop(_ alwaysOnTop: Bool) { let targetLevel: NSWindow.Level = alwaysOnTop ? .statusBar : .normal guard @@ -259,8 +467,29 @@ final class MainFlutterWindow: NSWindow { } appliedAlwaysOnTop = alwaysOnTop level = targetLevel + collapsedIconPanel?.level = targetLevel if alwaysOnTop { - orderFrontRegardless() + 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 } } @@ -274,8 +503,35 @@ final class MainFlutterWindow: NSWindow { self.updateService = updateService } - private func configureDragOverlay(for view: NSView) { - let overlay = CollapsedDragOverlayView(frame: view.bounds) + 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 { @@ -303,7 +559,7 @@ final class MainFlutterWindow: NSWindow { for: Layout.collapsedSize, on: targetScreen ) - self.setFrameOrigin(origin) + self.collapsedIconPanel?.setFrameOrigin(origin) self.collapsedOrigin = origin self.pendingExpansionAnchor = nil } @@ -311,15 +567,18 @@ 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 { @@ -330,18 +589,21 @@ final class MainFlutterWindow: NSWindow { 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) + 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( @@ -349,14 +611,70 @@ final class MainFlutterWindow: NSWindow { for: Layout.collapsedSize, on: targetScreen ) - setFrame( + collapsedIconPanel?.setFrame( NSRect(origin: collapsedOrigin, size: Layout.collapsedSize), - display: true + display: false ) - orderFrontRegardless() - resignKey() + collapsedIconPanel?.alphaValue = animated ? 0 : 1 + collapsedIconPanel?.orderFrontRegardless() + transitionWindows( + showMainWindow: false, + animated: animated, + completion: 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() } - completion() } private func activateAndFocusFlutterContent() { @@ -550,6 +868,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? diff --git a/test/app/floatick_app_test.dart b/test/app/floatick_app_test.dart index e5143fd..0c157ad 100644 --- a/test/app/floatick_app_test.dart +++ b/test/app/floatick_app_test.dart @@ -1,10 +1,11 @@ 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/settings_repository.dart'; import 'package:floatick/features/settings/domain/app_settings.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,10 +13,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'; @@ -74,22 +75,9 @@ 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), - ); - final floatingMark = tester.widget( - find.descendant( - of: find.byKey(const ValueKey('floating-todo-icon')), - matching: find.byType(FloatickBrandMark), - ), - ); - expect(floatingMark.shape, FloatickBrandMarkShape.circle); - expect(floatingMark.shadows, isEmpty); - await tester.pump(); + expect(windowBridge.floatingIconCounts, [0]); + expect(windowBridge.preferredThemeValues, ['system']); final tooltipMouse = await tester.createGesture( kind: PointerDeviceKind.mouse, ); @@ -99,6 +87,8 @@ void main() { windowBridge.expandRequestHandler?.call(WindowExpansionAnchor.topRight); await tester.pumpAndSettle(); + expect(windowBridge.expandedValues, [true]); + expect(windowBridge.expandedAnimatedValues, [true]); expect( tester .widget( @@ -270,6 +260,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(); @@ -410,7 +405,7 @@ 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( @@ -758,8 +753,7 @@ void main() { 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'))); @@ -815,15 +809,36 @@ 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( @@ -837,6 +852,7 @@ void main() { boardController: stickyBoardController, todoController: todoController, windowBridge: windowBridge, + windowLauncher: (_) async {}, ); await Future.wait(>[ todoController.load(), @@ -881,6 +897,23 @@ void main() { await tester.tap(find.byKey(const Key('submit-sticky-board'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('sticky-board-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); await tester.tap(find.byKey(const Key('sticky-board-board-launch'))); await tester.pumpAndSettle(); @@ -908,6 +941,44 @@ void main() { 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')), ); @@ -928,6 +999,7 @@ void main() { find.byKey(const Key('todo-title-field')), 'Share the release notes', ); + stickyBoardRepository.failNextSave = true; await tester.pump(); expect( tester @@ -938,6 +1010,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'), @@ -990,6 +1074,98 @@ void main() { 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(), + ); + 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.text('Archive 1')); + await tester.pumpAndSettle(); + + 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 { @@ -1202,6 +1378,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'; @@ -1211,6 +1388,10 @@ class _WidgetTestStickyBoardRepository implements StickyBoardRepository { @override Future save(StickyBoardWorkspace workspace) async { + if (failNextSave) { + failNextSave = false; + throw const StorageFailure(kind: StorageFailureKind.write); + } savedWorkspace = workspace; } } @@ -1244,7 +1425,10 @@ 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; @@ -1259,8 +1443,14 @@ 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 @@ -1268,11 +1458,19 @@ class _WidgetTestWindowBridge implements WindowBridge { 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) async {} + Future configureBorderlessSecondaryWindow( + int viewId, { + bool positionAdjacentToMainWindow = false, + }) async {} } diff --git a/test/core/platform/window_bridge_test.dart b/test/core/platform/window_bridge_test.dart index bb6e2e5..cfe0b75 100644 --- a/test/core/platform/window_bridge_test.dart +++ b/test/core/platform/window_bridge_test.dart @@ -21,10 +21,42 @@ void main() { }); final bridge = MethodChannelWindowBridge(); - await bridge.configureBorderlessSecondaryWindow(42); + await bridge.configureBorderlessSecondaryWindow( + 42, + positionAdjacentToMainWindow: true, + ); expect(calls, hasLength(1)); expect(calls.single.method, 'configureBorderlessSecondaryWindow'); - expect(calls.single.arguments, 42); + expect(calls.single.arguments, { + 'viewId': 42, + 'positionAdjacentToMainWindow': true, + }); + }); + + 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/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_window_coordinator_test.dart b/test/features/sticky_boards/presentation/sticky_board_window_coordinator_test.dart index d0b0346..729ea7d 100644 --- a/test/features/sticky_boards/presentation/sticky_board_window_coordinator_test.dart +++ b/test/features/sticky_boards/presentation/sticky_board_window_coordinator_test.dart @@ -1,5 +1,6 @@ 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'; @@ -42,11 +43,132 @@ void main() { ); 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 >{}, + ), + ), + ); + await boardController.load(); + final coordinator = StickyBoardWindowCoordinator( + boardController: boardController, + todoController: TodoViewModel( + todoRepository: _MemoryTodoRepository(), + tagRepository: _MemoryTagRepository(), + ), + windowBridge: _MemoryWindowBridge(), + windowLauncher: (_) async {}, + ); + + await coordinator.togglePin('board-toggle'); + expect(boardController.boardById('board-toggle')?.isPinned, isTrue); + + await coordinator.togglePin('board-toggle'); + expect(boardController.boardById('board-toggle')?.isPinned, isFalse); + }); } class _MemoryWindowBridge implements WindowBridge { @override - Future configureBorderlessSecondaryWindow(int viewId) async {} + Future configureBorderlessSecondaryWindow( + int viewId, { + bool positionAdjacentToMainWindow = false, + }) async {} @override Future preferredExpansionAnchor() async { @@ -57,21 +179,32 @@ class _MemoryWindowBridge implements WindowBridge { void setExpandRequestHandler(ExpandRequestHandler? handler) {} @override - Future setExpanded(bool expanded) async {} + 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 => StickyBoardWorkspace.empty(); + Future load() async => _workspace; @override Future save(StickyBoardWorkspace workspace) async {} diff --git a/test/features/todos/presentation/todo_editor_drawer_test.dart b/test/features/todos/presentation/todo_editor_drawer_test.dart index 5cc51d4..0cc5ee6 100644 --- a/test/features/todos/presentation/todo_editor_drawer_test.dart +++ b/test/features/todos/presentation/todo_editor_drawer_test.dart @@ -170,6 +170,20 @@ 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); }); diff --git a/test/features/todos/presentation/todo_list_row_test.dart b/test/features/todos/presentation/todo_list_row_test.dart index 6236077..4d0d195 100644 --- a/test/features/todos/presentation/todo_list_row_test.dart +++ b/test/features/todos/presentation/todo_list_row_test.dart @@ -1,3 +1,5 @@ +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'; @@ -172,6 +174,7 @@ void main() { locale: const Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, + theme: ThemeData(platform: TargetPlatform.android), home: Scaffold( body: TodoListRow( item: item, @@ -220,6 +223,14 @@ void main() { 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(); @@ -228,6 +239,131 @@ void main() { }, ); + 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 { diff --git a/test/features/todos/presentation/todo_view_model_test.dart b/test/features/todos/presentation/todo_view_model_test.dart index 2f69df5..7e5bec8 100644 --- a/test/features/todos/presentation/todo_view_model_test.dart +++ b/test/features/todos/presentation/todo_view_model_test.dart @@ -366,6 +366,72 @@ void main() { 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 { @@ -649,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'; @@ -661,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); } @@ -673,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'; @@ -683,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); } From 8961ab2cd3ad35365d9ea8ef3bd4c3c036f1c9a0 Mon Sep 17 00:00:00 2001 From: lucaslushuo Date: Mon, 27 Jul 2026 22:38:20 +0800 Subject: [PATCH 08/12] fix(sticky-boards): synchronize pin visibility Hide and reuse sticky board windows when unpinning so persisted state and native visibility cannot diverge. Roll back pin state when hiding fails and cover repeated pin cycles. --- .../pinned_sticky_board_window.dart | 28 ++++++---- .../sticky_board_window_coordinator.dart | 51 ++++++++++++------- .../sticky_board_window_coordinator_test.dart | 43 ++++++++++++++++ 3 files changed, 93 insertions(+), 29 deletions(-) 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 c496af0..e9e9cd7 100644 --- a/lib/features/sticky_boards/presentation/pinned_sticky_board_window.dart +++ b/lib/features/sticky_boards/presentation/pinned_sticky_board_window.dart @@ -39,7 +39,7 @@ class _PinnedStickyBoardWindowState extends State with WindowListener { final StickyBoardFrameSaveScheduler _frameSaveScheduler = StickyBoardFrameSaveScheduler(); - bool _isClosing = false; + bool _isUnpinning = false; String? _detailsTodoId; @override @@ -90,7 +90,7 @@ class _PinnedStickyBoardWindowState extends State @override void onWindowClose() { - if (!_isClosing) { + if (!_isUnpinning) { _frameSaveScheduler.cancel(); unawaited(_persistBoundsAndUnpin()); } @@ -111,7 +111,7 @@ class _PinnedStickyBoardWindowState extends State } Future _persistBounds({bool allowClosing = false}) async { - if (!mounted || (_isClosing && !allowClosing)) { + if (!mounted || (_isUnpinning && !allowClosing)) { return; } try { @@ -130,21 +130,29 @@ class _PinnedStickyBoardWindowState extends State } Future _unpin() async { - if (_isClosing) { + if (_isUnpinning) { return; } _frameSaveScheduler.cancel(); - _isClosing = true; - await widget.coordinator.unpin(widget.boardId); + _isUnpinning = true; + try { + await widget.coordinator.unpin(widget.boardId); + } finally { + _isUnpinning = false; + } } Future _persistBoundsAndUnpin() async { - if (_isClosing) { + if (_isUnpinning) { return; } - _isClosing = true; - await _persistBounds(allowClosing: true); - await widget.coordinator.unpin(widget.boardId); + _isUnpinning = true; + try { + await _persistBounds(allowClosing: true); + await widget.coordinator.unpin(widget.boardId); + } finally { + _isUnpinning = false; + } } void _openMain({ 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 f889d26..612c1b2 100644 --- a/lib/features/sticky_boards/presentation/sticky_board_window_coordinator.dart +++ b/lib/features/sticky_boards/presentation/sticky_board_window_coordinator.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:multiview_desktop/multiview_desktop.dart'; @@ -29,6 +27,7 @@ class StickyBoardMainWindowRequest { typedef StickyBoardMainWindowRequestHandler = void Function(StickyBoardMainWindowRequest request); typedef StickyBoardWindowLauncher = Future Function(String boardId); +typedef StickyBoardWindowHider = Future Function(String boardId); class StickyBoardWindowCoordinator { StickyBoardWindowCoordinator({ @@ -36,6 +35,7 @@ class StickyBoardWindowCoordinator { required TodoViewModel todoController, required this.windowBridge, this.windowLauncher, + this.windowHider, }) : _boards = boardController, _todos = todoController; @@ -47,6 +47,7 @@ class StickyBoardWindowCoordinator { final TodoViewModel _todos; final WindowBridge windowBridge; final StickyBoardWindowLauncher? windowLauncher; + final StickyBoardWindowHider? windowHider; final Map _windowIdsByBoardId = {}; final Map> _boardWindowOperations = >{}; @@ -90,7 +91,7 @@ class StickyBoardWindowCoordinator { await _openWindow(boardId); _restoredPinnedBoardIds.add(boardId); } on Object catch (error, stackTrace) { - _closeRegisteredWindowWithoutWaiting(boardId); + await _hideRegisteredWindowBestEffort(boardId); hadFailure = true; debugPrint('Floatick could not restore sticky board $boardId: $error'); debugPrintStack(stackTrace: stackTrace); @@ -130,12 +131,12 @@ class StickyBoardWindowCoordinator { try { await _openWindow(boardId, positionAdjacentToMainWindow: true); if (!board.isPinned && !await _boards.setPinned(boardId, true)) { - _closeRegisteredWindowWithoutWaiting(boardId); + await _hideRegisteredWindowBestEffort(boardId); return; } _restoredPinnedBoardIds.add(boardId); } on Object catch (error, stackTrace) { - _closeRegisteredWindowWithoutWaiting(boardId); + await _hideRegisteredWindowBestEffort(boardId); debugPrint('Floatick could not pin sticky board $boardId: $error'); debugPrintStack(stackTrace: stackTrace); } @@ -147,7 +148,20 @@ class StickyBoardWindowCoordinator { if (!await _boards.setPinned(boardId, false)) { return; } - _closeRegisteredWindowWithoutWaiting(boardId); + try { + await _hideRegisteredWindow(boardId); + } on Object catch (error, stackTrace) { + final restored = await _boards.setPinned(boardId, true); + if (restored) { + _restoredPinnedBoardIds.add(boardId); + } else { + debugPrint( + 'Floatick could not restore the pin state for sticky board $boardId.', + ); + } + debugPrint('Floatick could not unpin sticky board $boardId: $error'); + debugPrintStack(stackTrace: stackTrace); + } } Future deleteBoard(String boardId) async { @@ -155,7 +169,7 @@ class StickyBoardWindowCoordinator { _didRestorePinnedBoards = false; final result = await _boards.deleteBoard(boardId); if (result == StickyBoardMutationResult.success) { - _closeRegisteredWindowWithoutWaiting(boardId); + await _hideRegisteredWindowBestEffort(boardId); } return result; } @@ -262,24 +276,23 @@ class StickyBoardWindowCoordinator { }); } - void _closeRegisteredWindowWithoutWaiting(String boardId) { - final viewId = _windowIdsByBoardId.remove(boardId); - if (viewId == null) { + Future _hideRegisteredWindow(String boardId) async { + final hider = windowHider; + if (hider != null) { + await hider(boardId); return; } - unawaited(_closeWindow(boardId: boardId, viewId: viewId)); + final viewId = _windowIdsByBoardId[boardId]; + if (viewId != null) { + await MultiViewDesktop.fromId(viewId).hide(); + } } - Future _closeWindow({ - required String boardId, - required int viewId, - }) async { + Future _hideRegisteredWindowBestEffort(String boardId) async { try { - final window = MultiViewDesktop.fromId(viewId); - await window.setPreventClose(false); - await window.closeWindow(); + await _hideRegisteredWindow(boardId); } on Object catch (error, stackTrace) { - debugPrint('Floatick could not close sticky board $boardId: $error'); + debugPrint('Floatick could not hide sticky board $boardId: $error'); debugPrintStack(stackTrace: stackTrace); } } 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 index 729ea7d..f18629b 100644 --- a/test/features/sticky_boards/presentation/sticky_board_window_coordinator_test.dart +++ b/test/features/sticky_boards/presentation/sticky_board_window_coordinator_test.dart @@ -144,6 +144,7 @@ void main() { ), ), ); + final hiddenBoardIds = []; await boardController.load(); final coordinator = StickyBoardWindowCoordinator( boardController: boardController, @@ -153,6 +154,7 @@ void main() { ), windowBridge: _MemoryWindowBridge(), windowLauncher: (_) async {}, + windowHider: (boardId) async => hiddenBoardIds.add(boardId), ); await coordinator.togglePin('board-toggle'); @@ -160,6 +162,47 @@ void main() { 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); }); } From 7efbd66b3a88e96b82706961d7c3b35534d10c44 Mon Sep 17 00:00:00 2001 From: lucaslushuo Date: Tue, 28 Jul 2026 11:52:54 +0800 Subject: [PATCH 09/12] fix(app): polish sticky boards and interactions --- lib/app/theme/floatick_theme.dart | 38 +- lib/core/platform/window_bridge.dart | 10 + lib/core/ui/floatick_hover_motion.dart | 183 ++++ .../pinned_sticky_board_window.dart | 175 ++-- .../presentation/sticky_board_drawers.dart | 786 ++++++++++++------ .../presentation/sticky_board_palette.dart | 11 + .../sticky_board_window_coordinator.dart | 5 + .../sticky_board_management_todo_row.dart | 239 ++++++ .../sticky_board_read_only_todo_row.dart | 148 ++++ .../widgets/sticky_board_todo_details.dart | 8 - .../presentation/tag_management_drawer.dart | 7 +- .../presentation/todo_editor_drawer.dart | 47 +- .../todos/presentation/todo_panel.dart | 24 +- .../widgets/floatick_tag_chip.dart | 6 +- .../widgets/floating_todo_icon.dart | 3 +- .../presentation/widgets/todo_list_row.dart | 4 +- lib/l10n/app_en.arb | 2 +- lib/l10n/app_localizations.dart | 12 +- lib/l10n/app_localizations_en.dart | 6 +- lib/l10n/app_localizations_zh.dart | 6 +- lib/l10n/app_zh.arb | 2 +- macos/Runner/MainFlutterWindow.swift | 142 +++- test/app/floatick_app_test.dart | 168 +++- test/app/theme/floatick_theme_test.dart | 107 +++ test/core/platform/window_bridge_test.dart | 16 + test/core/ui/floatick_hover_motion_test.dart | 150 ++++ .../sticky_board_palette_test.dart | 42 + .../sticky_board_window_coordinator_test.dart | 3 + ...sticky_board_management_todo_row_test.dart | 92 ++ .../sticky_board_read_only_todo_row_test.dart | 76 ++ .../sticky_board_todo_details_test.dart | 7 +- 31 files changed, 2043 insertions(+), 482 deletions(-) create mode 100644 lib/core/ui/floatick_hover_motion.dart create mode 100644 lib/features/sticky_boards/presentation/widgets/sticky_board_management_todo_row.dart create mode 100644 lib/features/sticky_boards/presentation/widgets/sticky_board_read_only_todo_row.dart create mode 100644 test/core/ui/floatick_hover_motion_test.dart create mode 100644 test/features/sticky_boards/presentation/sticky_board_palette_test.dart create mode 100644 test/features/sticky_boards/presentation/widgets/sticky_board_management_todo_row_test.dart create mode 100644 test/features/sticky_boards/presentation/widgets/sticky_board_read_only_todo_row_test.dart diff --git a/lib/app/theme/floatick_theme.dart b/lib/app/theme/floatick_theme.dart index d1c4ed2..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,12 +10,9 @@ abstract final class FloatickColors { static const mutedInk = Color(0xFF657178); static const darkSurface = Color(0xFF182125); static const darkSurfaceElevated = Color(0xFF222D31); - static const darkGlassSurface = Color(0xDE182125); - static const lightGlassSurface = Color(0xEBF9FBFA); + static const lightSurface = Color(0xFFF9FBFA); } -const _iconButtonStateDuration = Duration(milliseconds: 120); - ThemeData buildFloatickTheme(Brightness brightness) { final isDark = brightness == Brightness.dark; final colorScheme = @@ -23,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, ); @@ -39,7 +40,8 @@ ThemeData buildFloatickTheme(Brightness brightness) { visualDensity: VisualDensity.standard, iconButtonTheme: IconButtonThemeData( style: ButtonStyle( - animationDuration: _iconButtonStateDuration, + animationDuration: FloatickMotion.hoverDuration, + foregroundBuilder: FloatickMotion.iconButtonForegroundBuilder, overlayColor: const WidgetStatePropertyAll(Colors.transparent), foregroundColor: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.disabled)) { @@ -57,6 +59,30 @@ ThemeData buildFloatickTheme(Brightness brightness) { }), ), ), + 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 51e6c12..f27c088 100644 --- a/lib/core/platform/window_bridge.dart +++ b/lib/core/platform/window_bridge.dart @@ -36,6 +36,8 @@ abstract interface class WindowBridge { int viewId, { bool positionAdjacentToMainWindow = false, }); + + Future revealBorderlessSecondaryWindow(int viewId); } class MethodChannelWindowBridge implements WindowBridge { @@ -101,6 +103,14 @@ class MethodChannelWindowBridge implements WindowBridge { ); } + @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/features/sticky_boards/presentation/pinned_sticky_board_window.dart b/lib/features/sticky_boards/presentation/pinned_sticky_board_window.dart index e9e9cd7..5b0d297 100644 --- a/lib/features/sticky_boards/presentation/pinned_sticky_board_window.dart +++ b/lib/features/sticky_boards/presentation/pinned_sticky_board_window.dart @@ -4,14 +4,16 @@ import 'package:flutter/material.dart'; import 'package:multiview_desktop/multiview_desktop.dart'; import '../../../app/theme/floatick_theme.dart'; +import '../../../core/ui/floatick_hover_motion.dart'; import '../../../l10n/l10n.dart'; import '../../todos/domain/todo_item.dart'; import '../../todos/presentation/todo_view_model.dart'; -import '../../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'; +import 'widgets/sticky_board_read_only_todo_row.dart'; import 'widgets/sticky_board_todo_details.dart'; class PinnedStickyBoardWindow extends StatefulWidget { @@ -155,19 +157,6 @@ class _PinnedStickyBoardWindowState extends State } } - void _openMain({ - required StickyBoardMainWindowDestination destination, - String? todoId, - }) { - widget.coordinator.requestMainWindow( - StickyBoardMainWindowRequest( - boardId: widget.boardId, - destination: destination, - todoId: todoId, - ), - ); - } - void _showTodoDetails(String todoId) { setState(() => _detailsTodoId = todoId); } @@ -187,66 +176,71 @@ class _PinnedStickyBoardWindowState extends State }); return const SizedBox.shrink(); } - final isDark = Theme.of(context).brightness == Brightness.dark; + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final boardColor = StickyBoardPalette.color(board.colorValue); + final surfaceColor = StickyBoardPalette.surfaceColor( + value: board.colorValue, + baseColor: isDark + ? FloatickColors.darkSurface + : FloatickColors.lightSurface, + brightness: theme.brightness, + ); final detailsItem = _detailsTodoId == null ? null : widget.todoController.itemById(_detailsTodoId!); + const borderRadius = BorderRadius.all(Radius.circular(22)); return Material( type: MaterialType.transparency, - child: DecoratedBox( - decoration: BoxDecoration( - color: isDark - ? FloatickColors.darkGlassSurface - : FloatickColors.lightGlassSurface, - borderRadius: BorderRadius.circular(22), - border: Border.all( - color: isDark - ? Colors.white.withValues(alpha: 0.12) - : Colors.white.withValues(alpha: 0.90), - ), - ), - child: Column( - children: [ - _PinnedHeader(board: board, onUnpin: () => unawaited(_unpin())), - Divider( - height: 1, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.08), + child: ClipRRect( + borderRadius: borderRadius, + clipBehavior: Clip.antiAlias, + child: DecoratedBox( + decoration: BoxDecoration( + color: surfaceColor, + borderRadius: borderRadius, + border: Border.all( + color: boardColor.withValues(alpha: isDark ? 0.44 : 0.34), ), - 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, - onEdit: () => _openMain( - destination: - StickyBoardMainWindowDestination.todoEdit, - todoId: detailsItem.id, + ), + 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, ), - ), + ), ), - ), - ], + ], + ), ), ), ); @@ -281,27 +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: () => _showTodoDetails(item.id), - onEdit: () => _openMain( - destination: StickyBoardMainWindowDestination.todoEdit, - todoId: 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), - onOpenTagAssignment: () => _openMain( - destination: StickyBoardMainWindowDestination.todoEdit, - todoId: item.id, - ), - showArchiveAction: false, - compact: true, ); }, ); @@ -321,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, @@ -340,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, + ), ), ), ], diff --git a/lib/features/sticky_boards/presentation/sticky_board_drawers.dart b/lib/features/sticky_boards/presentation/sticky_board_drawers.dart index d4df4c4..4d04fb7 100644 --- a/lib/features/sticky_boards/presentation/sticky_board_drawers.dart +++ b/lib/features/sticky_boards/presentation/sticky_board_drawers.dart @@ -3,19 +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, @@ -27,6 +30,7 @@ class StickyBoardManagementDrawer extends StatefulWidget { }); final StickyBoardViewModel controller; + final TodoViewModel todoController; final bool isOpen; final bool borderOnLeft; final VoidCallback onClose; @@ -46,7 +50,6 @@ class _StickyBoardManagementDrawerState final _queryFocusNode = FocusNode(); String? _editingBoardId; - String? _pendingDeleteBoardId; String? _validationMessage; int _selectedColorValue = StickyBoardPalette.teal; bool _isSaving = false; @@ -116,7 +119,6 @@ class _StickyBoardManagementDrawerState _isSaving = false; if (result == StickyBoardMutationResult.success) { _editingBoardId = null; - _pendingDeleteBoardId = null; _queryController.clear(); _selectedColorValue = StickyBoardPalette.teal; } else { @@ -131,7 +133,6 @@ class _StickyBoardManagementDrawerState void _beginEditing(StickyBoard board) { setState(() { _editingBoardId = board.id; - _pendingDeleteBoardId = null; _validationMessage = null; _selectedColorValue = board.colorValue; _queryController.text = board.name; @@ -153,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 => @@ -214,10 +255,7 @@ class _StickyBoardManagementDrawerState ), ], onChanged: (_) { - setState(() { - _validationMessage = null; - _pendingDeleteBoardId = null; - }); + setState(() => _validationMessage = null); }, onSubmitted: (_) => unawaited(_submit()), decoration: InputDecoration( @@ -338,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)), + ); }, ); }, @@ -371,7 +415,7 @@ class _StickyBoardManagementDrawerState } } -class StickyBoardDetailDrawer extends StatelessWidget { +class StickyBoardDetailDrawer extends StatefulWidget { const StickyBoardDetailDrawer({ required this.board, required this.todoController, @@ -382,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, }); @@ -398,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: [ @@ -424,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, ), ), @@ -441,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( @@ -459,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, - ); - }, - ), - ), - ], - ), + ), + ); + }, + ), + ), + ], ); } } @@ -822,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, @@ -862,6 +965,7 @@ class _ManagedBoardRow extends StatefulWidget { }); final StickyBoard board; + final List previewItems; final int todoCount; final bool isEditing; final VoidCallback onOpen; @@ -870,110 +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, + ), + ), + ), + ], ), - ], - ), - ), - IconButton( - key: ValueKey( - 'toggle-sticky-board-pin-${widget.board.id}', - ), - 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, - color: widget.board.isPinned - ? theme.colorScheme.primary - : null, - ), - ), - 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: context.l10n.deleteStickyBoardTooltip, - onPressed: widget.onDelete, - icon: const Icon( - Icons.delete_outline_rounded, - 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, + ), ), - ), - ], + 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, + ), + ), + ], + ), + ), + ), + ], + ), ), - ), + ], ), - ], + ), ), ), ), @@ -981,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, ), - ], + ), ), ], ), @@ -1042,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_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_window_coordinator.dart b/lib/features/sticky_boards/presentation/sticky_board_window_coordinator.dart index 612c1b2..ef83e16 100644 --- a/lib/features/sticky_boards/presentation/sticky_board_window_coordinator.dart +++ b/lib/features/sticky_boards/presentation/sticky_board_window_coordinator.dart @@ -256,6 +256,11 @@ class StickyBoardWindowCoordinator { 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( 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 index a057c4d..1db8f0b 100644 --- a/lib/features/sticky_boards/presentation/widgets/sticky_board_todo_details.dart +++ b/lib/features/sticky_boards/presentation/widgets/sticky_board_todo_details.dart @@ -11,14 +11,12 @@ class StickyBoardTodoDetails extends StatelessWidget { required this.item, required this.tags, required this.onBack, - required this.onEdit, super.key, }); final TodoItem item; final List tags; final VoidCallback onBack; - final VoidCallback onEdit; @override Widget build(BuildContext context) { @@ -48,12 +46,6 @@ class StickyBoardTodoDetails extends StatelessWidget { ), ), ), - IconButton( - key: const Key('sticky-board-details-edit'), - tooltip: context.l10n.editTooltip, - onPressed: onEdit, - icon: const Icon(Icons.edit_outlined, size: 18), - ), ], ), ), diff --git a/lib/features/todos/presentation/tag_management_drawer.dart b/lib/features/todos/presentation/tag_management_drawer.dart index ba73e84..89fed1a 100644 --- a/lib/features/todos/presentation/tag_management_drawer.dart +++ b/lib/features/todos/presentation/tag_management_drawer.dart @@ -3,6 +3,7 @@ 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'; @@ -443,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 0f0736a..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'; @@ -651,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, + ), ), ), ), diff --git a/lib/features/todos/presentation/todo_panel.dart b/lib/features/todos/presentation/todo_panel.dart index 0360f59..0562f2f 100644 --- a/lib/features/todos/presentation/todo_panel.dart +++ b/lib/features/todos/presentation/todo_panel.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../../../app/theme/floatick_theme.dart'; +import '../../../core/ui/floatick_hover_motion.dart'; import '../../../core/platform/window_bridge.dart'; import '../../../core/ui/floatick_brand_mark.dart'; import '../../../core/ui/floatick_surface_metrics.dart'; @@ -408,11 +409,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; @@ -709,9 +705,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 @@ -788,8 +782,8 @@ class _TodoPanelState extends State { key: const Key('todo-panel-surface'), decoration: BoxDecoration( color: isDark - ? FloatickColors.darkGlassSurface - : FloatickColors.lightGlassSurface, + ? FloatickColors.darkSurface + : FloatickColors.lightSurface, borderRadius: BorderRadius.circular( FloatickSurfaceMetrics.panelRadius, ), @@ -1114,10 +1108,6 @@ class _TodoPanelState extends State { onCreateTodo: () => _openTodoCreate( stickyBoardId: board.id, ), - onOpenDetails: _openTodoDetails, - onEditTodo: _openTodoEdit, - onOpenTagManagement: - _openTagManagementFromStickyBoard, closeFocusNode: _stickyBoardCloseFocusNode, ); @@ -1125,6 +1115,7 @@ class _TodoPanelState extends State { return StickyBoardManagementDrawer( controller: widget.stickyBoardController, + todoController: widget.controller, isOpen: isStickyBoardManagementOpen, borderOnLeft: !tagDrawerOnLeft, onClose: _closeActiveDrawer, @@ -1490,8 +1481,9 @@ class _ScopeButton extends StatelessWidget { child: Semantics( button: true, selected: selected, - child: MouseRegion( - cursor: SystemMouseCursors.click, + child: FloatickHoverMotion( + hoverScale: FloatickMotion.controlHoverScale, + pressedScale: FloatickMotion.controlPressedScale, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: onPressed, 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 23d95c0..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, diff --git a/lib/features/todos/presentation/widgets/todo_list_row.dart b/lib/features/todos/presentation/widgets/todo_list_row.dart index 6a4a35c..cb47519 100644 --- a/lib/features/todos/presentation/widgets/todo_list_row.dart +++ b/lib/features/todos/presentation/widgets/todo_list_row.dart @@ -1,5 +1,6 @@ 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'; @@ -146,8 +147,7 @@ class _TodoListRowState extends State { child: Semantics( button: true, checked: item.isCompleted, - child: MouseRegion( - cursor: SystemMouseCursors.click, + child: FloatickHoverMotion( child: GestureDetector( key: ValueKey( 'toggle-todo-${widget.item.id}', diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 90ccfc2..b9050a3 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -133,7 +133,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", @@ -161,6 +160,7 @@ "markdownWriteLabel": "Write", "markdownPreviewLabel": "Preview", "cancelAction": "Cancel", + "confirmAction": "Confirm", "createTodoAction": "Add todo", "saveChangesAction": "Save changes", "saveTodoFailedMessage": "Couldn't save this todo.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 24b7dd5..e86a3a4 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -602,12 +602,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: @@ -770,6 +764,12 @@ 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: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index fac6aae..68d9ff3 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -301,9 +301,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'; @@ -387,6 +384,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get cancelAction => 'Cancel'; + @override + String get confirmAction => 'Confirm'; + @override String get createTodoAction => 'Add todo'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index fe086ad..9ef589d 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -279,9 +279,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get keepStickyBoardAction => '保留便利板'; - @override - String get confirmDeleteStickyBoardAction => '删除便利板'; - @override String get pinStickyBoardTooltip => '固定到桌面'; @@ -363,6 +360,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get cancelAction => '取消'; + @override + String get confirmAction => '确认'; + @override String get createTodoAction => '添加待办'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 3586523..a00bc4a 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -84,7 +84,6 @@ "deleteStickyBoardTitle": "删除这个便利板?", "deleteStickyBoardMessage": "其中的待办仍会安全保留在全部待办中。", "keepStickyBoardAction": "保留便利板", - "confirmDeleteStickyBoardAction": "删除便利板", "pinStickyBoardTooltip": "固定到桌面", "unpinStickyBoardTooltip": "取消桌面固定", "stickyBoardPinnedLabel": "已固定", @@ -112,6 +111,7 @@ "markdownWriteLabel": "编辑", "markdownPreviewLabel": "预览", "cancelAction": "取消", + "confirmAction": "确认", "createTodoAction": "添加待办", "saveChangesAction": "保存修改", "saveTodoFailedMessage": "无法保存这个待办。", diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index 7647664..03fe234 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -52,6 +52,8 @@ final class MainFlutterWindow: NSWindow { private var updateService: UpdateService? 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 } @@ -104,13 +106,14 @@ final class MainFlutterWindow: NSWindow { configureWindow() contentViewController = flutterViewController flutterContentView = flutterViewController.view - installFrostedBackground( + configureRoundedFlutterSurface( in: flutterViewController, cornerRadius: 26 ) RegisterGeneratedPlugins(registry: flutterViewController) configureWindowChannel(for: flutterViewController) configureUpdateService(for: flutterViewController) + observeInitialSecondaryWindowPresentation() let origin = restoredCollapsedOrigin() ?? defaultCollapsedOrigin() collapsedOrigin = clampedOrigin( @@ -300,6 +303,34 @@ final class MainFlutterWindow: NSWindow { 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) } @@ -326,20 +357,15 @@ final class MainFlutterWindow: NSWindow { return false } - flutterViewController.backgroundColor = .clear - installFrostedBackground( - in: flutterViewController, - cornerRadius: 22 + targetWindow.alphaValue = 0 + configureTransparentRoundedWindow( + targetWindow, + flutterViewController: flutterViewController ) let existingFrame = targetWindow.frame targetWindow.styleMask = [.borderless, .resizable] - targetWindow.setFrame(existingFrame, display: true) - targetWindow.backgroundColor = .clear - targetWindow.isOpaque = false - targetWindow.hasShadow = false + targetWindow.setFrame(existingFrame, display: false) targetWindow.preservesContentDuringLiveResize = true - targetWindow.contentView?.wantsLayer = true - targetWindow.contentView?.layer?.backgroundColor = NSColor.clear.cgColor targetWindow.contentView?.layerContentsRedrawPolicy = .onSetNeedsDisplay targetWindow.contentView?.layerContentsPlacement = .scaleAxesIndependently targetWindow.appearance = preferredAppearance.nativeAppearance @@ -354,9 +380,81 @@ final class MainFlutterWindow: NSWindow { 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 ) { @@ -428,33 +526,17 @@ final class MainFlutterWindow: NSWindow { return nil } - private func installFrostedBackground( + 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 - - let effectIdentifier = NSUserInterfaceItemIdentifier( - "floatick.frosted-background" - ) - if rootView.subviews.contains(where: { - $0.identifier == effectIdentifier - }) { - return - } - - let effectView = NSVisualEffectView(frame: rootView.bounds) - effectView.identifier = effectIdentifier - effectView.autoresizingMask = [.width, .height] - effectView.blendingMode = .behindWindow - effectView.material = .underWindowBackground - effectView.state = .active - effectView.isEmphasized = true - rootView.addSubview(effectView, positioned: .below, relativeTo: nil) } private func setAlwaysOnTop(_ alwaysOnTop: Bool) { diff --git a/test/app/floatick_app_test.dart b/test/app/floatick_app_test.dart index 0c157ad..95904fd 100644 --- a/test/app/floatick_app_test.dart +++ b/test/app/floatick_app_test.dart @@ -797,7 +797,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); @@ -896,7 +896,15 @@ 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'), ); @@ -915,6 +923,56 @@ void main() { 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); @@ -992,6 +1050,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(); @@ -1071,6 +1215,25 @@ void main() { 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); }); @@ -1473,4 +1636,7 @@ class _WidgetTestWindowBridge implements WindowBridge { int viewId, { bool positionAdjacentToMainWindow = false, }) async {} + + @override + Future revealBorderlessSecondaryWindow(int viewId) async {} } diff --git a/test/app/theme/floatick_theme_test.dart b/test/app/theme/floatick_theme_test.dart index f27dc2a..f63aab2 100644 --- a/test/app/theme/floatick_theme_test.dart +++ b/test/app/theme/floatick_theme_test.dart @@ -1,8 +1,17 @@ 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', @@ -34,7 +43,105 @@ void main() { }), 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 index cfe0b75..1537ad5 100644 --- a/test/core/platform/window_bridge_test.dart +++ b/test/core/platform/window_bridge_test.dart @@ -34,6 +34,22 @@ void main() { }); }); + 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 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/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_window_coordinator_test.dart b/test/features/sticky_boards/presentation/sticky_board_window_coordinator_test.dart index f18629b..33aa25b 100644 --- a/test/features/sticky_boards/presentation/sticky_board_window_coordinator_test.dart +++ b/test/features/sticky_boards/presentation/sticky_board_window_coordinator_test.dart @@ -213,6 +213,9 @@ class _MemoryWindowBridge implements WindowBridge { bool positionAdjacentToMainWindow = false, }) async {} + @override + Future revealBorderlessSecondaryWindow(int viewId) async {} + @override Future preferredExpansionAnchor() async { return WindowExpansionAnchor.topRight; 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 index 3df5399..e96b028 100644 --- 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 @@ -6,11 +6,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - testWidgets('shows todo content locally and exposes edit separately', ( + testWidgets('shows todo content locally without edit actions', ( tester, ) async { var backCount = 0; - var editCount = 0; final item = TodoItem( id: 'todo-1', title: 'Prepare release', @@ -36,7 +35,6 @@ void main() { item: item, tags: [tag], onBack: () => backCount += 1, - onEdit: () => editCount += 1, ), ), ), @@ -48,11 +46,10 @@ void main() { 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'))); - await tester.tap(find.byKey(const Key('sticky-board-details-edit'))); expect(backCount, 1); - expect(editCount, 1); }); } From 15377eb351e29a8b00e371b519e9fc4a75c84d26 Mon Sep 17 00:00:00 2001 From: lucaslushuo Date: Tue, 28 Jul 2026 12:07:18 +0800 Subject: [PATCH 10/12] feat(settings): add open at login --- .../settings/data/login_item_repository.dart | 55 +++++++++ .../settings/domain/login_item_status.dart | 34 ++++++ .../presentation/settings_drawer.dart | 111 ++++++++++++++++-- .../presentation/settings_view_model.dart | 89 +++++++++++++- lib/l10n/app_en.arb | 6 + lib/l10n/app_localizations.dart | 36 ++++++ lib/l10n/app_localizations_en.dart | 21 ++++ lib/l10n/app_localizations_zh.dart | 18 +++ lib/l10n/app_zh.arb | 6 + lib/main.dart | 2 + macos/Runner.xcodeproj/project.pbxproj | 4 + macos/Runner/LoginItemService.swift | 111 ++++++++++++++++++ macos/Runner/MainFlutterWindow.swift | 12 ++ test/app/floatick_app_test.dart | 46 ++++++++ .../data/login_item_repository_test.dart | 85 ++++++++++++++ .../settings_view_model_test.dart | 89 +++++++++++++- 16 files changed, 707 insertions(+), 18 deletions(-) create mode 100644 lib/features/settings/data/login_item_repository.dart create mode 100644 lib/features/settings/domain/login_item_status.dart create mode 100644 macos/Runner/LoginItemService.swift create mode 100644 test/features/settings/data/login_item_repository_test.dart 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/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 f10e9a8..089b455 100644 --- a/lib/features/settings/presentation/settings_drawer.dart +++ b/lib/features/settings/presentation/settings_drawer.dart @@ -6,6 +6,7 @@ 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'; @@ -89,6 +90,25 @@ class SettingsDrawer extends StatelessWidget { ), 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), @@ -141,25 +161,78 @@ class _AlwaysOnTopSetting extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = Theme.of(context); final enabled = !viewModel.isSaving; - return Semantics( + return _SettingsToggleRow( + settingKey: const Key('always-on-top-setting'), + toggleKey: const Key('always-on-top-toggle'), label: context.l10n.alwaysOnTopLabel, - toggled: viewModel.alwaysOnTop, + 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: const Key('always-on-top-setting'), + key: settingKey, borderRadius: BorderRadius.circular(8), hoverColor: theme.colorScheme.primary.withValues(alpha: 0.06), highlightColor: theme.colorScheme.primary.withValues(alpha: 0.10), - onTap: enabled - ? () { - unawaited(viewModel.setAlwaysOnTop(!viewModel.alwaysOnTop)); - } - : null, + onTap: onTap, child: ConstrainedBox( constraints: const BoxConstraints(minHeight: 34), child: Padding( @@ -168,7 +241,7 @@ class _AlwaysOnTopSetting extends StatelessWidget { children: [ Expanded( child: Text( - context.l10n.alwaysOnTopLabel, + label, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith( @@ -183,8 +256,8 @@ class _AlwaysOnTopSetting extends StatelessWidget { ), const SizedBox(width: 12), CompactSettingsToggle( - key: const Key('always-on-top-toggle'), - value: viewModel.alwaysOnTop, + key: toggleKey, + value: value, enabled: enabled, ), ], @@ -198,6 +271,20 @@ class _AlwaysOnTopSetting extends StatelessWidget { } } +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 503b744..f50c1fe 100644 --- a/lib/features/settings/presentation/settings_view_model.dart +++ b/lib/features/settings/presentation/settings_view_model.dart @@ -1,42 +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; } } @@ -64,6 +98,43 @@ class SettingsViewModel extends ChangeNotifier { 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; @@ -89,4 +160,12 @@ class SettingsViewModel extends ChangeNotifier { _error = null; notifyListeners(); } + + void dismissLoginItemError() { + if (_loginItemError == null) { + return; + } + _loginItemError = null; + notifyListeners(); + } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b9050a3..f92e969 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -11,6 +11,12 @@ "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": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index e86a3a4..69da0f0 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -164,6 +164,42 @@ abstract class AppLocalizations { /// **'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: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 68d9ff3..a6ba42d 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -41,6 +41,27 @@ class AppLocalizationsEn extends AppLocalizations { @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'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 9ef589d..fddf1e8 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -41,6 +41,24 @@ class AppLocalizationsZh extends AppLocalizations { @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 => '更新'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index a00bc4a..c911e88 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -11,6 +11,12 @@ "languageEnglishTooltip": "English", "windowSectionTitle": "窗口", "alwaysOnTopLabel": "始终置顶", + "startupSectionTitle": "启动", + "openAtLoginLabel": "登录时打开", + "openAtLoginLoadError": "暂时无法读取登录项设置。", + "openAtLoginUpdateError": "无法修改登录项设置。", + "openAtLoginApprovalRequired": "请前往“系统设置 → 通用 → 登录项”允许 Floatick。", + "openAtLoginUnsupported": "登录时打开需要 macOS 13 或更高版本。", "updatesSectionTitle": "更新", "currentVersionLabel": "v{version}", "automaticUpdateChecksLabel": "自动检查", diff --git a/lib/main.dart b/lib/main.dart index 1d199bc..e8a8f4d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ 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'; @@ -23,6 +24,7 @@ Future main() async { ); final settingsController = SettingsViewModel( settingsRepository: LocalSettingsRepository(), + loginItemRepository: MethodChannelLoginItemRepository(), ); final updateController = UpdateViewModel( updateRepository: MethodChannelUpdateRepository(), diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index b2b125f..776de2e 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 */; }; + F10A00052F21000100F10A01 /* 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 */, @@ -363,6 +366,7 @@ files = ( 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, F10A00012F21000100F10A01 /* UpdateService.swift in Sources */, + F10A00052F21000100F10A01 /* LoginItemService.swift in Sources */, 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, ); 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 03fe234..a02fa2c 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -50,6 +50,7 @@ final class MainFlutterWindow: NSWindow { 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? @@ -113,6 +114,7 @@ final class MainFlutterWindow: NSWindow { RegisterGeneratedPlugins(registry: flutterViewController) configureWindowChannel(for: flutterViewController) configureUpdateService(for: flutterViewController) + configureLoginItemService(for: flutterViewController) observeInitialSecondaryWindowPresentation() let origin = restoredCollapsedOrigin() ?? defaultCollapsedOrigin() @@ -585,6 +587,16 @@ final class MainFlutterWindow: NSWindow { self.updateService = updateService } + 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), diff --git a/test/app/floatick_app_test.dart b/test/app/floatick_app_test.dart index 95904fd..6dc5a41 100644 --- a/test/app/floatick_app_test.dart +++ b/test/app/floatick_app_test.dart @@ -1,8 +1,10 @@ import 'package:floatick/app/floatick_app.dart'; import 'package:floatick/core/platform/window_bridge.dart'; import 'package:floatick/core/storage/storage_failure.dart'; +import 'package:floatick/features/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'; @@ -44,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( @@ -153,6 +157,8 @@ void main() { 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); @@ -183,6 +189,15 @@ void main() { 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(); @@ -439,6 +454,7 @@ void main() { ); final settingsController = SettingsViewModel( settingsRepository: _WidgetTestSettingsRepository(), + loginItemRepository: _WidgetTestLoginItemRepository(), ); final updateController = UpdateViewModel( updateRepository: _WidgetTestUpdateRepository(), @@ -843,6 +859,7 @@ void main() { ); final settingsController = SettingsViewModel( settingsRepository: _WidgetTestSettingsRepository(), + loginItemRepository: _WidgetTestLoginItemRepository(), ); final updateController = UpdateViewModel( updateRepository: _WidgetTestUpdateRepository(), @@ -1274,6 +1291,7 @@ void main() { final boardController = StickyBoardViewModel(repository: boardRepository); final settingsController = SettingsViewModel( settingsRepository: _WidgetTestSettingsRepository(), + loginItemRepository: _WidgetTestLoginItemRepository(), ); final updateController = UpdateViewModel( updateRepository: _WidgetTestUpdateRepository(), @@ -1343,6 +1361,7 @@ void main() { ); final settingsController = SettingsViewModel( settingsRepository: _WidgetTestSettingsRepository(), + loginItemRepository: _WidgetTestLoginItemRepository(), ); final updateController = UpdateViewModel( updateRepository: _WidgetTestUpdateRepository(), @@ -1399,6 +1418,8 @@ void main() { 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); @@ -1422,6 +1443,7 @@ void main() { final settingsRepository = _WidgetTestSettingsRepository(); final settingsController = SettingsViewModel( settingsRepository: settingsRepository, + loginItemRepository: _WidgetTestLoginItemRepository(), ); final updateController = UpdateViewModel( updateRepository: _WidgetTestUpdateRepository(), @@ -1507,6 +1529,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 = []; 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/presentation/settings_view_model_test.dart b/test/features/settings/presentation/settings_view_model_test.dart index 30e009b..79f1474 100644 --- a/test/features/settings/presentation/settings_view_model_test.dart +++ b/test/features/settings/presentation/settings_view_model_test.dart @@ -1,18 +1,25 @@ 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 { @@ -21,13 +28,16 @@ void main() { 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 { @@ -129,6 +139,53 @@ void main() { 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 { @@ -156,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; + } +} From 494045e345065c1cf660645c7a1f696b7af2b9ee Mon Sep 17 00:00:00 2001 From: lucaslushuo Date: Tue, 28 Jul 2026 12:12:20 +0800 Subject: [PATCH 11/12] fix(macos): avoid Xcode project object collision --- macos/Runner.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 776de2e..0648797 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -30,7 +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 */; }; - F10A00052F21000100F10A01 /* LoginItemService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10A00062F21000100F10A01 /* LoginItemService.swift */; }; + F10A00072F21000100F10A01 /* LoginItemService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10A00062F21000100F10A01 /* LoginItemService.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -364,9 +364,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, - F10A00012F21000100F10A01 /* UpdateService.swift in Sources */, - F10A00052F21000100F10A01 /* LoginItemService.swift in Sources */, + 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 */, ); From eb5cf8a60074bf1539662f842a0d6b317931b4c3 Mon Sep 17 00:00:00 2001 From: lucaslushuo Date: Tue, 28 Jul 2026 14:45:43 +0800 Subject: [PATCH 12/12] feat(app): refine onboarding and automate macOS UI flows --- .github/workflows/ci.yml | 5 +- README.md | 6 +- README.zh-CN.md | 6 +- docs/DEVELOPMENT_WORKFLOW.md | 7 +- docs/TESTING.md | 90 +++ integration_test/floatick_ui_test.dart | 515 ++++++++++++++++++ .../data/first_run_workspace_seeder.dart | 156 ++++++ .../todos/presentation/todo_panel.dart | 228 +++----- .../todos/presentation/todo_view_model.dart | 14 +- lib/l10n/app_en.arb | 1 + lib/l10n/app_localizations.dart | 6 + lib/l10n/app_localizations_en.dart | 3 + lib/l10n/app_localizations_zh.dart | 3 + lib/l10n/app_zh.arb | 1 + lib/main.dart | 14 +- macos/Runner/MainFlutterWindow.swift | 2 +- macos/RunnerTests/RunnerTests.swift | 25 +- pubspec.lock | 55 ++ pubspec.yaml | 2 + test/app/floatick_app_test.dart | 29 +- .../data/first_run_workspace_seeder_test.dart | 108 ++++ tool/release/smoke_test_app.sh | 27 +- tool/test/run_ui_tests.sh | 22 + 23 files changed, 1149 insertions(+), 176 deletions(-) create mode 100644 docs/TESTING.md create mode 100644 integration_test/floatick_ui_test.dart create mode 100644 lib/features/todos/data/first_run_workspace_seeder.dart create mode 100644 test/features/todos/data/first_run_workspace_seeder_test.dart create mode 100755 tool/test/run_ui_tests.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f4ac27..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,6 +44,9 @@ 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 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 f811333..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`。 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/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/todo_panel.dart b/lib/features/todos/presentation/todo_panel.dart index 0562f2f..782872e 100644 --- a/lib/features/todos/presentation/todo_panel.dart +++ b/lib/features/todos/presentation/todo_panel.dart @@ -4,7 +4,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../../../app/theme/floatick_theme.dart'; -import '../../../core/ui/floatick_hover_motion.dart'; import '../../../core/platform/window_bridge.dart'; import '../../../core/ui/floatick_brand_mark.dart'; import '../../../core/ui/floatick_surface_metrics.dart'; @@ -143,6 +142,14 @@ class _TodoPanelState extends State { _showDrawer(_TodoPanelDrawerMode.settings); } + void _toggleArchiveScope() { + setState(() { + _scope = _scope == TodoListScope.active + ? TodoListScope.archived + : TodoListScope.active; + }); + } + void _handleRequestedStickyBoard() { if (_lastHandledStickyBoardRequestSerial == widget.stickyBoardRequestSerial) { @@ -816,7 +823,11 @@ class _TodoPanelState extends State { return Column( children: [ _PanelHeader( + scope: _scope, activeCount: widget.controller.activeCount, + archivedCount: + widget.controller.archivedCount, + onToggleArchive: _toggleArchiveScope, onOpenStickyBoards: _openStickyBoards, onOpenSettings: _openSettings, onCollapse: widget.onCollapse, @@ -830,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( @@ -898,33 +897,34 @@ class _TodoPanelState extends State { .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, - ), - ), - ), - ], ], ), ), @@ -1344,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; @@ -1359,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( @@ -1367,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, @@ -1409,124 +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: FloatickHoverMotion( - hoverScale: FloatickMotion.controlHoverScale, - pressedScale: FloatickMotion.controlPressedScale, - 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}); diff --git a/lib/features/todos/presentation/todo_view_model.dart b/lib/features/todos/presentation/todo_view_model.dart index 87ef230..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,19 +33,24 @@ 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(); @@ -143,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) { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f92e969..8bb9c15 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -168,6 +168,7 @@ "cancelAction": "Cancel", "confirmAction": "Confirm", "createTodoAction": "Add todo", + "newTodoAction": "New", "saveChangesAction": "Save changes", "saveTodoFailedMessage": "Couldn't save this todo.", "todoNotFoundMessage": "This todo no longer exists.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 69da0f0..c383276 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -812,6 +812,12 @@ abstract class AppLocalizations { /// **'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: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index a6ba42d..6337081 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -411,6 +411,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get createTodoAction => 'Add todo'; + @override + String get newTodoAction => 'New'; + @override String get saveChangesAction => 'Save changes'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index fddf1e8..99af96c 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -384,6 +384,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get createTodoAction => '添加待办'; + @override + String get newTodoAction => '新建'; + @override String get saveChangesAction => '保存修改'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index c911e88..58ae5dd 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -119,6 +119,7 @@ "cancelAction": "取消", "confirmAction": "确认", "createTodoAction": "添加待办", + "newTodoAction": "新建", "saveChangesAction": "保存修改", "saveTodoFailedMessage": "无法保存这个待办。", "todoNotFoundMessage": "这个待办已不存在。", diff --git a/lib/main.dart b/lib/main.dart index e8a8f4d..de33615 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:flutter/widgets.dart'; import 'package:multiview_desktop/multiview_desktop.dart'; @@ -9,6 +11,7 @@ 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'; @@ -18,9 +21,16 @@ 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(), diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index a02fa2c..3046f67 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -1175,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 bcc9b65..82c18a7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 6dc5a41..6ac3b22 100644 --- a/test/app/floatick_app_test.dart +++ b/test/app/floatick_app_test.dart @@ -130,13 +130,36 @@ 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')), ); @@ -1322,8 +1345,10 @@ void main() { ); windowBridge.expandRequestHandler?.call(WindowExpansionAnchor.topRight); await tester.pumpAndSettle(); - await tester.tap(find.text('Archive 1')); + 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); @@ -1400,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, ); 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/tool/release/smoke_test_app.sh b/tool/release/smoke_test_app.sh index 9a9117b..ef6fea7 100755 --- a/tool/release/smoke_test_app.sh +++ b/tool/release/smoke_test_app.sh @@ -17,6 +17,11 @@ if [[ ! -d "$app_path" || "$app_path" != *.app ]]; then 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 @@ -35,6 +40,11 @@ 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() { @@ -43,10 +53,11 @@ cleanup() { wait "$app_pid" >/dev/null 2>&1 || true fi rm -f "$log_path" + rm -rf "$test_home" } trap cleanup EXIT -"$executable_path" >"$log_path" 2>&1 & +HOME="$test_home" "$executable_path" >"$log_path" 2>&1 & app_pid=$! sleep "$startup_seconds" @@ -62,4 +73,16 @@ if ! kill -0 "$app_pid" >/dev/null 2>&1; then exit 1 fi -echo "App remained running for the ${startup_seconds}s startup smoke test." +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/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