From 71abb4102881b5eca4c86087c152369db267e953 Mon Sep 17 00:00:00 2001 From: lucaslushuo Date: Wed, 29 Jul 2026 18:09:06 +0800 Subject: [PATCH 1/3] feat(app): streamline todo handoff and window behavior --- integration_test/floatick_ui_test.dart | 25 +- lib/app/floatick_app.dart | 35 +- lib/core/platform/window_bridge.dart | 65 +- .../settings/domain/app_settings.dart | 28 +- .../presentation/settings_drawer.dart | 29 + .../presentation/settings_view_model.dart | 9 + .../widgets/sticky_board_todo_details.dart | 41 +- .../todos/domain/todo_markdown_formatter.dart | 27 + .../todo_clipboard_controller.dart | 61 ++ .../presentation/todo_editor_drawer.dart | 19 + .../todos/presentation/widgets/tag_menus.dart | 95 +-- .../widgets/todo_actions_bottom_sheet.dart | 284 ++++++++ .../widgets/todo_copy_button.dart | 87 +++ .../presentation/widgets/todo_list_row.dart | 606 +++++++++--------- lib/l10n/app_en.arb | 9 + lib/l10n/app_localizations.dart | 54 ++ lib/l10n/app_localizations_en.dart | 28 + lib/l10n/app_localizations_zh.dart | 27 + lib/l10n/app_zh.arb | 9 + lib/main.dart | 9 +- macos/Runner/AppDelegate.swift | 5 + macos/Runner/MainFlutterWindow.swift | 193 +++++- macos/RunnerTests/RunnerTests.swift | 80 +++ test/app/floatick_app_test.dart | 99 ++- test/core/platform/window_bridge_test.dart | 44 +- .../data/settings_repository_test.dart | 43 +- .../settings_view_model_test.dart | 25 + .../sticky_board_window_coordinator_test.dart | 6 + .../sticky_board_todo_details_test.dart | 27 + .../domain/todo_markdown_formatter_test.dart | 45 ++ .../presentation/todo_editor_drawer_test.dart | 25 + .../presentation/todo_list_row_test.dart | 109 +++- 32 files changed, 1804 insertions(+), 444 deletions(-) create mode 100644 lib/features/todos/domain/todo_markdown_formatter.dart create mode 100644 lib/features/todos/presentation/todo_clipboard_controller.dart create mode 100644 lib/features/todos/presentation/widgets/todo_actions_bottom_sheet.dart create mode 100644 lib/features/todos/presentation/widgets/todo_copy_button.dart create mode 100644 test/features/todos/domain/todo_markdown_formatter_test.dart diff --git a/integration_test/floatick_ui_test.dart b/integration_test/floatick_ui_test.dart index 954f320..ae7f2c3 100644 --- a/integration_test/floatick_ui_test.dart +++ b/integration_test/floatick_ui_test.dart @@ -76,7 +76,16 @@ void main() { await tester.pumpAndSettle(); expect(harness.todoController.itemById('ui-todo-1')?.isCompleted, isTrue); - await tester.tap(find.byKey(const Key('archive-todo-ui-todo-1'))); + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(mouse.removePointer); + await mouse.addPointer(); + await mouse.moveTo( + tester.getCenter(find.byKey(const Key('todo-title-ui-todo-1'))), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('more-todo-ui-todo-1'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('todo-action-archive-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'))); @@ -87,7 +96,13 @@ void main() { findsOneWidget, ); - await tester.tap(find.byKey(const Key('restore-todo-ui-todo-1'))); + await mouse.moveTo( + tester.getCenter(find.byKey(const Key('todo-title-ui-todo-1'))), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('more-todo-ui-todo-1'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('todo-action-restore-ui-todo-1'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('archive-scope-button'))); await tester.pumpAndSettle(); @@ -441,6 +456,12 @@ class _UiTestWindowBridge implements WindowBridge { expandRequestHandler = handler; } + @override + void setCollapseRequestHandler(CollapseRequestHandler? handler) {} + + @override + Future synchronizeCollapsedState() async {} + @override Future preferredExpansionAnchor() async { return WindowExpansionAnchor.topRight; diff --git a/lib/app/floatick_app.dart b/lib/app/floatick_app.dart index fbe2b29..52aa66f 100644 --- a/lib/app/floatick_app.dart +++ b/lib/app/floatick_app.dart @@ -99,6 +99,7 @@ class _FloatickShellState extends State<_FloatickShell> { bool _isExpanded = false; bool _isChangingWindow = false; + bool _collapseAfterWindowChange = false; bool _isPanelPrepared = false; bool _panelTooltipsEnabled = false; bool _hasSyncedPreferredLanguage = false; @@ -118,6 +119,7 @@ class _FloatickShellState extends State<_FloatickShell> { void initState() { super.initState(); widget.windowBridge.setExpandRequestHandler(_handleNativeExpandRequest); + widget.windowBridge.setCollapseRequestHandler(_handleNativeCollapseRequest); widget.controller.addListener(_handleTodoStateChanged); widget.settingsController.addListener(_handleSettingsChanged); widget.stickyBoardWindowCoordinator.setMainWindowRequestHandler( @@ -137,7 +139,11 @@ class _FloatickShellState extends State<_FloatickShell> { super.didUpdateWidget(oldWidget); if (oldWidget.windowBridge != widget.windowBridge) { oldWidget.windowBridge.setExpandRequestHandler(null); + oldWidget.windowBridge.setCollapseRequestHandler(null); widget.windowBridge.setExpandRequestHandler(_handleNativeExpandRequest); + widget.windowBridge.setCollapseRequestHandler( + _handleNativeCollapseRequest, + ); _hasSyncedPreferredLanguage = false; _hasSyncedPreferredTheme = false; _hasSyncedAlwaysOnTop = false; @@ -178,6 +184,7 @@ class _FloatickShellState extends State<_FloatickShell> { @override void dispose() { widget.windowBridge.setExpandRequestHandler(null); + widget.windowBridge.setCollapseRequestHandler(null); widget.controller.removeListener(_handleTodoStateChanged); widget.settingsController.removeListener(_handleSettingsChanged); widget.stickyBoardWindowCoordinator.setMainWindowRequestHandler(null); @@ -193,6 +200,9 @@ class _FloatickShellState extends State<_FloatickShell> { } void _handleSettingsChanged() { + if (!widget.settingsController.collapseWhenClickingOutside) { + _collapseAfterWindowChange = false; + } unawaited(_syncPreferredLanguage()); unawaited(_syncPreferredTheme()); unawaited(_syncAlwaysOnTop()); @@ -227,13 +237,12 @@ class _FloatickShellState extends State<_FloatickShell> { return _panelPreparationFuture ??= _preparePanel(); } - Future _preparePanel() async { + Future _preparePanel() { if (!_isPanelPrepared && mounted) { setState(() => _isPanelPrepared = true); - await WidgetsBinding.instance.endOfFrame; } _startRendererWarmUp(); - await _rendererWarmUpFuture; + return Future.value(); } Future _syncPreferredLanguage() async { @@ -320,6 +329,18 @@ class _FloatickShellState extends State<_FloatickShell> { unawaited(_setExpanded(true, requestedAnchor: expansionAnchor)); } + void _handleNativeCollapseRequest() { + if (!_isExpanded || + !widget.settingsController.collapseWhenClickingOutside) { + return; + } + if (_isChangingWindow) { + _collapseAfterWindowChange = true; + return; + } + unawaited(_setExpanded(false)); + } + void _enablePanelTooltips() { if (!_isExpanded || _panelTooltipsEnabled) { return; @@ -407,7 +428,15 @@ class _FloatickShellState extends State<_FloatickShell> { } } finally { if (mounted) { + final shouldCollapseAfterWindowChange = + _collapseAfterWindowChange && + _isExpanded && + widget.settingsController.collapseWhenClickingOutside; + _collapseAfterWindowChange = false; setState(() => _isChangingWindow = false); + if (shouldCollapseAfterWindowChange) { + unawaited(_setExpanded(false)); + } } } } diff --git a/lib/core/platform/window_bridge.dart b/lib/core/platform/window_bridge.dart index f27c088..d5ecbd0 100644 --- a/lib/core/platform/window_bridge.dart +++ b/lib/core/platform/window_bridge.dart @@ -16,10 +16,15 @@ enum WindowExpansionAnchor { typedef ExpandRequestHandler = void Function(WindowExpansionAnchor expansionAnchor); +typedef CollapseRequestHandler = void Function(); abstract interface class WindowBridge { void setExpandRequestHandler(ExpandRequestHandler? handler); + void setCollapseRequestHandler(CollapseRequestHandler? handler); + + Future synchronizeCollapsedState(); + Future preferredExpansionAnchor(); Future setExpanded(bool expanded, {bool animated = true}); @@ -41,16 +46,42 @@ abstract interface class WindowBridge { } class MethodChannelWindowBridge implements WindowBridge { - MethodChannelWindowBridge() { + MethodChannelWindowBridge([ + this._channel = const MethodChannel('floatick/window'), + ]) { _channel.setMethodCallHandler(_handleNativeMethod); } - static const MethodChannel _channel = MethodChannel('floatick/window'); + final MethodChannel _channel; ExpandRequestHandler? _expandRequestHandler; + CollapseRequestHandler? _collapseRequestHandler; + WindowExpansionAnchor? _pendingExpansionAnchor; + bool _pendingCollapseRequest = false; @override void setExpandRequestHandler(ExpandRequestHandler? handler) { _expandRequestHandler = handler; + final pendingExpansionAnchor = _pendingExpansionAnchor; + if (handler == null || pendingExpansionAnchor == null) { + return; + } + _pendingExpansionAnchor = null; + handler(pendingExpansionAnchor); + } + + @override + void setCollapseRequestHandler(CollapseRequestHandler? handler) { + _collapseRequestHandler = handler; + if (handler == null || !_pendingCollapseRequest) { + return; + } + _pendingCollapseRequest = false; + handler(); + } + + @override + Future synchronizeCollapsedState() { + return _channel.invokeMethod('synchronizeCollapsedState'); } @override @@ -112,12 +143,30 @@ class MethodChannelWindowBridge implements WindowBridge { } Future _handleNativeMethod(MethodCall call) async { - if (call.method == 'requestExpand') { - _expandRequestHandler?.call( - WindowExpansionAnchor.fromWireValue(call.arguments), - ); - return; + switch (call.method) { + case 'requestExpand': + final expansionAnchor = WindowExpansionAnchor.fromWireValue( + call.arguments, + ); + final handler = _expandRequestHandler; + if (handler == null) { + _pendingExpansionAnchor = expansionAnchor; + } else { + handler(expansionAnchor); + } + return; + case 'requestCollapse': + final handler = _collapseRequestHandler; + if (handler == null) { + _pendingCollapseRequest = true; + } else { + handler(); + } + return; + default: + throw MissingPluginException( + 'Unsupported native method: ${call.method}', + ); } - throw MissingPluginException('Unsupported native method: ${call.method}'); } } diff --git a/lib/features/settings/domain/app_settings.dart b/lib/features/settings/domain/app_settings.dart index 91c7e58..27f766d 100644 --- a/lib/features/settings/domain/app_settings.dart +++ b/lib/features/settings/domain/app_settings.dart @@ -41,21 +41,26 @@ class AppSettings { this.themePreference = AppThemePreference.system, this.languagePreference = AppLanguagePreference.system, this.alwaysOnTop = true, + this.collapseWhenClickingOutside = true, }); final AppThemePreference themePreference; final AppLanguagePreference languagePreference; final bool alwaysOnTop; + final bool collapseWhenClickingOutside; AppSettings copyWith({ AppThemePreference? themePreference, AppLanguagePreference? languagePreference, bool? alwaysOnTop, + bool? collapseWhenClickingOutside, }) { return AppSettings( themePreference: themePreference ?? this.themePreference, languagePreference: languagePreference ?? this.languagePreference, alwaysOnTop: alwaysOnTop ?? this.alwaysOnTop, + collapseWhenClickingOutside: + collapseWhenClickingOutside ?? this.collapseWhenClickingOutside, ); } @@ -75,6 +80,14 @@ class AppSettings { throw const FormatException('Settings alwaysOnTop must be a Boolean.'); } + final rawCollapseWhenClickingOutside = json['collapseWhenClickingOutside']; + if (rawCollapseWhenClickingOutside != null && + rawCollapseWhenClickingOutside is! bool) { + throw const FormatException( + 'Settings collapseWhenClickingOutside must be a Boolean.', + ); + } + return AppSettings( themePreference: rawTheme == null ? AppThemePreference.system @@ -83,15 +96,17 @@ class AppSettings { ? AppLanguagePreference.system : AppLanguagePreference.fromStorageValue(rawLanguage), alwaysOnTop: rawAlwaysOnTop ?? true, + collapseWhenClickingOutside: rawCollapseWhenClickingOutside ?? true, ); } Map toJson() { return { - 'version': 3, + 'version': 4, 'theme': themePreference.storageValue, 'language': languagePreference.storageValue, 'alwaysOnTop': alwaysOnTop, + 'collapseWhenClickingOutside': collapseWhenClickingOutside, }; } @@ -100,10 +115,15 @@ class AppSettings { return other is AppSettings && themePreference == other.themePreference && languagePreference == other.languagePreference && - alwaysOnTop == other.alwaysOnTop; + alwaysOnTop == other.alwaysOnTop && + collapseWhenClickingOutside == other.collapseWhenClickingOutside; } @override - int get hashCode => - Object.hash(themePreference, languagePreference, alwaysOnTop); + int get hashCode => Object.hash( + themePreference, + languagePreference, + alwaysOnTop, + collapseWhenClickingOutside, + ); } diff --git a/lib/features/settings/presentation/settings_drawer.dart b/lib/features/settings/presentation/settings_drawer.dart index 089b455..bd3ed18 100644 --- a/lib/features/settings/presentation/settings_drawer.dart +++ b/lib/features/settings/presentation/settings_drawer.dart @@ -90,6 +90,8 @@ class SettingsDrawer extends StatelessWidget { ), const SizedBox(height: 6), _AlwaysOnTopSetting(viewModel: viewModel), + const SizedBox(height: 2), + _CollapseWhenClickingOutsideSetting(viewModel: viewModel), const SizedBox(height: 24), Text( context.l10n.startupSectionTitle, @@ -200,6 +202,33 @@ class _OpenAtLoginSetting extends StatelessWidget { } } +class _CollapseWhenClickingOutsideSetting extends StatelessWidget { + const _CollapseWhenClickingOutsideSetting({required this.viewModel}); + + final SettingsViewModel viewModel; + + @override + Widget build(BuildContext context) { + final enabled = !viewModel.isSaving; + return _SettingsToggleRow( + settingKey: const Key('collapse-when-clicking-outside-setting'), + toggleKey: const Key('collapse-when-clicking-outside-toggle'), + label: context.l10n.collapseWhenClickingOutsideLabel, + value: viewModel.collapseWhenClickingOutside, + enabled: enabled, + onTap: enabled + ? () { + unawaited( + viewModel.setCollapseWhenClickingOutside( + !viewModel.collapseWhenClickingOutside, + ), + ); + } + : null, + ); + } +} + class _SettingsToggleRow extends StatelessWidget { const _SettingsToggleRow({ required this.settingKey, diff --git a/lib/features/settings/presentation/settings_view_model.dart b/lib/features/settings/presentation/settings_view_model.dart index f50c1fe..66ef941 100644 --- a/lib/features/settings/presentation/settings_view_model.dart +++ b/lib/features/settings/presentation/settings_view_model.dart @@ -27,6 +27,7 @@ class SettingsViewModel extends ChangeNotifier { AppThemePreference get themePreference => _settings.themePreference; AppLanguagePreference get languagePreference => _settings.languagePreference; bool get alwaysOnTop => _settings.alwaysOnTop; + bool get collapseWhenClickingOutside => _settings.collapseWhenClickingOutside; LoginItemStatus get loginItemStatus => _loginItemStatus; bool get openAtLogin => _loginItemStatus == LoginItemStatus.enabled; bool get canChangeOpenAtLogin => @@ -98,6 +99,14 @@ class SettingsViewModel extends ChangeNotifier { await _save(_settings.copyWith(alwaysOnTop: alwaysOnTop)); } + Future setCollapseWhenClickingOutside(bool enabled) async { + if (_isSaving || enabled == _settings.collapseWhenClickingOutside) { + return; + } + + await _save(_settings.copyWith(collapseWhenClickingOutside: enabled)); + } + Future setOpenAtLogin(bool enabled) async { if (!canChangeOpenAtLogin || enabled == openAtLogin) { return; 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 1db8f0b..258f604 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 @@ -3,10 +3,12 @@ import 'package:flutter/material.dart'; import '../../../../l10n/l10n.dart'; import '../../../todos/domain/todo_item.dart'; import '../../../todos/domain/todo_tag.dart'; +import '../../../todos/presentation/todo_clipboard_controller.dart'; import '../../../todos/presentation/widgets/floatick_tag_chip.dart'; +import '../../../todos/presentation/widgets/todo_copy_button.dart'; import '../../../todos/presentation/widgets/todo_markdown.dart'; -class StickyBoardTodoDetails extends StatelessWidget { +class StickyBoardTodoDetails extends StatefulWidget { const StickyBoardTodoDetails({ required this.item, required this.tags, @@ -18,10 +20,34 @@ class StickyBoardTodoDetails extends StatelessWidget { final List tags; final VoidCallback onBack; + @override + State createState() => _StickyBoardTodoDetailsState(); +} + +class _StickyBoardTodoDetailsState extends State { + final _copyController = TodoClipboardController(); + + @override + void didUpdateWidget(covariant StickyBoardTodoDetails oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.item.id != widget.item.id || + oldWidget.item.title != widget.item.title || + oldWidget.item.content != widget.item.content) { + _copyController.reset(); + } + } + + @override + void dispose() { + _copyController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); final onSurface = theme.colorScheme.onSurface; + final item = widget.item; return Column( key: const Key('sticky-board-todo-details'), @@ -34,7 +60,7 @@ class StickyBoardTodoDetails extends StatelessWidget { IconButton( key: const Key('sticky-board-details-back'), tooltip: MaterialLocalizations.of(context).backButtonTooltip, - onPressed: onBack, + onPressed: widget.onBack, icon: const Icon(Icons.arrow_back_rounded, size: 18), ), const SizedBox(width: 2), @@ -46,6 +72,13 @@ class StickyBoardTodoDetails extends StatelessWidget { ), ), ), + TodoCopyButton( + key: const Key('sticky-board-details-copy'), + item: item, + controller: _copyController, + dimension: 40, + iconSize: 18, + ), ], ), ), @@ -60,7 +93,7 @@ class StickyBoardTodoDetails extends StatelessWidget { ), ), ), - if (tags.isNotEmpty) + if (widget.tags.isNotEmpty) Padding( padding: const EdgeInsets.fromLTRB(18, 10, 18, 2), child: Wrap( @@ -68,7 +101,7 @@ class StickyBoardTodoDetails extends StatelessWidget { spacing: 6, runSpacing: 5, children: [ - for (final tag in tags) + for (final tag in widget.tags) FloatickTagChip( key: ValueKey('sticky-board-details-tag-${tag.id}'), tag: tag, diff --git a/lib/features/todos/domain/todo_markdown_formatter.dart b/lib/features/todos/domain/todo_markdown_formatter.dart new file mode 100644 index 0000000..2430f90 --- /dev/null +++ b/lib/features/todos/domain/todo_markdown_formatter.dart @@ -0,0 +1,27 @@ +import 'todo_item.dart'; + +abstract final class TodoMarkdownFormatter { + static String format(TodoItem item) { + final title = item.title.trim().replaceAll(RegExp(r'\s*[\r\n]+\s*'), ' '); + final content = _trimBlankEdgeLines( + item.content.replaceAll('\r\n', '\n').replaceAll('\r', '\n'), + ); + if (content.isEmpty) { + return '# $title'; + } + return '# $title\n\n$content'; + } + + static String _trimBlankEdgeLines(String value) { + final lines = value.split('\n'); + var start = 0; + var end = lines.length; + while (start < end && lines[start].trim().isEmpty) { + start += 1; + } + while (end > start && lines[end - 1].trim().isEmpty) { + end -= 1; + } + return lines.sublist(start, end).join('\n'); + } +} diff --git a/lib/features/todos/presentation/todo_clipboard_controller.dart b/lib/features/todos/presentation/todo_clipboard_controller.dart new file mode 100644 index 0000000..8be8b26 --- /dev/null +++ b/lib/features/todos/presentation/todo_clipboard_controller.dart @@ -0,0 +1,61 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import '../domain/todo_item.dart'; +import '../domain/todo_markdown_formatter.dart'; + +enum TodoCopyStatus { idle, copied, failed } + +class TodoClipboardController extends ValueNotifier { + TodoClipboardController() : super(TodoCopyStatus.idle); + + static const feedbackDuration = Duration(milliseconds: 1200); + + Timer? _resetTimer; + bool _isDisposed = false; + + Future copy(TodoItem item) async { + _resetTimer?.cancel(); + value = TodoCopyStatus.idle; + try { + await Clipboard.setData( + ClipboardData(text: TodoMarkdownFormatter.format(item)), + ); + if (!_isDisposed) { + value = TodoCopyStatus.copied; + } + } on Object { + if (!_isDisposed) { + value = TodoCopyStatus.failed; + } + } + _scheduleReset(); + } + + void reset() { + _resetTimer?.cancel(); + if (!_isDisposed && value != TodoCopyStatus.idle) { + value = TodoCopyStatus.idle; + } + } + + void _scheduleReset() { + if (_isDisposed) { + return; + } + _resetTimer = Timer(feedbackDuration, () { + if (!_isDisposed) { + value = TodoCopyStatus.idle; + } + }); + } + + @override + void dispose() { + _isDisposed = true; + _resetTimer?.cancel(); + super.dispose(); + } +} diff --git a/lib/features/todos/presentation/todo_editor_drawer.dart b/lib/features/todos/presentation/todo_editor_drawer.dart index e5cfffb..3a951a6 100644 --- a/lib/features/todos/presentation/todo_editor_drawer.dart +++ b/lib/features/todos/presentation/todo_editor_drawer.dart @@ -8,7 +8,9 @@ import '../../../core/ui/floatick_hover_motion.dart'; import '../../../l10n/l10n.dart'; import '../domain/todo_item.dart'; import '../domain/todo_tag.dart'; +import 'todo_clipboard_controller.dart'; import 'widgets/floatick_tag_chip.dart'; +import 'widgets/todo_copy_button.dart'; import 'widgets/todo_markdown.dart'; enum TodoEditorDrawerMode { create, details, edit } @@ -56,6 +58,7 @@ class _TodoEditorDrawerState extends State { final _contentController = TextEditingController(); final _titleFocusNode = FocusNode(); final _contentFocusNode = FocusNode(); + final _copyController = TodoClipboardController(); bool _showPreview = false; bool _isSaving = false; @@ -84,6 +87,7 @@ class _TodoEditorDrawerState extends State { oldWidget.item?.content != widget.item?.content; final didOpen = !oldWidget.isOpen && widget.isOpen; if (changedContext) { + _copyController.reset(); _formKey.currentState?.reset(); _syncControllers(); _showPreview = false; @@ -105,6 +109,7 @@ class _TodoEditorDrawerState extends State { _contentController.dispose(); _titleFocusNode.dispose(); _contentFocusNode.dispose(); + _copyController.dispose(); super.dispose(); } @@ -203,7 +208,9 @@ class _TodoEditorDrawerState extends State { children: [ _DrawerHeader( mode: widget.mode, + item: widget.item, canEdit: widget.canEdit, + copyController: _copyController, onEdit: widget.onEdit, onClose: widget.onClose, closeFocusNode: widget.closeFocusNode, @@ -273,14 +280,18 @@ class _TodoEditorDrawerState extends State { class _DrawerHeader extends StatelessWidget { const _DrawerHeader({ required this.mode, + required this.item, required this.canEdit, + required this.copyController, required this.onEdit, required this.onClose, required this.closeFocusNode, }); final TodoEditorDrawerMode mode; + final TodoItem? item; final bool canEdit; + final TodoClipboardController copyController; final VoidCallback onEdit; final VoidCallback onClose; final FocusNode closeFocusNode; @@ -304,6 +315,14 @@ class _DrawerHeader extends StatelessWidget { ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), ), ), + if (mode == TodoEditorDrawerMode.details && item != null) + TodoCopyButton( + key: const Key('todo-details-copy'), + item: item!, + controller: copyController, + dimension: 40, + iconSize: 19, + ), if (mode == TodoEditorDrawerMode.details && canEdit) IconButton( key: const Key('todo-details-edit'), diff --git a/lib/features/todos/presentation/widgets/tag_menus.dart b/lib/features/todos/presentation/widgets/tag_menus.dart index 0eb99fa..77763b6 100644 --- a/lib/features/todos/presentation/widgets/tag_menus.dart +++ b/lib/features/todos/presentation/widgets/tag_menus.dart @@ -6,7 +6,6 @@ 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_selection_row.dart'; const double _tagFilterButtonDimension = 42; @@ -132,79 +131,27 @@ class TagFilterButton extends StatelessWidget { } } -class TagAssignmentMenu extends StatefulWidget { - const TagAssignmentMenu({ - required this.todoId, - required this.tags, - required this.assignedTagIds, - required this.onToggle, - required this.onManageTags, - super.key, - }); - - final String todoId; - final List tags; - final List assignedTagIds; - final Future Function(String tagId) onToggle; - final VoidCallback onManageTags; - - @override - State createState() => _TagAssignmentMenuState(); -} - -class _TagAssignmentMenuState extends State { - Future _openBottomSheet() async { - final shouldManageTags = await showFloatickModalBottomSheet( - context: context, - builder: (context) { - return _TagAssignmentBottomSheet( - todoId: widget.todoId, - tags: widget.tags, - assignedTagIds: widget.assignedTagIds, - onToggle: widget.onToggle, - ); - }, - ); - if (shouldManageTags == true && mounted) { - widget.onManageTags(); - } - } - - @override - Widget build(BuildContext context) { - final assignedIds = widget.assignedTagIds.toSet(); - final assignedTags = widget.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-${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, - ), - ), - ), - ], - ); +Future showTodoTagAssignmentSheet({ + required BuildContext context, + required String todoId, + required List tags, + required List assignedTagIds, + required Future Function(String tagId) onToggle, + required VoidCallback onManageTags, +}) async { + final shouldManageTags = await showFloatickModalBottomSheet( + context: context, + builder: (context) { + return _TagAssignmentBottomSheet( + todoId: todoId, + tags: tags, + assignedTagIds: assignedTagIds, + onToggle: onToggle, + ); + }, + ); + if (shouldManageTags == true && context.mounted) { + onManageTags(); } } diff --git a/lib/features/todos/presentation/widgets/todo_actions_bottom_sheet.dart b/lib/features/todos/presentation/widgets/todo_actions_bottom_sheet.dart new file mode 100644 index 0000000..9557f0d --- /dev/null +++ b/lib/features/todos/presentation/widgets/todo_actions_bottom_sheet.dart @@ -0,0 +1,284 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/ui/floatick_modal_bottom_sheet.dart'; +import '../../../../core/ui/floatick_surface_metrics.dart'; +import '../../../../l10n/l10n.dart'; + +enum TodoActionsSheetAction { + copy, + viewDetails, + edit, + assignTags, + archive, + removeFromStickyBoard, + restore, + deletePermanently, +} + +Future showTodoActionsBottomSheet({ + required BuildContext context, + required String todoId, + required bool archivedScope, + required bool showArchiveAction, + required bool showRemoveFromStickyBoard, +}) { + return showFloatickModalBottomSheet( + context: context, + builder: (context) { + return _TodoActionsBottomSheet( + todoId: todoId, + archivedScope: archivedScope, + showArchiveAction: showArchiveAction, + showRemoveFromStickyBoard: showRemoveFromStickyBoard, + ); + }, + ); +} + +class _TodoActionsBottomSheet extends StatelessWidget { + const _TodoActionsBottomSheet({ + required this.todoId, + required this.archivedScope, + required this.showArchiveAction, + required this.showRemoveFromStickyBoard, + }); + + final String todoId; + final bool archivedScope; + final bool showArchiveAction; + final bool showRemoveFromStickyBoard; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final isMacOS = theme.platform == TargetPlatform.macOS; + final actions = _buildActions(context); + final mediaSize = MediaQuery.sizeOf(context); + final desiredHeight = 82.0 + (actions.length * 48.0); + final maxHeight = mediaSize.height * (mediaSize.width < 600 ? 0.72 : 0.58); + final minimumHeight = maxHeight < 220 ? maxHeight : 220.0; + final sheetHeight = desiredHeight + .clamp(minimumHeight, maxHeight) + .toDouble(); + final sheetBorderRadius = BorderRadius.only( + topLeft: const Radius.circular( + FloatickSurfaceMetrics.bottomSheetTopRadius, + ), + topRight: const Radius.circular( + FloatickSurfaceMetrics.bottomSheetTopRadius, + ), + bottomLeft: Radius.circular( + isMacOS ? FloatickSurfaceMetrics.panelContentRadius : 0, + ), + bottomRight: Radius.circular( + isMacOS ? FloatickSurfaceMetrics.panelContentRadius : 0, + ), + ); + + return SizedBox( + key: const Key('todo-actions-bottom-sheet'), + width: double.infinity, + height: sheetHeight, + child: DecoratedBox( + key: const Key('todo-actions-bottom-sheet-surface'), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF202A2E) : const Color(0xFFF9FBFA), + borderRadius: sheetBorderRadius, + border: Border( + top: BorderSide( + color: isDark + ? Colors.white.withValues(alpha: 0.11) + : Colors.black.withValues(alpha: 0.07), + ), + ), + ), + child: ClipRRect( + borderRadius: sheetBorderRadius, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 8), + Center( + child: Container( + 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: Text( + context.l10n.todoActionsSheetTitle, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + key: const Key('todo-actions-bottom-sheet-close'), + tooltip: MaterialLocalizations.of( + context, + ).closeButtonTooltip, + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close_rounded, size: 19), + ), + ], + ), + ), + Divider( + height: 1, + thickness: 1, + color: isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.06), + ), + Expanded( + child: SafeArea( + top: false, + minimum: const EdgeInsets.fromLTRB(12, 8, 12, 16), + child: ListView( + padding: EdgeInsets.zero, + itemExtent: 48, + children: actions, + ), + ), + ), + ], + ), + ), + ), + ); + } + + List _buildActions(BuildContext context) { + final localizations = context.l10n; + return [ + _TodoActionRow( + key: ValueKey('todo-action-copy-$todoId'), + icon: Icons.content_copy_rounded, + label: localizations.copyTodoAsMarkdownTooltip, + onTap: () => _select(context, TodoActionsSheetAction.copy), + ), + _TodoActionRow( + key: ValueKey('todo-action-view-$todoId'), + icon: Icons.subject_rounded, + label: localizations.viewTodoDetailsTooltip, + onTap: () => _select(context, TodoActionsSheetAction.viewDetails), + ), + if (!archivedScope) ...[ + _TodoActionRow( + key: ValueKey('todo-action-edit-$todoId'), + icon: Icons.edit_outlined, + label: localizations.editTodoAction, + onTap: () => _select(context, TodoActionsSheetAction.edit), + ), + _TodoActionRow( + key: ValueKey('todo-action-tags-$todoId'), + icon: Icons.sell_outlined, + label: localizations.assignTagsTooltip, + onTap: () => _select(context, TodoActionsSheetAction.assignTags), + ), + if (showArchiveAction) + _TodoActionRow( + key: ValueKey('todo-action-archive-$todoId'), + icon: Icons.archive_outlined, + label: localizations.archiveTooltip, + onTap: () => _select(context, TodoActionsSheetAction.archive), + ), + if (showRemoveFromStickyBoard) + _TodoActionRow( + key: ValueKey('todo-action-remove-$todoId'), + icon: Icons.remove_circle_outline_rounded, + label: localizations.removeFromStickyBoardTooltip, + onTap: () => + _select(context, TodoActionsSheetAction.removeFromStickyBoard), + ), + ] else ...[ + _TodoActionRow( + key: ValueKey('todo-action-restore-$todoId'), + icon: Icons.unarchive_outlined, + label: localizations.restoreTooltip, + onTap: () => _select(context, TodoActionsSheetAction.restore), + ), + _TodoActionRow( + key: ValueKey('todo-action-delete-$todoId'), + icon: Icons.delete_outline_rounded, + label: localizations.deleteTodoPermanentlyTooltip, + destructive: true, + onTap: () => + _select(context, TodoActionsSheetAction.deletePermanently), + ), + ], + ]; + } + + void _select(BuildContext context, TodoActionsSheetAction action) { + Navigator.of(context).pop(action); + } +} + +class _TodoActionRow extends StatelessWidget { + const _TodoActionRow({ + required this.icon, + required this.label, + required this.onTap, + this.destructive = false, + super.key, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + final bool destructive; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final foreground = destructive + ? theme.colorScheme.error + : theme.colorScheme.onSurface.withValues(alpha: 0.88); + return Semantics( + button: true, + label: label, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(10), + clipBehavior: Clip.antiAlias, + child: InkWell( + mouseCursor: SystemMouseCursors.click, + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + Icon(icon, size: 18, color: foreground), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: foreground, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/todos/presentation/widgets/todo_copy_button.dart b/lib/features/todos/presentation/widgets/todo_copy_button.dart new file mode 100644 index 0000000..6e87ef1 --- /dev/null +++ b/lib/features/todos/presentation/widgets/todo_copy_button.dart @@ -0,0 +1,87 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../../../l10n/l10n.dart'; +import '../../domain/todo_item.dart'; +import '../todo_clipboard_controller.dart'; + +class TodoCopyButton extends StatelessWidget { + const TodoCopyButton({ + required this.item, + required this.controller, + this.visible = true, + this.dimension = 30, + this.iconSize = 17, + super.key, + }); + + final TodoItem item; + final TodoClipboardController controller; + final bool visible; + final double dimension; + final double iconSize; + + @override + Widget build(BuildContext context) { + final reduceMotion = MediaQuery.disableAnimationsOf(context); + return SizedBox.square( + dimension: dimension, + child: AnimatedOpacity( + duration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 140), + opacity: visible ? 1 : 0, + child: IgnorePointer( + ignoring: !visible, + child: ExcludeFocus( + excluding: !visible, + child: ValueListenableBuilder( + valueListenable: controller, + builder: (context, status, _) { + final (tooltip, icon, color) = switch (status) { + TodoCopyStatus.idle => ( + context.l10n.copyTodoAsMarkdownTooltip, + Icons.content_copy_rounded, + null, + ), + TodoCopyStatus.copied => ( + context.l10n.todoCopiedAsMarkdownMessage, + Icons.check_rounded, + Theme.of(context).colorScheme.primary, + ), + TodoCopyStatus.failed => ( + context.l10n.todoCopyFailedMessage, + Icons.error_outline_rounded, + Theme.of(context).colorScheme.error, + ), + }; + return Semantics( + liveRegion: status != TodoCopyStatus.idle, + label: tooltip, + button: true, + child: IconButton( + tooltip: tooltip, + onPressed: () => unawaited(controller.copy(item)), + padding: EdgeInsets.zero, + icon: AnimatedSwitcher( + duration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 140), + child: Icon( + icon, + key: ValueKey(status), + size: iconSize, + color: color, + ), + ), + ), + ); + }, + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/todos/presentation/widgets/todo_list_row.dart b/lib/features/todos/presentation/widgets/todo_list_row.dart index cb47519..dd06c5a 100644 --- a/lib/features/todos/presentation/widgets/todo_list_row.dart +++ b/lib/features/todos/presentation/widgets/todo_list_row.dart @@ -1,11 +1,16 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import '../../../../core/ui/floatick_hover_motion.dart'; import '../../../../l10n/l10n.dart'; import '../../domain/todo_item.dart'; import '../../domain/todo_tag.dart'; +import '../todo_clipboard_controller.dart'; import 'floatick_tag_chip.dart'; import 'tag_menus.dart'; +import 'todo_actions_bottom_sheet.dart'; +import 'todo_copy_button.dart'; class TodoListRow extends StatefulWidget { const TodoListRow({ @@ -25,6 +30,7 @@ class TodoListRow extends StatefulWidget { this.onDeletePermanently, this.showArchiveAction = true, this.compact = false, + this.hoverEnabled = true, super.key, }) : assert( archivedScope || @@ -52,6 +58,7 @@ class TodoListRow extends StatefulWidget { final VoidCallback? onDeletePermanently; final bool showArchiveAction; final bool compact; + final bool hoverEnabled; @override State createState() => _TodoListRowState(); @@ -59,180 +66,274 @@ class TodoListRow extends StatefulWidget { class _TodoListRowState extends State { final _rowFocusNode = FocusNode(); + final _copyController = TodoClipboardController(); bool _isHovered = false; bool _hasFocus = false; - bool _isConfirmingDelete = false; + + void _setHovered(bool value) { + if (_isHovered == value) { + return; + } + setState(() => _isHovered = value); + } + + @override + void didUpdateWidget(covariant TodoListRow oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.hoverEnabled && !widget.hoverEnabled) { + _isHovered = false; + } + if (oldWidget.item.id != widget.item.id || + oldWidget.item.title != widget.item.title || + oldWidget.item.content != widget.item.content) { + _copyController.reset(); + } + } @override void dispose() { _rowFocusNode.dispose(); + _copyController.dispose(); super.dispose(); } - void _requestPermanentDelete() { - setState(() => _isConfirmingDelete = true); - _rowFocusNode.requestFocus(); + void _openTagAssignment() { + final externalHandler = widget.onOpenTagAssignment; + if (externalHandler != null) { + externalHandler(); + return; + } + unawaited( + showTodoTagAssignmentSheet( + context: context, + todoId: widget.item.id, + tags: widget.tags, + assignedTagIds: widget.assignedTagIds, + onToggle: widget.onToggleTag!, + onManageTags: widget.onOpenTagManagement!, + ), + ); } - void _cancelPermanentDelete() { - setState(() => _isConfirmingDelete = false); + Future _confirmPermanentDelete() async { + final shouldDelete = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text(context.l10n.deleteTodoConfirmationTitle), + content: Text(context.l10n.deleteTodoConfirmationMessage), + actions: [ + TextButton( + key: ValueKey('cancel-delete-todo-${widget.item.id}'), + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(context.l10n.cancelAction), + ), + TextButton( + key: ValueKey('confirm-delete-todo-${widget.item.id}'), + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text( + context.l10n.deleteTodoAction, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ], + ); + }, + ); + if (shouldDelete == true && mounted) { + widget.onDeletePermanently?.call(); + } } - void _confirmPermanentDelete() { - setState(() => _isConfirmingDelete = false); - widget.onDeletePermanently?.call(); + Future _showActions() async { + _rowFocusNode.requestFocus(); + final action = await showTodoActionsBottomSheet( + context: context, + todoId: widget.item.id, + archivedScope: widget.archivedScope, + showArchiveAction: widget.showArchiveAction, + showRemoveFromStickyBoard: widget.onRemoveFromStickyBoard != null, + ); + if (!mounted || action == null) { + return; + } + + switch (action) { + case TodoActionsSheetAction.copy: + await _copyController.copy(widget.item); + return; + case TodoActionsSheetAction.viewDetails: + widget.onOpenDetails(); + return; + case TodoActionsSheetAction.edit: + widget.onEdit?.call(); + return; + case TodoActionsSheetAction.assignTags: + _openTagAssignment(); + return; + case TodoActionsSheetAction.archive: + widget.onArchive(); + return; + case TodoActionsSheetAction.removeFromStickyBoard: + widget.onRemoveFromStickyBoard?.call(); + return; + case TodoActionsSheetAction.restore: + widget.onRestore(); + return; + case TodoActionsSheetAction.deletePermanently: + await _confirmPermanentDelete(); + return; + } } @override Widget build(BuildContext context) { final item = widget.item; final localizations = context.l10n; - final isDark = Theme.of(context).brightness == Brightness.dark; - final onSurface = Theme.of(context).colorScheme.onSurface; + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final onSurface = theme.colorScheme.onSurface; final reduceMotion = MediaQuery.disableAnimationsOf(context); - final showContextActions = _isHovered || _hasFocus || _isConfirmingDelete; - final trailingActionCount = widget.archivedScope - ? 3 - : 2 + - (widget.showArchiveAction ? 1 : 0) + - (widget.onRemoveFromStickyBoard == null ? 0 : 1); + final showContextActions = (widget.hoverEnabled && _isHovered) || _hasFocus; - return Focus( - focusNode: _rowFocusNode, - onFocusChange: (hasFocus) { - if (_hasFocus != hasFocus) { - setState(() => _hasFocus = hasFocus); - } - }, - child: Semantics( - container: true, - label: item.title, - value: item.isCompleted - ? localizations.completedStatus - : localizations.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: EdgeInsets.fromLTRB( - widget.compact ? 4 : 7, - widget.compact ? 6 : 8, - 5, - widget.compact ? 6 : 8, - ), - decoration: BoxDecoration( - color: _isHovered - ? (isDark - ? 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: [ - if (!widget.archivedScope) - Tooltip( - message: item.isCompleted - ? localizations.markIncompleteTooltip - : localizations.markCompleteTooltip, - child: Semantics( - button: true, - checked: item.isCompleted, - child: FloatickHoverMotion( - child: GestureDetector( - key: ValueKey( - 'toggle-todo-${widget.item.id}', - ), - behavior: HitTestBehavior.opaque, - onTap: widget.onToggle, - child: Padding( - padding: const EdgeInsets.all(4), - child: AnimatedContainer( - duration: reduceMotion - ? Duration.zero - : const Duration(milliseconds: 160), - width: 21, - height: 21, - decoration: BoxDecoration( - color: item.isCompleted - ? Theme.of(context).colorScheme.primary - : Colors.transparent, - borderRadius: BorderRadius.circular(7), - border: Border.all( + return GestureDetector( + behavior: HitTestBehavior.translucent, + onSecondaryTapDown: (_) => unawaited(_showActions()), + child: Focus( + focusNode: _rowFocusNode, + onFocusChange: (hasFocus) { + if (_hasFocus != hasFocus) { + setState(() => _hasFocus = hasFocus); + } + }, + child: Semantics( + container: true, + label: item.title, + value: item.isCompleted + ? localizations.completedStatus + : localizations.incompleteStatus, + child: MouseRegion( + onEnter: widget.hoverEnabled ? (_) => _setHovered(true) : null, + onExit: (_) => _setHovered(false), + child: AnimatedContainer( + duration: reduceMotion || !widget.hoverEnabled + ? Duration.zero + : const Duration(milliseconds: 150), + margin: const EdgeInsets.symmetric(vertical: 2), + padding: EdgeInsets.fromLTRB( + widget.compact ? 4 : 7, + widget.compact ? 6 : 8, + 5, + widget.compact ? 6 : 8, + ), + decoration: BoxDecoration( + color: _isHovered + ? (isDark + ? 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: [ + if (!widget.archivedScope) + Tooltip( + message: item.isCompleted + ? localizations.markIncompleteTooltip + : localizations.markCompleteTooltip, + child: Semantics( + button: true, + checked: item.isCompleted, + child: FloatickHoverMotion( + child: GestureDetector( + key: ValueKey( + 'toggle-todo-${widget.item.id}', + ), + behavior: HitTestBehavior.opaque, + onTap: widget.onToggle, + child: Padding( + padding: const EdgeInsets.all(4), + child: AnimatedContainer( + duration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 160), + width: 21, + height: 21, + decoration: BoxDecoration( color: item.isCompleted - ? Theme.of( - context, - ).colorScheme.primary - : onSurface.withValues(alpha: 0.28), - width: 1.4, + ? 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, ), - 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}', + ) + 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), ), - 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, - 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, + const SizedBox(width: 7), + Expanded( + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + key: ValueKey( + 'todo-open-details-region-${widget.item.id}', + ), + behavior: HitTestBehavior.opaque, + onDoubleTap: widget.onOpenDetails, + child: SizedBox( + height: 30, + child: Align( + alignment: Alignment.centerLeft, + child: Text( + item.title, + key: ValueKey( + 'todo-title-${widget.item.id}', ), - fontSize: widget.compact ? 12.5 : 13.5, - height: 1.3, - decoration: item.isCompleted - ? TextDecoration.lineThrough - : null, - decorationColor: onSurface.withValues( - alpha: 0.42, + 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, + ), ), ), ), @@ -240,142 +341,74 @@ class _TodoListRowState extends State { ), ), ), - ), - const SizedBox(width: 3), - SizedBox( - width: trailingActionCount * 30, - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - 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}', - ), - ), - if (widget.archivedScope && _isConfirmingDelete) ...[ - _ActionButton( + const SizedBox(width: 3), + SizedBox( + width: 60, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TodoCopyButton( key: ValueKey( - 'cancel-delete-todo-${widget.item.id}', - ), - 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}', + 'copy-todo-${widget.item.id}', ), + item: item, + controller: _copyController, 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}', + 'more-todo-${widget.item.id}', ), visible: showContextActions, - tooltip: - localizations.removeFromStickyBoardTooltip, - onPressed: widget.onRemoveFromStickyBoard!, - icon: Icons.remove_circle_outline_rounded, + tooltip: localizations.moreTodoActionsTooltip, + onPressed: () => unawaited(_showActions()), + icon: Icons.more_horiz_rounded, ), - ], + ], + ), ), + ], + ), + SizedBox(height: widget.compact ? 3 : 5), + Row( + key: ValueKey( + 'todo-metadata-row-${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, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(width: 36), + Expanded( + child: widget.archivedScope + ? _ReadOnlyTodoTags( + todoId: item.id, + tags: widget.tags, + assignedTagIds: widget.assignedTagIds, + ) + : _ExternalTagAssignment( + todoId: item.id, + tags: widget.tags, + assignedTagIds: widget.assignedTagIds, + onPressed: _openTagAssignment, + ), ), - key: ValueKey('todo-time-${widget.item.id}'), - style: TextStyle( - color: onSurface.withValues(alpha: 0.35), - fontSize: 10.5, + const SizedBox(width: 7), + Text( + _formatTime( + context, + widget.archivedScope + ? (item.archivedAt ?? item.createdAt) + : item.createdAt, + ), + key: ValueKey('todo-time-${widget.item.id}'), + style: TextStyle( + color: onSurface.withValues(alpha: 0.35), + fontSize: 10.5, + ), ), - ), - ], - ), - ], + ], + ), + ], + ), ), ), ), @@ -474,7 +507,6 @@ class _HoverAction extends StatelessWidget { required this.tooltip, required this.onPressed, required this.icon, - this.color, super.key, }); @@ -482,7 +514,6 @@ class _HoverAction extends StatelessWidget { final String tooltip; final VoidCallback onPressed; final IconData icon; - final Color? color; @override Widget build(BuildContext context) { @@ -496,11 +527,14 @@ class _HoverAction extends StatelessWidget { opacity: visible ? 1 : 0, child: IgnorePointer( ignoring: !visible, - child: IconButton( - tooltip: tooltip, - onPressed: onPressed, - padding: EdgeInsets.zero, - icon: Icon(icon, size: 16, color: color), + child: ExcludeFocus( + excluding: !visible, + child: IconButton( + tooltip: tooltip, + onPressed: onPressed, + padding: EdgeInsets.zero, + icon: Icon(icon, size: 17), + ), ), ), ), @@ -508,34 +542,6 @@ class _HoverAction extends StatelessWidget { } } -class _ActionButton extends StatelessWidget { - const _ActionButton({ - required this.tooltip, - required this.onPressed, - required this.icon, - this.color, - super.key, - }); - - final String tooltip; - final VoidCallback onPressed; - final IconData icon; - final Color? color; - - @override - Widget build(BuildContext context) { - return SizedBox.square( - dimension: 30, - child: IconButton( - tooltip: tooltip, - onPressed: onPressed, - padding: EdgeInsets.zero, - icon: Icon(icon, size: 17, color: color), - ), - ); - } -} - String _formatTime(BuildContext context, DateTime date) { final local = date.toLocal(); return MaterialLocalizations.of(context).formatTimeOfDay( diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8bb9c15..0f45ed2 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -11,6 +11,7 @@ "languageEnglishTooltip": "English", "windowSectionTitle": "Window", "alwaysOnTopLabel": "Keep above other apps", + "collapseWhenClickingOutsideLabel": "Collapse when clicking outside", "startupSectionTitle": "Startup", "openAtLoginLabel": "Open at login", "openAtLoginLoadError": "Couldn't read the login item setting.", @@ -177,6 +178,11 @@ "markdownPreviewEmptyMessage": "Nothing to preview yet", "markdownImageBlockedMessage": "Images are not displayed in todo details.", "viewTodoDetailsTooltip": "View details", + "copyTodoAsMarkdownTooltip": "Copy as Markdown", + "todoCopiedAsMarkdownMessage": "Copied as Markdown", + "todoCopyFailedMessage": "Couldn't copy this todo.", + "moreTodoActionsTooltip": "More actions", + "todoActionsSheetTitle": "Actions", "dismissErrorTooltip": "Dismiss error", "completedStatus": "Completed", "incompleteStatus": "Incomplete", @@ -188,6 +194,9 @@ "restoreTooltip": "Restore to todos", "archiveTooltip": "Archive", "deleteTodoPermanentlyTooltip": "Delete permanently", + "deleteTodoConfirmationTitle": "Delete this todo?", + "deleteTodoConfirmationMessage": "This action cannot be undone.", + "deleteTodoAction": "Delete", "cancelDeleteTodoTooltip": "Keep archived todo", "confirmDeleteTodoTooltip": "Permanently delete this todo", "archivedTodoNoContentMessage": "No additional notes were saved.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index c383276..edd9157 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -164,6 +164,12 @@ abstract class AppLocalizations { /// **'Keep above other apps'** String get alwaysOnTopLabel; + /// No description provided for @collapseWhenClickingOutsideLabel. + /// + /// In en, this message translates to: + /// **'Collapse when clicking outside'** + String get collapseWhenClickingOutsideLabel; + /// No description provided for @startupSectionTitle. /// /// In en, this message translates to: @@ -866,6 +872,36 @@ abstract class AppLocalizations { /// **'View details'** String get viewTodoDetailsTooltip; + /// No description provided for @copyTodoAsMarkdownTooltip. + /// + /// In en, this message translates to: + /// **'Copy as Markdown'** + String get copyTodoAsMarkdownTooltip; + + /// No description provided for @todoCopiedAsMarkdownMessage. + /// + /// In en, this message translates to: + /// **'Copied as Markdown'** + String get todoCopiedAsMarkdownMessage; + + /// No description provided for @todoCopyFailedMessage. + /// + /// In en, this message translates to: + /// **'Couldn\'t copy this todo.'** + String get todoCopyFailedMessage; + + /// No description provided for @moreTodoActionsTooltip. + /// + /// In en, this message translates to: + /// **'More actions'** + String get moreTodoActionsTooltip; + + /// No description provided for @todoActionsSheetTitle. + /// + /// In en, this message translates to: + /// **'Actions'** + String get todoActionsSheetTitle; + /// No description provided for @dismissErrorTooltip. /// /// In en, this message translates to: @@ -932,6 +968,24 @@ abstract class AppLocalizations { /// **'Delete permanently'** String get deleteTodoPermanentlyTooltip; + /// No description provided for @deleteTodoConfirmationTitle. + /// + /// In en, this message translates to: + /// **'Delete this todo?'** + String get deleteTodoConfirmationTitle; + + /// No description provided for @deleteTodoConfirmationMessage. + /// + /// In en, this message translates to: + /// **'This action cannot be undone.'** + String get deleteTodoConfirmationMessage; + + /// No description provided for @deleteTodoAction. + /// + /// In en, this message translates to: + /// **'Delete'** + String get deleteTodoAction; + /// No description provided for @cancelDeleteTodoTooltip. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 6337081..89721a7 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -41,6 +41,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get alwaysOnTopLabel => 'Keep above other apps'; + @override + String get collapseWhenClickingOutsideLabel => + 'Collapse when clicking outside'; + @override String get startupSectionTitle => 'Startup'; @@ -439,6 +443,21 @@ class AppLocalizationsEn extends AppLocalizations { @override String get viewTodoDetailsTooltip => 'View details'; + @override + String get copyTodoAsMarkdownTooltip => 'Copy as Markdown'; + + @override + String get todoCopiedAsMarkdownMessage => 'Copied as Markdown'; + + @override + String get todoCopyFailedMessage => 'Couldn\'t copy this todo.'; + + @override + String get moreTodoActionsTooltip => 'More actions'; + + @override + String get todoActionsSheetTitle => 'Actions'; + @override String get dismissErrorTooltip => 'Dismiss error'; @@ -472,6 +491,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get deleteTodoPermanentlyTooltip => 'Delete permanently'; + @override + String get deleteTodoConfirmationTitle => 'Delete this todo?'; + + @override + String get deleteTodoConfirmationMessage => 'This action cannot be undone.'; + + @override + String get deleteTodoAction => 'Delete'; + @override String get cancelDeleteTodoTooltip => 'Keep archived todo'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 99af96c..dfb70da 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -41,6 +41,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get alwaysOnTopLabel => '始终置顶'; + @override + String get collapseWhenClickingOutsideLabel => '点击外部时收起'; + @override String get startupSectionTitle => '启动'; @@ -411,6 +414,21 @@ class AppLocalizationsZh extends AppLocalizations { @override String get viewTodoDetailsTooltip => '查看详情'; + @override + String get copyTodoAsMarkdownTooltip => '复制为 Markdown'; + + @override + String get todoCopiedAsMarkdownMessage => '已复制为 Markdown'; + + @override + String get todoCopyFailedMessage => '无法复制这个待办。'; + + @override + String get moreTodoActionsTooltip => '更多操作'; + + @override + String get todoActionsSheetTitle => '操作'; + @override String get dismissErrorTooltip => '关闭错误提示'; @@ -444,6 +462,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String get deleteTodoPermanentlyTooltip => '永久删除'; + @override + String get deleteTodoConfirmationTitle => '删除这个待办?'; + + @override + String get deleteTodoConfirmationMessage => '此操作无法撤销。'; + + @override + String get deleteTodoAction => '删除'; + @override String get cancelDeleteTodoTooltip => '保留归档待办'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 58ae5dd..56d1f3e 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -11,6 +11,7 @@ "languageEnglishTooltip": "English", "windowSectionTitle": "窗口", "alwaysOnTopLabel": "始终置顶", + "collapseWhenClickingOutsideLabel": "点击外部时收起", "startupSectionTitle": "启动", "openAtLoginLabel": "登录时打开", "openAtLoginLoadError": "暂时无法读取登录项设置。", @@ -128,6 +129,11 @@ "markdownPreviewEmptyMessage": "暂无可预览内容", "markdownImageBlockedMessage": "待办详情中暂不显示图片。", "viewTodoDetailsTooltip": "查看详情", + "copyTodoAsMarkdownTooltip": "复制为 Markdown", + "todoCopiedAsMarkdownMessage": "已复制为 Markdown", + "todoCopyFailedMessage": "无法复制这个待办。", + "moreTodoActionsTooltip": "更多操作", + "todoActionsSheetTitle": "操作", "dismissErrorTooltip": "关闭错误提示", "completedStatus": "已完成", "incompleteStatus": "未完成", @@ -139,6 +145,9 @@ "restoreTooltip": "恢复到待办", "archiveTooltip": "归档", "deleteTodoPermanentlyTooltip": "永久删除", + "deleteTodoConfirmationTitle": "删除这个待办?", + "deleteTodoConfirmationMessage": "此操作无法撤销。", + "deleteTodoAction": "删除", "cancelDeleteTodoTooltip": "保留归档待办", "confirmDeleteTodoTooltip": "永久删除这个待办", "archivedTodoNoContentMessage": "没有保存更多说明。", diff --git a/lib/main.dart b/lib/main.dart index de33615..5041dee 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -21,6 +21,14 @@ import 'features/updates/presentation/update_view_model.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); + final windowBridge = MethodChannelWindowBridge(); + try { + await windowBridge.synchronizeCollapsedState(); + } on Object catch (error, stackTrace) { + debugPrint('Floatick could not synchronize the native window: $error'); + debugPrintStack(stackTrace: stackTrace); + } + final todoRepository = LocalTodoRepository(); final tagRepository = LocalTagRepository(); final controller = TodoViewModel( @@ -48,7 +56,6 @@ Future main() async { updateController.load(), stickyBoardController.load(), ]); - final windowBridge = MethodChannelWindowBridge(); final stickyBoardWindowCoordinator = StickyBoardWindowCoordinator( boardController: stickyBoardController, todoController: controller, diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift index 2c6e329..9565f87 100644 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -16,6 +16,11 @@ class AppDelegate: FlutterAppDelegate { _ sender: NSApplication, hasVisibleWindows flag: Bool ) -> Bool { + if let mainWindow = sender.windows.first(where: { $0 is MainFlutterWindow }) + as? MainFlutterWindow + { + return mainWindow.handleApplicationReopen() + } if MultiviewDesktopPlugin.applicationShouldHandleReopen( sender, hasVisibleWindows: flag diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index 3046f67..308eb05 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -53,8 +53,10 @@ final class MainFlutterWindow: NSWindow { private var loginItemService: LoginItemService? private var appliedAlwaysOnTop: Bool? private var preferredAppearance = PreferredAppearance.system + private var isCollapseRequestPending = false private var secondaryWindowKeyObserver: NSObjectProtocol? private let configuredSecondaryWindows = NSHashTable.weakObjects() + private let initialSecondaryWindows = NSHashTable.weakObjects() override var canBecomeKey: Bool { true } override var canBecomeMain: Bool { true } @@ -90,6 +92,27 @@ final class MainFlutterWindow: NSWindow { super.sendEvent(event) } + override func resignKey() { + super.resignKey() + guard isExpanded else { + return + } + DispatchQueue.main.async { [weak self] in + self?.requestCollapseIfNeeded() + } + } + + func handleApplicationReopen() -> Bool { + if isExpanded { + activateAndFocusFlutterContent() + } else { + orderOut(nil) + collapsedIconPanel?.orderFrontRegardless() + collapsedIconView?.playAttentionAnimation() + } + return true + } + override func awakeFromNib() { let engine = FlutterEngine( name: "floatick_main_engine", @@ -180,6 +203,9 @@ final class MainFlutterWindow: NSWindow { } switch call.method { + case "synchronizeCollapsedState": + self.synchronizeCollapsedState() + result(nil) case "preferredExpansionAnchor": let anchor = self.preferredExpansionAnchor() self.pendingExpansionAnchor = anchor @@ -432,11 +458,27 @@ final class MainFlutterWindow: NSWindow { // apply its WindowOptions. Keep that initial native surface invisible; // the coordinator reveals it only after configuration, positioning and // Flutter's first completed frame. + self.initialSecondaryWindows.add(targetWindow) targetWindow.alphaValue = 0 self.configureTransparentRoundedWindow( targetWindow, flutterViewController: flutterViewController ) + DispatchQueue.main.async { [weak self, weak targetWindow] in + guard let self, let targetWindow else { + return + } + defer { + self.initialSecondaryWindows.remove(targetWindow) + } + guard self.isExpanded else { + return + } + // Creating a pinned board briefly makes its hidden native window key. + // Restore the main window so that this programmatic handoff is not + // mistaken for an outside click. + self.activateAndFocusFlutterContent() + } } } @@ -670,6 +712,30 @@ final class MainFlutterWindow: NSWindow { collapsedDragOverlay = overlay } + private func requestCollapseIfNeeded() { + if + let keyWindow = NSApp.keyWindow, + initialSecondaryWindows.contains(keyWindow) + { + return + } + guard + isExpanded, + !isKeyWindow, + !isCollapseRequestPending, + let windowChannel + else { + return + } + isCollapseRequestPending = true + windowChannel.invokeMethod( + "requestCollapse", + arguments: nil + ) { [weak self] _ in + self?.isCollapseRequestPending = false + } + } + private func setExpanded( _ expanded: Bool, animated: Bool, @@ -719,6 +785,29 @@ final class MainFlutterWindow: NSWindow { } } + private func synchronizeCollapsedState() { + isExpanded = false + isCollapseRequestPending = false + pendingExpansionAnchor = nil + lockMainWindowSize() + alphaValue = 1 + orderOut(nil) + resignKey() + + let targetScreen = screen(containing: collapsedOrigin) + collapsedOrigin = clampedOrigin( + collapsedOrigin, + for: Layout.collapsedSize, + on: targetScreen + ) + collapsedIconPanel?.setFrame( + NSRect(origin: collapsedOrigin, size: Layout.collapsedSize), + display: false + ) + collapsedIconPanel?.alphaValue = 1 + collapsedIconPanel?.orderFrontRegardless() + } + private func lockMainWindowSize() { styleMask = [.borderless] minSize = Layout.expandedSize @@ -896,9 +985,13 @@ final class MainFlutterWindow: NSWindow { private func defaultCollapsedOrigin() -> NSPoint { let visibleFrame = (NSScreen.main ?? NSScreen.screens[0]).visibleFrame + return Self.defaultCollapsedOrigin(in: visibleFrame) + } + + static func defaultCollapsedOrigin(in visibleFrame: NSRect) -> NSPoint { return NSPoint( x: visibleFrame.maxX - Layout.collapsedSize.width - 24, - y: visibleFrame.maxY - Layout.collapsedSize.height - 24 + y: visibleFrame.minY + 24 ) } @@ -962,15 +1055,24 @@ final class MainFlutterWindow: NSWindow { } } -private final class FloatingTodoIconView: NSView { +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 + static let attentionIconAnimationKey = "floatick-attention-icon" + static let attentionGlowAnimationKey = "floatick-attention-glow" + static let attentionDuration: CFTimeInterval = 0.52 + static let reducedMotionAttentionDuration: CFTimeInterval = 0.24 + static let attentionMaximumScale: CGFloat = 1.1 + static let attentionGlowLineWidth: CGFloat = 2 + static let attentionGlowShadowRadius: CGFloat = 4 + static let attentionGlowFrame = brandFrame.insetBy(dx: 1.5, dy: 1.5) } private var activeCount: Int + private(set) var attentionGlowLayer = CAShapeLayer() override var isFlipped: Bool { true } override var isOpaque: Bool { false } @@ -980,6 +1082,8 @@ private final class FloatingTodoIconView: NSView { super.init(frame: frameRect) wantsLayer = true layer?.backgroundColor = NSColor.clear.cgColor + layer?.masksToBounds = false + configureAttentionGlow() } @available(*, unavailable) @@ -995,6 +1099,60 @@ private final class FloatingTodoIconView: NSView { needsDisplay = true } + func playAttentionAnimation( + reduceMotion: Bool = + NSWorkspace.shared.accessibilityDisplayShouldReduceMotion + ) { + guard let layer else { + return + } + + layer.removeAnimation(forKey: Metrics.attentionIconAnimationKey) + attentionGlowLayer.removeAnimation( + forKey: Metrics.attentionGlowAnimationKey + ) + + let glowOpacity = CAKeyframeAnimation(keyPath: "opacity") + glowOpacity.values = [0, 0.7, 0.35, 0] + glowOpacity.keyTimes = [0, 0.24, 0.68, 1] + + let glowAnimation = CAAnimationGroup() + glowAnimation.animations = [glowOpacity] + glowAnimation.duration = reduceMotion + ? Metrics.reducedMotionAttentionDuration + : Metrics.attentionDuration + glowAnimation.timingFunction = CAMediaTimingFunction(name: .easeOut) + attentionGlowLayer.add( + glowAnimation, + forKey: Metrics.attentionGlowAnimationKey + ) + + guard !reduceMotion else { + return + } + + let scale = CAKeyframeAnimation(keyPath: "transform.scale") + scale.values = [1, Metrics.attentionMaximumScale, 0.98, 1.04, 1] + scale.keyTimes = [0, 0.24, 0.46, 0.72, 1] + + let iconAnimation = CAAnimationGroup() + iconAnimation.animations = [scale] + iconAnimation.duration = Metrics.attentionDuration + iconAnimation.timingFunction = CAMediaTimingFunction(name: .easeOut) + layer.add( + iconAnimation, + forKey: Metrics.attentionIconAnimationKey + ) + } + + override func layout() { + super.layout() + CATransaction.begin() + CATransaction.setDisableActions(true) + attentionGlowLayer.frame = bounds + CATransaction.commit() + } + override func draw(_ dirtyRect: NSRect) { super.draw(dirtyRect) drawBrandMark() @@ -1061,6 +1219,37 @@ private final class FloatingTodoIconView: NSView { ) } + private func configureAttentionGlow() { + let glowColor = NSColor( + calibratedRed: 44 / 255, + green: 204 / 255, + blue: 189 / 255, + alpha: 1 + ) + let glowPath = CGPath( + ellipseIn: Metrics.attentionGlowFrame, + transform: nil + ) + attentionGlowLayer.frame = bounds + attentionGlowLayer.path = glowPath + attentionGlowLayer.fillColor = NSColor.clear.cgColor + attentionGlowLayer.strokeColor = glowColor.withAlphaComponent(0.9).cgColor + attentionGlowLayer.lineWidth = Metrics.attentionGlowLineWidth + attentionGlowLayer.shadowColor = glowColor.cgColor + attentionGlowLayer.shadowPath = glowPath + attentionGlowLayer.shadowOffset = .zero + attentionGlowLayer.shadowOpacity = 0.95 + attentionGlowLayer.shadowRadius = Metrics.attentionGlowShadowRadius + attentionGlowLayer.opacity = 0 + attentionGlowLayer.actions = [ + "bounds": NSNull(), + "frame": NSNull(), + "opacity": NSNull(), + "position": NSNull(), + ] + layer?.addSublayer(attentionGlowLayer) + } + private func point(x: CGFloat, y: CGFloat) -> NSPoint { NSPoint( x: Metrics.brandFrame.minX + (Metrics.brandFrame.width * x), diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift index 268b9c3..417887a 100644 --- a/macos/RunnerTests/RunnerTests.swift +++ b/macos/RunnerTests/RunnerTests.swift @@ -26,4 +26,84 @@ class RunnerTests: XCTestCase { XCTAssertTrue(overlay.accessibilityPerformPress()) XCTAssertEqual(pressCount, 1) } + + func testDefaultFloatingIconOriginUsesBottomRightOfVisibleFrame() { + let visibleFrame = NSRect(x: 100, y: 50, width: 1_200, height: 800) + + let origin = MainFlutterWindow.defaultCollapsedOrigin(in: visibleFrame) + + XCTAssertEqual(origin.x, 1_204) + XCTAssertEqual(origin.y, 74) + } + + func testFloatingIconAttentionAnimationIncludesScaleAndGlow() { + let iconView = FloatingTodoIconView( + frame: NSRect(x: 0, y: 0, width: 72, height: 72), + activeCount: 2 + ) + + iconView.playAttentionAnimation(reduceMotion: false) + + let iconAnimation = iconView.layer?.animation( + forKey: "floatick-attention-icon" + ) as? CAAnimationGroup + let iconKeyPaths = iconAnimation?.animations? + .compactMap { ($0 as? CAPropertyAnimation)?.keyPath } + let glowAnimation = iconView.attentionGlowLayer.animation( + forKey: "floatick-attention-glow" + ) as? CAAnimationGroup + let glowKeyPaths = glowAnimation?.animations? + .compactMap { ($0 as? CAPropertyAnimation)?.keyPath } + + XCTAssertEqual(Set(iconKeyPaths ?? []), Set(["transform.scale"])) + XCTAssertEqual(Set(glowKeyPaths ?? []), Set(["opacity"])) + } + + func testFloatingIconGlowFitsInsideTransparentWindowBoundary() { + let iconView = FloatingTodoIconView( + frame: NSRect(x: 0, y: 0, width: 72, height: 72), + activeCount: 2 + ) + + let glowLayer = iconView.attentionGlowLayer + let glowBounds = glowLayer.shadowPath?.boundingBox ?? .zero + let maximumVisibleRadius = ( + (glowBounds.width / 2) + + (glowLayer.lineWidth / 2) + + glowLayer.shadowRadius + ) * 1.1 + + XCTAssertEqual( + glowBounds, + NSRect(x: 11.5, y: 11.5, width: 49, height: 49) + ) + XCTAssertTrue( + glowLayer.path?.contains(CGPoint(x: 36, y: 36)) == true + ) + XCTAssertTrue( + glowLayer.path?.contains(CGPoint(x: 11.5, y: 11.5)) == false + ) + XCTAssertLessThan(maximumVisibleRadius, iconView.bounds.width / 2) + XCTAssertNil(iconView.layer?.shadowPath) + } + + func testFloatingIconAttentionRespectsReducedMotion() { + let iconView = FloatingTodoIconView( + frame: NSRect(x: 0, y: 0, width: 72, height: 72), + activeCount: 0 + ) + + iconView.playAttentionAnimation(reduceMotion: true) + + let glowAnimation = iconView.attentionGlowLayer.animation( + forKey: "floatick-attention-glow" + ) as? CAAnimationGroup + let glowKeyPaths = glowAnimation?.animations? + .compactMap { ($0 as? CAPropertyAnimation)?.keyPath } + + XCTAssertNil( + iconView.layer?.animation(forKey: "floatick-attention-icon") + ) + XCTAssertEqual(Set(glowKeyPaths ?? []), Set(["opacity"])) + } } diff --git a/test/app/floatick_app_test.dart b/test/app/floatick_app_test.dart index 6ac3b22..ad0109f 100644 --- a/test/app/floatick_app_test.dart +++ b/test/app/floatick_app_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:floatick/app/floatick_app.dart'; import 'package:floatick/core/platform/window_bridge.dart'; import 'package:floatick/core/storage/storage_failure.dart'; @@ -180,6 +182,7 @@ 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('更新'), findsOneWidget); @@ -212,6 +215,12 @@ void main() { tester.getSize(find.byKey(const Key('always-on-top-toggle'))), const Size(32, 18), ); + expect( + tester.getSize( + find.byKey(const Key('collapse-when-clicking-outside-toggle')), + ), + const Size(32, 18), + ); expect( tester.getSize(find.byKey(const Key('open-at-login-toggle'))), const Size(32, 18), @@ -227,6 +236,16 @@ void main() { expect(settingsController.alwaysOnTop, isFalse); expect(settingsRepository.savedSettings.alwaysOnTop, isFalse); expect(windowBridge.alwaysOnTopValues, [true, false]); + expect(settingsController.collapseWhenClickingOutside, isTrue); + await tester.tap( + find.byKey(const Key('collapse-when-clicking-outside-setting')), + ); + await tester.pumpAndSettle(); + expect(settingsController.collapseWhenClickingOutside, isFalse); + expect( + settingsRepository.savedSettings.collapseWhenClickingOutside, + isFalse, + ); expect( tester.getSize(find.byKey(const Key('update-settings-section'))).height, lessThan(105), @@ -371,7 +390,16 @@ void main() { const Offset(0, 1), ); - await tester.tap(find.byKey(const Key('view-todo-new-todo'))); + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: Offset.zero); + addTearDown(mouse.removePointer); + await mouse.moveTo( + tester.getCenter(find.text('Design the floating icon').hitTestable()), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('more-todo-new-todo'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('todo-action-view-new-todo'))); await tester.pumpAndSettle(); expect(find.text('详情'), findsOneWidget); expect(find.byKey(const Key('todo-details-title')), findsOneWidget); @@ -409,9 +437,6 @@ void main() { await tester.tap(find.byKey(const Key('todo-drawer-close'))); await tester.pumpAndSettle(); - final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); - await mouse.addPointer(location: Offset.zero); - addTearDown(mouse.removePointer); await mouse.moveTo( tester.getCenter(find.text('Polish the Floatick icon').hitTestable()), ); @@ -424,9 +449,11 @@ void main() { await tester.pumpAndSettle(); expect(tester.takeException(), isNull); - final editButton = find.byKey(const Key('edit-todo-new-todo')); - expect(editButton, findsOneWidget); - await tester.tap(editButton); + final moreButton = find.byKey(const Key('more-todo-new-todo')); + expect(moreButton, findsOneWidget); + await tester.tap(moreButton); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('todo-action-edit-new-todo'))); await tester.pumpAndSettle(); expect(find.text('编辑待办'), findsOneWidget); await tester.enterText( @@ -439,7 +466,12 @@ void main() { expect(find.text('Polish the Floatick icon').hitTestable(), findsOneWidget); expect(windowBridge.expandedValues, [true]); - await tester.sendKeyEvent(LogicalKeyboardKey.escape); + windowBridge.collapseRequestHandler?.call(); + await tester.pumpAndSettle(); + expect(windowBridge.expandedValues, [true]); + + await settingsController.setCollapseWhenClickingOutside(true); + windowBridge.collapseRequestHandler?.call(); await tester.pumpAndSettle(); expect(windowBridge.expandedValues, [true, false]); @@ -452,6 +484,19 @@ void main() { .visible, isFalse, ); + + final expansionBarrier = Completer(); + windowBridge.setExpandedBarrier = expansionBarrier.future; + windowBridge.expandRequestHandler?.call(WindowExpansionAnchor.topRight); + await tester.pump(); + await tester.pump(); + expect(windowBridge.expandedValues.last, isTrue); + + windowBridge.collapseRequestHandler?.call(); + expansionBarrier.complete(); + await tester.pumpAndSettle(); + + expect(windowBridge.expandedValues, [true, false, true, false]); }); testWidgets('tags can be created, assigned, and used as a filter', ( @@ -681,16 +726,6 @@ void main() { 'tag-personal', ]); expect(find.text('Tagged task').hitTestable(), findsOneWidget); - expect( - (tester - .widget( - find.byKey(const Key('assign-tags-todo-1')), - ) - .icon - as Icon) - .icon, - Icons.sell_rounded, - ); await tester.tap(find.byKey(const Key('assign-tags-todo-1'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('assign-todo-1-tag-work'))); @@ -706,16 +741,6 @@ void main() { tester.getSize(find.byKey(const Key('todo-tag-todo-1-tag-work'))).height, 17, ); - expect( - (tester - .widget( - find.byKey(const Key('assign-tags-todo-1')), - ) - .icon - as Icon) - .icon, - Icons.sell_rounded, - ); await tester.tap( find.byKey(const Key('tag-assignment-bottom-sheet-close')), ); @@ -1357,7 +1382,11 @@ void main() { await tester.pumpAndSettle(); boardRepository.failNextSave = true; - await tester.tap(find.byKey(const Key('delete-todo-archived-linked'))); + await tester.tap(find.byKey(const Key('more-todo-archived-linked'))); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const Key('todo-action-delete-archived-linked')), + ); await tester.pumpAndSettle(); await tester.tap( find.byKey(const Key('confirm-delete-todo-archived-linked')), @@ -1443,6 +1472,7 @@ void main() { expect(find.text('Language'), findsOneWidget); expect(find.text('Window'), findsOneWidget); expect(find.text('Keep above other apps'), findsOneWidget); + expect(find.text('Collapse when clicking outside'), findsOneWidget); expect(find.text('Startup'), findsOneWidget); expect(find.text('Open at login'), findsOneWidget); expect(find.text('Updates'), findsOneWidget); @@ -1665,12 +1695,22 @@ class _WidgetTestWindowBridge implements WindowBridge { final List preferredThemeValues = []; final List alwaysOnTopValues = []; ExpandRequestHandler? expandRequestHandler; + CollapseRequestHandler? collapseRequestHandler; + Future? setExpandedBarrier; @override void setExpandRequestHandler(ExpandRequestHandler? handler) { expandRequestHandler = handler; } + @override + void setCollapseRequestHandler(CollapseRequestHandler? handler) { + collapseRequestHandler = handler; + } + + @override + Future synchronizeCollapsedState() async {} + @override Future preferredExpansionAnchor() async { return WindowExpansionAnchor.topRight; @@ -1680,6 +1720,7 @@ class _WidgetTestWindowBridge implements WindowBridge { Future setExpanded(bool expanded, {bool animated = true}) async { expandedValues.add(expanded); expandedAnimatedValues.add(animated); + await setExpandedBarrier; } @override diff --git a/test/core/platform/window_bridge_test.dart b/test/core/platform/window_bridge_test.dart index 1537ad5..0408b61 100644 --- a/test/core/platform/window_bridge_test.dart +++ b/test/core/platform/window_bridge_test.dart @@ -59,20 +59,60 @@ void main() { }); final bridge = MethodChannelWindowBridge(); + await bridge.synchronizeCollapsedState(); await bridge.setFloatingIconCount(7); await bridge.setPreferredTheme('dark'); await bridge.setExpanded(true, animated: false); expect(calls.map((call) => call.method), [ + 'synchronizeCollapsedState', 'setFloatingIconCount', 'setPreferredTheme', 'setExpanded', ]); - expect(calls.first.arguments, 7); - expect(calls[1].arguments, 'dark'); + expect(calls[1].arguments, 7); + expect(calls[2].arguments, 'dark'); expect(calls.last.arguments, { 'expanded': true, 'animated': false, }); }); + + test('replays an expand request received before the UI is ready', () async { + final bridge = MethodChannelWindowBridge(); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + await messenger.handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall( + const MethodCall('requestExpand', 'bottomLeft'), + ), + null, + ); + + final receivedAnchors = []; + bridge.setExpandRequestHandler(receivedAnchors.add); + + expect(receivedAnchors, [ + WindowExpansionAnchor.bottomLeft, + ]); + }); + + test('replays a collapse request received before the UI is ready', () async { + final bridge = MethodChannelWindowBridge(); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + await messenger.handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall(const MethodCall('requestCollapse')), + null, + ); + + var collapseRequestCount = 0; + bridge.setCollapseRequestHandler(() => collapseRequestCount += 1); + + expect(collapseRequestCount, 1); + }); } diff --git a/test/features/settings/data/settings_repository_test.dart b/test/features/settings/data/settings_repository_test.dart index 0c5cdca..ccb9e3e 100644 --- a/test/features/settings/data/settings_repository_test.dart +++ b/test/features/settings/data/settings_repository_test.dart @@ -34,6 +34,7 @@ void main() { expect(settings.themePreference, AppThemePreference.system); expect(settings.languagePreference, AppLanguagePreference.system); expect(settings.alwaysOnTop, isTrue); + expect(settings.collapseWhenClickingOutside, isTrue); expect(await repository.rootDirectory.exists(), isTrue); }, ); @@ -43,6 +44,7 @@ void main() { themePreference: AppThemePreference.light, languagePreference: AppLanguagePreference.simplifiedChinese, alwaysOnTop: false, + collapseWhenClickingOutside: false, ); await repository.save(settings); @@ -51,10 +53,11 @@ void main() { expect(loadedSettings, settings); expect(json, { - 'version': 3, + 'version': 4, 'theme': 'light', 'language': 'zh', 'alwaysOnTop': false, + 'collapseWhenClickingOutside': false, }); }); @@ -81,6 +84,20 @@ void main() { expect(settings.languagePreference, AppLanguagePreference.english); expect(settings.alwaysOnTop, isTrue); + expect(settings.collapseWhenClickingOutside, isTrue); + }); + + test('version 3 settings default to collapsing on outside clicks', () async { + await repository.rootDirectory.create(recursive: true); + await File(repository.storagePath).writeAsString( + '{"version": 3, "theme": "system", "language": "en",' + '"alwaysOnTop": false}', + ); + + final settings = await repository.load(); + + expect(settings.alwaysOnTop, isFalse); + expect(settings.collapseWhenClickingOutside, isTrue); }); test('damaged storage is reported and left unchanged', () async { @@ -142,4 +159,28 @@ void main() { ); expect(await file.readAsString(), damagedContent); }); + + test( + 'invalid outside-click setting is reported and left unchanged', + () async { + await repository.rootDirectory.create(recursive: true); + final file = File(repository.storagePath); + const damagedContent = + '{"version": 4, "theme": "system", "language": "en",' + '"alwaysOnTop": true, "collapseWhenClickingOutside": "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 79f1474..11e680b 100644 --- a/test/features/settings/presentation/settings_view_model_test.dart +++ b/test/features/settings/presentation/settings_view_model_test.dart @@ -27,6 +27,7 @@ void main() { themePreference: AppThemePreference.dark, languagePreference: AppLanguagePreference.english, alwaysOnTop: false, + collapseWhenClickingOutside: false, ); loginItemRepository.status = LoginItemStatus.enabled; @@ -35,6 +36,7 @@ void main() { expect(controller.themePreference, AppThemePreference.dark); expect(controller.languagePreference, AppLanguagePreference.english); expect(controller.alwaysOnTop, isFalse); + expect(controller.collapseWhenClickingOutside, isFalse); expect(controller.openAtLogin, isTrue); expect(controller.error, isNull); expect(controller.loginItemError, isNull); @@ -140,6 +142,29 @@ void main() { }, ); + test('outside-click behavior changes immediately and persists', () async { + await controller.load(); + + await controller.setCollapseWhenClickingOutside(false); + + expect(controller.collapseWhenClickingOutside, isFalse); + expect(repository.savedSettings.collapseWhenClickingOutside, isFalse); + expect(controller.error, isNull); + }); + + test( + 'a failed outside-click save rolls the visible preference back', + () async { + await controller.load(); + repository.failNextSave = true; + + await controller.setCollapseWhenClickingOutside(false); + + expect(controller.collapseWhenClickingOutside, isTrue); + expect(controller.error?.kind, StorageFailureKind.write); + }, + ); + test( 'login item changes immediately and synchronizes native state', () async { 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 33aa25b..0966a37 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 @@ -207,6 +207,12 @@ void main() { } class _MemoryWindowBridge implements WindowBridge { + @override + void setCollapseRequestHandler(CollapseRequestHandler? handler) {} + + @override + Future synchronizeCollapsedState() async {} + @override Future configureBorderlessSecondaryWindow( int viewId, { 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 e96b028..4c06800 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 @@ -3,9 +3,29 @@ import 'package:floatick/features/todos/domain/todo_item.dart'; import 'package:floatick/features/todos/domain/todo_tag.dart'; import 'package:floatick/l10n/app_localizations.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + late String clipboardText; + + setUp(() { + clipboardText = ''; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'Clipboard.setData') { + clipboardText = + (call.arguments as Map)['text'] as String; + } + return null; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); + }); + testWidgets('shows todo content locally without edit actions', ( tester, ) async { @@ -48,6 +68,13 @@ void main() { expect(find.text('Release'), findsOneWidget); expect(find.byKey(const Key('sticky-board-details-edit')), findsNothing); + await tester.tap(find.byKey(const Key('sticky-board-details-copy'))); + await tester.pump(); + expect( + clipboardText, + '# Prepare release\n\n## Checklist\n\n- Verify the DMG', + ); + await tester.tap(find.byKey(const Key('sticky-board-details-back'))); expect(backCount, 1); diff --git a/test/features/todos/domain/todo_markdown_formatter_test.dart b/test/features/todos/domain/todo_markdown_formatter_test.dart new file mode 100644 index 0000000..8e02993 --- /dev/null +++ b/test/features/todos/domain/todo_markdown_formatter_test.dart @@ -0,0 +1,45 @@ +import 'package:floatick/features/todos/domain/todo_item.dart'; +import 'package:floatick/features/todos/domain/todo_markdown_formatter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('formats a title and Markdown content for agent handoff', () { + final item = TodoItem( + id: 'todo-1', + title: ' Prepare agent handoff ', + content: '\n\n- Review context\n- Implement change\n\n', + createdAt: DateTime.utc(2026, 7, 29), + ); + + expect( + TodoMarkdownFormatter.format(item), + '# Prepare agent handoff\n\n' + '- Review context\n' + '- Implement change', + ); + }); + + test('formats a title-only todo without an empty content section', () { + final item = TodoItem( + id: 'todo-2', + title: 'Capture the idea', + createdAt: DateTime.utc(2026, 7, 29), + ); + + expect(TodoMarkdownFormatter.format(item), '# Capture the idea'); + }); + + test('normalizes title line breaks and preserves content indentation', () { + final item = TodoItem( + id: 'todo-3', + title: 'Review\n generated plan', + content: '\r\n indented code\r\n\r\n', + createdAt: DateTime.utc(2026, 7, 29), + ); + + expect( + TodoMarkdownFormatter.format(item), + '# Review generated plan\n\n indented code', + ); + }); +} diff --git a/test/features/todos/presentation/todo_editor_drawer_test.dart b/test/features/todos/presentation/todo_editor_drawer_test.dart index 0cc5ee6..f221f7f 100644 --- a/test/features/todos/presentation/todo_editor_drawer_test.dart +++ b/test/features/todos/presentation/todo_editor_drawer_test.dart @@ -3,9 +3,29 @@ import 'package:floatick/features/todos/domain/todo_item.dart'; import 'package:floatick/features/todos/domain/todo_tag.dart'; import 'package:floatick/l10n/app_localizations.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + late String clipboardText; + + setUp(() { + clipboardText = ''; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'Clipboard.setData') { + clipboardText = + (call.arguments as Map)['text'] as String; + } + return null; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); + }); + testWidgets('a failed create stays open and shows an inline error', ( WidgetTester tester, ) async { @@ -150,6 +170,7 @@ void main() { item: TodoItem( id: 'todo-1', title: 'Prepare release', + content: '- Verify the DMG', createdAt: DateTime.utc(2026, 7, 25), ), availableTags: [tag], @@ -185,6 +206,10 @@ void main() { findsNothing, ); expect(find.text('Work'), findsOneWidget); + + await tester.tap(find.byKey(const Key('todo-details-copy'))); + await tester.pump(); + expect(clipboardText, '# Prepare release\n\n- Verify the DMG'); }); testWidgets('archived details are read-only', (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 4d0d195..33b2d94 100644 --- a/test/features/todos/presentation/todo_list_row_test.dart +++ b/test/features/todos/presentation/todo_list_row_test.dart @@ -6,17 +6,41 @@ 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/services.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + late String clipboardText; + + setUp(() { + clipboardText = ''; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'Clipboard.setData') { + clipboardText = + (call.arguments as Map)['text'] as String; + } + if (call.method == 'Clipboard.getData') { + return {'text': clipboardText}; + } + return null; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); + }); + testWidgets( - 'primary controls align and double-clicking the title opens details', + 'hover actions align, copy Markdown, and double-click opens details', (tester) async { var toggleCount = 0; var detailsCount = 0; final item = TodoItem( id: 'aligned', title: 'Review the aligned row', + content: 'The redundant content indicator should stay hidden.', createdAt: DateTime.utc(2026, 7, 27, 8), ); final tags = [ @@ -55,14 +79,19 @@ void main() { ), ); + 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(); + 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', + 'copy-todo-aligned', + 'more-todo-aligned', ]) { expect( tester.getCenter(find.byKey(Key(key))).dy, @@ -78,6 +107,36 @@ void main() { .dy; expect(timeCenterY, closeTo(tagCenterY, 0.5)); expect(tagCenterY, greaterThan(primaryCenterY + 10)); + expect(find.byKey(const Key('todo-has-content-aligned')), findsNothing); + + await tester.tap(find.byKey(const Key('copy-todo-aligned'))); + await tester.pump(); + expect( + clipboardText, + '# Review the aligned row\n\n' + 'The redundant content indicator should stay hidden.', + ); + expect( + find.descendant( + of: find.byKey(const Key('copy-todo-aligned')), + matching: find.byIcon(Icons.check_rounded), + ), + findsOneWidget, + ); + + await tester.tap(find.byKey(const Key('more-todo-aligned'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('todo-action-view-aligned')), findsOneWidget); + expect(find.byKey(const Key('todo-action-edit-aligned')), findsOneWidget); + expect( + find.byKey(const Key('todo-action-archive-aligned')), + findsOneWidget, + ); + expect(find.byKey(const Key('todo-action-tags-aligned')), findsOneWidget); + await tester.tap( + find.byKey(const Key('todo-actions-bottom-sheet-close')), + ); + await tester.pumpAndSettle(); final detailsRegion = find.byKey( const Key('todo-open-details-region-aligned'), @@ -142,8 +201,21 @@ void main() { expect(find.byIcon(Icons.archive_outlined), findsNothing); await tester.tap(find.byKey(const Key('assign-tags-todo-1'))); + await tester.pumpAndSettle(); expect(openCount, 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('more-todo-todo-1'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('todo-action-tags-todo-1'))); + await tester.pumpAndSettle(); + + expect(openCount, 2); }); testWidgets( @@ -365,7 +437,7 @@ void main() { ); testWidgets( - 'archived row only offers view, restore, and confirmed deletion', + 'archived row menu only offers copy, view, restore, and deletion', (tester) async { var viewCount = 0; var restoreCount = 0; @@ -408,20 +480,29 @@ void main() { ); 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.tap(find.byKey(const Key('more-todo-archived'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('todo-action-edit-archived')), findsNothing); + expect(find.byKey(const Key('todo-action-tags-archived')), findsNothing); + await tester.tap(find.byKey(const Key('todo-action-view-archived'))); + await tester.pumpAndSettle(); + expect(viewCount, 1); + + await tester.tap(find.byKey(const Key('more-todo-archived'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('todo-action-restore-archived'))); + await tester.pumpAndSettle(); + expect(restoreCount, 1); + + await tester.tap(find.byKey(const Key('more-todo-archived'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('todo-action-delete-archived'))); await tester.pumpAndSettle(); expect( From b3395bf18be344bac4322bb93129eee42932cebe Mon Sep 17 00:00:00 2001 From: lucaslushuo Date: Wed, 29 Jul 2026 18:09:18 +0800 Subject: [PATCH 2/3] perf(todos): optimize large-list scrolling --- benchmark/todo_data_benchmark_test.dart | 132 ++++++++++ docs/TESTING.md | 5 + docs/TODO_LIST_PERFORMANCE.md | 124 +++++++++ .../todo_scroll_performance_test.dart | 242 ++++++++++++++++++ .../todos/presentation/todo_panel.dart | 201 +++++++++++---- .../todos/presentation/todo_view_model.dart | 134 +++++++--- .../presentation/todo_view_model_test.dart | 56 ++++ test_driver/performance_test_driver.dart | 5 + 8 files changed, 814 insertions(+), 85 deletions(-) create mode 100644 benchmark/todo_data_benchmark_test.dart create mode 100644 docs/TODO_LIST_PERFORMANCE.md create mode 100644 integration_test/todo_scroll_performance_test.dart create mode 100644 test_driver/performance_test_driver.dart diff --git a/benchmark/todo_data_benchmark_test.dart b/benchmark/todo_data_benchmark_test.dart new file mode 100644 index 0000000..5f35751 --- /dev/null +++ b/benchmark/todo_data_benchmark_test.dart @@ -0,0 +1,132 @@ +import 'dart:io'; + +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_view_model.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// A comparative data-path benchmark, not a UI frame-rate benchmark. +/// +/// Run it explicitly: +/// `flutter test benchmark/todo_data_benchmark_test.dart --reporter expanded` +/// +/// Results vary by hardware and build mode. Keep this outside `test/` so normal +/// unit-test runs do not treat wall-clock measurements as correctness gates. +void main() { + test('todo data path benchmark', () async { + const itemCounts = [100, 1000, 5000, 10000]; + const tagCount = 8; + final createdAt = DateTime.utc(2026, 7, 29, 8); + + debugPrint( + 'items,todo_save_ms,tag_save_ms,load_index_ms,' + 'cold_search_ms,cached_search_ms,tag_filter_ms,todo_bytes,tag_bytes', + ); + + for (final itemCount in itemCounts) { + final directory = await Directory.systemTemp.createTemp( + 'floatick-data-benchmark-', + ); + try { + final todoRepository = LocalTodoRepository(rootDirectory: directory); + final tagRepository = LocalTagRepository(rootDirectory: directory); + final tags = List.generate( + tagCount, + (index) => TodoTag( + id: 'tag-$index', + name: 'Tag $index', + colorValue: 0xFF20BFB2 + index, + createdAt: createdAt, + ), + growable: false, + ); + final todos = List.generate( + itemCount, + (index) => TodoItem( + id: 'todo-$index', + title: 'Todo item $index', + content: 'Benchmark notes for todo item $index.', + createdAt: createdAt.add(Duration(seconds: index)), + ), + growable: false, + ); + final workspace = TagWorkspace( + tags: tags, + assignments: >{ + for (var index = 0; index < itemCount; index++) + 'todo-$index': [ + 'tag-${index % tagCount}', + 'tag-${(index + 1) % tagCount}', + ], + }, + ); + + final todoSave = Stopwatch()..start(); + await todoRepository.save(todos); + todoSave.stop(); + + final tagSave = Stopwatch()..start(); + await tagRepository.save(workspace); + tagSave.stop(); + + final controller = TodoViewModel( + todoRepository: todoRepository, + tagRepository: tagRepository, + ); + final loadAndIndex = Stopwatch()..start(); + await controller.load(); + loadAndIndex.stop(); + + final query = 'item ${itemCount - 1}'; + final coldSearch = Stopwatch()..start(); + final searchResults = controller.itemsForView( + archived: false, + query: query, + ); + coldSearch.stop(); + expect(searchResults.single.id, 'todo-${itemCount - 1}'); + + final cachedSearch = Stopwatch()..start(); + final cachedResults = controller.itemsForView( + archived: false, + query: query, + ); + cachedSearch.stop(); + expect(identical(searchResults, cachedResults), isTrue); + + final tagFilter = Stopwatch()..start(); + final filteredResults = controller.itemsForView( + archived: false, + query: '', + selectedTagIds: const {'tag-0', 'tag-1'}, + ); + tagFilter.stop(); + expect(filteredResults, isNotEmpty); + + final todoBytes = await File(todoRepository.storagePath).length(); + final tagBytes = await File(tagRepository.storagePath).length(); + debugPrint( + '$itemCount,' + '${_milliseconds(todoSave)},' + '${_milliseconds(tagSave)},' + '${_milliseconds(loadAndIndex)},' + '${_milliseconds(coldSearch)},' + '${_milliseconds(cachedSearch)},' + '${_milliseconds(tagFilter)},' + '$todoBytes,' + '$tagBytes', + ); + } finally { + await directory.delete(recursive: true); + } + } + }); +} + +String _milliseconds(Stopwatch stopwatch) { + return (stopwatch.elapsedMicroseconds / 1000).toStringAsFixed(3); +} diff --git a/docs/TESTING.md b/docs/TESTING.md index de15862..747ad30 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -88,3 +88,8 @@ Release 人工验收中: 这些项目不是遗漏,而是由操作系统或外部进程控制;自动化负责提前拦截确定性的功能 回归,Draft 验收负责最终用户环境。 + +## 性能与容量 + +Todo 列表的数据基准、10,000 条滚动基准、60/120Hz 指标及本地数据库迁移阈值见 +[Todo 列表性能与容量方案](TODO_LIST_PERFORMANCE.md)。 diff --git a/docs/TODO_LIST_PERFORMANCE.md b/docs/TODO_LIST_PERFORMANCE.md new file mode 100644 index 0000000..f5b2244 --- /dev/null +++ b/docs/TODO_LIST_PERFORMANCE.md @@ -0,0 +1,124 @@ +# Todo 列表性能与容量方案 + +本文定义 Floatick Todo 列表的现状、性能目标、验证方法和数据量增长方案。性能结论必须来自 +Profile/Release 模式与真实设备;Debug 模式只用于功能调试。 + +## 当前实现 + +```mermaid +flowchart LR + A["todos.json / tags.json
启动时全量读取"] --> B["TodoViewModel
内存索引、排序与筛选"] + B --> C["ListView.builder
只创建可见区域附近的行"] + C --> D["TodoListRow
滚动时暂停 hover 动画"] +``` + +- **Widget 已懒构建**:列表使用 `ListView.builder`,不会同时创建全部 Todo 行。 +- **数据未分页**:Todo 与 Tag 仍会在启动时全部载入内存;修改后会原子性重写整个 JSON。 +- **派生数据已缓存**:活动/归档排序、数量、标题与 Tag 搜索索引在数据变化时重建,不再随 + 每次 Widget rebuild 重复计算。 +- **日期分组已缓存**:结果集、语言、日期与列表范围没有变化时复用分组条目。 +- **滚动期间暂停 hover**:鼠标固定在列表上时,滚动会让不同 Todo 行不断经过指针。滚动 + 期间暂停 hover 背景和操作按钮动画,结束后再恢复,避免持续触发动画和 rebuild。 +- **预构建下一屏**:列表在可见区域外缓存约 0.75 个窗口高度,减少快速滚动时临时创建 + Widget 的尖峰。 + +## 性能目标 + +| 指标 | 60Hz 设备 | 120Hz 设备 | +| --- | ---: | ---: | +| 单帧预算 | 16.67 ms | 8.33 ms | +| 滚动 build/raster p95 | 不超过单帧预算 | 不超过单帧预算 | +| 严重慢帧率 | < 1% | < 1% | +| 10,000 条搜索响应 | < 50 ms | < 50 ms | +| 10,000 条冷启动加载与索引 | < 250 ms | < 250 ms | + +120Hz 的结论必须在真实 120Hz 屏幕上验证。CI 虚拟机的帧率和负载不稳定,只适合发现明显 +回归,不能替代 M3 Pro/Intel Mac 的 Draft 验收。 + +## 当前容量结论 + +当前没有代码层面的 Todo 数量硬上限,但“没有硬上限”不代表已证明任意数量都流畅。 + +| 等级 | 数据量 | 用途 | 当前状态 | +| --- | ---: | --- | --- | +| 日常基线 | 1,000 | 普通用户长期使用 | 数据路径已验证 | +| 扩展基线 | 5,000 | 重度用户 | 数据路径已验证,需持续做 Profile 帧测试 | +| 压力基线 | 10,000 | 回归与容量压力测试 | 数据路径已验证,不能据此宣称 120Hz 已达标 | +| 迁移阈值 | 10,000+ | 超大工作区 | 应迁移到带索引和分页的本地数据库 | + +2026-07-29 当前开发机上的一次对比基准如下。该结果用于观察数量级,不作为跨机器的绝对 +承诺: + +| Todo | 保存 JSON | 加载并建索引 | 首次搜索 | 缓存搜索 | 双 Tag 筛选 | +| ---: | ---: | ---: | ---: | ---: | ---: | +| 100 | 34.7 ms | 47.8 ms | 1.7 ms | 0.6 ms | 0.9 ms | +| 1,000 | 18.7 ms | 49.4 ms | 2.1 ms | 0.03 ms | 1.6 ms | +| 5,000 | 45.3 ms | 109.1 ms | 10.1 ms | 0.10 ms | 11.7 ms | +| 10,000 | 60.9 ms | 121.8 ms | 12.4 ms | 0.01 ms | 4.5 ms | + +少量数据的时间会受 JIT 预热和文件系统缓存影响,因此只比较整体趋势,不比较相邻两行的 +细小差异。 + +## 基准命令 + +数据路径: + +```bash +flutter test benchmark/todo_data_benchmark_test.dart --reporter expanded +``` + +真实 Flutter 引擎滚动帧: + +```bash +flutter drive --profile -d macos \ + --driver=test_driver/performance_test_driver.dart \ + --target=integration_test/todo_scroll_performance_test.dart +``` + +帧测试固定生成 10,000 条 Todo 并连续快速滚动。验收时还应在 DevTools Performance 中 +检查 UI/GPU 两条时间线:UI 超预算优先排查 build/layout;GPU 超预算优先排查裁剪、阴影和 +saveLayer。 + +## 压测矩阵 + +每个 Draft 至少覆盖 1,000 与 10,000 两档,发布前抽测 5,000: + +1. 启动、悬浮图标展开主容器、首次显示列表; +2. 触控板慢滚、快速 fling、滚动中移动鼠标; +3. 输入搜索、清空搜索、单/多 Tag 筛选; +4. 完成、归档、恢复、永久删除; +5. 同一天全部数据与跨 365 天分组两种分布; +6. 短标题、长标题、Markdown content、0/1/多 Tag; +7. Intel 60Hz、Apple Silicon 60Hz、Apple Silicon 120Hz。 + +记录指标包括 build/raster p50、p95、p99,慢帧数量,内存峰值,冷启动时间以及单次写入 +耗时。任何优化都应在相同设备、相同数据集、相同 Profile 构建下做前后对比。 + +## 后续演进 + +### 阶段 1:当前 JSON 架构内继续优化 + +- 搜索输入增加短防抖,但保留回车立即搜索; +- 根据真实行高分布评估 `itemExtentBuilder`,避免为了估算高度引入跳动; +- 在独立物理 Mac 上保留 Profile 基准历史,监控 p95/p99 回归。 + +### 阶段 2:工作区超过 10,000 条 + +将 Todo、Tag 和 assignment 迁移到 SQLite 类本地数据库: + +- 为 `archived_at`、`created_at`、规范化标题和 assignment 建索引; +- 使用 keyset/cursor 分页,不使用越往后越慢的深 offset; +- 首屏只读一页,滚动接近尾部时预取下一页; +- 搜索和 Tag 筛选下推到数据库; +- 保留 JSON/Markdown 导入导出,不再把 JSON 作为运行时主存储。 + +数据库迁移应单独写 ADR,并提供幂等迁移、备份与失败回滚;在此之前不为了“可能的数据量” +引入新的持久化依赖。 + +## 发布门槛 + +- 功能测试全部通过; +- 10,000 条数据路径基准无数量级退化; +- Profile 滚动基准能够执行并保留结果; +- 目标 Intel 与 M3 Pro 设备人工验证 60/120Hz; +- 若某项因工具链或设备缺失无法验证,Release 说明必须明确残余风险,不得写成“已支持”。 diff --git a/integration_test/todo_scroll_performance_test.dart b/integration_test/todo_scroll_performance_test.dart new file mode 100644 index 0000000..42a28b2 --- /dev/null +++ b/integration_test/todo_scroll_performance_test.dart @@ -0,0 +1,242 @@ +import 'dart:convert'; + +import 'package:floatick/app/theme/floatick_theme.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/app_settings.dart'; +import 'package:floatick/features/settings/domain/login_item_status.dart'; +import 'package:floatick/features/settings/presentation/settings_view_model.dart'; +import 'package:floatick/features/sticky_boards/data/sticky_board_repository.dart'; +import 'package:floatick/features/sticky_boards/domain/sticky_board_workspace.dart'; +import 'package:floatick/features/sticky_boards/presentation/sticky_board_view_model.dart'; +import 'package:floatick/features/sticky_boards/presentation/sticky_board_window_coordinator.dart'; +import 'package:floatick/features/todos/data/tag_repository.dart'; +import 'package:floatick/features/todos/data/todo_repository.dart'; +import 'package:floatick/features/todos/domain/tag_workspace.dart'; +import 'package:floatick/features/todos/domain/todo_item.dart'; +import 'package:floatick/features/todos/presentation/todo_panel.dart'; +import 'package:floatick/features/todos/presentation/todo_view_model.dart'; +import 'package:floatick/features/updates/data/update_repository.dart'; +import 'package:floatick/features/updates/domain/update_settings_snapshot.dart'; +import 'package:floatick/features/updates/presentation/update_view_model.dart'; +import 'package:floatick/l10n/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +/// Profile-mode UI benchmark for a 10,000-item workspace. +/// +/// Run it explicitly on macOS: +/// `flutter drive --profile -d macos \ +/// --driver=test_driver/performance_test_driver.dart \ +/// --target=integration_test/todo_scroll_performance_test.dart` +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('scrolls a 10,000-item todo list', (tester) async { + tester.view.physicalSize = const Size(500, 760); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final createdAt = DateTime.utc(2026, 7, 29, 8); + final todoController = TodoViewModel( + todoRepository: _BenchmarkTodoRepository( + List.generate( + 10000, + (index) => TodoItem( + id: 'todo-$index', + title: 'Performance todo $index', + createdAt: createdAt.add(Duration(seconds: index)), + ), + growable: false, + ), + ), + tagRepository: _BenchmarkTagRepository(), + ); + final settingsController = SettingsViewModel( + settingsRepository: _BenchmarkSettingsRepository(), + loginItemRepository: _BenchmarkLoginItemRepository(), + ); + final updateController = UpdateViewModel( + updateRepository: _BenchmarkUpdateRepository(), + ); + final stickyBoardController = StickyBoardViewModel( + repository: _BenchmarkStickyBoardRepository(), + ); + final windowBridge = _BenchmarkWindowBridge(); + final stickyBoardWindowCoordinator = StickyBoardWindowCoordinator( + boardController: stickyBoardController, + todoController: todoController, + windowBridge: windowBridge, + ); + addTearDown(todoController.dispose); + addTearDown(settingsController.dispose); + addTearDown(updateController.dispose); + addTearDown(stickyBoardController.dispose); + + await Future.wait(>[ + todoController.load(), + settingsController.load(), + updateController.load(), + stickyBoardController.load(), + ]); + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: buildFloatickTheme(Brightness.light), + darkTheme: buildFloatickTheme(Brightness.dark), + home: Center( + child: TodoPanel( + controller: todoController, + settingsController: settingsController, + updateController: updateController, + stickyBoardController: stickyBoardController, + stickyBoardWindowCoordinator: stickyBoardWindowCoordinator, + windowBridge: windowBridge, + expansionAnchor: WindowExpansionAnchor.topRight, + stickyBoardRequest: null, + stickyBoardRequestSerial: 0, + onCollapse: () {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final list = find.byType(ListView).first; + expect(list, findsOneWidget); + await tester.fling(list, const Offset(0, -600), 1800); + await tester.pumpAndSettle(); + + await binding.watchPerformance(() async { + for (var iteration = 0; iteration < 8; iteration++) { + await tester.fling(list, const Offset(0, -900), 2200); + await tester.pumpAndSettle(); + } + }, reportKey: 'todo_scroll_10000'); + + final result = binding.reportData?['todo_scroll_10000']; + expect(result, isA>()); + debugPrint(const JsonEncoder.withIndent(' ').convert(result)); + }); +} + +class _BenchmarkTodoRepository implements TodoRepository { + _BenchmarkTodoRepository(this.items); + + List items; + + @override + String get storagePath => '/tmp/floatick-scroll-benchmark/todos.json'; + + @override + Future> load() async => List.of(items); + + @override + Future save(List items) async { + this.items = List.of(items); + } +} + +class _BenchmarkTagRepository implements TagRepository { + @override + String get storagePath => '/tmp/floatick-scroll-benchmark/tags.json'; + + @override + Future load() async => TagWorkspace.empty(); + + @override + Future save(TagWorkspace workspace) async {} +} + +class _BenchmarkSettingsRepository implements SettingsRepository { + @override + String get storagePath => '/tmp/floatick-scroll-benchmark/settings.json'; + + @override + Future load() async => const AppSettings(); + + @override + Future save(AppSettings settings) async {} +} + +class _BenchmarkLoginItemRepository implements LoginItemRepository { + @override + Future loadStatus() async => LoginItemStatus.disabled; + + @override + Future setEnabled(bool enabled) async { + return enabled ? LoginItemStatus.enabled : LoginItemStatus.disabled; + } +} + +class _BenchmarkUpdateRepository implements UpdateRepository { + @override + Future loadSettings() async { + return const UpdateSettingsSnapshot( + automaticallyChecksForUpdates: false, + currentVersion: 'benchmark', + ); + } + + @override + Future setAutomaticallyChecksForUpdates(bool enabled) async {} + + @override + Future checkForUpdates() async {} +} + +class _BenchmarkStickyBoardRepository implements StickyBoardRepository { + @override + String get storagePath => '/tmp/floatick-scroll-benchmark/sticky_boards.json'; + + @override + Future load() async => StickyBoardWorkspace.empty(); + + @override + Future save(StickyBoardWorkspace workspace) async {} +} + +class _BenchmarkWindowBridge implements WindowBridge { + @override + void setExpandRequestHandler(ExpandRequestHandler? handler) {} + + @override + void setCollapseRequestHandler(CollapseRequestHandler? handler) {} + + @override + Future synchronizeCollapsedState() async {} + + @override + Future preferredExpansionAnchor() async { + return WindowExpansionAnchor.topRight; + } + + @override + Future setExpanded(bool expanded, {bool animated = true}) async {} + + @override + Future setFloatingIconCount(int activeCount) async {} + + @override + Future setPreferredLanguage(String? languageCode) async {} + + @override + Future setPreferredTheme(String themePreference) async {} + + @override + Future setAlwaysOnTop(bool alwaysOnTop) async {} + + @override + Future configureBorderlessSecondaryWindow( + int viewId, { + bool positionAdjacentToMainWindow = false, + }) async {} + + @override + Future revealBorderlessSecondaryWindow(int viewId) async {} +} diff --git a/lib/features/todos/presentation/todo_panel.dart b/lib/features/todos/presentation/todo_panel.dart index 782872e..a64cfe6 100644 --- a/lib/features/todos/presentation/todo_panel.dart +++ b/lib/features/todos/presentation/todo_panel.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show ScrollCacheExtent; import 'package:flutter/services.dart'; import '../../../app/theme/floatick_theme.dart'; @@ -29,6 +30,8 @@ const double _stickyBoardDrawerWidth = 336; const double _todoDrawerHeight = 520; const Duration _drawerSlideDuration = Duration(milliseconds: 220); const Duration _drawerScrimDuration = Duration(milliseconds: 160); +const Duration _scrollHoverResumeDelay = Duration(milliseconds: 120); +const double _todoListCacheExtentViewportFraction = 0.75; enum TodoListScope { active, archived } @@ -1480,7 +1483,7 @@ class _ErrorBanner extends StatelessWidget { } } -class _TodoList extends StatelessWidget { +class _TodoList extends StatefulWidget { const _TodoList({ required this.controller, required this.scope, @@ -1503,9 +1506,20 @@ class _TodoList extends StatelessWidget { final ValueChanged onEditTodo; final ValueChanged onDeleteTodo; + @override + State<_TodoList> createState() => _TodoListState(); +} + +class _TodoListState extends State<_TodoList> { + List? _cachedItems; + List<_ListEntry> _cachedEntries = const <_ListEntry>[]; + Locale? _cachedLocale; + DateTime? _cachedToday; + bool? _cachedArchived; + @override Widget build(BuildContext context) { - if (controller.isLoading) { + if (widget.controller.isLoading) { return const Center( child: SizedBox.square( dimension: 22, @@ -1517,58 +1531,41 @@ class _TodoList extends StatelessWidget { final entries = _buildEntries(context); if (entries.isEmpty) { return _EmptyList( - scope: scope, - hasQuery: query.isNotEmpty || selectedTagIds.isNotEmpty, - onClearTagFilters: selectedTagIds.isEmpty ? null : onClearTagFilters, + scope: widget.scope, + hasQuery: widget.query.isNotEmpty || widget.selectedTagIds.isNotEmpty, + onClearTagFilters: widget.selectedTagIds.isEmpty + ? null + : widget.onClearTagFilters, ); } - return ListView.builder( - padding: const EdgeInsets.fromLTRB(14, 10, 14, 18), - itemCount: entries.length, - itemBuilder: (context, index) { - final entry = entries[index]; - return switch (entry) { - _DateEntry() => _DateDivider(label: entry.label), - _ItemEntry() => TodoListRow( - key: ValueKey(entry.item.id), - item: entry.item, - archivedScope: scope == TodoListScope.archived, - onToggle: () => - unawaited(controller.toggleCompletion(entry.item.id)), - onOpenDetails: () => onOpenDetails(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: 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, - ), - }; - }, + return _ScrollableTodoEntries( + entries: entries, + controller: widget.controller, + scope: widget.scope, + onOpenTagManagement: widget.onOpenTagManagement, + onOpenDetails: widget.onOpenDetails, + onEditTodo: widget.onEditTodo, + onDeleteTodo: widget.onDeleteTodo, ); } List<_ListEntry> _buildEntries(BuildContext context) { - final archived = scope == TodoListScope.archived; - final items = controller.itemsForView( + final archived = widget.scope == TodoListScope.archived; + final items = widget.controller.itemsForView( archived: archived, - query: query, - selectedTagIds: selectedTagIds, + query: widget.query, + selectedTagIds: widget.selectedTagIds, ); + final locale = Localizations.localeOf(context); + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + if (identical(items, _cachedItems) && + locale == _cachedLocale && + today == _cachedToday && + archived == _cachedArchived) { + return _cachedEntries; + } DateTime relevantDate(TodoItem item) { if (archived) { @@ -1588,7 +1585,119 @@ class _TodoList extends StatelessWidget { } entries.add(_ItemEntry(item)); } - return entries; + _cachedItems = items; + _cachedEntries = List<_ListEntry>.unmodifiable(entries); + _cachedLocale = locale; + _cachedToday = today; + _cachedArchived = archived; + return _cachedEntries; + } +} + +class _ScrollableTodoEntries extends StatefulWidget { + const _ScrollableTodoEntries({ + required this.entries, + required this.controller, + required this.scope, + required this.onOpenTagManagement, + required this.onOpenDetails, + required this.onEditTodo, + required this.onDeleteTodo, + }); + + final List<_ListEntry> entries; + final TodoViewModel controller; + final TodoListScope scope; + final VoidCallback onOpenTagManagement; + final ValueChanged onOpenDetails; + final ValueChanged onEditTodo; + final ValueChanged onDeleteTodo; + + @override + State<_ScrollableTodoEntries> createState() => _ScrollableTodoEntriesState(); +} + +class _ScrollableTodoEntriesState extends State<_ScrollableTodoEntries> { + Timer? _hoverResumeTimer; + bool _isScrolling = false; + + @override + void dispose() { + _hoverResumeTimer?.cancel(); + super.dispose(); + } + + bool _handleScrollNotification(ScrollNotification notification) { + if (notification is ScrollStartNotification || + notification is ScrollUpdateNotification || + notification is OverscrollNotification) { + _hoverResumeTimer?.cancel(); + if (!_isScrolling) { + setState(() => _isScrolling = true); + } + } + if (notification is ScrollUpdateNotification || + notification is OverscrollNotification || + notification is ScrollEndNotification) { + _scheduleHoverResume(); + } + return false; + } + + void _scheduleHoverResume() { + _hoverResumeTimer?.cancel(); + _hoverResumeTimer = Timer(_scrollHoverResumeDelay, () { + if (mounted && _isScrolling) { + setState(() => _isScrolling = false); + } + }); + } + + @override + Widget build(BuildContext context) { + final archived = widget.scope == TodoListScope.archived; + return NotificationListener( + onNotification: _handleScrollNotification, + child: ListView.builder( + padding: const EdgeInsets.fromLTRB(14, 10, 14, 18), + scrollCacheExtent: const ScrollCacheExtent.viewport( + _todoListCacheExtentViewportFraction, + ), + itemCount: widget.entries.length, + itemBuilder: (context, index) { + final entry = widget.entries[index]; + return switch (entry) { + _DateEntry() => _DateDivider(label: entry.label), + _ItemEntry() => TodoListRow( + key: ValueKey(entry.item.id), + item: entry.item, + archivedScope: archived, + hoverEnabled: !_isScrolling, + onToggle: () => + unawaited(widget.controller.toggleCompletion(entry.item.id)), + onOpenDetails: () => widget.onOpenDetails(entry.item.id), + onEdit: archived ? null : () => widget.onEditTodo(entry.item.id), + onArchive: () => + unawaited(widget.controller.archive(entry.item.id)), + onRestore: () => + unawaited(widget.controller.restore(entry.item.id)), + tags: widget.controller.tags, + assignedTagIds: widget.controller.tagIdsForTodo(entry.item.id), + onToggleTag: archived + ? null + : (tagId) => widget.controller.toggleTagForTodo( + todoId: entry.item.id, + tagId: tagId, + ), + onOpenTagManagement: archived ? null : widget.onOpenTagManagement, + onDeletePermanently: archived + ? () => widget.onDeleteTodo(entry.item.id) + : null, + ), + }; + }, + ), + ); } } diff --git a/lib/features/todos/presentation/todo_view_model.dart b/lib/features/todos/presentation/todo_view_model.dart index b931eb5..db1f12a 100644 --- a/lib/features/todos/presentation/todo_view_model.dart +++ b/lib/features/todos/presentation/todo_view_model.dart @@ -52,7 +52,19 @@ class TodoViewModel extends ChangeNotifier { final TagIdGenerator _tagIdGenerator; final FirstRunWorkspaceSeeder? _firstRunWorkspaceSeeder; - List _items = []; + List _items = const []; + Map _itemsById = const {}; + List _activeViewItems = const []; + List _archivedViewItems = const []; + Map _normalizedTitlesByTodoId = const {}; + Map> _normalizedTagNamesByTodoId = + const >{}; + int _activeCount = 0; + int _archivedCount = 0; + bool? _cachedViewArchived; + String? _cachedViewQuery; + Set _cachedViewTagIds = const {}; + List? _cachedViewItems; TagWorkspace _tagWorkspace = TagWorkspace.empty(); Map _tagUsageCounts = const {}; StorageFailure? _error; @@ -60,26 +72,17 @@ class TodoViewModel extends ChangeNotifier { Future _mutationQueue = Future.value(); Future _tagMutationQueue = Future.value(); - List get items => List.unmodifiable(_items); + List get items => _items; List get tags => _tagWorkspace.tags; StorageFailure? get error => _error; bool get isLoading => _isLoading; String get storageDirectoryPath => File(_repository.storagePath).parent.path; - int get activeCount { - return _items.where((item) => !item.isArchived && !item.isCompleted).length; - } + int get activeCount => _activeCount; - int get archivedCount => _items.where((item) => item.isArchived).length; + int get archivedCount => _archivedCount; - TodoItem? itemById(String id) { - for (final item in _items) { - if (item.id == id) { - return item; - } - } - return null; - } + TodoItem? itemById(String id) => _itemsById[id]; TodoTag? tagById(String id) { for (final tag in _tagWorkspace.tags) { @@ -115,32 +118,33 @@ class TodoViewModel extends ChangeNotifier { Set selectedTagIds = const {}, }) { final normalizedQuery = query.trim().toLowerCase(); - final visibleItems = _items.where((item) { - final matchesScope = archived ? item.isArchived : !item.isArchived; + if (_cachedViewItems != null && + _cachedViewArchived == archived && + _cachedViewQuery == normalizedQuery && + setEquals(_cachedViewTagIds, selectedTagIds)) { + return _cachedViewItems!; + } + + final sourceItems = archived ? _archivedViewItems : _activeViewItems; + final visibleItems = sourceItems.where((item) { final assignedTagIds = tagIdsForTodo(item.id); final matchesTag = 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) || - assignedTagNames.any((name) => name.contains(normalizedQuery)); - return matchesScope && matchesTag && matchesQuery; + (_normalizedTitlesByTodoId[item.id] ?? '').contains( + normalizedQuery, + ) || + (_normalizedTagNamesByTodoId[item.id] ?? const []).any( + (name) => name.contains(normalizedQuery), + ); + return matchesTag && matchesQuery; }).toList(); - - DateTime relevantDate(TodoItem item) { - if (archived) { - return item.archivedAt ?? item.createdAt; - } - return item.createdAt; - } - - visibleItems.sort((left, right) { - return relevantDate(right).compareTo(relevantDate(left)); - }); - return List.unmodifiable(visibleItems); + _cachedViewArchived = archived; + _cachedViewQuery = normalizedQuery; + _cachedViewTagIds = Set.unmodifiable(selectedTagIds); + _cachedViewItems = List.unmodifiable(visibleItems); + return _cachedViewItems!; } Future load() async { @@ -156,7 +160,7 @@ class TodoViewModel extends ChangeNotifier { } try { - _items = await _repository.load(); + _setItems(await _repository.load()); } on StorageFailure catch (error) { loadError = error; } @@ -521,7 +525,7 @@ class TodoViewModel extends ChangeNotifier { try { await _repository.save(updatedItems); - _items = updatedItems; + _setItems(updatedItems); _error = null; } on StorageFailure catch (error) { _error = error; @@ -594,7 +598,7 @@ class TodoViewModel extends ChangeNotifier { if (tagsChanged) { await _tagRepository.save(updatedWorkspace); } - _items = updatedItems; + _setItems(updatedItems); _setTagWorkspace(updatedWorkspace); _error = null; notifyListeners(); @@ -606,13 +610,13 @@ class TodoViewModel extends ChangeNotifier { } on StorageFailure { try { await _tagRepository.save(updatedWorkspace); - _items = updatedItems; + _setItems(updatedItems); _setTagWorkspace(updatedWorkspace); _error = null; notifyListeners(); return true; } on StorageFailure catch (recoveryError) { - _items = updatedItems; + _setItems(updatedItems); _error = recoveryError; notifyListeners(); return false; @@ -664,6 +668,58 @@ class TodoViewModel extends ChangeNotifier { } _tagWorkspace = workspace; _tagUsageCounts = Map.unmodifiable(usageCounts); + final normalizedTagNamesById = { + for (final tag in workspace.tags) tag.id: tag.name.toLowerCase(), + }; + _normalizedTagNamesByTodoId = + Map>.unmodifiable(>{ + for (final assignment in workspace.assignments.entries) + assignment.key: List.unmodifiable( + assignment.value + .map((tagId) => normalizedTagNamesById[tagId]) + .whereType(), + ), + }); + _invalidateViewCache(); + } + + void _setItems(Iterable items) { + final immutableItems = List.unmodifiable(items); + final itemsById = {}; + for (final item in immutableItems) { + itemsById.putIfAbsent(item.id, () => item); + } + + final activeViewItems = + immutableItems.where((item) => !item.isArchived).toList(growable: false) + ..sort((left, right) => right.createdAt.compareTo(left.createdAt)); + final archivedViewItems = + immutableItems.where((item) => item.isArchived).toList(growable: false) + ..sort((left, right) { + final leftDate = left.archivedAt ?? left.createdAt; + final rightDate = right.archivedAt ?? right.createdAt; + return rightDate.compareTo(leftDate); + }); + + _items = immutableItems; + _itemsById = Map.unmodifiable(itemsById); + _activeViewItems = List.unmodifiable(activeViewItems); + _archivedViewItems = List.unmodifiable(archivedViewItems); + _normalizedTitlesByTodoId = Map.unmodifiable( + { + for (final item in immutableItems) item.id: item.title.toLowerCase(), + }, + ); + _activeCount = activeViewItems.where((item) => !item.isCompleted).length; + _archivedCount = archivedViewItems.length; + _invalidateViewCache(); + } + + void _invalidateViewCache() { + _cachedViewArchived = null; + _cachedViewQuery = null; + _cachedViewTagIds = const {}; + _cachedViewItems = null; } static bool _sameTagName(String left, String right) { diff --git a/test/features/todos/presentation/todo_view_model_test.dart b/test/features/todos/presentation/todo_view_model_test.dart index 7e5bec8..3ff5175 100644 --- a/test/features/todos/presentation/todo_view_model_test.dart +++ b/test/features/todos/presentation/todo_view_model_test.dart @@ -116,6 +116,62 @@ void main() { }, ); + test( + 'derived list snapshots are reused until todo or tag data changes', + () async { + repository.savedItems = [ + TodoItem( + id: 'todo-1', + title: 'Prepare release', + createdAt: DateTime.parse(firstDate), + ), + ]; + tagRepository.savedWorkspace = TagWorkspace( + tags: [ + TodoTag( + id: 'tag-focus', + name: 'Focus', + colorValue: 0xFF4C8FF5, + createdAt: DateTime.parse(firstDate), + ), + ], + assignments: const >{ + 'todo-1': ['tag-focus'], + }, + ); + await controller.load(); + + final itemsReference = controller.items; + final firstView = controller.itemsForView( + archived: false, + query: ' FOCUS ', + selectedTagIds: const {'tag-focus'}, + ); + final equivalentView = controller.itemsForView( + archived: false, + query: 'focus', + selectedTagIds: const {'tag-focus'}, + ); + + expect(identical(controller.items, itemsReference), isTrue); + expect(identical(firstView, equivalentView), isTrue); + expect( + () => controller.items.add(repository.savedItems.single), + throwsUnsupportedError, + ); + + await controller.toggleCompletion('todo-1'); + final updatedView = controller.itemsForView( + archived: false, + query: 'focus', + selectedTagIds: const {'tag-focus'}, + ); + + expect(identical(updatedView, equivalentView), isFalse); + expect(updatedView.single.isCompleted, isTrue); + }, + ); + test('rename trims and persists the updated title', () async { repository.savedItems = [ TodoItem( diff --git a/test_driver/performance_test_driver.dart b/test_driver/performance_test_driver.dart new file mode 100644 index 0000000..b2e53fc --- /dev/null +++ b/test_driver/performance_test_driver.dart @@ -0,0 +1,5 @@ +import 'package:integration_test/integration_test_driver_extended.dart'; + +Future main() { + return integrationDriver(); +} From e021b3cdfdbe833ba8b411137c84ba5211d47509 Mon Sep 17 00:00:00 2001 From: lucaslushuo Date: Wed, 29 Jul 2026 18:10:01 +0800 Subject: [PATCH 3/3] chore(release): prepare 0.3.0 candidate --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 82c18a7..e1b9ef0 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+6 +version: 0.3.0+7 environment: sdk: ^3.12.2