From 6f18f9d4246c81d5567c8a8504aacedb3ba38d8f Mon Sep 17 00:00:00 2001 From: lucaslus <282139159+lucaslus@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:47:12 +0800 Subject: [PATCH 1/5] feat(notes): add lightweight notes with shared tags --- lib/app/floatick_app.dart | 7 + lib/core/ui/floatick_editor_components.dart | 476 ++++++++++++++ .../ui/floatick_markdown.dart} | 39 +- lib/features/notes/data/note_repository.dart | 103 +++ lib/features/notes/domain/note_item.dart | 189 ++++++ .../presentation/note_editor_drawer.dart | 363 +++++++++++ .../presentation/note_panel_content.dart | 422 +++++++++++++ .../notes/presentation/note_view_model.dart | 296 +++++++++ .../widgets/sticky_board_todo_details.dart | 4 +- .../todos/presentation/tag_filter_drawer.dart | 13 +- .../presentation/tag_management_drawer.dart | 20 +- .../presentation/todo_editor_drawer.dart | 491 ++++----------- .../todos/presentation/todo_panel.dart | 586 +++++++++++++++--- .../todos/presentation/todo_view_model.dart | 18 +- .../widgets/editor_tag_selector.dart | 76 +++ lib/l10n/app_en.arb | 43 +- lib/l10n/app_localizations.dart | 196 +++++- lib/l10n/app_localizations_en.dart | 109 +++- lib/l10n/app_localizations_zh.dart | 102 ++- lib/l10n/app_zh.arb | 43 +- lib/main.dart | 5 + test/app/floatick_app_test.dart | 60 +- .../ui/floatick_editor_components_test.dart | 142 +++++ .../ui/floatick_markdown_test.dart} | 4 +- .../notes/data/note_repository_test.dart | 110 ++++ .../presentation/note_editor_drawer_test.dart | 147 +++++ .../presentation/note_panel_content_test.dart | 96 +++ .../presentation/note_view_model_test.dart | 219 +++++++ .../presentation/todo_editor_drawer_test.dart | 76 +++ .../presentation/todo_view_model_test.dart | 64 +- 30 files changed, 3964 insertions(+), 555 deletions(-) create mode 100644 lib/core/ui/floatick_editor_components.dart rename lib/{features/todos/presentation/widgets/todo_markdown.dart => core/ui/floatick_markdown.dart} (81%) create mode 100644 lib/features/notes/data/note_repository.dart create mode 100644 lib/features/notes/domain/note_item.dart create mode 100644 lib/features/notes/presentation/note_editor_drawer.dart create mode 100644 lib/features/notes/presentation/note_panel_content.dart create mode 100644 lib/features/notes/presentation/note_view_model.dart create mode 100644 lib/features/todos/presentation/widgets/editor_tag_selector.dart create mode 100644 test/core/ui/floatick_editor_components_test.dart rename test/{features/todos/presentation/todo_markdown_test.dart => core/ui/floatick_markdown_test.dart} (88%) create mode 100644 test/features/notes/data/note_repository_test.dart create mode 100644 test/features/notes/presentation/note_editor_drawer_test.dart create mode 100644 test/features/notes/presentation/note_panel_content_test.dart create mode 100644 test/features/notes/presentation/note_view_model_test.dart diff --git a/lib/app/floatick_app.dart b/lib/app/floatick_app.dart index 52aa66f..062592d 100644 --- a/lib/app/floatick_app.dart +++ b/lib/app/floatick_app.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import '../core/platform/window_bridge.dart'; import '../core/ui/floatick_surface_metrics.dart'; +import '../features/notes/presentation/note_view_model.dart'; import '../features/settings/domain/app_settings.dart'; import '../features/settings/presentation/settings_view_model.dart'; import '../features/sticky_boards/presentation/sticky_board_view_model.dart'; @@ -18,6 +19,7 @@ import 'theme/floatick_theme.dart'; class FloatickApp extends StatelessWidget { const FloatickApp({ required this.controller, + this.noteController, required this.settingsController, required this.updateController, required this.stickyBoardController, @@ -28,6 +30,7 @@ class FloatickApp extends StatelessWidget { }); final TodoViewModel controller; + final NoteViewModel? noteController; final SettingsViewModel settingsController; final UpdateViewModel updateController; final StickyBoardViewModel stickyBoardController; @@ -61,6 +64,7 @@ class FloatickApp extends StatelessWidget { }, home: _FloatickShell( controller: controller, + noteController: noteController, settingsController: settingsController, updateController: updateController, stickyBoardController: stickyBoardController, @@ -76,6 +80,7 @@ class FloatickApp extends StatelessWidget { class _FloatickShell extends StatefulWidget { const _FloatickShell({ required this.controller, + required this.noteController, required this.settingsController, required this.updateController, required this.stickyBoardController, @@ -84,6 +89,7 @@ class _FloatickShell extends StatefulWidget { }); final TodoViewModel controller; + final NoteViewModel? noteController; final SettingsViewModel settingsController; final UpdateViewModel updateController; final StickyBoardViewModel stickyBoardController; @@ -470,6 +476,7 @@ class _FloatickShellState extends State<_FloatickShell> { visible: _panelTooltipsEnabled, child: TodoPanel( controller: widget.controller, + noteController: widget.noteController, settingsController: widget.settingsController, updateController: widget.updateController, stickyBoardController: widget.stickyBoardController, diff --git a/lib/core/ui/floatick_editor_components.dart b/lib/core/ui/floatick_editor_components.dart new file mode 100644 index 0000000..73d3865 --- /dev/null +++ b/lib/core/ui/floatick_editor_components.dart @@ -0,0 +1,476 @@ +import 'package:flutter/material.dart'; + +import '../../l10n/l10n.dart'; +import 'floatick_hover_motion.dart'; + +const _editorDrawerRadius = Radius.circular(22); + +abstract final class FloatickEditorMetrics { + static const bodyPadding = EdgeInsets.fromLTRB(20, 16, 20, 12); + static const double sectionGap = 12; +} + +class FloatickEditorDrawerSurface extends StatelessWidget { + const FloatickEditorDrawerSurface({ + required this.title, + required this.closeTooltip, + required this.onClose, + required this.closeFocusNode, + required this.child, + this.headerActions = const [], + this.closeButtonKey, + super.key, + }); + + final String title; + final String closeTooltip; + final VoidCallback onClose; + final FocusNode closeFocusNode; + final List headerActions; + final Key? closeButtonKey; + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + return DecoratedBox( + decoration: BoxDecoration( + color: isDark ? const Color(0xFF202A2E) : const Color(0xFFF9FBFA), + borderRadius: const BorderRadius.vertical(top: _editorDrawerRadius), + border: Border( + top: BorderSide( + color: isDark + ? Colors.white.withValues(alpha: 0.11) + : Colors.black.withValues(alpha: 0.07), + ), + ), + ), + child: ClipRRect( + borderRadius: const BorderRadius.vertical(top: _editorDrawerRadius), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FloatickEditorDrawerHeader( + title: title, + closeTooltip: closeTooltip, + onClose: onClose, + closeFocusNode: closeFocusNode, + actions: headerActions, + closeButtonKey: closeButtonKey, + ), + const FloatickEditorDivider(), + Expanded(child: child), + ], + ), + ), + ); + } +} + +class FloatickEditorDrawerHeader extends StatelessWidget { + const FloatickEditorDrawerHeader({ + required this.title, + required this.closeTooltip, + required this.onClose, + required this.closeFocusNode, + this.actions = const [], + this.closeButtonKey, + super.key, + }); + + final String title; + final String closeTooltip; + final VoidCallback onClose; + final FocusNode closeFocusNode; + final List actions; + final Key? closeButtonKey; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 10, 11), + child: Row( + children: [ + Expanded( + child: Text( + title, + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), + ), + ), + ...actions, + IconButton( + key: closeButtonKey ?? const Key('editor-drawer-close'), + focusNode: closeFocusNode, + tooltip: closeTooltip, + onPressed: onClose, + icon: const Icon(Icons.close_rounded, size: 19), + ), + ], + ), + ); + } +} + +class FloatickEditorSectionLabel extends StatelessWidget { + const FloatickEditorSectionLabel(this.label, {super.key}); + + final String label; + + @override + Widget build(BuildContext context) { + return Text( + label, + style: Theme.of( + context, + ).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600), + ); + } +} + +class FloatickDocumentEditor extends StatelessWidget { + const FloatickDocumentEditor({ + required this.titleController, + required this.contentController, + required this.titleFocusNode, + required this.contentFocusNode, + required this.titleHint, + required this.contentHint, + required this.titleSemanticsLabel, + required this.contentSemanticsLabel, + required this.showPreview, + required this.preview, + required this.onPreviewChanged, + this.toolbarLeading, + this.enabled = true, + this.onTitleChanged, + this.onContentChanged, + this.editorSurfaceKey = const Key('floatick-document-editor'), + this.titleFieldKey = const Key('floatick-document-title-field'), + this.contentFieldKey = const Key('floatick-document-content-field'), + this.modeSwitchKey = const Key('floatick-document-mode-switch'), + this.writeTabKey = const Key('floatick-document-write-tab'), + this.previewTabKey = const Key('floatick-document-preview-tab'), + super.key, + }); + + final TextEditingController titleController; + final TextEditingController contentController; + final FocusNode titleFocusNode; + final FocusNode contentFocusNode; + final String titleHint; + final String contentHint; + final String titleSemanticsLabel; + final String contentSemanticsLabel; + final bool showPreview; + final Widget preview; + final ValueChanged onPreviewChanged; + final Widget? toolbarLeading; + final bool enabled; + final ValueChanged? onTitleChanged; + final ValueChanged? onContentChanged; + final Key editorSurfaceKey; + final Key titleFieldKey; + final Key contentFieldKey; + final Key modeSwitchKey; + final Key writeTabKey; + final Key previewTabKey; + + void _focusContent() { + if (!enabled) { + return; + } + contentFocusNode.requestFocus(); + } + + void _handlePreviewChanged(BuildContext context, bool showPreview) { + if (showPreview) { + contentFocusNode.unfocus(); + } + onPreviewChanged(showPreview); + if (!showPreview) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted && enabled) { + contentFocusNode.requestFocus(); + } + }); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final inputDecorationTheme = theme.inputDecorationTheme; + final focusListenable = Listenable.merge([ + titleFocusNode, + contentFocusNode, + ]); + final fieldDecoration = InputDecoration( + hintText: titleHint, + filled: false, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + if (toolbarLeading case final leading?) + Expanded(child: leading) + else + const Spacer(), + const SizedBox(width: 8), + FloatickEditorModeSwitch( + showPreview: showPreview, + controlKey: modeSwitchKey, + writeTabKey: writeTabKey, + previewTabKey: previewTabKey, + onChanged: (value) => _handlePreviewChanged(context, value), + ), + ], + ), + const SizedBox(height: 7), + Expanded( + child: ListenableBuilder( + listenable: focusListenable, + builder: (context, _) { + final hasFocus = + titleFocusNode.hasFocus || contentFocusNode.hasFocus; + final borderColor = hasFocus + ? theme.colorScheme.primary + : theme.colorScheme.onSurface.withValues( + alpha: theme.brightness == Brightness.dark ? 0.08 : 0.06, + ); + return AnimatedContainer( + key: editorSurfaceKey, + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 150), + curve: Curves.easeOut, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: inputDecorationTheme.fillColor, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: borderColor, + width: hasFocus ? 1.5 : 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Semantics( + label: titleSemanticsLabel, + textField: true, + child: TextField( + key: titleFieldKey, + controller: titleController, + focusNode: titleFocusNode, + enabled: enabled, + maxLines: 1, + textInputAction: TextInputAction.next, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + onChanged: onTitleChanged, + onSubmitted: (_) => _focusContent(), + decoration: fieldDecoration.copyWith( + contentPadding: const EdgeInsets.fromLTRB( + 16, + 15, + 16, + 13, + ), + ), + ), + ), + Divider( + key: const Key('floatick-document-title-divider'), + height: 1, + thickness: 1, + indent: 16, + endIndent: 16, + color: theme.colorScheme.onSurface.withValues( + alpha: 0.08, + ), + ), + Expanded( + child: showPreview + ? preview + : Semantics( + label: contentSemanticsLabel, + textField: true, + child: TextField( + key: contentFieldKey, + controller: contentController, + focusNode: contentFocusNode, + enabled: enabled, + expands: true, + minLines: null, + maxLines: null, + textAlignVertical: TextAlignVertical.top, + keyboardType: TextInputType.multiline, + onChanged: onContentChanged, + decoration: fieldDecoration.copyWith( + hintText: contentHint, + alignLabelWithHint: true, + contentPadding: const EdgeInsets.fromLTRB( + 16, + 13, + 16, + 16, + ), + ), + ), + ), + ), + ], + ), + ); + }, + ), + ), + ], + ); + } +} + +class FloatickEditorModeSwitch extends StatelessWidget { + const FloatickEditorModeSwitch({ + required this.showPreview, + required this.onChanged, + this.controlKey = const Key('floatick-editor-mode-switch'), + this.writeTabKey = const Key('markdown-write-tab'), + this.previewTabKey = const Key('markdown-preview-tab'), + super.key, + }); + + final bool showPreview; + final ValueChanged onChanged; + final Key controlKey; + final Key writeTabKey; + final Key previewTabKey; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + key: controlKey, + height: 30, + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: theme.colorScheme.onSurface.withValues(alpha: 0.055), + borderRadius: BorderRadius.circular(9), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _EditorModeButton( + key: writeTabKey, + label: context.l10n.markdownWriteLabel, + selected: !showPreview, + onPressed: () => onChanged(false), + ), + _EditorModeButton( + key: previewTabKey, + label: context.l10n.markdownPreviewLabel, + selected: showPreview, + onPressed: () => onChanged(true), + ), + ], + ), + ); + } +} + +class FloatickEditorDivider extends StatelessWidget { + const FloatickEditorDivider({super.key}); + + @override + Widget build(BuildContext context) { + return Divider( + height: 1, + thickness: 1, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.08), + ); + } +} + +class FloatickEditorFooter extends StatelessWidget { + const FloatickEditorFooter({required this.child, super.key}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const FloatickEditorDivider(), + Padding( + padding: const EdgeInsets.fromLTRB(20, 10, 20, 14), + child: child, + ), + ], + ); + } +} + +class _EditorModeButton extends StatelessWidget { + const _EditorModeButton({ + required this.label, + required this.selected, + required this.onPressed, + super.key, + }); + + final String label; + final bool selected; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Semantics( + button: true, + selected: selected, + child: FloatickHoverMotion( + hoverScale: FloatickMotion.controlHoverScale, + pressedScale: FloatickMotion.controlPressedScale, + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(7), + child: AnimatedContainer( + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 140), + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: 9), + decoration: BoxDecoration( + color: selected + ? theme.colorScheme.surface.withValues(alpha: 0.92) + : Colors.transparent, + borderRadius: BorderRadius.circular(7), + ), + child: Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: selected + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurface.withValues(alpha: 0.52), + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/todos/presentation/widgets/todo_markdown.dart b/lib/core/ui/floatick_markdown.dart similarity index 81% rename from lib/features/todos/presentation/widgets/todo_markdown.dart rename to lib/core/ui/floatick_markdown.dart index 1a34152..29e419d 100644 --- a/lib/features/todos/presentation/widgets/todo_markdown.dart +++ b/lib/core/ui/floatick_markdown.dart @@ -1,16 +1,34 @@ import 'package:flutter/material.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; -import '../../../../l10n/l10n.dart'; +import '../../l10n/l10n.dart'; -class TodoMarkdownPreview extends StatelessWidget { - const TodoMarkdownPreview({required this.content, super.key}); +class FloatickMarkdownPreview extends StatelessWidget { + const FloatickMarkdownPreview({ + required this.content, + this.embedded = false, + super.key, + }); final String content; + final bool embedded; @override Widget build(BuildContext context) { final theme = Theme.of(context); + final child = content.trim().isEmpty + ? Center( + child: Text( + context.l10n.markdownPreviewEmptyMessage, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.42), + ), + ), + ) + : FloatickMarkdownContent(content: content); + if (embedded) { + return child; + } return DecoratedBox( decoration: BoxDecoration( color: theme.colorScheme.onSurface.withValues(alpha: 0.035), @@ -19,22 +37,13 @@ class TodoMarkdownPreview extends StatelessWidget { color: theme.colorScheme.onSurface.withValues(alpha: 0.07), ), ), - child: content.trim().isEmpty - ? Center( - child: Text( - context.l10n.markdownPreviewEmptyMessage, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurface.withValues(alpha: 0.42), - ), - ), - ) - : TodoMarkdownContent(content: content), + child: child, ); } } -class TodoMarkdownContent extends StatelessWidget { - const TodoMarkdownContent({required this.content, super.key}); +class FloatickMarkdownContent extends StatelessWidget { + const FloatickMarkdownContent({required this.content, super.key}); final String content; diff --git a/lib/features/notes/data/note_repository.dart b/lib/features/notes/data/note_repository.dart new file mode 100644 index 0000000..14d6b58 --- /dev/null +++ b/lib/features/notes/data/note_repository.dart @@ -0,0 +1,103 @@ +import 'dart:convert'; +import 'dart:io'; + +import '../../../core/storage/storage_failure.dart'; +import '../../todos/data/todo_repository.dart'; +import '../domain/note_item.dart'; + +abstract interface class NoteRepository { + String get storagePath; + + Future> load(); + + Future save(List items); +} + +class LocalNoteRepository implements NoteRepository { + LocalNoteRepository({Directory? rootDirectory}) + : rootDirectory = + rootDirectory ?? Directory(_defaultStorageDirectoryPath()); + + static const fileName = 'notes.json'; + + final Directory rootDirectory; + + File get _storageFile => File('${rootDirectory.path}/$fileName'); + + @override + String get storagePath => _storageFile.path; + + @override + Future> load() async { + try { + await rootDirectory.create(recursive: true); + if (!await _storageFile.exists()) { + return []; + } + + final decoded = jsonDecode(await _storageFile.readAsString()); + if (decoded is! List) { + throw const FormatException('Note storage root must be a JSON array.'); + } + final items = decoded + .map((entry) { + if (entry is! Map) { + throw const FormatException('Each note must be a JSON object.'); + } + return NoteItem.fromJson(Map.from(entry)); + }) + .toList(growable: false); + if (items.map((item) => item.id).toSet().length != items.length) { + throw const FormatException('Note ids must be unique.'); + } + return items; + } on FormatException catch (error) { + throw StorageFailure( + kind: StorageFailureKind.invalidData, + path: storagePath, + cause: error, + ); + } on FileSystemException catch (error) { + throw StorageFailure( + kind: StorageFailureKind.read, + path: storagePath, + cause: error, + ); + } + } + + @override + Future save(List items) async { + final temporaryFile = File( + '${_storageFile.path}.tmp-$pid-${DateTime.now().microsecondsSinceEpoch}', + ); + + try { + await rootDirectory.create(recursive: true); + final encoded = const JsonEncoder.withIndent( + ' ', + ).convert(items.map((item) => item.toJson()).toList(growable: false)); + await temporaryFile.writeAsString('$encoded\n', flush: true); + await temporaryFile.rename(_storageFile.path); + } on FileSystemException catch (error) { + if (await temporaryFile.exists()) { + await temporaryFile.delete(); + } + throw StorageFailure( + kind: StorageFailureKind.write, + path: storagePath, + cause: error, + ); + } + } + + static String _defaultStorageDirectoryPath() { + final homeDirectory = Platform.environment['HOME']; + if (homeDirectory == null || homeDirectory.trim().isEmpty) { + throw const StorageFailure( + kind: StorageFailureKind.homeDirectoryUnavailable, + ); + } + return '$homeDirectory/${LocalTodoRepository.directoryName}'; + } +} diff --git a/lib/features/notes/domain/note_item.dart b/lib/features/notes/domain/note_item.dart new file mode 100644 index 0000000..2cc0c84 --- /dev/null +++ b/lib/features/notes/domain/note_item.dart @@ -0,0 +1,189 @@ +import 'package:flutter/foundation.dart'; + +class NoteItem { + NoteItem({ + required this.id, + required this.title, + required this.content, + required this.createdAt, + required this.updatedAt, + Iterable tagIds = const [], + this.pinnedAt, + this.archivedAt, + }) : tagIds = List.unmodifiable(tagIds.toSet()); + + final String id; + final String title; + final String content; + final DateTime createdAt; + final DateTime updatedAt; + final List tagIds; + final DateTime? pinnedAt; + final DateTime? archivedAt; + + bool get isPinned => pinnedAt != null; + bool get isArchived => archivedAt != null; + + NoteItem withDetails({ + required String title, + required String content, + required DateTime updatedAt, + required Iterable tagIds, + }) { + return NoteItem( + id: id, + title: title, + content: content, + createdAt: createdAt, + updatedAt: updatedAt, + tagIds: tagIds, + pinnedAt: pinnedAt, + archivedAt: archivedAt, + ); + } + + NoteItem withPinnedAt(DateTime? value) { + return NoteItem( + id: id, + title: title, + content: content, + createdAt: createdAt, + updatedAt: updatedAt, + tagIds: tagIds, + pinnedAt: value, + archivedAt: archivedAt, + ); + } + + NoteItem withArchivedAt(DateTime? value, {required DateTime updatedAt}) { + return NoteItem( + id: id, + title: title, + content: content, + createdAt: createdAt, + updatedAt: updatedAt, + tagIds: tagIds, + pinnedAt: value == null ? pinnedAt : null, + archivedAt: value, + ); + } + + factory NoteItem.fromJson(Map json) { + final createdAt = _requiredDate(json, 'createdAt'); + return NoteItem( + id: _requiredString(json, 'id'), + title: _requiredString(json, 'title'), + content: _optionalString(json, 'content'), + createdAt: createdAt, + updatedAt: _optionalDate(json, 'updatedAt') ?? createdAt, + tagIds: _optionalStringList(json, 'tagIds'), + pinnedAt: _optionalDate(json, 'pinnedAt'), + archivedAt: _optionalDate(json, 'archivedAt'), + ); + } + + Map toJson() { + return { + 'id': id, + 'title': title, + if (content.isNotEmpty) 'content': content, + if (tagIds.isNotEmpty) 'tagIds': tagIds, + 'createdAt': createdAt.toUtc().toIso8601String(), + 'updatedAt': updatedAt.toUtc().toIso8601String(), + if (pinnedAt != null) 'pinnedAt': pinnedAt!.toUtc().toIso8601String(), + if (archivedAt != null) + 'archivedAt': archivedAt!.toUtc().toIso8601String(), + }; + } + + static String _requiredString(Map json, String key) { + final value = json[key]; + if (value is! String || value.trim().isEmpty) { + throw FormatException('Note field "$key" must be a non-empty string.'); + } + return value; + } + + static DateTime _requiredDate(Map json, String key) { + final value = json[key]; + if (value is! String) { + throw FormatException('Note field "$key" must be an ISO-8601 string.'); + } + return DateTime.parse(value); + } + + static String _optionalString(Map json, String key) { + final value = json[key]; + if (value == null) { + return ''; + } + if (value is! String) { + throw FormatException('Note field "$key" must be a string.'); + } + return value; + } + + static DateTime? _optionalDate(Map json, String key) { + final value = json[key]; + if (value == null) { + return null; + } + if (value is! String) { + throw FormatException('Note field "$key" must be an ISO-8601 string.'); + } + return DateTime.parse(value); + } + + static List _optionalStringList( + Map json, + String key, + ) { + final value = json[key]; + if (value == null) { + return const []; + } + if (value is! List) { + throw FormatException('Note field "$key" must be a string array.'); + } + final values = []; + for (final entry in value) { + if (entry is! String || entry.trim().isEmpty) { + throw FormatException( + 'Note field "$key" must contain non-empty strings.', + ); + } + values.add(entry); + } + if (values.toSet().length != values.length) { + throw FormatException('Note field "$key" must not contain duplicates.'); + } + return values; + } + + @override + bool operator ==(Object other) { + return other is NoteItem && + other.id == id && + other.title == title && + other.content == content && + listEquals(other.tagIds, tagIds) && + other.createdAt == createdAt && + other.updatedAt == updatedAt && + other.pinnedAt == pinnedAt && + other.archivedAt == archivedAt; + } + + @override + int get hashCode { + return Object.hash( + id, + title, + content, + Object.hashAll(tagIds), + createdAt, + updatedAt, + pinnedAt, + archivedAt, + ); + } +} diff --git a/lib/features/notes/presentation/note_editor_drawer.dart b/lib/features/notes/presentation/note_editor_drawer.dart new file mode 100644 index 0000000..25e05a4 --- /dev/null +++ b/lib/features/notes/presentation/note_editor_drawer.dart @@ -0,0 +1,363 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../../core/ui/floatick_editor_components.dart'; +import '../../../core/ui/floatick_markdown.dart'; +import '../../../l10n/l10n.dart'; +import '../../todos/domain/todo_tag.dart'; +import '../../todos/presentation/widgets/editor_tag_selector.dart'; +import '../domain/note_item.dart'; + +const Duration _autoSaveDelay = Duration(milliseconds: 450); + +typedef SaveNoteDraft = + Future Function({ + String? id, + required String title, + required String content, + required List tagIds, + }); + +class NoteEditorDrawer extends StatefulWidget { + const NoteEditorDrawer({ + required this.item, + required this.availableTags, + required this.assignedTagIds, + required this.isOpen, + required this.onSave, + required this.onOpenTagAssignment, + required this.onClose, + required this.closeFocusNode, + super.key, + }); + + final NoteItem? item; + final List availableTags; + final List assignedTagIds; + final bool isOpen; + final SaveNoteDraft onSave; + final VoidCallback onOpenTagAssignment; + final VoidCallback onClose; + final FocusNode closeFocusNode; + + @override + State createState() => NoteEditorDrawerState(); +} + +class NoteEditorDrawerState extends State { + final _titleController = TextEditingController(); + final _contentController = TextEditingController(); + final _titleFocusNode = FocusNode(); + final _contentFocusNode = FocusNode(); + Timer? _autoSaveTimer; + Future? _saveFuture; + String? _noteId; + String _lastSavedTitle = ''; + String _lastSavedContent = ''; + List _lastSavedTagIds = const []; + bool _saveAgain = false; + bool _isSaving = false; + bool _saveFailed = false; + bool _showPreview = false; + + bool get _isCreating => widget.item == null; + bool get _hasUnsavedChanges => + _titleController.text != _lastSavedTitle || + _contentController.text != _lastSavedContent || + !listEquals(_currentTagIds, _lastSavedTagIds); + List get _currentTagIds => _effectiveTagIdsFor( + availableTags: widget.availableTags, + assignedTagIds: widget.assignedTagIds, + ); + + @override + void initState() { + super.initState(); + _loadItem(widget.item); + _titleController.addListener(_handleInputChanged); + _contentController.addListener(_handleInputChanged); + if (widget.isOpen) { + _requestInitialFocus(); + } + } + + @override + void didUpdateWidget(covariant NoteEditorDrawer oldWidget) { + super.didUpdateWidget(oldWidget); + final previousTagIds = _effectiveTagIdsFor( + availableTags: oldWidget.availableTags, + assignedTagIds: oldWidget.assignedTagIds, + ); + if (!listEquals(previousTagIds, _currentTagIds)) { + _handleInputChanged(); + } + if (!oldWidget.isOpen && widget.isOpen) { + _requestInitialFocus(); + } + } + + @override + void dispose() { + _autoSaveTimer?.cancel(); + _titleController + ..removeListener(_handleInputChanged) + ..dispose(); + _contentController + ..removeListener(_handleInputChanged) + ..dispose(); + _titleFocusNode.dispose(); + _contentFocusNode.dispose(); + super.dispose(); + } + + Future flush() async { + _autoSaveTimer?.cancel(); + if (!_hasUnsavedChanges) { + return !_saveFailed; + } + return _requestSave(); + } + + void _loadItem(NoteItem? item) { + _noteId = item?.id; + _lastSavedTitle = item?.title ?? ''; + _lastSavedContent = item?.content ?? ''; + _lastSavedTagIds = item?.tagIds ?? const []; + _titleController.text = _lastSavedTitle; + _contentController.text = _lastSavedContent; + } + + void _requestInitialFocus() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && widget.isOpen) { + _titleController.selection = TextSelection( + baseOffset: 0, + extentOffset: _titleController.text.length, + ); + _titleFocusNode.requestFocus(); + } + }); + } + + void _handleInputChanged() { + if (!_hasUnsavedChanges) { + return; + } + if (_saveFailed) { + setState(() => _saveFailed = false); + } + _autoSaveTimer?.cancel(); + _autoSaveTimer = Timer(_autoSaveDelay, () { + unawaited(_requestSave()); + }); + } + + Future _requestSave() { + _saveAgain = true; + final activeSave = _saveFuture; + if (activeSave != null) { + return activeSave; + } + final save = _runSaveLoop(); + _saveFuture = save; + unawaited( + save.whenComplete(() { + if (identical(_saveFuture, save)) { + _saveFuture = null; + } + }), + ); + return save; + } + + Future _runSaveLoop() async { + while (_saveAgain) { + _saveAgain = false; + final title = _titleController.text; + final content = _contentController.text; + final tagIds = _currentTagIds; + if (title == _lastSavedTitle && + content == _lastSavedContent && + listEquals(tagIds, _lastSavedTagIds)) { + continue; + } + if (_noteId == null && title.trim().isEmpty && content.trim().isEmpty) { + _lastSavedTitle = title; + _lastSavedContent = content; + _lastSavedTagIds = tagIds; + continue; + } + + if (mounted) { + setState(() { + _isSaving = true; + _saveFailed = false; + }); + } + NoteItem? saved; + try { + saved = await widget.onSave( + id: _noteId, + title: title, + content: content, + tagIds: tagIds, + ); + } on Object catch (error, stackTrace) { + debugPrint('Floatick could not autosave a note: $error'); + debugPrintStack(stackTrace: stackTrace); + } + if (saved == null) { + if (mounted) { + setState(() { + _isSaving = false; + _saveFailed = true; + }); + } + return false; + } + _noteId = saved.id; + _lastSavedTitle = title; + _lastSavedContent = content; + _lastSavedTagIds = tagIds; + if (_titleController.text != title || + _contentController.text != content || + !listEquals(_currentTagIds, tagIds)) { + _saveAgain = true; + } + } + if (mounted) { + setState(() { + _isSaving = false; + _saveFailed = false; + }); + } + return true; + } + + Future _closeAfterFlush() async { + FocusManager.instance.primaryFocus?.unfocus(); + if (await flush() && mounted) { + widget.onClose(); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final onSurface = theme.colorScheme.onSurface; + return FloatickEditorDrawerSurface( + key: const Key('note-editor-drawer'), + title: _isCreating + ? context.l10n.newNoteDrawerTitle + : context.l10n.editNoteDrawerTitle, + closeTooltip: context.l10n.closeNoteDrawerTooltip, + closeButtonKey: const Key('close-note-editor'), + onClose: () => unawaited(_closeAfterFlush()), + closeFocusNode: widget.closeFocusNode, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: Padding( + padding: FloatickEditorMetrics.bodyPadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: FloatickDocumentEditor( + editorSurfaceKey: const Key('note-document-editor'), + titleFieldKey: const Key('note-title-field'), + contentFieldKey: const Key('note-content-field'), + modeSwitchKey: const Key('note-editor-mode-switch'), + writeTabKey: const Key('note-markdown-write-tab'), + previewTabKey: const Key('note-markdown-preview-tab'), + titleController: _titleController, + contentController: _contentController, + titleFocusNode: _titleFocusNode, + contentFocusNode: _contentFocusNode, + titleHint: context.l10n.noteTitleOptionalHint, + contentHint: context.l10n.noteContentHint, + titleSemanticsLabel: context.l10n.noteTitleLabel, + contentSemanticsLabel: context.l10n.noteContentLabel, + toolbarLeading: EditorTagSelector( + availableTags: widget.availableTags, + selectedTagIds: widget.assignedTagIds.toSet(), + buttonKey: const Key('note-editor-tag-button'), + tagKeyPrefix: 'note-editor-tag', + onPressed: widget.onOpenTagAssignment, + ), + showPreview: _showPreview, + preview: FloatickMarkdownPreview( + key: const Key('note-content-preview'), + content: _contentController.text, + embedded: true, + ), + onPreviewChanged: (value) => + setState(() => _showPreview = value), + ), + ), + ], + ), + ), + ), + FloatickEditorFooter( + key: const Key('note-editor-footer'), + child: Row( + children: [ + Icon( + _saveFailed + ? Icons.error_outline_rounded + : _isSaving + ? Icons.sync_rounded + : Icons.check_rounded, + size: 16, + color: _saveFailed + ? theme.colorScheme.error + : onSurface.withValues(alpha: 0.48), + ), + const SizedBox(width: 7), + Expanded( + child: Text( + _saveFailed + ? context.l10n.noteAutoSaveFailed + : _isSaving + ? context.l10n.noteAutoSaving + : _noteId == null + ? context.l10n.noteEmptyDraftHint + : context.l10n.noteAutoSaved, + style: theme.textTheme.bodySmall?.copyWith( + color: _saveFailed + ? theme.colorScheme.error + : onSurface.withValues(alpha: 0.48), + ), + ), + ), + TextButton( + key: const Key('finish-note-editor'), + onPressed: _isSaving + ? null + : () => unawaited(_closeAfterFlush()), + child: Text(context.l10n.finishNoteAction), + ), + ], + ), + ), + ], + ), + ); + } + + static List _effectiveTagIdsFor({ + required List availableTags, + required List assignedTagIds, + }) { + final selectedTagIds = assignedTagIds.toSet(); + return List.unmodifiable( + availableTags + .where((tag) => selectedTagIds.contains(tag.id)) + .map((tag) => tag.id), + ); + } +} diff --git a/lib/features/notes/presentation/note_panel_content.dart b/lib/features/notes/presentation/note_panel_content.dart new file mode 100644 index 0000000..8c6796a --- /dev/null +++ b/lib/features/notes/presentation/note_panel_content.dart @@ -0,0 +1,422 @@ +import 'package:flutter/material.dart'; + +import '../../../l10n/l10n.dart'; +import '../../todos/domain/todo_tag.dart'; +import '../../todos/presentation/widgets/floatick_tag_chip.dart'; +import '../domain/note_item.dart'; +import 'note_view_model.dart'; + +class NotePanelContent extends StatelessWidget { + const NotePanelContent({ + required this.controller, + required this.archived, + required this.query, + required this.availableTags, + required this.selectedTagIds, + required this.onOpen, + super.key, + }); + + final NoteViewModel controller; + final bool archived; + final String query; + final List availableTags; + final Set selectedTagIds; + final ValueChanged onOpen; + + @override + Widget build(BuildContext context) { + final items = controller.itemsForView( + archived: archived, + query: query, + selectedTagIds: selectedTagIds, + ); + if (items.isEmpty) { + return _EmptyNotes( + archived: archived, + hasQuery: query.isNotEmpty || selectedTagIds.isNotEmpty, + ); + } + + final pinned = archived + ? const [] + : items.where((item) => item.isPinned).toList(growable: false); + final regular = archived + ? items + : items.where((item) => !item.isPinned).toList(growable: false); + return ListView( + key: const Key('note-list'), + padding: const EdgeInsets.fromLTRB(14, 10, 14, 18), + children: [ + if (pinned.isNotEmpty) ...[ + _SectionLabel( + icon: Icons.push_pin_outlined, + label: context.l10n.pinnedNotesLabel, + ), + ...pinned.map( + (item) => _NoteListRow( + item: item, + archived: false, + controller: controller, + availableTags: availableTags, + onOpen: () => onOpen(item.id), + ), + ), + const SizedBox(height: 7), + ], + if (regular.isNotEmpty) ...[ + _SectionLabel( + icon: archived + ? Icons.inventory_2_outlined + : Icons.schedule_rounded, + label: archived + ? context.l10n.archiveScopeLabel + : context.l10n.recentNotesLabel, + ), + ...regular.map( + (item) => _NoteListRow( + item: item, + archived: archived, + controller: controller, + availableTags: availableTags, + onOpen: () => onOpen(item.id), + ), + ), + ], + ], + ); + } +} + +class _SectionLabel extends StatelessWidget { + const _SectionLabel({required this.icon, required this.label}); + + final IconData icon; + final String label; + + @override + Widget build(BuildContext context) { + final color = Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.48); + return Padding( + padding: const EdgeInsets.fromLTRB(7, 6, 7, 7), + child: Row( + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 7), + Text( + label, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: color, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} + +class _NoteListRow extends StatefulWidget { + const _NoteListRow({ + required this.item, + required this.archived, + required this.controller, + required this.availableTags, + required this.onOpen, + }); + + final NoteItem item; + final bool archived; + final NoteViewModel controller; + final List availableTags; + final VoidCallback onOpen; + + @override + State<_NoteListRow> createState() => _NoteListRowState(); +} + +class _NoteListRowState extends State<_NoteListRow> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final onSurface = theme.colorScheme.onSurface; + final preview = _previewFor(widget.item.content); + final assignedTagIds = widget.item.tagIds.toSet(); + final assignedTags = widget.availableTags + .where((tag) => assignedTagIds.contains(tag.id)) + .toList(growable: false); + return Semantics( + button: true, + label: widget.item.title, + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: InkWell( + key: ValueKey('note-row-${widget.item.id}'), + onTap: widget.onOpen, + borderRadius: BorderRadius.circular(12), + hoverColor: theme.colorScheme.primary.withValues(alpha: 0.05), + child: Container( + constraints: const BoxConstraints(minHeight: 64), + padding: const EdgeInsets.fromLTRB(11, 9, 5, 9), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: onSurface.withValues(alpha: 0.065)), + ), + ), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(9), + ), + child: Icon( + Icons.description_outlined, + size: 17, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + widget.item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + if (widget.item.isPinned && !_hovered) + Icon( + Icons.push_pin_rounded, + size: 13, + color: theme.colorScheme.primary, + ), + ], + ), + const SizedBox(height: 3), + Row( + children: [ + Expanded( + child: Text( + preview.isEmpty + ? context.l10n.noteWithoutContent + : preview, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: onSurface.withValues(alpha: 0.46), + ), + ), + ), + const SizedBox(width: 8), + Text( + _formatUpdatedTime(context, widget.item.updatedAt), + style: theme.textTheme.labelSmall?.copyWith( + color: onSurface.withValues(alpha: 0.36), + ), + ), + ], + ), + if (assignedTags.isNotEmpty) ...[ + const SizedBox(height: 5), + Wrap( + spacing: 4, + runSpacing: 3, + children: [ + for (final tag in assignedTags) + FloatickTagChip( + key: ValueKey( + 'note-tag-${widget.item.id}-${tag.id}', + ), + tag: tag, + compact: true, + ), + ], + ), + ], + ], + ), + ), + AnimatedSwitcher( + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 140), + child: _hovered + ? _NoteRowActions( + key: const ValueKey('actions'), + item: widget.item, + archived: widget.archived, + controller: widget.controller, + ) + : const SizedBox( + key: ValueKey('spacing'), + width: 8, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _NoteRowActions extends StatelessWidget { + const _NoteRowActions({ + required this.item, + required this.archived, + required this.controller, + super.key, + }); + + final NoteItem item; + final bool archived; + final NoteViewModel controller; + + @override + Widget build(BuildContext context) { + if (archived) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + key: ValueKey('restore-note-${item.id}'), + tooltip: context.l10n.restoreNoteTooltip, + onPressed: () => controller.restore(item.id), + icon: const Icon(Icons.unarchive_outlined, size: 17), + ), + IconButton( + key: ValueKey('delete-note-${item.id}'), + tooltip: context.l10n.deleteNoteTooltip, + onPressed: () => controller.deletePermanently(item.id), + icon: const Icon(Icons.delete_outline_rounded, size: 17), + ), + ], + ); + } + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + key: ValueKey('pin-note-${item.id}'), + tooltip: item.isPinned + ? context.l10n.unpinNoteTooltip + : context.l10n.pinNoteTooltip, + onPressed: () => controller.togglePin(item.id), + icon: Icon( + item.isPinned ? Icons.push_pin_rounded : Icons.push_pin_outlined, + size: 17, + ), + ), + IconButton( + key: ValueKey('archive-note-${item.id}'), + tooltip: context.l10n.archiveNoteTooltip, + onPressed: () => controller.archive(item.id), + icon: const Icon(Icons.archive_outlined, size: 17), + ), + ], + ); + } +} + +class _EmptyNotes extends StatelessWidget { + const _EmptyNotes({required this.archived, required this.hasQuery}); + + final bool archived; + final bool hasQuery; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.09), + borderRadius: BorderRadius.circular(18), + ), + child: Icon( + hasQuery + ? Icons.search_off_rounded + : archived + ? Icons.inventory_2_outlined + : Icons.edit_note_rounded, + size: 26, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(height: 14), + Text( + hasQuery + ? context.l10n.noSearchResultsTitle + : archived + ? context.l10n.emptyNoteArchiveTitle + : context.l10n.emptyNotesTitle, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 5), + Text( + hasQuery + ? context.l10n.noSearchResultsMessage + : archived + ? context.l10n.emptyNoteArchiveMessage + : context.l10n.emptyNotesMessage, + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.44), + ), + ), + ], + ), + ), + ); + } +} + +String _previewFor(String content) { + return content + .replaceAll(RegExp(r'[#>*_`~\[\]()]'), ' ') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); +} + +String _formatUpdatedTime(BuildContext context, DateTime value) { + final local = value.toLocal(); + final now = DateTime.now(); + if (local.year == now.year && + local.month == now.month && + local.day == now.day) { + return MaterialLocalizations.of(context).formatTimeOfDay( + TimeOfDay.fromDateTime(local), + alwaysUse24HourFormat: true, + ); + } + return '${local.month}/${local.day}'; +} diff --git a/lib/features/notes/presentation/note_view_model.dart b/lib/features/notes/presentation/note_view_model.dart new file mode 100644 index 0000000..ddf2b84 --- /dev/null +++ b/lib/features/notes/presentation/note_view_model.dart @@ -0,0 +1,296 @@ +import 'dart:math'; + +import 'package:flutter/foundation.dart'; + +import '../../../core/storage/storage_failure.dart'; +import '../data/note_repository.dart'; +import '../domain/note_item.dart'; + +typedef NoteClock = DateTime Function(); +typedef NoteIdGenerator = String Function(); + +class NoteViewModel extends ChangeNotifier { + NoteViewModel({ + required NoteRepository repository, + NoteClock? clock, + NoteIdGenerator? idGenerator, + }) : _repository = repository, + _clock = clock ?? DateTime.now, + _idGenerator = idGenerator ?? _generateUuidV4; + + static const String untitledFallback = 'Untitled note'; + + final NoteRepository _repository; + final NoteClock _clock; + final NoteIdGenerator _idGenerator; + + List _items = const []; + Map _itemsById = const {}; + StorageFailure? _error; + bool _isLoading = false; + Future _mutationQueue = Future.value(); + + List get items => _items; + StorageFailure? get error => _error; + bool get isLoading => _isLoading; + int get activeCount => _items.where((item) => !item.isArchived).length; + int get archivedCount => _items.where((item) => item.isArchived).length; + + NoteItem? itemById(String id) => _itemsById[id]; + + List itemsForView({ + required bool archived, + required String query, + Iterable selectedTagIds = const [], + }) { + final normalizedQuery = query.trim().toLowerCase(); + final selectedTags = selectedTagIds.toSet(); + final visibleItems = _items + .where((item) { + if (item.isArchived != archived) { + return false; + } + if (selectedTags.isNotEmpty && + !selectedTags.any(item.tagIds.contains)) { + return false; + } + return normalizedQuery.isEmpty || + item.title.toLowerCase().contains(normalizedQuery) || + item.content.toLowerCase().contains(normalizedQuery); + }) + .toList(growable: false); + visibleItems.sort((left, right) { + if (!archived && left.isPinned != right.isPinned) { + return left.isPinned ? -1 : 1; + } + if (archived) { + final leftDate = left.archivedAt ?? left.updatedAt; + final rightDate = right.archivedAt ?? right.updatedAt; + return rightDate.compareTo(leftDate); + } + return right.updatedAt.compareTo(left.updatedAt); + }); + return List.unmodifiable(visibleItems); + } + + Map tagUsageCountsFor(Iterable tagIds) { + final requestedTagIds = tagIds.toSet(); + final counts = {for (final tagId in requestedTagIds) tagId: 0}; + for (final item in _items) { + for (final tagId in item.tagIds) { + if (requestedTagIds.contains(tagId)) { + counts[tagId] = counts[tagId]! + 1; + } + } + } + return Map.unmodifiable(counts); + } + + Future load() async { + _isLoading = true; + _error = null; + notifyListeners(); + try { + _setItems(await _repository.load()); + } on StorageFailure catch (error) { + _error = error; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + Future save({ + String? id, + required String title, + required String content, + Iterable? tagIds, + }) { + final normalizedTitle = title.trim(); + if (id == null && normalizedTitle.isEmpty && content.trim().isEmpty) { + return Future.value(null); + } + + return _enqueueMutation(() async { + final now = _clock().toUtc(); + if (id == null) { + final resolvedTitle = normalizedTitle.isEmpty + ? untitledFallback + : normalizedTitle; + final generatedId = _idGenerator(); + if (_itemsById.containsKey(generatedId)) { + return null; + } + final item = NoteItem( + id: generatedId, + title: resolvedTitle, + content: content, + createdAt: now, + updatedAt: now, + tagIds: _normalizeTagIds(tagIds ?? const []), + ); + return await _commit([..._items, item]) ? item : null; + } + + final index = _items.indexWhere((item) => item.id == id); + if (index == -1 || _items[index].isArchived) { + return null; + } + final existing = _items[index]; + final resolvedTagIds = tagIds == null + ? existing.tagIds + : _normalizeTagIds(tagIds); + final resolvedTitle = normalizedTitle.isEmpty + ? untitledFallback + : normalizedTitle; + if (existing.title == resolvedTitle && + existing.content == content && + listEquals(existing.tagIds, resolvedTagIds)) { + return existing; + } + final updated = existing.withDetails( + title: resolvedTitle, + content: content, + updatedAt: now, + tagIds: resolvedTagIds, + ); + final items = List.of(_items)..[index] = updated; + return await _commit(items) ? updated : null; + }); + } + + static List _normalizeTagIds(Iterable tagIds) { + return List.unmodifiable( + tagIds + .map((tagId) => tagId.trim()) + .where((tagId) => tagId.isNotEmpty) + .toSet(), + ); + } + + Future togglePin(String id) { + return _update(id, (item) { + if (item.isArchived) { + return item; + } + return item.withPinnedAt(item.isPinned ? null : _clock().toUtc()); + }); + } + + Future archive(String id) { + final now = _clock().toUtc(); + return _update( + id, + (item) => + item.isArchived ? item : item.withArchivedAt(now, updatedAt: now), + ); + } + + Future restore(String id) { + final now = _clock().toUtc(); + return _update( + id, + (item) => + !item.isArchived ? item : item.withArchivedAt(null, updatedAt: now), + ); + } + + Future deletePermanently(String id) { + return _enqueueMutation(() async { + final item = _itemsById[id]; + if (item == null || !item.isArchived) { + return false; + } + return _commit(_items.where((candidate) => candidate.id != id).toList()); + }); + } + + Future removeTag(String tagId) { + return _enqueueMutation(() async { + var changed = false; + final updatedItems = _items + .map((item) { + if (!item.tagIds.contains(tagId)) { + return item; + } + changed = true; + return item.withDetails( + title: item.title, + content: item.content, + updatedAt: item.updatedAt, + tagIds: item.tagIds.where((candidate) => candidate != tagId), + ); + }) + .toList(growable: false); + return !changed || await _commit(updatedItems); + }); + } + + void dismissError() { + if (_error == null) { + return; + } + _error = null; + notifyListeners(); + } + + Future _update(String id, NoteItem Function(NoteItem item) update) { + return _enqueueMutation(() async { + final index = _items.indexWhere((item) => item.id == id); + if (index == -1) { + return false; + } + final updated = update(_items[index]); + if (updated == _items[index]) { + return true; + } + final items = List.of(_items)..[index] = updated; + return _commit(items); + }); + } + + Future _enqueueMutation(Future Function() mutation) { + final operation = _mutationQueue.then((_) => mutation()); + _mutationQueue = operation.then( + (_) {}, + onError: (Object _, StackTrace _) {}, + ); + return operation; + } + + Future _commit(List updatedItems) async { + try { + await _repository.save(updatedItems); + _setItems(updatedItems); + _error = null; + notifyListeners(); + return true; + } on StorageFailure catch (error) { + _error = error; + notifyListeners(); + return false; + } + } + + void _setItems(Iterable items) { + _items = List.unmodifiable(items); + _itemsById = Map.unmodifiable({ + for (final item in _items) item.id: item, + }); + } + + static String _generateUuidV4() { + final random = Random.secure(); + final bytes = List.generate(16, (_) => random.nextInt(256)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + final hex = bytes + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + return '${hex.substring(0, 8)}-' + '${hex.substring(8, 12)}-' + '${hex.substring(12, 16)}-' + '${hex.substring(16, 20)}-' + '${hex.substring(20)}'; + } +} diff --git a/lib/features/sticky_boards/presentation/widgets/sticky_board_todo_details.dart b/lib/features/sticky_boards/presentation/widgets/sticky_board_todo_details.dart index 258f604..57e83d1 100644 --- a/lib/features/sticky_boards/presentation/widgets/sticky_board_todo_details.dart +++ b/lib/features/sticky_boards/presentation/widgets/sticky_board_todo_details.dart @@ -6,7 +6,7 @@ import '../../../todos/domain/todo_tag.dart'; import '../../../todos/presentation/todo_clipboard_controller.dart'; import '../../../todos/presentation/widgets/floatick_tag_chip.dart'; import '../../../todos/presentation/widgets/todo_copy_button.dart'; -import '../../../todos/presentation/widgets/todo_markdown.dart'; +import '../../../../core/ui/floatick_markdown.dart'; class StickyBoardTodoDetails extends StatefulWidget { const StickyBoardTodoDetails({ @@ -113,7 +113,7 @@ class _StickyBoardTodoDetailsState extends State { Expanded( child: item.content.trim().isEmpty ? _EmptyStickyBoardTodoContent(onSurface: onSurface) - : TodoMarkdownContent( + : FloatickMarkdownContent( key: const Key('sticky-board-details-markdown'), content: item.content, ), diff --git a/lib/features/todos/presentation/tag_filter_drawer.dart b/lib/features/todos/presentation/tag_filter_drawer.dart index c2a63f7..16de408 100644 --- a/lib/features/todos/presentation/tag_filter_drawer.dart +++ b/lib/features/todos/presentation/tag_filter_drawer.dart @@ -16,6 +16,7 @@ class TagFilterDrawer extends StatelessWidget { required this.onManageTags, required this.onClose, required this.closeFocusNode, + this.usageCounts, super.key, }) : mode = TagDrawerSelectionMode.filter; @@ -29,7 +30,8 @@ class TagFilterDrawer extends StatelessWidget { required this.closeFocusNode, super.key, }) : mode = TagDrawerSelectionMode.assignment, - onClear = null; + onClear = null, + usageCounts = null; final TagDrawerSelectionMode mode; final TodoViewModel controller; @@ -40,6 +42,7 @@ class TagFilterDrawer extends StatelessWidget { final VoidCallback onManageTags; final VoidCallback onClose; final FocusNode closeFocusNode; + final Map? usageCounts; @override Widget build(BuildContext context) { @@ -75,9 +78,9 @@ class TagFilterDrawer extends StatelessWidget { final effectiveSelectedTagIds = selectedTagIds .where(knownTagIds.contains) .toSet(); - final usageCounts = controller.tagUsageCountsFor( - tags.map((tag) => tag.id), - ); + final resolvedUsageCounts = + usageCounts ?? + controller.tagUsageCountsFor(tags.map((tag) => tag.id)); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -182,7 +185,7 @@ class TagFilterDrawer extends StatelessWidget { ), tag: tag, label: tag.name, - trailing: '${usageCounts[tag.id] ?? 0}', + trailing: '${resolvedUsageCounts[tag.id] ?? 0}', selected: effectiveSelectedTagIds.contains(tag.id), onPressed: () => onToggled(tag.id), ); diff --git a/lib/features/todos/presentation/tag_management_drawer.dart b/lib/features/todos/presentation/tag_management_drawer.dart index 89fed1a..5b90e94 100644 --- a/lib/features/todos/presentation/tag_management_drawer.dart +++ b/lib/features/todos/presentation/tag_management_drawer.dart @@ -19,6 +19,8 @@ class TagManagementDrawer extends StatefulWidget { required this.borderOnLeft, required this.onClose, required this.closeFocusNode, + this.additionalUsageCounts = const {}, + this.onTagDeleted, super.key, }); @@ -27,6 +29,8 @@ class TagManagementDrawer extends StatefulWidget { final bool borderOnLeft; final VoidCallback onClose; final FocusNode closeFocusNode; + final Map additionalUsageCounts; + final Future Function(String tagId)? onTagDeleted; @override State createState() => _TagManagementDrawerState(); @@ -147,7 +151,13 @@ class _TagManagementDrawerState extends State { return; } setState(() => _isSaving = true); - final result = await widget.controller.deleteTag(tagId); + var result = await widget.controller.deleteTag(tagId); + if (result == TagMutationResult.success && widget.onTagDeleted != null) { + final removedFromDependents = await widget.onTagDeleted!(tagId); + if (!removedFromDependents) { + result = TagMutationResult.storageFailure; + } + } if (!mounted) { return; } @@ -213,9 +223,15 @@ class _TagManagementDrawerState extends State { return query.isEmpty || tag.name.toLowerCase().contains(query); }) .toList(growable: false); - final usageCounts = widget.controller.tagUsageCountsFor( + final todoUsageCounts = widget.controller.tagUsageCountsFor( filteredTags.map((tag) => tag.id), ); + final usageCounts = { + for (final tag in filteredTags) + tag.id: + (todoUsageCounts[tag.id] ?? 0) + + (widget.additionalUsageCounts[tag.id] ?? 0), + }; final canSubmit = !_isSaving; return Column( diff --git a/lib/features/todos/presentation/todo_editor_drawer.dart b/lib/features/todos/presentation/todo_editor_drawer.dart index 3a951a6..e347cdd 100644 --- a/lib/features/todos/presentation/todo_editor_drawer.dart +++ b/lib/features/todos/presentation/todo_editor_drawer.dart @@ -4,14 +4,15 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import '../../../core/ui/floatick_hover_motion.dart'; +import '../../../core/ui/floatick_editor_components.dart'; +import '../../../core/ui/floatick_markdown.dart'; import '../../../l10n/l10n.dart'; import '../domain/todo_item.dart'; import '../domain/todo_tag.dart'; import 'todo_clipboard_controller.dart'; +import 'widgets/editor_tag_selector.dart'; import 'widgets/floatick_tag_chip.dart'; import 'widgets/todo_copy_button.dart'; -import 'widgets/todo_markdown.dart'; enum TodoEditorDrawerMode { create, details, edit } @@ -124,11 +125,11 @@ class _TodoEditorDrawerState extends State { return; } if (_isEditing) { - _titleFocusNode.requestFocus(); _titleController.selection = TextSelection( baseOffset: 0, extentOffset: _titleController.text.length, ); + _titleFocusNode.requestFocus(); } else { widget.closeFocusNode.requestFocus(); } @@ -136,7 +137,10 @@ class _TodoEditorDrawerState extends State { } bool get _canSave { - if (_isSaving || _titleController.text.trim().isEmpty) { + final hasText = + _titleController.text.trim().isNotEmpty || + _contentController.text.trim().isNotEmpty; + if (_isSaving || !hasText) { return false; } if (widget.mode == TodoEditorDrawerMode.create) { @@ -185,159 +189,79 @@ class _TodoEditorDrawerState extends State { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final isDark = theme.brightness == Brightness.dark; final availableTags = widget.availableTags; - return DecoratedBox( - key: const Key('todo-editor-drawer'), - decoration: BoxDecoration( - color: isDark ? const Color(0xFF202A2E) : const Color(0xFFF9FBFA), - borderRadius: const BorderRadius.vertical(top: Radius.circular(22)), - border: Border( - top: BorderSide( - color: isDark - ? Colors.white.withValues(alpha: 0.11) - : Colors.black.withValues(alpha: 0.07), - ), - ), - ), - child: ClipRRect( - borderRadius: const BorderRadius.vertical(top: Radius.circular(22)), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _DrawerHeader( - mode: widget.mode, - item: widget.item, - canEdit: widget.canEdit, - copyController: _copyController, - onEdit: widget.onEdit, - onClose: widget.onClose, - closeFocusNode: widget.closeFocusNode, - ), - Divider( - height: 1, - thickness: 1, - color: isDark - ? Colors.white.withValues(alpha: 0.08) - : Colors.black.withValues(alpha: 0.06), - ), - Expanded( - child: AnimatedSwitcher( - duration: MediaQuery.disableAnimationsOf(context) - ? Duration.zero - : const Duration(milliseconds: 160), - switchInCurve: Curves.easeOut, - switchOutCurve: Curves.easeIn, - child: widget.mode == TodoEditorDrawerMode.details - ? _TodoDetails( - key: ValueKey( - 'details-${widget.item?.id ?? 'missing'}', - ), - item: widget.item, - canEdit: widget.canEdit, - tags: availableTags - .where( - (tag) => widget.assignedTagIds.contains(tag.id), - ) - .toList(growable: false), - ) - : _TodoEditor( - key: const ValueKey('todo-editor'), - formKey: _formKey, - titleController: _titleController, - contentController: _contentController, - titleFocusNode: _titleFocusNode, - contentFocusNode: _contentFocusNode, - availableTags: availableTags, - selectedTagIds: widget.assignedTagIds.toSet(), - showPreview: _showPreview, - isSaving: _isSaving, - saveFailed: _saveFailed, - canSave: _canSave, - mode: widget.mode, - onChanged: () { - setState(() { - _saveFailed = false; - }); - }, - onPreviewChanged: (showPreview) { - setState(() => _showPreview = showPreview); - }, - onOpenTagAssignment: widget.onOpenTagAssignment, - onSubmit: () => unawaited(_submit()), - onCancel: widget.onClose, - ), - ), - ), - ], - ), - ), - ); - } -} - -class _DrawerHeader extends StatelessWidget { - const _DrawerHeader({ - required this.mode, - required this.item, - required this.canEdit, - required this.copyController, - required this.onEdit, - required this.onClose, - required this.closeFocusNode, - }); - - final TodoEditorDrawerMode mode; - final TodoItem? item; - final bool canEdit; - final TodoClipboardController copyController; - final VoidCallback onEdit; - final VoidCallback onClose; - final FocusNode closeFocusNode; - - @override - Widget build(BuildContext context) { - final title = switch (mode) { + final title = switch (widget.mode) { TodoEditorDrawerMode.create => context.l10n.newTodoDrawerTitle, TodoEditorDrawerMode.details => context.l10n.todoDetailsDrawerTitle, TodoEditorDrawerMode.edit => context.l10n.editTodoDrawerTitle, }; - return Padding( - padding: const EdgeInsets.fromLTRB(20, 12, 10, 11), - child: Row( - children: [ - Expanded( - child: Text( - title, - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), - ), + return FloatickEditorDrawerSurface( + key: const Key('todo-editor-drawer'), + title: title, + closeTooltip: context.l10n.closeTodoDrawerTooltip, + closeButtonKey: const Key('todo-drawer-close'), + onClose: widget.onClose, + closeFocusNode: widget.closeFocusNode, + headerActions: [ + if (widget.mode == TodoEditorDrawerMode.details && widget.item != null) + TodoCopyButton( + key: const Key('todo-details-copy'), + item: widget.item!, + controller: _copyController, + dimension: 40, + iconSize: 19, ), - if (mode == TodoEditorDrawerMode.details && item != null) - TodoCopyButton( - key: const Key('todo-details-copy'), - item: item!, - controller: copyController, - dimension: 40, - iconSize: 19, - ), - if (mode == TodoEditorDrawerMode.details && canEdit) - IconButton( - key: const Key('todo-details-edit'), - onPressed: onEdit, - tooltip: context.l10n.editTodoAction, - icon: const Icon(Icons.edit_outlined, size: 19), - ), + if (widget.mode == TodoEditorDrawerMode.details && widget.canEdit) IconButton( - key: const Key('todo-drawer-close'), - focusNode: closeFocusNode, - tooltip: context.l10n.closeTodoDrawerTooltip, - onPressed: onClose, - icon: const Icon(Icons.close_rounded, size: 19), + key: const Key('todo-details-edit'), + onPressed: widget.onEdit, + tooltip: context.l10n.editTodoAction, + icon: const Icon(Icons.edit_outlined, size: 19), ), - ], + ], + child: AnimatedSwitcher( + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 160), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: widget.mode == TodoEditorDrawerMode.details + ? _TodoDetails( + key: ValueKey( + 'details-${widget.item?.id ?? 'missing'}', + ), + item: widget.item, + canEdit: widget.canEdit, + tags: availableTags + .where((tag) => widget.assignedTagIds.contains(tag.id)) + .toList(growable: false), + ) + : _TodoEditor( + key: const ValueKey('todo-editor'), + formKey: _formKey, + titleController: _titleController, + contentController: _contentController, + titleFocusNode: _titleFocusNode, + contentFocusNode: _contentFocusNode, + availableTags: availableTags, + selectedTagIds: widget.assignedTagIds.toSet(), + showPreview: _showPreview, + isSaving: _isSaving, + saveFailed: _saveFailed, + canSave: _canSave, + mode: widget.mode, + onChanged: () { + setState(() { + _saveFailed = false; + }); + }, + onPreviewChanged: (showPreview) { + setState(() => _showPreview = showPreview); + }, + onOpenTagAssignment: widget.onOpenTagAssignment, + onSubmit: () => unawaited(_submit()), + onCancel: widget.onClose, + ), ), ); } @@ -415,145 +339,46 @@ class _TodoEditor extends StatelessWidget { children: [ Expanded( child: Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 12), + padding: FloatickEditorMetrics.bodyPadding, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - context.l10n.todoTitleLabel, - style: Theme.of(context).textTheme.labelLarge?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 7), - TextFormField( - key: const Key('todo-title-field'), - controller: titleController, - focusNode: titleFocusNode, - enabled: !isSaving, - maxLines: 1, - textInputAction: TextInputAction.next, - onChanged: (_) => onChanged(), - onFieldSubmitted: (_) => - contentFocusNode.requestFocus(), - validator: (value) { - return value == null || value.trim().isEmpty - ? context.l10n.todoTitleRequiredHint - : null; - }, - decoration: InputDecoration( - hintText: context.l10n.todoTitleFieldHint, - ), - ), - const SizedBox(height: 12), - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - context.l10n.assignTagsTitle, - style: Theme.of(context).textTheme.labelLarge - ?.copyWith(fontWeight: FontWeight.w600), - ), - const SizedBox(width: 12), - Expanded( - child: Wrap( - alignment: WrapAlignment.end, - crossAxisAlignment: WrapCrossAlignment.center, - spacing: 5, - runSpacing: 5, - children: [ - for (final tag in availableTags) - if (selectedTagIds.contains(tag.id)) - FloatickTagChip( - key: ValueKey( - 'todo-editor-tag-${tag.id}', - ), - tag: tag, - compact: true, - ), - IconButton( - key: const Key('todo-editor-tag-button'), - tooltip: context.l10n.assignTagsTooltip, - onPressed: isSaving - ? null - : onOpenTagAssignment, - style: IconButton.styleFrom( - minimumSize: const Size.square(30), - maximumSize: const Size.square(30), - padding: EdgeInsets.zero, - tapTargetSize: - MaterialTapTargetSize.shrinkWrap, - foregroundColor: selectedTagIds.isEmpty - ? Theme.of(context) - .colorScheme - .onSurface - .withValues(alpha: 0.56) - : Theme.of(context).colorScheme.primary, - ), - icon: Icon( - selectedTagIds.isEmpty - ? Icons.sell_outlined - : Icons.sell_rounded, - size: 17, - ), - ), - ], - ), + Expanded( + child: FloatickDocumentEditor( + editorSurfaceKey: const Key('todo-document-editor'), + titleFieldKey: const Key('todo-title-field'), + contentFieldKey: const Key('todo-content-field'), + modeSwitchKey: const Key( + 'floatick-editor-mode-switch', ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: Text( - context.l10n.todoContentLabel, - style: Theme.of(context).textTheme.labelLarge - ?.copyWith(fontWeight: FontWeight.w600), - ), + writeTabKey: const Key('markdown-write-tab'), + previewTabKey: const Key('markdown-preview-tab'), + titleController: titleController, + contentController: contentController, + titleFocusNode: titleFocusNode, + contentFocusNode: contentFocusNode, + titleHint: context.l10n.todoTitleFieldHint, + contentHint: context.l10n.todoContentFieldHint, + titleSemanticsLabel: context.l10n.todoTitleLabel, + contentSemanticsLabel: context.l10n.todoContentLabel, + toolbarLeading: EditorTagSelector( + availableTags: availableTags, + selectedTagIds: selectedTagIds, + enabled: !isSaving, + buttonKey: const Key('todo-editor-tag-button'), + tagKeyPrefix: 'todo-editor-tag', + onPressed: onOpenTagAssignment, ), - _EditorModeSwitch( - showPreview: showPreview, - onChanged: onPreviewChanged, + enabled: !isSaving, + showPreview: showPreview, + preview: FloatickMarkdownPreview( + key: const Key('todo-content-preview'), + content: contentController.text, + embedded: true, ), - ], - ), - const SizedBox(height: 7), - Expanded( - child: AnimatedSwitcher( - duration: MediaQuery.disableAnimationsOf(context) - ? Duration.zero - : const Duration(milliseconds: 140), - child: showPreview - ? TodoMarkdownPreview( - key: const Key('todo-content-preview'), - content: contentController.text, - ) - : TextFormField( - key: const Key('todo-content-field'), - controller: contentController, - focusNode: contentFocusNode, - enabled: !isSaving, - expands: true, - minLines: null, - maxLines: null, - textAlignVertical: TextAlignVertical.top, - keyboardType: TextInputType.multiline, - onChanged: (_) => onChanged(), - decoration: InputDecoration( - hintText: context.l10n.todoContentFieldHint, - alignLabelWithHint: true, - ), - ), - ), - ), - const SizedBox(height: 7), - Text( - context.l10n.markdownSupportedHint, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.42), + onTitleChanged: (_) => onChanged(), + onContentChanged: (_) => onChanged(), + onPreviewChanged: onPreviewChanged, ), ), ], @@ -570,15 +395,8 @@ class _TodoEditor extends StatelessWidget { ), ), ), - Divider( - height: 1, - thickness: 1, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.08), - ), - Padding( - padding: const EdgeInsets.fromLTRB(20, 10, 20, 14), + FloatickEditorFooter( + key: const Key('todo-editor-footer'), child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ @@ -616,95 +434,6 @@ class _TodoEditor extends StatelessWidget { } } -class _EditorModeSwitch extends StatelessWidget { - const _EditorModeSwitch({required this.showPreview, required this.onChanged}); - - final bool showPreview; - final ValueChanged onChanged; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Container( - height: 30, - padding: const EdgeInsets.all(2), - decoration: BoxDecoration( - color: theme.colorScheme.onSurface.withValues(alpha: 0.055), - borderRadius: BorderRadius.circular(9), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _EditorModeButton( - key: const Key('markdown-write-tab'), - label: context.l10n.markdownWriteLabel, - selected: !showPreview, - onPressed: () => onChanged(false), - ), - _EditorModeButton( - key: const Key('markdown-preview-tab'), - label: context.l10n.markdownPreviewLabel, - selected: showPreview, - onPressed: () => onChanged(true), - ), - ], - ), - ); - } -} - -class _EditorModeButton extends StatelessWidget { - const _EditorModeButton({ - required this.label, - required this.selected, - required this.onPressed, - super.key, - }); - - final String label; - final bool selected; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Semantics( - button: true, - selected: selected, - child: FloatickHoverMotion( - hoverScale: FloatickMotion.controlHoverScale, - pressedScale: FloatickMotion.controlPressedScale, - child: InkWell( - onTap: onPressed, - borderRadius: BorderRadius.circular(7), - child: AnimatedContainer( - duration: MediaQuery.disableAnimationsOf(context) - ? Duration.zero - : const Duration(milliseconds: 140), - alignment: Alignment.center, - padding: const EdgeInsets.symmetric(horizontal: 9), - decoration: BoxDecoration( - color: selected - ? theme.colorScheme.surface.withValues(alpha: 0.92) - : Colors.transparent, - borderRadius: BorderRadius.circular(7), - ), - child: Text( - label, - style: theme.textTheme.labelSmall?.copyWith( - color: selected - ? theme.colorScheme.onSurface - : theme.colorScheme.onSurface.withValues(alpha: 0.52), - fontWeight: selected ? FontWeight.w600 : FontWeight.w500, - ), - ), - ), - ), - ), - ); - } -} - class _TodoDetails extends StatelessWidget { const _TodoDetails({ required this.item, @@ -754,7 +483,7 @@ class _TodoDetails extends StatelessWidget { Expanded( child: item.content.trim().isEmpty ? _EmptyTodoContent(canEdit: canEdit) - : TodoMarkdownContent( + : FloatickMarkdownContent( key: const Key('todo-details-markdown'), content: item.content, ), diff --git a/lib/features/todos/presentation/todo_panel.dart b/lib/features/todos/presentation/todo_panel.dart index a64cfe6..c7907f7 100644 --- a/lib/features/todos/presentation/todo_panel.dart +++ b/lib/features/todos/presentation/todo_panel.dart @@ -10,6 +10,9 @@ import '../../../core/ui/floatick_brand_mark.dart'; import '../../../core/ui/floatick_surface_metrics.dart'; import '../../../l10n/l10n.dart'; import '../../../l10n/storage_failure_localizations.dart'; +import '../../notes/presentation/note_editor_drawer.dart'; +import '../../notes/presentation/note_panel_content.dart'; +import '../../notes/presentation/note_view_model.dart'; import '../../settings/presentation/settings_drawer.dart'; import '../../settings/presentation/settings_view_model.dart'; import '../../sticky_boards/presentation/sticky_board_drawers.dart'; @@ -28,6 +31,7 @@ const double _settingsDrawerWidth = 268; const double _tagDrawerWidth = 292; const double _stickyBoardDrawerWidth = 336; const double _todoDrawerHeight = 520; +const double _noteDrawerHeight = 590; const Duration _drawerSlideDuration = Duration(milliseconds: 220); const Duration _drawerScrimDuration = Duration(milliseconds: 160); const Duration _scrollHoverResumeDelay = Duration(milliseconds: 120); @@ -35,6 +39,8 @@ const double _todoListCacheExtentViewportFraction = 0.75; enum TodoListScope { active, archived } +enum _PanelContentKind { todos, notes } + enum _TodoPanelDrawerMode { none, settings, @@ -47,13 +53,22 @@ enum _TodoPanelDrawerMode { createTodo, todoDetails, editTodo, + createNote, + editNote, } -enum _TodoPanelDrawerFamily { settings, tags, stickyBoards, todoEditor } +enum _TodoPanelDrawerFamily { + settings, + tags, + stickyBoards, + todoEditor, + noteEditor, +} class TodoPanel extends StatefulWidget { const TodoPanel({ required this.controller, + this.noteController, required this.settingsController, required this.updateController, required this.stickyBoardController, @@ -67,6 +82,7 @@ class TodoPanel extends StatefulWidget { }); final TodoViewModel controller; + final NoteViewModel? noteController; final SettingsViewModel settingsController; final UpdateViewModel updateController; final StickyBoardViewModel stickyBoardController; @@ -91,8 +107,13 @@ class _TodoPanelState extends State { final _tagManagementCloseFocusNode = FocusNode(); final _stickyBoardCloseFocusNode = FocusNode(); final _todoDrawerCloseFocusNode = FocusNode(); + final _noteDrawerCloseFocusNode = FocusNode(); + GlobalKey _noteEditorKey = + GlobalKey(); + _PanelContentKind _contentKind = _PanelContentKind.todos; TodoListScope _scope = TodoListScope.active; + bool _noteArchived = false; String _query = ''; final Set _selectedTagIds = {}; String? _selectedTodoId; @@ -104,12 +125,14 @@ class _TodoPanelState extends State { _TodoPanelDrawerMode? _tagAssignmentReturnMode; _TodoPanelDrawerMode? _todoDrawerReturnMode; Set _todoEditorTagIds = {}; + Set _noteEditorTagIds = {}; final Set<_TodoPanelDrawerFamily> _mountedDrawerFamilies = <_TodoPanelDrawerFamily>{}; String? _selectedStickyBoardId; String? _todoCreationBoardId; String? _pendingCreatedTodoId; int _todoEditorSession = 0; + String? _selectedNoteId; int _lastHandledStickyBoardRequestSerial = -1; int _drawerRequestSerial = 0; @@ -138,6 +161,7 @@ class _TodoPanelState extends State { _tagManagementCloseFocusNode.dispose(); _stickyBoardCloseFocusNode.dispose(); _todoDrawerCloseFocusNode.dispose(); + _noteDrawerCloseFocusNode.dispose(); super.dispose(); } @@ -183,6 +207,9 @@ class _TodoPanelState extends State { } _selectedStickyBoardId = boardId; + if (_contentKind != _PanelContentKind.todos) { + setState(() => _contentKind = _PanelContentKind.todos); + } if (!_mountedDrawerFamilies.contains(_TodoPanelDrawerFamily.stickyBoards)) { setState( () => _mountedDrawerFamilies.add(_TodoPanelDrawerFamily.stickyBoards), @@ -276,6 +303,84 @@ class _TodoPanelState extends State { _showDrawer(_TodoPanelDrawerMode.tagFilter); } + void _selectContentKind(_PanelContentKind kind) { + if (_contentKind == kind || _drawerMode != _TodoPanelDrawerMode.none) { + return; + } + _searchController.clear(); + setState(() { + _contentKind = kind; + _query = ''; + }); + } + + void _toggleNoteArchive() { + setState(() => _noteArchived = !_noteArchived); + } + + void _openNoteCreate() { + if (widget.noteController == null) { + return; + } + _showNoteDrawer( + _TodoPanelDrawerMode.createNote, + initialTagIds: const [], + ); + } + + void _openNote(String noteId) { + if (widget.noteController?.itemById(noteId) == null) { + return; + } + _showNoteDrawer( + _TodoPanelDrawerMode.editNote, + noteId: noteId, + initialTagIds: widget.noteController!.itemById(noteId)!.tagIds, + ); + } + + void _showNoteDrawer( + _TodoPanelDrawerMode mode, { + required Iterable initialTagIds, + String? noteId, + }) { + assert( + mode == _TodoPanelDrawerMode.createNote || + mode == _TodoPanelDrawerMode.editNote, + ); + final requestSerial = ++_drawerRequestSerial; + _noteEditorKey = GlobalKey(); + final needsMount = !_mountedDrawerFamilies.contains( + _TodoPanelDrawerFamily.noteEditor, + ); + setState(() { + _selectedNoteId = noteId; + _noteEditorTagIds = initialTagIds.toSet(); + if (needsMount) { + _mountedDrawerFamilies.add(_TodoPanelDrawerFamily.noteEditor); + _pendingDrawerMode = mode; + } else { + _pendingDrawerMode = null; + _drawerMode = mode; + } + if (mode == _TodoPanelDrawerMode.createNote) { + _noteArchived = false; + } + }); + if (!needsMount) { + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || requestSerial != _drawerRequestSerial) { + return; + } + setState(() { + _pendingDrawerMode = null; + _drawerMode = mode; + }); + }); + } + void _openTagManagement() { _tagManagementReturnMode = null; _tagAssignmentReturnMode = null; @@ -402,9 +507,11 @@ class _TodoPanelState extends State { return linked; } - void _openTagAssignmentFromTodo() { + void _openTagAssignmentFromEditor() { if (_drawerMode != _TodoPanelDrawerMode.createTodo && - _drawerMode != _TodoPanelDrawerMode.editTodo) { + _drawerMode != _TodoPanelDrawerMode.editTodo && + _drawerMode != _TodoPanelDrawerMode.createNote && + _drawerMode != _TodoPanelDrawerMode.editNote) { return; } _tagAssignmentReturnMode = _drawerMode; @@ -419,13 +526,19 @@ class _TodoPanelState extends State { _showDrawer(_TodoPanelDrawerMode.tagManagement); } - void _toggleTodoEditorTag(String tagId) { + void _toggleEditorTag(String tagId) { if (widget.controller.tagById(tagId) == null) { return; } setState(() { - if (!_todoEditorTagIds.add(tagId)) { - _todoEditorTagIds.remove(tagId); + final isNoteAssignment = + _tagAssignmentReturnMode == _TodoPanelDrawerMode.createNote || + _tagAssignmentReturnMode == _TodoPanelDrawerMode.editNote; + final editorTagIds = isNoteAssignment + ? _noteEditorTagIds + : _todoEditorTagIds; + if (!editorTagIds.add(tagId)) { + editorTagIds.remove(tagId); } }); } @@ -437,6 +550,7 @@ class _TodoPanelState extends State { _tagManagementCloseFocusNode.unfocus(); _stickyBoardCloseFocusNode.unfocus(); _todoDrawerCloseFocusNode.unfocus(); + _noteDrawerCloseFocusNode.unfocus(); } void _showDrawer(_TodoPanelDrawerMode mode) { @@ -496,7 +610,9 @@ class _TodoPanelState extends State { _TodoPanelDrawerMode.tagManagement || _TodoPanelDrawerMode.createTodo || _TodoPanelDrawerMode.todoDetails || - _TodoPanelDrawerMode.editTodo => null, + _TodoPanelDrawerMode.editTodo || + _TodoPanelDrawerMode.createNote || + _TodoPanelDrawerMode.editNote => null, }; focusNode?.requestFocus(); }); @@ -604,6 +720,10 @@ class _TodoPanelState extends State { _todoCreationBoardId = null; _pendingCreatedTodoId = null; } + if (_isNoteDrawerMode(closedMode)) { + _selectedNoteId = null; + _noteEditorTagIds = {}; + } }); if (returnMode != null) { _requestDrawerFocus(returnMode); @@ -618,6 +738,16 @@ class _TodoPanelState extends State { } } + Future _requestCloseActiveDrawer() async { + if (_isNoteDrawerMode(_drawerMode)) { + final canClose = await _noteEditorKey.currentState?.flush() ?? true; + if (!canClose || !mounted) { + return; + } + } + _closeActiveDrawer(); + } + void _restorePanelFocus() { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _drawerMode == _TodoPanelDrawerMode.none) { @@ -650,6 +780,11 @@ class _TodoPanelState extends State { mode == _TodoPanelDrawerMode.editTodo; } + bool _isNoteDrawerMode(_TodoPanelDrawerMode mode) { + return mode == _TodoPanelDrawerMode.createNote || + mode == _TodoPanelDrawerMode.editNote; + } + _TodoPanelDrawerFamily? _drawerFamilyFor(_TodoPanelDrawerMode mode) { return switch (mode) { _TodoPanelDrawerMode.settings => _TodoPanelDrawerFamily.settings, @@ -663,6 +798,8 @@ class _TodoPanelState extends State { _TodoPanelDrawerMode.createTodo || _TodoPanelDrawerMode.todoDetails || _TodoPanelDrawerMode.editTodo => _TodoPanelDrawerFamily.todoEditor, + _TodoPanelDrawerMode.createNote || + _TodoPanelDrawerMode.editNote => _TodoPanelDrawerFamily.noteEditor, _TodoPanelDrawerMode.none => null, }; } @@ -696,6 +833,9 @@ class _TodoPanelState extends State { _drawerMode == _TodoPanelDrawerMode.createTodo || _drawerMode == _TodoPanelDrawerMode.todoDetails || _drawerMode == _TodoPanelDrawerMode.editTodo; + final isNoteDrawerOpen = + _drawerMode == _TodoPanelDrawerMode.createNote || + _drawerMode == _TodoPanelDrawerMode.editNote; final hasSettingsDrawer = _mountedDrawerFamilies.contains( _TodoPanelDrawerFamily.settings, ); @@ -708,14 +848,27 @@ class _TodoPanelState extends State { final hasTodoDrawer = _mountedDrawerFamilies.contains( _TodoPanelDrawerFamily.todoEditor, ); - final isTodoContextOverlayOpen = + final hasNoteDrawer = _mountedDrawerFamilies.contains( + _TodoPanelDrawerFamily.noteEditor, + ); + final isEditorContextOverlayOpen = isTagAssignmentOpen || (isTagManagementOpen && _tagManagementReturnMode == _TodoPanelDrawerMode.tagAssignment); - final isTodoDrawerVisible = isTodoDrawerOpen || isTodoContextOverlayOpen; + final tagAssignmentOwner = _tagAssignmentReturnMode; + final isTodoTagContext = + isEditorContextOverlayOpen && + (tagAssignmentOwner == _TodoPanelDrawerMode.createTodo || + tagAssignmentOwner == _TodoPanelDrawerMode.editTodo); + final isNoteTagContext = + isEditorContextOverlayOpen && + (tagAssignmentOwner == _TodoPanelDrawerMode.createNote || + tagAssignmentOwner == _TodoPanelDrawerMode.editNote); + final isTodoDrawerVisible = isTodoDrawerOpen || isTodoTagContext; + final isNoteDrawerVisible = isNoteDrawerOpen || isNoteTagContext; final isStickyBoardContextVisible = (_todoDrawerReturnMode == _TodoPanelDrawerMode.stickyBoardDetail && - (isTodoDrawerOpen || isTodoContextOverlayOpen)); + (isTodoDrawerOpen || isTodoTagContext)); final isStickyBoardDrawerVisible = isStickyBoardDrawerOpen || isStickyBoardContextVisible; final visibleTagDrawerMode = isTagDrawerOpen @@ -732,10 +885,20 @@ class _TodoPanelState extends State { final selectedTodo = _selectedTodoId == null ? null : widget.controller.itemById(_selectedTodoId!); + final selectedNote = _selectedNoteId == null + ? null + : widget.noteController?.itemById(_selectedNoteId!); final todoEditorTagIds = widget.controller.tags .where((tag) => _todoEditorTagIds.contains(tag.id)) .map((tag) => tag.id) .toList(growable: false); + final noteEditorTagIds = widget.controller.tags + .where((tag) => _noteEditorTagIds.contains(tag.id)) + .map((tag) => tag.id) + .toList(growable: false); + final assignmentTagIds = isNoteTagContext + ? _noteEditorTagIds + : _todoEditorTagIds; final originalTodoTagIds = selectedTodo == null ? const [] : widget.controller.tagIdsForTodo(selectedTodo.id); @@ -756,7 +919,7 @@ class _TodoPanelState extends State { _CollapseIntent: CallbackAction<_CollapseIntent>( onInvoke: (_) { if (isDrawerOpen) { - _closeActiveDrawer(); + unawaited(_requestCloseActiveDrawer()); } else { widget.onCollapse(); } @@ -771,7 +934,11 @@ class _TodoPanelState extends State { ), _NewTodoIntent: CallbackAction<_NewTodoIntent>( onInvoke: (_) { - _openTodoCreate(); + if (_contentKind == _PanelContentKind.notes) { + _openNoteCreate(); + } else { + _openTodoCreate(); + } return null; }, ), @@ -813,7 +980,12 @@ class _TodoPanelState extends State { ExcludeFocus( excluding: isDrawerOpen, child: AnimatedBuilder( - animation: widget.controller, + animation: widget.noteController == null + ? widget.controller + : Listenable.merge([ + widget.controller, + widget.noteController!, + ]), builder: (context, _) { final selectedTags = widget.controller.tags .where( @@ -830,11 +1002,33 @@ class _TodoPanelState extends State { activeCount: widget.controller.activeCount, archivedCount: widget.controller.archivedCount, + notesSelected: + _contentKind == _PanelContentKind.notes, + noteArchived: _noteArchived, + noteActiveCount: + widget.noteController?.activeCount ?? 0, + noteArchivedCount: + widget.noteController?.archivedCount ?? + 0, onToggleArchive: _toggleArchiveScope, + onToggleNoteArchive: _toggleNoteArchive, onOpenStickyBoards: _openStickyBoards, onOpenSettings: _openSettings, onCollapse: widget.onCollapse, ), + if (widget.noteController != null) + Padding( + padding: const EdgeInsets.fromLTRB( + 20, + 0, + 20, + 12, + ), + child: _ContentSwitcher( + selected: _contentKind, + onSelected: _selectContentKind, + ), + ), Padding( padding: const EdgeInsets.fromLTRB( 20, @@ -858,14 +1052,25 @@ class _TodoPanelState extends State { }, decoration: InputDecoration( hintText: - _scope == - TodoListScope.active - ? context - .l10n - .searchTodosHint - : context - .l10n - .searchArchiveHint, + _contentKind == + _PanelContentKind + .notes + ? (_noteArchived + ? context + .l10n + .searchNoteArchiveHint + : context + .l10n + .searchNotesHint) + : (_scope == + TodoListScope + .active + ? context + .l10n + .searchTodosHint + : context + .l10n + .searchArchiveHint), prefixIcon: const Icon( Icons.search_rounded, size: 19, @@ -900,16 +1105,32 @@ class _TodoPanelState extends State { .length, onPressed: _openTagFilter, ), - if (_scope == - TodoListScope.active) ...[ + if ((_contentKind == + _PanelContentKind + .todos && + _scope == + TodoListScope.active) || + (_contentKind == + _PanelContentKind + .notes && + !_noteArchived)) ...[ const SizedBox(width: 9), SizedBox( height: 42, child: FilledButton.tonalIcon( - key: const Key( - 'add-todo-button', + key: Key( + _contentKind == + _PanelContentKind + .notes + ? 'add-note-button' + : 'add-todo-button', ), - onPressed: _openTodoCreate, + onPressed: + _contentKind == + _PanelContentKind + .notes + ? _openNoteCreate + : _openTodoCreate, style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric( @@ -921,7 +1142,15 @@ class _TodoPanelState extends State { size: 18, ), label: Text( - context.l10n.newTodoAction, + _contentKind == + _PanelContentKind + .notes + ? context + .l10n + .newNoteAction + : context + .l10n + .newTodoAction, ), ), ), @@ -931,7 +1160,8 @@ class _TodoPanelState extends State { ], ), ), - if (widget.controller.error != null) + if (_contentKind == _PanelContentKind.todos && + widget.controller.error != null) _ErrorBanner( message: context.l10n .messageForStorageFailure( @@ -939,23 +1169,34 @@ class _TodoPanelState extends State { ), onDismiss: widget.controller.dismissError, ), - AnimatedBuilder( - animation: widget.stickyBoardController, - builder: (context, _) { - final error = - widget.stickyBoardController.error; - if (error == null) { - return const SizedBox.shrink(); - } - return _ErrorBanner( - message: context.l10n - .messageForStorageFailure(error), - onDismiss: widget - .stickyBoardController - .dismissError, - ); - }, - ), + if (_contentKind == _PanelContentKind.notes && + widget.noteController?.error != null) + _ErrorBanner( + message: context.l10n + .messageForStorageFailure( + widget.noteController!.error!, + ), + onDismiss: + widget.noteController!.dismissError, + ), + if (_contentKind == _PanelContentKind.todos) + AnimatedBuilder( + animation: widget.stickyBoardController, + builder: (context, _) { + final error = + widget.stickyBoardController.error; + if (error == null) { + return const SizedBox.shrink(); + } + return _ErrorBanner( + message: context.l10n + .messageForStorageFailure(error), + onDismiss: widget + .stickyBoardController + .dismissError, + ); + }, + ), Divider( height: 1, thickness: 1, @@ -964,18 +1205,32 @@ class _TodoPanelState extends State { : Colors.black.withValues(alpha: 0.055), ), Expanded( - child: _TodoList( - controller: widget.controller, - scope: _scope, - query: _query, - selectedTagIds: effectiveSelectedTagIds, - onClearTagFilters: _clearTagFilters, - onOpenTagManagement: _openTagManagement, - onOpenDetails: _openTodoDetails, - onEditTodo: _openTodoEdit, - onDeleteTodo: - _deleteArchivedTodoPermanently, - ), + child: + _contentKind == _PanelContentKind.notes + ? NotePanelContent( + controller: widget.noteController!, + archived: _noteArchived, + query: _query, + availableTags: + widget.controller.tags, + selectedTagIds: + effectiveSelectedTagIds, + onOpen: _openNote, + ) + : _TodoList( + controller: widget.controller, + scope: _scope, + query: _query, + selectedTagIds: + effectiveSelectedTagIds, + onClearTagFilters: _clearTagFilters, + onOpenTagManagement: + _openTagManagement, + onOpenDetails: _openTodoDetails, + onEditTodo: _openTodoEdit, + onDeleteTodo: + _deleteArchivedTodoPermanently, + ), ), ], ); @@ -997,7 +1252,8 @@ class _TodoPanelState extends State { child: GestureDetector( key: const Key('panel-drawer-dismiss'), behavior: HitTestBehavior.opaque, - onTap: _closeActiveDrawer, + onTap: () => + unawaited(_requestCloseActiveDrawer()), child: ColoredBox( color: Colors.black.withValues( alpha: isDark ? 0.22 : 0.12, @@ -1177,7 +1433,7 @@ class _TodoPanelState extends State { } }, onOpenTagAssignment: - _openTagAssignmentFromTodo, + _openTagAssignmentFromEditor, onSave: (title, content, tagIds) { if (todoEditorMode == TodoEditorDrawerMode.create) { @@ -1222,11 +1478,67 @@ class _TodoPanelState extends State { ), ), ), - if (hasTodoDrawer && hasTagDrawer) + if (hasNoteDrawer) + Positioned( + left: 0, + right: 0, + bottom: 0, + height: _noteDrawerHeight, + child: IgnorePointer( + key: const Key('note-drawer-pointer'), + ignoring: !isNoteDrawerOpen, + child: ExcludeFocus( + excluding: !isNoteDrawerOpen, + child: ExcludeSemantics( + excluding: !isNoteDrawerOpen, + child: AnimatedSlide( + key: const Key('note-drawer-slide'), + duration: reduceMotion + ? Duration.zero + : _drawerSlideDuration, + curve: Curves.easeOutCubic, + offset: isNoteDrawerVisible + ? Offset.zero + : const Offset(0, 1), + child: FocusTraversalGroup( + child: NoteEditorDrawer( + key: _noteEditorKey, + item: selectedNote, + availableTags: widget.controller.tags, + assignedTagIds: noteEditorTagIds, + isOpen: isNoteDrawerOpen, + onOpenTagAssignment: + _openTagAssignmentFromEditor, + onSave: + ({ + id, + required title, + required content, + required tagIds, + }) { + return widget.noteController! + .save( + id: id, + title: title, + content: content, + tagIds: tagIds, + ); + }, + onClose: _closeActiveDrawer, + closeFocusNode: + _noteDrawerCloseFocusNode, + ), + ), + ), + ), + ), + ), + ), + if ((hasTodoDrawer || hasNoteDrawer) && hasTagDrawer) Positioned.fill( child: IgnorePointer( key: const Key('todo-context-scrim-pointer'), - ignoring: !isTodoContextOverlayOpen, + ignoring: !isEditorContextOverlayOpen, child: ExcludeSemantics( child: AnimatedOpacity( key: const Key('todo-context-scrim'), @@ -1234,7 +1546,7 @@ class _TodoPanelState extends State { ? Duration.zero : _drawerScrimDuration, curve: Curves.easeOut, - opacity: isTodoContextOverlayOpen ? 1 : 0, + opacity: isEditorContextOverlayOpen ? 1 : 0, child: GestureDetector( key: const Key('todo-context-dismiss'), behavior: HitTestBehavior.opaque, @@ -1290,6 +1602,16 @@ class _TodoPanelState extends State { 'tag-management-drawer-content', ), controller: widget.controller, + additionalUsageCounts: + widget.noteController + ?.tagUsageCountsFor( + widget.controller.tags + .map((tag) => tag.id), + ) ?? + const {}, + onTagDeleted: widget + .noteController + ?.removeTag, isOpen: isTagManagementOpen, borderOnLeft: !tagDrawerOnLeft, onClose: _closeActiveDrawer, @@ -1302,9 +1624,9 @@ class _TodoPanelState extends State { 'tag-assignment-drawer-content', ), controller: widget.controller, - selectedTagIds: _todoEditorTagIds, + selectedTagIds: assignmentTagIds, borderOnLeft: !tagDrawerOnLeft, - onToggled: _toggleTodoEditorTag, + onToggled: _toggleEditorTag, onManageTags: _openTagManagementFromTagAssignment, onClose: _closeActiveDrawer, @@ -1318,6 +1640,15 @@ class _TodoPanelState extends State { controller: widget.controller, selectedTagIds: _selectedTagIds, borderOnLeft: !tagDrawerOnLeft, + usageCounts: + _contentKind == + _PanelContentKind.notes + ? widget.noteController + ?.tagUsageCountsFor( + widget.controller.tags + .map((tag) => tag.id), + ) + : null, onToggled: _toggleTagFilter, onClear: _clearTagFilters, onManageTags: _openTagManagement, @@ -1345,12 +1676,102 @@ class _TodoPanelState extends State { } } +class _ContentSwitcher extends StatelessWidget { + const _ContentSwitcher({required this.selected, required this.onSelected}); + + final _PanelContentKind selected; + final ValueChanged<_PanelContentKind> onSelected; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final onSurface = theme.colorScheme.onSurface; + return Container( + height: 40, + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: onSurface.withValues(alpha: 0.035), + borderRadius: BorderRadius.circular(11), + border: Border.all(color: onSurface.withValues(alpha: 0.07)), + ), + child: Row( + children: [ + _ContentSwitcherButton( + key: const Key('todo-tab'), + label: context.l10n.todoTabLabel, + selected: selected == _PanelContentKind.todos, + onPressed: () => onSelected(_PanelContentKind.todos), + ), + const SizedBox(width: 3), + _ContentSwitcherButton( + key: const Key('note-tab'), + label: context.l10n.notesTabLabel, + selected: selected == _PanelContentKind.notes, + onPressed: () => onSelected(_PanelContentKind.notes), + ), + ], + ), + ); + } +} + +class _ContentSwitcherButton extends StatelessWidget { + const _ContentSwitcherButton({ + required this.label, + required this.selected, + required this.onPressed, + super.key, + }); + + final String label; + final bool selected; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Expanded( + child: Semantics( + button: true, + selected: selected, + child: Material( + color: selected + ? theme.colorScheme.primary.withValues(alpha: 0.13) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(8), + hoverColor: theme.colorScheme.primary.withValues(alpha: 0.06), + child: Center( + child: Text( + label, + style: theme.textTheme.labelLarge?.copyWith( + color: selected + ? theme.colorScheme.primary + : theme.colorScheme.onSurface.withValues(alpha: 0.62), + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + ), + ), + ), + ), + ), + ), + ); + } +} + class _PanelHeader extends StatelessWidget { const _PanelHeader({ required this.scope, required this.activeCount, required this.archivedCount, + required this.notesSelected, + required this.noteArchived, + required this.noteActiveCount, + required this.noteArchivedCount, required this.onToggleArchive, + required this.onToggleNoteArchive, required this.onOpenStickyBoards, required this.onOpenSettings, required this.onCollapse, @@ -1359,7 +1780,12 @@ class _PanelHeader extends StatelessWidget { final TodoListScope scope; final int activeCount; final int archivedCount; + final bool notesSelected; + final bool noteArchived; + final int noteActiveCount; + final int noteArchivedCount; final VoidCallback onToggleArchive; + final VoidCallback onToggleNoteArchive; final VoidCallback onOpenStickyBoards; final VoidCallback onOpenSettings; final VoidCallback onCollapse; @@ -1368,7 +1794,14 @@ class _PanelHeader extends StatelessWidget { Widget build(BuildContext context) { final onSurface = Theme.of(context).colorScheme.onSurface; final localizations = context.l10n; - final statusText = scope == TodoListScope.archived + final archiveSelected = notesSelected + ? noteArchived + : scope == TodoListScope.archived; + final statusText = notesSelected + ? noteArchived + ? '${localizations.archiveScopeLabel} · $noteArchivedCount' + : localizations.noteCountLabel(noteActiveCount) + : scope == TodoListScope.archived ? '${localizations.archiveScopeLabel} · $archivedCount' : activeCount == 0 ? localizations.allClearToday @@ -1389,30 +1822,31 @@ class _PanelHeader extends StatelessWidget { ), ), Semantics( - selected: scope == TodoListScope.archived, + selected: archiveSelected, child: IconButton( key: const Key('archive-scope-button'), - tooltip: scope == TodoListScope.archived + tooltip: archiveSelected ? localizations.activeScopeLabel : localizations.archiveScopeLabel, - onPressed: onToggleArchive, - color: scope == TodoListScope.archived + onPressed: notesSelected ? onToggleNoteArchive : onToggleArchive, + color: archiveSelected ? Theme.of(context).colorScheme.primary : null, icon: Icon( - scope == TodoListScope.archived + archiveSelected ? Icons.archive_rounded : Icons.archive_outlined, size: 19, ), ), ), - IconButton( - key: const Key('sticky-boards-button'), - tooltip: localizations.stickyBoardsTooltip, - onPressed: onOpenStickyBoards, - icon: const Icon(Icons.sticky_note_2_outlined, size: 19), - ), + if (!notesSelected) + IconButton( + key: const Key('sticky-boards-button'), + tooltip: localizations.stickyBoardsTooltip, + onPressed: onOpenStickyBoards, + icon: const Icon(Icons.sticky_note_2_outlined, size: 19), + ), IconButton( key: const Key('settings-button'), tooltip: localizations.settingsTooltip, diff --git a/lib/features/todos/presentation/todo_view_model.dart b/lib/features/todos/presentation/todo_view_model.dart index db1f12a..1bb9cb1 100644 --- a/lib/features/todos/presentation/todo_view_model.dart +++ b/lib/features/todos/presentation/todo_view_model.dart @@ -52,6 +52,8 @@ class TodoViewModel extends ChangeNotifier { final TagIdGenerator _tagIdGenerator; final FirstRunWorkspaceSeeder? _firstRunWorkspaceSeeder; + static const String untitledFallback = 'Untitled todo'; + List _items = const []; Map _itemsById = const {}; List _activeViewItems = const []; @@ -190,9 +192,12 @@ class TodoViewModel extends ChangeNotifier { Iterable tagIds = const [], }) { final normalizedTitle = title.trim(); - if (normalizedTitle.isEmpty) { + if (normalizedTitle.isEmpty && content.trim().isEmpty) { return Future.value(null); } + final resolvedTitle = normalizedTitle.isEmpty + ? untitledFallback + : normalizedTitle; final todoId = _idGenerator(); return _enqueueTodoAndTagMutation(() async { @@ -205,7 +210,7 @@ class TodoViewModel extends ChangeNotifier { } final createdItem = TodoItem( id: todoId, - title: normalizedTitle, + title: resolvedTitle, content: content, createdAt: _clock().toUtc(), ); @@ -262,9 +267,12 @@ class TodoViewModel extends ChangeNotifier { Iterable? tagIds, }) { final normalizedTitle = title.trim(); - if (normalizedTitle.isEmpty) { + if (normalizedTitle.isEmpty && content.trim().isEmpty) { return Future.value(false); } + final resolvedTitle = normalizedTitle.isEmpty + ? untitledFallback + : normalizedTitle; return _enqueueTodoAndTagMutation(() async { final existingIndex = _items.indexWhere((item) => item.id == id); @@ -283,7 +291,7 @@ class TodoViewModel extends ChangeNotifier { } final todoChanged = - existingItem.title != normalizedTitle || + existingItem.title != resolvedTitle || existingItem.content != content; final tagsChanged = !listEquals(tagIdsForTodo(id), normalizedTagIds); if (!todoChanged && !tagsChanged) { @@ -293,7 +301,7 @@ class TodoViewModel extends ChangeNotifier { final updatedItems = List.of(_items); if (todoChanged) { updatedItems[existingIndex] = existingItem.withDetails( - title: normalizedTitle, + title: resolvedTitle, content: content, ); } diff --git a/lib/features/todos/presentation/widgets/editor_tag_selector.dart b/lib/features/todos/presentation/widgets/editor_tag_selector.dart new file mode 100644 index 0000000..c433b61 --- /dev/null +++ b/lib/features/todos/presentation/widgets/editor_tag_selector.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; + +import '../../../../l10n/l10n.dart'; +import '../../domain/todo_tag.dart'; +import 'floatick_tag_chip.dart'; + +class EditorTagSelector extends StatelessWidget { + const EditorTagSelector({ + required this.availableTags, + required this.selectedTagIds, + required this.onPressed, + this.enabled = true, + this.buttonKey = const Key('editor-tag-button'), + this.tagKeyPrefix = 'editor-tag', + super.key, + }); + + final List availableTags; + final Set selectedTagIds; + final VoidCallback onPressed; + final bool enabled; + final Key buttonKey; + final String tagKeyPrefix; + + @override + Widget build(BuildContext context) { + final selectedTags = availableTags + .where((tag) => selectedTagIds.contains(tag.id)) + .toList(growable: false); + final theme = Theme.of(context); + return Row( + children: [ + IconButton( + key: buttonKey, + tooltip: context.l10n.assignTagsTooltip, + onPressed: enabled ? onPressed : null, + style: IconButton.styleFrom( + minimumSize: const Size.square(30), + maximumSize: const Size.square(30), + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + foregroundColor: selectedTags.isEmpty + ? theme.colorScheme.onSurface.withValues(alpha: 0.56) + : theme.colorScheme.primary, + ), + icon: Icon( + selectedTags.isEmpty ? Icons.sell_outlined : Icons.sell_rounded, + size: 17, + ), + ), + if (selectedTags.isNotEmpty) ...[ + const SizedBox(width: 6), + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (var index = 0; index < selectedTags.length; index++) ...[ + if (index > 0) const SizedBox(width: 5), + FloatickTagChip( + key: ValueKey( + '$tagKeyPrefix-${selectedTags[index].id}', + ), + tag: selectedTags[index], + compact: true, + ), + ], + ], + ), + ), + ), + ], + ], + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0f45ed2..3b06e0e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -160,10 +160,9 @@ "editTodoAction": "Edit", "closeTodoDrawerTooltip": "Close todo drawer", "todoTitleLabel": "Title", - "todoTitleFieldHint": "What needs to be done?", + "todoTitleFieldHint": "Title (optional)", "todoContentLabel": "Content", "todoContentFieldHint": "Add notes with Markdown…", - "markdownSupportedHint": "Markdown supported · ⌘ Return saves", "markdownWriteLabel": "Write", "markdownPreviewLabel": "Preview", "cancelAction": "Cancel", @@ -176,7 +175,7 @@ "noTodoContentTitle": "No details yet", "noTodoContentMessage": "Edit this todo to add Markdown notes.", "markdownPreviewEmptyMessage": "Nothing to preview yet", - "markdownImageBlockedMessage": "Images are not displayed in todo details.", + "markdownImageBlockedMessage": "Images are not displayed in previews.", "viewTodoDetailsTooltip": "View details", "copyTodoAsMarkdownTooltip": "Copy as Markdown", "todoCopiedAsMarkdownMessage": "Copied as Markdown", @@ -188,7 +187,6 @@ "incompleteStatus": "Incomplete", "markIncompleteTooltip": "Mark incomplete", "markCompleteTooltip": "Mark complete", - "todoTitleRequiredHint": "Todo title cannot be empty", "editTooltip": "Edit", "cancelEditTooltip": "Cancel editing", "restoreTooltip": "Restore to todos", @@ -206,6 +204,43 @@ "noSearchResultsMessage": "Try another keyword", "emptyArchiveMessage": "Archived items will appear here", "emptyTodosMessage": "Add a new item above whenever you like", + "todoTabLabel": "Todos", + "notesTabLabel": "Notes", + "noteCountLabel": "{count, plural, =1{1 note} other{{count} notes}}", + "@noteCountLabel": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "searchNotesHint": "Search notes", + "searchNoteArchiveHint": "Search note archive", + "newNoteAction": "New", + "newNoteDrawerTitle": "New note", + "editNoteDrawerTitle": "Edit note", + "closeNoteDrawerTooltip": "Close note drawer", + "noteTitleOptionalHint": "Title (optional)", + "noteTitleLabel": "Title", + "noteContentLabel": "Content", + "noteContentHint": "Capture it now, organize it later…", + "noteAutoSaving": "Saving automatically…", + "noteAutoSaved": "Saved automatically", + "noteAutoSaveFailed": "Autosave failed. Try again.", + "noteEmptyDraftHint": "Blank notes are not saved", + "finishNoteAction": "Done", + "pinnedNotesLabel": "Pinned", + "recentNotesLabel": "Recently edited", + "noteWithoutContent": "No content yet", + "pinNoteTooltip": "Pin note", + "unpinNoteTooltip": "Unpin note", + "archiveNoteTooltip": "Archive note", + "restoreNoteTooltip": "Restore note", + "deleteNoteTooltip": "Delete note permanently", + "emptyNotesTitle": "Write your first note", + "emptyNotesMessage": "Capture ideas, daily updates, and useful tips here", + "emptyNoteArchiveTitle": "Note archive is empty", + "emptyNoteArchiveMessage": "Archived notes will appear here", "todayLabel": "Today", "yesterdayLabel": "Yesterday", "storageInvalidDataError": "The local data file is damaged and was left unchanged.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index edd9157..b910f7f 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -767,7 +767,7 @@ abstract class AppLocalizations { /// No description provided for @todoTitleFieldHint. /// /// In en, this message translates to: - /// **'What needs to be done?'** + /// **'Title (optional)'** String get todoTitleFieldHint; /// No description provided for @todoContentLabel. @@ -782,12 +782,6 @@ abstract class AppLocalizations { /// **'Add notes with Markdown…'** String get todoContentFieldHint; - /// No description provided for @markdownSupportedHint. - /// - /// In en, this message translates to: - /// **'Markdown supported · ⌘ Return saves'** - String get markdownSupportedHint; - /// No description provided for @markdownWriteLabel. /// /// In en, this message translates to: @@ -863,7 +857,7 @@ abstract class AppLocalizations { /// No description provided for @markdownImageBlockedMessage. /// /// In en, this message translates to: - /// **'Images are not displayed in todo details.'** + /// **'Images are not displayed in previews.'** String get markdownImageBlockedMessage; /// No description provided for @viewTodoDetailsTooltip. @@ -932,12 +926,6 @@ abstract class AppLocalizations { /// **'Mark complete'** String get markCompleteTooltip; - /// No description provided for @todoTitleRequiredHint. - /// - /// In en, this message translates to: - /// **'Todo title cannot be empty'** - String get todoTitleRequiredHint; - /// No description provided for @editTooltip. /// /// In en, this message translates to: @@ -1040,6 +1028,186 @@ abstract class AppLocalizations { /// **'Add a new item above whenever you like'** String get emptyTodosMessage; + /// No description provided for @todoTabLabel. + /// + /// In en, this message translates to: + /// **'Todos'** + String get todoTabLabel; + + /// No description provided for @notesTabLabel. + /// + /// In en, this message translates to: + /// **'Notes'** + String get notesTabLabel; + + /// No description provided for @noteCountLabel. + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 note} other{{count} notes}}'** + String noteCountLabel(int count); + + /// No description provided for @searchNotesHint. + /// + /// In en, this message translates to: + /// **'Search notes'** + String get searchNotesHint; + + /// No description provided for @searchNoteArchiveHint. + /// + /// In en, this message translates to: + /// **'Search note archive'** + String get searchNoteArchiveHint; + + /// No description provided for @newNoteAction. + /// + /// In en, this message translates to: + /// **'New'** + String get newNoteAction; + + /// No description provided for @newNoteDrawerTitle. + /// + /// In en, this message translates to: + /// **'New note'** + String get newNoteDrawerTitle; + + /// No description provided for @editNoteDrawerTitle. + /// + /// In en, this message translates to: + /// **'Edit note'** + String get editNoteDrawerTitle; + + /// No description provided for @closeNoteDrawerTooltip. + /// + /// In en, this message translates to: + /// **'Close note drawer'** + String get closeNoteDrawerTooltip; + + /// No description provided for @noteTitleOptionalHint. + /// + /// In en, this message translates to: + /// **'Title (optional)'** + String get noteTitleOptionalHint; + + /// No description provided for @noteTitleLabel. + /// + /// In en, this message translates to: + /// **'Title'** + String get noteTitleLabel; + + /// No description provided for @noteContentLabel. + /// + /// In en, this message translates to: + /// **'Content'** + String get noteContentLabel; + + /// No description provided for @noteContentHint. + /// + /// In en, this message translates to: + /// **'Capture it now, organize it later…'** + String get noteContentHint; + + /// No description provided for @noteAutoSaving. + /// + /// In en, this message translates to: + /// **'Saving automatically…'** + String get noteAutoSaving; + + /// No description provided for @noteAutoSaved. + /// + /// In en, this message translates to: + /// **'Saved automatically'** + String get noteAutoSaved; + + /// No description provided for @noteAutoSaveFailed. + /// + /// In en, this message translates to: + /// **'Autosave failed. Try again.'** + String get noteAutoSaveFailed; + + /// No description provided for @noteEmptyDraftHint. + /// + /// In en, this message translates to: + /// **'Blank notes are not saved'** + String get noteEmptyDraftHint; + + /// No description provided for @finishNoteAction. + /// + /// In en, this message translates to: + /// **'Done'** + String get finishNoteAction; + + /// No description provided for @pinnedNotesLabel. + /// + /// In en, this message translates to: + /// **'Pinned'** + String get pinnedNotesLabel; + + /// No description provided for @recentNotesLabel. + /// + /// In en, this message translates to: + /// **'Recently edited'** + String get recentNotesLabel; + + /// No description provided for @noteWithoutContent. + /// + /// In en, this message translates to: + /// **'No content yet'** + String get noteWithoutContent; + + /// No description provided for @pinNoteTooltip. + /// + /// In en, this message translates to: + /// **'Pin note'** + String get pinNoteTooltip; + + /// No description provided for @unpinNoteTooltip. + /// + /// In en, this message translates to: + /// **'Unpin note'** + String get unpinNoteTooltip; + + /// No description provided for @archiveNoteTooltip. + /// + /// In en, this message translates to: + /// **'Archive note'** + String get archiveNoteTooltip; + + /// No description provided for @restoreNoteTooltip. + /// + /// In en, this message translates to: + /// **'Restore note'** + String get restoreNoteTooltip; + + /// No description provided for @deleteNoteTooltip. + /// + /// In en, this message translates to: + /// **'Delete note permanently'** + String get deleteNoteTooltip; + + /// No description provided for @emptyNotesTitle. + /// + /// In en, this message translates to: + /// **'Write your first note'** + String get emptyNotesTitle; + + /// No description provided for @emptyNotesMessage. + /// + /// In en, this message translates to: + /// **'Capture ideas, daily updates, and useful tips here'** + String get emptyNotesMessage; + + /// No description provided for @emptyNoteArchiveTitle. + /// + /// In en, this message translates to: + /// **'Note archive is empty'** + String get emptyNoteArchiveTitle; + + /// No description provided for @emptyNoteArchiveMessage. + /// + /// In en, this message translates to: + /// **'Archived notes will appear here'** + String get emptyNoteArchiveMessage; + /// No description provided for @todayLabel. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 89721a7..f312c77 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -389,7 +389,7 @@ class AppLocalizationsEn extends AppLocalizations { String get todoTitleLabel => 'Title'; @override - String get todoTitleFieldHint => 'What needs to be done?'; + String get todoTitleFieldHint => 'Title (optional)'; @override String get todoContentLabel => 'Content'; @@ -397,9 +397,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get todoContentFieldHint => 'Add notes with Markdown…'; - @override - String get markdownSupportedHint => 'Markdown supported · ⌘ Return saves'; - @override String get markdownWriteLabel => 'Write'; @@ -438,7 +435,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get markdownImageBlockedMessage => - 'Images are not displayed in todo details.'; + 'Images are not displayed in previews.'; @override String get viewTodoDetailsTooltip => 'View details'; @@ -473,9 +470,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get markCompleteTooltip => 'Mark complete'; - @override - String get todoTitleRequiredHint => 'Todo title cannot be empty'; - @override String get editTooltip => 'Edit'; @@ -527,6 +521,105 @@ class AppLocalizationsEn extends AppLocalizations { @override String get emptyTodosMessage => 'Add a new item above whenever you like'; + @override + String get todoTabLabel => 'Todos'; + + @override + String get notesTabLabel => 'Notes'; + + @override + String noteCountLabel(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count notes', + one: '1 note', + ); + return '$_temp0'; + } + + @override + String get searchNotesHint => 'Search notes'; + + @override + String get searchNoteArchiveHint => 'Search note archive'; + + @override + String get newNoteAction => 'New'; + + @override + String get newNoteDrawerTitle => 'New note'; + + @override + String get editNoteDrawerTitle => 'Edit note'; + + @override + String get closeNoteDrawerTooltip => 'Close note drawer'; + + @override + String get noteTitleOptionalHint => 'Title (optional)'; + + @override + String get noteTitleLabel => 'Title'; + + @override + String get noteContentLabel => 'Content'; + + @override + String get noteContentHint => 'Capture it now, organize it later…'; + + @override + String get noteAutoSaving => 'Saving automatically…'; + + @override + String get noteAutoSaved => 'Saved automatically'; + + @override + String get noteAutoSaveFailed => 'Autosave failed. Try again.'; + + @override + String get noteEmptyDraftHint => 'Blank notes are not saved'; + + @override + String get finishNoteAction => 'Done'; + + @override + String get pinnedNotesLabel => 'Pinned'; + + @override + String get recentNotesLabel => 'Recently edited'; + + @override + String get noteWithoutContent => 'No content yet'; + + @override + String get pinNoteTooltip => 'Pin note'; + + @override + String get unpinNoteTooltip => 'Unpin note'; + + @override + String get archiveNoteTooltip => 'Archive note'; + + @override + String get restoreNoteTooltip => 'Restore note'; + + @override + String get deleteNoteTooltip => 'Delete note permanently'; + + @override + String get emptyNotesTitle => 'Write your first note'; + + @override + String get emptyNotesMessage => + 'Capture ideas, daily updates, and useful tips here'; + + @override + String get emptyNoteArchiveTitle => 'Note archive is empty'; + + @override + String get emptyNoteArchiveMessage => 'Archived notes will appear here'; + @override String get todayLabel => 'Today'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index dfb70da..cecf69b 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -361,7 +361,7 @@ class AppLocalizationsZh extends AppLocalizations { String get todoTitleLabel => '标题'; @override - String get todoTitleFieldHint => '要完成什么?'; + String get todoTitleFieldHint => '标题(可选)'; @override String get todoContentLabel => '内容'; @@ -369,9 +369,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get todoContentFieldHint => '使用 Markdown 添加更多说明…'; - @override - String get markdownSupportedHint => '支持 Markdown · ⌘ Return 保存'; - @override String get markdownWriteLabel => '编辑'; @@ -409,7 +406,7 @@ class AppLocalizationsZh extends AppLocalizations { String get markdownPreviewEmptyMessage => '暂无可预览内容'; @override - String get markdownImageBlockedMessage => '待办详情中暂不显示图片。'; + String get markdownImageBlockedMessage => '预览中暂不显示图片。'; @override String get viewTodoDetailsTooltip => '查看详情'; @@ -444,9 +441,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get markCompleteTooltip => '标记为已完成'; - @override - String get todoTitleRequiredHint => '待办内容不能为空'; - @override String get editTooltip => '编辑'; @@ -498,6 +492,98 @@ class AppLocalizationsZh extends AppLocalizations { @override String get emptyTodosMessage => '在上方随时添加新事项'; + @override + String get todoTabLabel => '待办'; + + @override + String get notesTabLabel => '笔记'; + + @override + String noteCountLabel(int count) { + return '$count 条笔记'; + } + + @override + String get searchNotesHint => '搜索笔记'; + + @override + String get searchNoteArchiveHint => '搜索笔记归档'; + + @override + String get newNoteAction => '新建'; + + @override + String get newNoteDrawerTitle => '新建笔记'; + + @override + String get editNoteDrawerTitle => '编辑笔记'; + + @override + String get closeNoteDrawerTooltip => '关闭笔记抽屉'; + + @override + String get noteTitleOptionalHint => '标题(可选)'; + + @override + String get noteTitleLabel => '标题'; + + @override + String get noteContentLabel => '内容'; + + @override + String get noteContentHint => '先记下来,稍后再整理…'; + + @override + String get noteAutoSaving => '正在自动保存…'; + + @override + String get noteAutoSaved => '已自动保存'; + + @override + String get noteAutoSaveFailed => '自动保存失败,请重试'; + + @override + String get noteEmptyDraftHint => '空白笔记不会保存'; + + @override + String get finishNoteAction => '完成'; + + @override + String get pinnedNotesLabel => '置顶'; + + @override + String get recentNotesLabel => '最近编辑'; + + @override + String get noteWithoutContent => '暂无正文'; + + @override + String get pinNoteTooltip => '置顶笔记'; + + @override + String get unpinNoteTooltip => '取消置顶'; + + @override + String get archiveNoteTooltip => '归档笔记'; + + @override + String get restoreNoteTooltip => '恢复笔记'; + + @override + String get deleteNoteTooltip => '永久删除笔记'; + + @override + String get emptyNotesTitle => '写下第一条笔记'; + + @override + String get emptyNotesMessage => '灵感、日报和小技巧,都可以随手记在这里'; + + @override + String get emptyNoteArchiveTitle => '笔记归档还是空的'; + + @override + String get emptyNoteArchiveMessage => '归档的笔记会保存在这里'; + @override String get todayLabel => '今天'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 56d1f3e..edb3853 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -111,10 +111,9 @@ "editTodoAction": "编辑", "closeTodoDrawerTooltip": "关闭待办抽屉", "todoTitleLabel": "标题", - "todoTitleFieldHint": "要完成什么?", + "todoTitleFieldHint": "标题(可选)", "todoContentLabel": "内容", "todoContentFieldHint": "使用 Markdown 添加更多说明…", - "markdownSupportedHint": "支持 Markdown · ⌘ Return 保存", "markdownWriteLabel": "编辑", "markdownPreviewLabel": "预览", "cancelAction": "取消", @@ -127,7 +126,7 @@ "noTodoContentTitle": "还没有详细内容", "noTodoContentMessage": "编辑待办即可添加 Markdown 说明。", "markdownPreviewEmptyMessage": "暂无可预览内容", - "markdownImageBlockedMessage": "待办详情中暂不显示图片。", + "markdownImageBlockedMessage": "预览中暂不显示图片。", "viewTodoDetailsTooltip": "查看详情", "copyTodoAsMarkdownTooltip": "复制为 Markdown", "todoCopiedAsMarkdownMessage": "已复制为 Markdown", @@ -139,7 +138,6 @@ "incompleteStatus": "未完成", "markIncompleteTooltip": "标记为未完成", "markCompleteTooltip": "标记为已完成", - "todoTitleRequiredHint": "待办内容不能为空", "editTooltip": "编辑", "cancelEditTooltip": "取消编辑", "restoreTooltip": "恢复到待办", @@ -157,6 +155,43 @@ "noSearchResultsMessage": "换一个关键词试试", "emptyArchiveMessage": "归档的事项会保存在这里", "emptyTodosMessage": "在上方随时添加新事项", + "todoTabLabel": "待办", + "notesTabLabel": "笔记", + "noteCountLabel": "{count} 条笔记", + "@noteCountLabel": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "searchNotesHint": "搜索笔记", + "searchNoteArchiveHint": "搜索笔记归档", + "newNoteAction": "新建", + "newNoteDrawerTitle": "新建笔记", + "editNoteDrawerTitle": "编辑笔记", + "closeNoteDrawerTooltip": "关闭笔记抽屉", + "noteTitleOptionalHint": "标题(可选)", + "noteTitleLabel": "标题", + "noteContentLabel": "内容", + "noteContentHint": "先记下来,稍后再整理…", + "noteAutoSaving": "正在自动保存…", + "noteAutoSaved": "已自动保存", + "noteAutoSaveFailed": "自动保存失败,请重试", + "noteEmptyDraftHint": "空白笔记不会保存", + "finishNoteAction": "完成", + "pinnedNotesLabel": "置顶", + "recentNotesLabel": "最近编辑", + "noteWithoutContent": "暂无正文", + "pinNoteTooltip": "置顶笔记", + "unpinNoteTooltip": "取消置顶", + "archiveNoteTooltip": "归档笔记", + "restoreNoteTooltip": "恢复笔记", + "deleteNoteTooltip": "永久删除笔记", + "emptyNotesTitle": "写下第一条笔记", + "emptyNotesMessage": "灵感、日报和小技巧,都可以随手记在这里", + "emptyNoteArchiveTitle": "笔记归档还是空的", + "emptyNoteArchiveMessage": "归档的笔记会保存在这里", "todayLabel": "今天", "yesterdayLabel": "昨天", "storageInvalidDataError": "本地数据文件已损坏,文件保持不变。", diff --git a/lib/main.dart b/lib/main.dart index 5041dee..17b20a1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,6 +5,8 @@ import 'package:multiview_desktop/multiview_desktop.dart'; import 'app/floatick_app.dart'; import 'core/platform/window_bridge.dart'; +import 'features/notes/data/note_repository.dart'; +import 'features/notes/presentation/note_view_model.dart'; import 'features/settings/data/login_item_repository.dart'; import 'features/settings/data/settings_repository.dart'; import 'features/settings/presentation/settings_view_model.dart'; @@ -30,6 +32,7 @@ Future main() async { } final todoRepository = LocalTodoRepository(); + final noteController = NoteViewModel(repository: LocalNoteRepository()); final tagRepository = LocalTagRepository(); final controller = TodoViewModel( todoRepository: todoRepository, @@ -52,6 +55,7 @@ Future main() async { ); await Future.wait(>[ controller.load(), + noteController.load(), settingsController.load(), updateController.load(), stickyBoardController.load(), @@ -65,6 +69,7 @@ Future main() async { runMultiApp( home: (context, viewId) => FloatickApp( controller: controller, + noteController: noteController, settingsController: settingsController, updateController: updateController, stickyBoardController: stickyBoardController, diff --git a/test/app/floatick_app_test.dart b/test/app/floatick_app_test.dart index 284a425..615a132 100644 --- a/test/app/floatick_app_test.dart +++ b/test/app/floatick_app_test.dart @@ -3,6 +3,9 @@ import 'dart:async'; import 'package:floatick/app/floatick_app.dart'; import 'package:floatick/core/platform/window_bridge.dart'; import 'package:floatick/core/storage/storage_failure.dart'; +import 'package:floatick/features/notes/data/note_repository.dart'; +import 'package:floatick/features/notes/domain/note_item.dart'; +import 'package:floatick/features/notes/presentation/note_view_model.dart'; import 'package:floatick/features/settings/data/login_item_repository.dart'; import 'package:floatick/features/settings/data/settings_repository.dart'; import 'package:floatick/features/settings/domain/app_settings.dart'; @@ -46,6 +49,12 @@ void main() { clock: () => DateTime.utc(2026, 7, 23, 8), idGenerator: () => 'new-todo', ); + final noteRepository = _WidgetTestNoteRepository(); + final noteController = NoteViewModel( + repository: noteRepository, + clock: () => DateTime.utc(2026, 7, 23, 8), + idGenerator: () => 'new-note', + ); final windowBridge = _WidgetTestWindowBridge(); final settingsRepository = _WidgetTestSettingsRepository(); final loginItemRepository = _WidgetTestLoginItemRepository(); @@ -66,6 +75,7 @@ void main() { windowBridge: windowBridge, ); await controller.load(); + await noteController.load(); await settingsController.load(); await updateController.load(); await stickyBoardController.load(); @@ -73,6 +83,7 @@ void main() { await tester.pumpWidget( FloatickApp( controller: controller, + noteController: noteController, settingsController: settingsController, updateController: updateController, stickyBoardController: stickyBoardController, @@ -173,6 +184,36 @@ void main() { expect(find.byKey(const Key('todo-drawer-slide')), findsNothing); expect(find.byKey(const Key('todo-context-scrim')), findsNothing); + expect(find.byKey(const Key('todo-tab')), findsOneWidget); + expect(find.byKey(const Key('note-tab')), findsOneWidget); + await tester.tap(find.byKey(const Key('note-tab'))); + await tester.pumpAndSettle(); + expect(find.text('搜索笔记'), findsOneWidget); + expect(find.byKey(const Key('tag-filter-button')), findsOneWidget); + expect(find.byKey(const Key('add-note-button')), findsOneWidget); + expect(find.text('写下第一条笔记'), findsOneWidget); + + await tester.tap(find.byKey(const Key('add-note-button'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('note-editor-drawer')), findsOneWidget); + expect(find.byKey(const Key('note-document-editor')), findsOneWidget); + expect(find.byKey(const Key('note-template-daily')), findsNothing); + expect(find.byKey(const Key('note-editor-tag-button')), findsOneWidget); + await tester.tap(find.byKey(const Key('note-editor-tag-button'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('tag-assignment-drawer')), findsOneWidget); + expect(find.byKey(const Key('note-editor-drawer')), findsOneWidget); + await tester.tap(find.byKey(const Key('tag-assignment-close'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('note-document-editor')), findsOneWidget); + await tester.tap(find.byKey(const Key('close-note-editor'))); + await tester.pumpAndSettle(); + expect(noteRepository.savedItems, isEmpty); + + await tester.tap(find.byKey(const Key('todo-tab'))); + await tester.pumpAndSettle(); + expect(find.text('搜索待办'), findsOneWidget); + await tester.tap(find.byKey(const Key('settings-button'))); await tester.pumpAndSettle(); @@ -695,7 +736,7 @@ void main() { ); expect( tester - .widget(find.byKey(const Key('todo-title-field'))) + .widget(find.byKey(const Key('todo-title-field'))) .controller ?.text, 'Tagged task', @@ -1267,7 +1308,7 @@ void main() { ); expect( tester - .widget(find.byKey(const Key('todo-title-field'))) + .widget(find.byKey(const Key('todo-title-field'))) .controller ?.text, 'Review the launch checklist', @@ -1618,6 +1659,21 @@ class _WidgetTestLoginItemRepository implements LoginItemRepository { } } +class _WidgetTestNoteRepository implements NoteRepository { + List savedItems = []; + + @override + String get storagePath => '/tmp/floatick-widget-test/notes.json'; + + @override + Future> load() async => List.of(savedItems); + + @override + Future save(List items) async { + savedItems = List.of(items); + } +} + class _WidgetTestRepository implements TodoRepository { List savedItems = []; diff --git a/test/core/ui/floatick_editor_components_test.dart b/test/core/ui/floatick_editor_components_test.dart new file mode 100644 index 0000000..78c1019 --- /dev/null +++ b/test/core/ui/floatick_editor_components_test.dart @@ -0,0 +1,142 @@ +import 'package:floatick/app/theme/floatick_theme.dart'; +import 'package:floatick/core/ui/floatick_editor_components.dart'; +import 'package:floatick/l10n/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('shared editor surface owns header, switch, and footer styling', ( + WidgetTester tester, + ) async { + final closeFocusNode = FocusNode(); + addTearDown(closeFocusNode.dispose); + var closeCount = 0; + bool? previewSelection; + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: buildFloatickTheme(Brightness.dark), + home: Scaffold( + body: SizedBox( + width: 440, + height: 520, + child: FloatickEditorDrawerSurface( + key: const Key('shared-editor-surface'), + title: 'Editor title', + closeTooltip: 'Close editor', + closeButtonKey: const Key('shared-editor-close'), + closeFocusNode: closeFocusNode, + onClose: () => closeCount += 1, + child: Column( + children: [ + FloatickEditorModeSwitch( + showPreview: false, + onChanged: (value) => previewSelection = value, + ), + const Spacer(), + const FloatickEditorFooter(child: Text('Footer')), + ], + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Editor title'), findsOneWidget); + expect( + find.byKey(const Key('floatick-editor-mode-switch')), + findsOneWidget, + ); + expect(find.text('Footer'), findsOneWidget); + + final surfaceDecoration = + tester + .widget( + find + .descendant( + of: find.byKey(const Key('shared-editor-surface')), + matching: find.byType(DecoratedBox), + ) + .first, + ) + .decoration + as BoxDecoration; + expect(surfaceDecoration.color, const Color(0xFF202A2E)); + + await tester.tap(find.byKey(const Key('markdown-preview-tab'))); + expect(previewSelection, isTrue); + await tester.tap(find.byKey(const Key('shared-editor-close'))); + expect(closeCount, 1); + }); + + testWidgets( + 'document editor visually unifies separate title and content fields', + (WidgetTester tester) async { + final titleController = TextEditingController(); + final contentController = TextEditingController(); + final titleFocusNode = FocusNode(); + final contentFocusNode = FocusNode(); + addTearDown(titleController.dispose); + addTearDown(contentController.dispose); + addTearDown(titleFocusNode.dispose); + addTearDown(contentFocusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: buildFloatickTheme(Brightness.dark), + home: Scaffold( + body: SizedBox( + width: 440, + height: 420, + child: FloatickDocumentEditor( + titleController: titleController, + contentController: contentController, + titleFocusNode: titleFocusNode, + contentFocusNode: contentFocusNode, + titleHint: 'Optional title', + contentHint: 'Start writing', + titleSemanticsLabel: 'Title', + contentSemanticsLabel: 'Content', + showPreview: false, + preview: const Text('Preview body'), + onPreviewChanged: (_) {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const Key('floatick-document-title-divider')), + findsOneWidget, + ); + final titleField = tester.widget( + find.byKey(const Key('floatick-document-title-field')), + ); + final contentField = tester.widget( + find.byKey(const Key('floatick-document-content-field')), + ); + expect(titleField.decoration?.filled, isFalse); + expect(titleField.decoration?.border, InputBorder.none); + expect(contentField.decoration?.filled, isFalse); + expect(contentField.decoration?.border, InputBorder.none); + + await tester.enterText( + find.byKey(const Key('floatick-document-title-field')), + 'A title', + ); + await tester.testTextInput.receiveAction(TextInputAction.next); + await tester.pump(); + expect(contentFocusNode.hasFocus, isTrue); + }, + ); +} diff --git a/test/features/todos/presentation/todo_markdown_test.dart b/test/core/ui/floatick_markdown_test.dart similarity index 88% rename from test/features/todos/presentation/todo_markdown_test.dart rename to test/core/ui/floatick_markdown_test.dart index 28ecf82..5c4d8df 100644 --- a/test/features/todos/presentation/todo_markdown_test.dart +++ b/test/core/ui/floatick_markdown_test.dart @@ -1,4 +1,4 @@ -import 'package:floatick/features/todos/presentation/widgets/todo_markdown.dart'; +import 'package:floatick/core/ui/floatick_markdown.dart'; import 'package:floatick/l10n/app_localizations.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -16,7 +16,7 @@ void main() { body: SizedBox( width: 400, height: 300, - child: TodoMarkdownContent( + child: FloatickMarkdownContent( content: '![Release diagram](https://example.com/diagram.png)', ), ), diff --git a/test/features/notes/data/note_repository_test.dart b/test/features/notes/data/note_repository_test.dart new file mode 100644 index 0000000..60e7d22 --- /dev/null +++ b/test/features/notes/data/note_repository_test.dart @@ -0,0 +1,110 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:floatick/core/storage/storage_failure.dart'; +import 'package:floatick/features/notes/data/note_repository.dart'; +import 'package:floatick/features/notes/domain/note_item.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late Directory temporaryDirectory; + late LocalNoteRepository repository; + + setUp(() async { + temporaryDirectory = await Directory.systemTemp.createTemp( + 'floatick-note-repository-test-', + ); + repository = LocalNoteRepository( + rootDirectory: Directory('${temporaryDirectory.path}/.floatick'), + ); + }); + + tearDown(() async { + if (await temporaryDirectory.exists()) { + await temporaryDirectory.delete(recursive: true); + } + }); + + test('missing storage creates the directory and returns no notes', () async { + expect(await repository.load(), isEmpty); + expect(await repository.rootDirectory.exists(), isTrue); + }); + + test('save and load preserve note timestamps and state', () async { + final item = NoteItem( + id: 'note-1', + title: 'Weekly review', + content: '## Completed\n\n- Shipped notes', + tagIds: const ['tag-work'], + createdAt: DateTime.utc(2026, 8, 3, 8), + updatedAt: DateTime.utc(2026, 8, 3, 9), + pinnedAt: DateTime.utc(2026, 8, 3, 9, 5), + ); + + await repository.save([item]); + + expect(await repository.load(), [item]); + expect( + jsonDecode(await File(repository.storagePath).readAsString()), + [ + { + 'id': 'note-1', + 'title': 'Weekly review', + 'content': '## Completed\n\n- Shipped notes', + 'tagIds': ['tag-work'], + 'createdAt': '2026-08-03T08:00:00.000Z', + 'updatedAt': '2026-08-03T09:00:00.000Z', + 'pinnedAt': '2026-08-03T09:05:00.000Z', + }, + ], + ); + }); + + test('legacy notes without updatedAt use createdAt', () async { + await repository.rootDirectory.create(recursive: true); + await File(repository.storagePath).writeAsString( + jsonEncode([ + { + 'id': 'legacy-note', + 'title': 'Legacy', + 'createdAt': '2026-08-03T08:00:00.000Z', + }, + ]), + ); + + final item = (await repository.load()).single; + expect(item.updatedAt, item.createdAt); + expect(item.content, isEmpty); + expect(item.tagIds, isEmpty); + }); + + test('duplicate ids are reported as invalid data', () async { + await repository.rootDirectory.create(recursive: true); + final file = File(repository.storagePath); + await file.writeAsString( + jsonEncode([ + { + 'id': 'duplicate', + 'title': 'One', + 'createdAt': '2026-08-03T08:00:00.000Z', + }, + { + 'id': 'duplicate', + 'title': 'Two', + 'createdAt': '2026-08-03T09:00:00.000Z', + }, + ]), + ); + + await expectLater( + repository.load(), + throwsA( + isA().having( + (error) => error.kind, + 'kind', + StorageFailureKind.invalidData, + ), + ), + ); + }); +} diff --git a/test/features/notes/presentation/note_editor_drawer_test.dart b/test/features/notes/presentation/note_editor_drawer_test.dart new file mode 100644 index 0000000..20a945c --- /dev/null +++ b/test/features/notes/presentation/note_editor_drawer_test.dart @@ -0,0 +1,147 @@ +import 'package:floatick/app/theme/floatick_theme.dart'; +import 'package:floatick/features/notes/domain/note_item.dart'; +import 'package:floatick/features/notes/presentation/note_editor_drawer.dart'; +import 'package:floatick/features/todos/domain/todo_tag.dart'; +import 'package:floatick/l10n/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('blank draft closes without creating a note', (tester) async { + var saveCount = 0; + var closeCount = 0; + await _pumpEditor( + tester, + onSave: ({id, required title, required content, required tagIds}) async { + saveCount += 1; + return null; + }, + onClose: () => closeCount += 1, + ); + + expect(find.byKey(const Key('note-document-editor')), findsOneWidget); + expect(find.text('标题'), findsNothing); + expect(find.text('内容'), findsNothing); + expect( + find.byKey(const Key('floatick-document-title-divider')), + findsOneWidget, + ); + expect(find.byIcon(Icons.title_rounded), findsNothing); + expect(find.byKey(const Key('note-template-blank')), findsNothing); + expect(find.byKey(const Key('note-template-daily')), findsNothing); + expect(find.byKey(const Key('note-template-weekly')), findsNothing); + expect(find.byKey(const Key('note-template-monthly')), findsNothing); + expect(find.byKey(const Key('note-editor-mode-switch')), findsOneWidget); + expect(find.byKey(const Key('note-editor-tag-button')), findsOneWidget); + expect(find.text('标签'), findsNothing); + expect( + tester.getTopLeft(find.byKey(const Key('note-editor-tag-button'))).dx, + lessThan( + tester.getTopLeft(find.byKey(const Key('note-editor-mode-switch'))).dx, + ), + ); + expect(find.byKey(const Key('note-editor-footer')), findsOneWidget); + expect( + tester + .widget(find.byKey(const Key('note-title-field'))) + .focusNode + ?.hasFocus, + isTrue, + ); + + await tester.tap(find.byKey(const Key('close-note-editor'))); + await tester.pumpAndSettle(); + + expect(saveCount, 0); + expect(closeCount, 1); + }); + + testWidgets('content is autosaved after the debounce', (tester) async { + final saves = + <({String? id, String title, String content, List tagIds})>[]; + await _pumpEditor( + tester, + availableTags: [ + TodoTag( + id: 'tag-work', + name: 'Work', + colorValue: 0xFF14B8A6, + createdAt: DateTime.utc(2026, 8, 3), + ), + ], + assignedTagIds: const ['tag-work'], + onSave: ({id, required title, required content, required tagIds}) async { + saves.add((id: id, title: title, content: content, tagIds: tagIds)); + return NoteItem( + id: id ?? 'note-1', + title: title.isEmpty ? 'First thought' : title, + content: content, + createdAt: DateTime.utc(2026, 8, 3, 8), + updatedAt: DateTime.utc(2026, 8, 3, 8), + tagIds: tagIds, + ); + }, + onClose: () {}, + ); + + await tester.enterText( + find.byKey(const Key('note-content-field')), + 'First thought', + ); + await tester.pump(const Duration(milliseconds: 449)); + expect(saves, isEmpty); + await tester.pump(const Duration(milliseconds: 1)); + await tester.pump(); + + expect(saves.single.content, 'First thought'); + expect(saves.single.tagIds, ['tag-work']); + expect(find.byKey(const Key('note-editor-tag-tag-work')), findsOneWidget); + expect(find.text('已自动保存'), findsOneWidget); + + await tester.tap(find.byKey(const Key('note-markdown-preview-tab'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('note-content-preview')), findsOneWidget); + expect(find.byKey(const Key('note-content-field')), findsNothing); + }); +} + +Future _pumpEditor( + WidgetTester tester, { + required SaveNoteDraft onSave, + required VoidCallback onClose, + List availableTags = const [], + List assignedTagIds = const [], +}) async { + tester.view.physicalSize = const Size(500, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final closeFocusNode = FocusNode(); + addTearDown(closeFocusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('zh'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: buildFloatickTheme(Brightness.dark), + home: Scaffold( + body: SizedBox( + width: 440, + height: 590, + child: NoteEditorDrawer( + item: null, + availableTags: availableTags, + assignedTagIds: assignedTagIds, + isOpen: true, + onSave: onSave, + onOpenTagAssignment: () {}, + onClose: onClose, + closeFocusNode: closeFocusNode, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); +} diff --git a/test/features/notes/presentation/note_panel_content_test.dart b/test/features/notes/presentation/note_panel_content_test.dart new file mode 100644 index 0000000..fc8eaaa --- /dev/null +++ b/test/features/notes/presentation/note_panel_content_test.dart @@ -0,0 +1,96 @@ +import 'package:floatick/features/notes/data/note_repository.dart'; +import 'package:floatick/features/notes/domain/note_item.dart'; +import 'package:floatick/features/notes/presentation/note_panel_content.dart'; +import 'package:floatick/features/notes/presentation/note_view_model.dart'; +import 'package:floatick/features/todos/domain/todo_tag.dart'; +import 'package:floatick/l10n/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('note list shows shared tags and applies the tag filter', ( + WidgetTester tester, + ) async { + final timestamp = DateTime.utc(2026, 8, 3, 8); + final controller = NoteViewModel( + repository: _MemoryNoteRepository([ + NoteItem( + id: 'note-work', + title: 'Work note', + content: 'Plan the release', + createdAt: timestamp, + updatedAt: timestamp, + tagIds: const ['tag-work'], + ), + NoteItem( + id: 'note-personal', + title: 'Personal note', + content: 'Buy coffee', + createdAt: timestamp, + updatedAt: timestamp, + tagIds: const ['tag-personal'], + ), + ]), + ); + await controller.load(); + final tags = [ + TodoTag( + id: 'tag-work', + name: 'Work', + colorValue: 0xFF14B8A6, + createdAt: timestamp, + ), + TodoTag( + id: 'tag-personal', + name: 'Personal', + colorValue: 0xFF60A5FA, + createdAt: timestamp, + ), + ]; + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SizedBox( + width: 440, + height: 500, + child: NotePanelContent( + controller: controller, + archived: false, + query: '', + availableTags: tags, + selectedTagIds: const {'tag-work'}, + onOpen: (_) {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Work note'), findsOneWidget); + expect(find.text('Personal note'), findsNothing); + expect( + find.byKey(const Key('note-tag-note-work-tag-work')), + findsOneWidget, + ); + }); +} + +class _MemoryNoteRepository implements NoteRepository { + _MemoryNoteRepository(this.items); + + final List items; + + @override + String get storagePath => '/tmp/floatick-note-panel-test/notes.json'; + + @override + Future> load() async => List.of(items); + + @override + Future save(List items) async {} +} diff --git a/test/features/notes/presentation/note_view_model_test.dart b/test/features/notes/presentation/note_view_model_test.dart new file mode 100644 index 0000000..3fbd54d --- /dev/null +++ b/test/features/notes/presentation/note_view_model_test.dart @@ -0,0 +1,219 @@ +import 'package:floatick/core/storage/storage_failure.dart'; +import 'package:floatick/features/notes/data/note_repository.dart'; +import 'package:floatick/features/notes/domain/note_item.dart'; +import 'package:floatick/features/notes/presentation/note_view_model.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'blank drafts are ignored and content-only notes stay untitled', + () async { + final repository = _MemoryNoteRepository(); + final controller = NoteViewModel( + repository: repository, + clock: () => DateTime.utc(2026, 8, 3, 8), + idGenerator: () => 'note-1', + ); + await controller.load(); + + expect(await controller.save(title: ' ', content: '\n '), isNull); + expect(repository.saveCount, 0); + + final created = await controller.save( + title: '', + content: '## Today insight\n\nKeep capture friction low.', + ); + + expect(created?.title, NoteViewModel.untitledFallback); + expect(controller.activeCount, 1); + expect(repository.saveCount, 1); + }, + ); + + test('active notes put pinned items first and search full content', () async { + final repository = _MemoryNoteRepository( + initialItems: [ + _note('older', title: 'Older', hour: 8, pinned: true), + _note('newer', title: 'Newer', content: 'server command', hour: 10), + ], + ); + final controller = NoteViewModel(repository: repository); + await controller.load(); + + expect( + controller + .itemsForView(archived: false, query: '') + .map((item) => item.id), + ['older', 'newer'], + ); + expect( + controller + .itemsForView(archived: false, query: 'SERVER') + .map((item) => item.id), + ['newer'], + ); + }); + + test( + 'archive clears pin and archived notes can restore then delete', + () async { + var now = DateTime.utc(2026, 8, 3, 12); + final repository = _MemoryNoteRepository( + initialItems: [ + _note('note-1', title: 'Pinned', hour: 8, pinned: true), + ], + ); + final controller = NoteViewModel( + repository: repository, + clock: () => now, + ); + await controller.load(); + + expect(await controller.archive('note-1'), isTrue); + expect(controller.itemById('note-1')?.isArchived, isTrue); + expect(controller.itemById('note-1')?.isPinned, isFalse); + + now = DateTime.utc(2026, 8, 3, 13); + expect(await controller.restore('note-1'), isTrue); + expect(controller.itemById('note-1')?.isArchived, isFalse); + expect(await controller.deletePermanently('note-1'), isFalse); + + await controller.archive('note-1'); + expect(await controller.deletePermanently('note-1'), isTrue); + expect(controller.items, isEmpty); + }, + ); + + test('storage failure keeps the previous in-memory note', () async { + final existing = _note('note-1', title: 'Original', hour: 8); + final repository = _MemoryNoteRepository( + initialItems: [existing], + ); + final controller = NoteViewModel( + repository: repository, + clock: () => DateTime.utc(2026, 8, 3, 12), + ); + await controller.load(); + repository.failSaves = true; + + expect( + await controller.save(id: 'note-1', title: 'Changed', content: ''), + isNull, + ); + expect(controller.itemById('note-1'), existing); + expect(controller.error?.kind, StorageFailureKind.write); + }); + + test('clearing an existing draft uses the untitled fallback', () async { + final existing = _note('note-1', title: 'Keep this title', hour: 8); + final repository = _MemoryNoteRepository( + initialItems: [existing], + ); + final controller = NoteViewModel( + repository: repository, + clock: () => DateTime.utc(2026, 8, 3, 12), + ); + await controller.load(); + + final saved = await controller.save( + id: existing.id, + title: '', + content: '', + ); + + expect(saved?.title, NoteViewModel.untitledFallback); + expect(saved?.content, isEmpty); + }); + + test('note tags persist, filter, count, and are removed globally', () async { + final repository = _MemoryNoteRepository( + initialItems: [ + _note( + 'tagged', + title: 'Tagged note', + hour: 8, + tagIds: const ['tag-work'], + ), + _note('plain', title: 'Plain note', hour: 9), + ], + ); + final controller = NoteViewModel(repository: repository); + await controller.load(); + + expect( + controller + .itemsForView( + archived: false, + query: '', + selectedTagIds: const ['tag-work'], + ) + .map((item) => item.id), + ['tagged'], + ); + expect(controller.tagUsageCountsFor(const ['tag-work']), { + 'tag-work': 1, + }); + + final updated = await controller.save( + id: 'plain', + title: 'Plain note', + content: '', + tagIds: const ['tag-work'], + ); + expect(updated?.tagIds, ['tag-work']); + expect(repository.items.last.tagIds, ['tag-work']); + expect(controller.tagUsageCountsFor(const ['tag-work']), { + 'tag-work': 2, + }); + + expect(await controller.removeTag('tag-work'), isTrue); + expect(controller.items.every((item) => item.tagIds.isEmpty), isTrue); + expect(controller.tagUsageCountsFor(const ['tag-work']), { + 'tag-work': 0, + }); + }); +} + +NoteItem _note( + String id, { + required String title, + String content = '', + required int hour, + bool pinned = false, + List tagIds = const [], +}) { + final timestamp = DateTime.utc(2026, 8, 3, hour); + return NoteItem( + id: id, + title: title, + content: content, + createdAt: timestamp, + updatedAt: timestamp, + tagIds: tagIds, + pinnedAt: pinned ? timestamp : null, + ); +} + +class _MemoryNoteRepository implements NoteRepository { + _MemoryNoteRepository({List initialItems = const []}) + : items = List.of(initialItems); + + List items; + bool failSaves = false; + int saveCount = 0; + + @override + String get storagePath => '/tmp/floatick-note-view-model-test/notes.json'; + + @override + Future> load() async => List.of(items); + + @override + Future save(List items) async { + saveCount += 1; + if (failSaves) { + throw StorageFailure(kind: StorageFailureKind.write, path: storagePath); + } + this.items = List.of(items); + } +} diff --git a/test/features/todos/presentation/todo_editor_drawer_test.dart b/test/features/todos/presentation/todo_editor_drawer_test.dart index f221f7f..d9bd200 100644 --- a/test/features/todos/presentation/todo_editor_drawer_test.dart +++ b/test/features/todos/presentation/todo_editor_drawer_test.dart @@ -66,6 +66,29 @@ void main() { ); await tester.pumpAndSettle(); + expect( + find.byKey(const Key('floatick-editor-mode-switch')), + findsOneWidget, + ); + expect(find.byKey(const Key('todo-document-editor')), findsOneWidget); + expect(find.byKey(const Key('todo-editor-footer')), findsOneWidget); + expect(find.text('Tags'), findsNothing); + expect( + tester.getTopLeft(find.byKey(const Key('todo-editor-tag-button'))).dx, + lessThan( + tester + .getTopLeft(find.byKey(const Key('floatick-editor-mode-switch'))) + .dx, + ), + ); + expect( + tester + .widget(find.byKey(const Key('todo-title-field'))) + .focusNode + ?.hasFocus, + isTrue, + ); + await tester.enterText( find.byKey(const Key('todo-title-field')), 'Write release notes', @@ -80,6 +103,59 @@ void main() { expect(find.byKey(const Key('todo-title-field')), findsOneWidget); }); + testWidgets('create drawer accepts a content-only todo', ( + WidgetTester tester, + ) async { + final closeFocusNode = FocusNode(); + addTearDown(closeFocusNode.dispose); + String? savedTitle; + String? savedContent; + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SizedBox( + width: 440, + height: 520, + child: TodoEditorDrawer( + mode: TodoEditorDrawerMode.create, + item: null, + availableTags: const [], + originalAssignedTagIds: const [], + assignedTagIds: const [], + isOpen: true, + onClose: () {}, + onEdit: () {}, + onOpenTagAssignment: () {}, + onSave: (title, content, tagIds) async { + savedTitle = title; + savedContent = content; + return true; + }, + onSaved: () {}, + closeFocusNode: closeFocusNode, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('todo-content-field')), + 'Capture this without stopping', + ); + await tester.pump(); + await tester.tap(find.byKey(const Key('save-todo-details'))); + await tester.pumpAndSettle(); + + expect(savedTitle, isEmpty); + expect(savedContent, 'Capture this without stopping'); + }); + testWidgets('create drawer saves selected tags with the todo', ( WidgetTester tester, ) async { diff --git a/test/features/todos/presentation/todo_view_model_test.dart b/test/features/todos/presentation/todo_view_model_test.dart index 3ff5175..662317e 100644 --- a/test/features/todos/presentation/todo_view_model_test.dart +++ b/test/features/todos/presentation/todo_view_model_test.dart @@ -41,14 +41,21 @@ void main() { expect(controller.activeCount, 1); }); - test('blank titles are ignored', () async { - await controller.load(); - final didAdd = await controller.add(' ', content: 'Not enough'); + test( + 'content-only todos use the untitled fallback and empty drafts are ignored', + () async { + await controller.load(); + final didAdd = await controller.add(' ', content: 'Not enough'); - expect(didAdd, isFalse); - expect(controller.items, isEmpty); - expect(repository.saveCount, 0); - }); + expect(didAdd, isTrue); + expect(controller.items.single.title, TodoViewModel.untitledFallback); + expect(repository.saveCount, 1); + + expect(await controller.add(' ', content: '\n '), isFalse); + expect(controller.items, hasLength(1)); + expect(repository.saveCount, 1); + }, + ); test('duplicate generated ids are rejected without persisting', () async { repository.savedItems = [ @@ -229,28 +236,31 @@ void main() { expect(repository.savedItems.single, controller.items.single); }); - test('updateDetails rejects blank titles and keeps existing data', () async { - repository.savedItems = [ - TodoItem( - id: 'existing', - title: 'Original title', - content: 'Original content', - createdAt: DateTime.parse(firstDate), - ), - ]; - await controller.load(); + test( + 'updateDetails uses the untitled fallback when the title is blank', + () async { + repository.savedItems = [ + TodoItem( + id: 'existing', + title: 'Original title', + content: 'Original content', + createdAt: DateTime.parse(firstDate), + ), + ]; + await controller.load(); - final didUpdate = await controller.updateDetails( - id: 'existing', - title: ' ', - content: 'Changed content', - ); + final didUpdate = await controller.updateDetails( + id: 'existing', + title: ' ', + content: 'Changed content', + ); - expect(didUpdate, isFalse); - expect(controller.items.single.title, 'Original title'); - expect(controller.items.single.content, 'Original content'); - expect(repository.saveCount, 0); - }); + expect(didUpdate, isTrue); + expect(controller.items.single.title, TodoViewModel.untitledFallback); + expect(controller.items.single.content, 'Changed content'); + expect(repository.saveCount, 1); + }, + ); test('a failed rename keeps the original title', () async { repository.savedItems = [ From e4a78853b0b82f3d1f93d9a3bc3426942e99d331 Mon Sep 17 00:00:00 2001 From: lucaslus <282139159+lucaslus@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:08:29 +0800 Subject: [PATCH 2/5] fix(editor): simplify title placeholder --- lib/features/notes/presentation/note_editor_drawer.dart | 2 +- lib/l10n/app_en.arb | 4 ++-- lib/l10n/app_localizations.dart | 8 ++++---- lib/l10n/app_localizations_en.dart | 4 ++-- lib/l10n/app_localizations_zh.dart | 4 ++-- lib/l10n/app_zh.arb | 4 ++-- .../notes/presentation/note_editor_drawer_test.dart | 3 ++- .../todos/presentation/todo_editor_drawer_test.dart | 2 ++ 8 files changed, 17 insertions(+), 14 deletions(-) diff --git a/lib/features/notes/presentation/note_editor_drawer.dart b/lib/features/notes/presentation/note_editor_drawer.dart index 25e05a4..0f274f4 100644 --- a/lib/features/notes/presentation/note_editor_drawer.dart +++ b/lib/features/notes/presentation/note_editor_drawer.dart @@ -277,7 +277,7 @@ class NoteEditorDrawerState extends State { contentController: _contentController, titleFocusNode: _titleFocusNode, contentFocusNode: _contentFocusNode, - titleHint: context.l10n.noteTitleOptionalHint, + titleHint: context.l10n.noteTitleHint, contentHint: context.l10n.noteContentHint, titleSemanticsLabel: context.l10n.noteTitleLabel, contentSemanticsLabel: context.l10n.noteContentLabel, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3b06e0e..fd88382 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -160,7 +160,7 @@ "editTodoAction": "Edit", "closeTodoDrawerTooltip": "Close todo drawer", "todoTitleLabel": "Title", - "todoTitleFieldHint": "Title (optional)", + "todoTitleFieldHint": "Title", "todoContentLabel": "Content", "todoContentFieldHint": "Add notes with Markdown…", "markdownWriteLabel": "Write", @@ -220,7 +220,7 @@ "newNoteDrawerTitle": "New note", "editNoteDrawerTitle": "Edit note", "closeNoteDrawerTooltip": "Close note drawer", - "noteTitleOptionalHint": "Title (optional)", + "noteTitleHint": "Title", "noteTitleLabel": "Title", "noteContentLabel": "Content", "noteContentHint": "Capture it now, organize it later…", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index b910f7f..6b2c3e7 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -767,7 +767,7 @@ abstract class AppLocalizations { /// No description provided for @todoTitleFieldHint. /// /// In en, this message translates to: - /// **'Title (optional)'** + /// **'Title'** String get todoTitleFieldHint; /// No description provided for @todoContentLabel. @@ -1082,11 +1082,11 @@ abstract class AppLocalizations { /// **'Close note drawer'** String get closeNoteDrawerTooltip; - /// No description provided for @noteTitleOptionalHint. + /// No description provided for @noteTitleHint. /// /// In en, this message translates to: - /// **'Title (optional)'** - String get noteTitleOptionalHint; + /// **'Title'** + String get noteTitleHint; /// No description provided for @noteTitleLabel. /// diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index f312c77..45c1a98 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -389,7 +389,7 @@ class AppLocalizationsEn extends AppLocalizations { String get todoTitleLabel => 'Title'; @override - String get todoTitleFieldHint => 'Title (optional)'; + String get todoTitleFieldHint => 'Title'; @override String get todoContentLabel => 'Content'; @@ -557,7 +557,7 @@ class AppLocalizationsEn extends AppLocalizations { String get closeNoteDrawerTooltip => 'Close note drawer'; @override - String get noteTitleOptionalHint => 'Title (optional)'; + String get noteTitleHint => 'Title'; @override String get noteTitleLabel => 'Title'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index cecf69b..3c6b96e 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -361,7 +361,7 @@ class AppLocalizationsZh extends AppLocalizations { String get todoTitleLabel => '标题'; @override - String get todoTitleFieldHint => '标题(可选)'; + String get todoTitleFieldHint => '标题'; @override String get todoContentLabel => '内容'; @@ -522,7 +522,7 @@ class AppLocalizationsZh extends AppLocalizations { String get closeNoteDrawerTooltip => '关闭笔记抽屉'; @override - String get noteTitleOptionalHint => '标题(可选)'; + String get noteTitleHint => '标题'; @override String get noteTitleLabel => '标题'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index edb3853..346af04 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -111,7 +111,7 @@ "editTodoAction": "编辑", "closeTodoDrawerTooltip": "关闭待办抽屉", "todoTitleLabel": "标题", - "todoTitleFieldHint": "标题(可选)", + "todoTitleFieldHint": "标题", "todoContentLabel": "内容", "todoContentFieldHint": "使用 Markdown 添加更多说明…", "markdownWriteLabel": "编辑", @@ -171,7 +171,7 @@ "newNoteDrawerTitle": "新建笔记", "editNoteDrawerTitle": "编辑笔记", "closeNoteDrawerTooltip": "关闭笔记抽屉", - "noteTitleOptionalHint": "标题(可选)", + "noteTitleHint": "标题", "noteTitleLabel": "标题", "noteContentLabel": "内容", "noteContentHint": "先记下来,稍后再整理…", diff --git a/test/features/notes/presentation/note_editor_drawer_test.dart b/test/features/notes/presentation/note_editor_drawer_test.dart index 20a945c..0fb13c4 100644 --- a/test/features/notes/presentation/note_editor_drawer_test.dart +++ b/test/features/notes/presentation/note_editor_drawer_test.dart @@ -20,7 +20,8 @@ void main() { ); expect(find.byKey(const Key('note-document-editor')), findsOneWidget); - expect(find.text('标题'), findsNothing); + expect(find.text('标题'), findsOneWidget); + expect(find.text('标题(可选)'), findsNothing); expect(find.text('内容'), findsNothing); expect( find.byKey(const Key('floatick-document-title-divider')), diff --git a/test/features/todos/presentation/todo_editor_drawer_test.dart b/test/features/todos/presentation/todo_editor_drawer_test.dart index d9bd200..7406f7f 100644 --- a/test/features/todos/presentation/todo_editor_drawer_test.dart +++ b/test/features/todos/presentation/todo_editor_drawer_test.dart @@ -71,6 +71,8 @@ void main() { findsOneWidget, ); expect(find.byKey(const Key('todo-document-editor')), findsOneWidget); + expect(find.text('Title'), findsOneWidget); + expect(find.text('Title (optional)'), findsNothing); expect(find.byKey(const Key('todo-editor-footer')), findsOneWidget); expect(find.text('Tags'), findsNothing); expect( From dd36a2d6d9df6c90acacc2e56bbe0294af4adcba Mon Sep 17 00:00:00 2001 From: lucaslus <282139159+lucaslus@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:12:11 +0800 Subject: [PATCH 3/5] chore(release): prepare 0.3.3 candidate --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 7f9fc9e..9fcb110 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: floatick description: A focused, local-first floating todo list for macOS. publish_to: 'none' -version: 0.3.2+9 +version: 0.3.3+10 environment: sdk: ^3.12.2 From e28eddb4b90eb99a96a56e4321f95083f7841c70 Mon Sep 17 00:00:00 2001 From: lucaslus <282139159+lucaslus@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:14:31 +0800 Subject: [PATCH 4/5] fix(notes): satisfy constructor lint --- lib/features/notes/presentation/note_view_model.dart | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/features/notes/presentation/note_view_model.dart b/lib/features/notes/presentation/note_view_model.dart index ddf2b84..2235c5d 100644 --- a/lib/features/notes/presentation/note_view_model.dart +++ b/lib/features/notes/presentation/note_view_model.dart @@ -11,11 +11,10 @@ typedef NoteIdGenerator = String Function(); class NoteViewModel extends ChangeNotifier { NoteViewModel({ - required NoteRepository repository, + required this._repository, NoteClock? clock, NoteIdGenerator? idGenerator, - }) : _repository = repository, - _clock = clock ?? DateTime.now, + }) : _clock = clock ?? DateTime.now, _idGenerator = idGenerator ?? _generateUuidV4; static const String untitledFallback = 'Untitled note'; From a20a5a4eca9bc2de82c17c47a10ae83580c400d5 Mon Sep 17 00:00:00 2001 From: lucaslus <282139159+lucaslus@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:23:29 +0800 Subject: [PATCH 5/5] feat(website): introduce lightweight notes --- README.md | 36 +++--- README.zh-CN.md | 32 +++-- docs/ARCHITECTURE.md | 18 +-- docs/RELEASING.md | 2 + docs/TESTING.md | 2 + website/src/components/ProductHero3D.astro | 16 +-- website/src/content/changelog.ts | 32 +++++ website/src/content/site-copy.ts | 140 +++++++++++---------- website/src/layouts/SiteLayout.astro | 12 +- website/src/scripts/product-hero-scene.ts | 91 ++++++++------ 10 files changed, 230 insertions(+), 151 deletions(-) diff --git a/README.md b/README.md index 4461ecb..c495981 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,12 @@ Floatick floating todo list, Markdown editor, tags, and settings for macOS -

Floatick — Floating Todo List for macOS

-

An open-source, local-first macOS todo app and lightweight task manager.

+

Floatick — Floating Todos and Notes for macOS

+

An open-source, local-first macOS app for lightweight tasks and notes.

- Capture and organize tasks one click away—without an account or cloud service. + Capture tasks and thoughts one click away—without an account or cloud service.

@@ -24,19 +24,21 @@

-Floatick is an offline-friendly desktop todo app that rests above your workspace -as a small, draggable icon. Click it and the icon expands into a focused todo -panel; collapse it and the icon returns to the same anchor. The panel chooses +Floatick is an offline-friendly desktop todo and notes app that rests above your +workspace as a small, draggable icon. Click it and the icon expands into a +focused panel; collapse it and the icon returns to the same anchor. The panel chooses its expansion direction from the available screen space, so it stays useful near any display edge. -## A focused macOS task manager +## A focused macOS space for tasks and notes - **Always within reach** — drag the floating icon anywhere, then click to open. - **Fast task flow** — create, edit, complete, search, archive, restore, and organize tasks in automatic daily sections. -- **Local by default** — no account, cloud service, or telemetry. Your todo data - remains in `~/.floatick`. +- **Lightweight notes** — capture ideas, logs, and snippets with search, pinning, + archiving, shared tags, autosave, and Markdown preview. +- **Local by default** — no account, cloud service, or telemetry. Your todo and + note data remains in `~/.floatick`. - **Made for macOS** — transparent AppKit window behavior, keyboard shortcuts, context-menu Quit, and support for Reduce Motion. - **Comfortable in any workspace** — system, light, and dark themes with English @@ -70,10 +72,12 @@ Only download Floatick from this repository's Releases page. | Action | How | | --- | --- | | Reposition Floatick | Drag the floating icon | -| Open the todo list | Click the floating icon | +| Open Floatick | Click the floating icon | | Collapse the panel | Click the collapse button or press `Esc` | | Create a todo | Press `⌘N`, or use the input at the top | -| Search | Press `⌘F` | +| Switch between todos and notes | Use the tabs at the top of the panel | +| Create a note | Open Notes and choose New | +| Search the current workspace | Press `⌘F` | | Edit a todo | Hover over the item and choose Edit | | Complete a todo | Select its checkbox | | Archive or restore | Use the action at the end of the item | @@ -86,11 +90,13 @@ Floatick creates its working directory on first launch: | Path | Purpose | | --- | --- | | `~/.floatick/todos.json` | Todos, completion state, and archive state | +| `~/.floatick/notes.json` | Notes, tags, pinning, and archive state | +| `~/.floatick/tags.json` | Reusable tags shared by todos and notes | | `~/.floatick/settings.json` | Theme and language preferences | Sparkle stores the automatic-update preference in standard macOS application preferences. Floatick does not require an account and does not upload your todo -data. Network access is used only to check for and download application updates. +or note data. Network access is used only to check for and download application updates. ## Development @@ -135,7 +141,7 @@ the macOS system boundaries that remain in Draft acceptance. lib/ app/ App composition and themes core/ Shared platform, storage, and UI primitives - features/ Todo, settings, and update features + features/ Todo, notes, settings, and update features l10n/ English and Simplified Chinese resources macos/Runner/ AppKit window shell and Sparkle integration test/ Repository, ViewModel, and widget tests @@ -144,7 +150,7 @@ tool/ Icon and release tooling ``` Flutter owns product UI and state. A small AppKit shell owns macOS-specific -window behavior and Sparkle. Todo data never crosses the platform channel. +window behavior and Sparkle. Product data never crosses the platform channel. See [Architecture](./docs/ARCHITECTURE.md) for the dependency boundaries. ## Development and release model diff --git a/README.zh-CN.md b/README.zh-CN.md index 519ff55..f3d6566 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -2,12 +2,12 @@ Floatick macOS 悬浮待办、Markdown 编辑器、标签与设置 -

Floatick — macOS 悬浮待办清单

-

一款开源、仅存本地的 macOS 待办应用与轻量任务管理工具。

+

Floatick — macOS 悬浮 Todo 与笔记

+

一款开源、本地优先的 macOS 轻量 Todo 与笔记应用。

- 无需账号或云服务,让任务记录与整理始终触手可及。 + 无需账号或云服务,让任务和想法始终触手可及。

@@ -24,16 +24,18 @@

-Floatick 是一款支持离线使用的 macOS 桌面待办应用。它平时以一个小巧、 -可拖动的图标悬浮在工作区上方;点击图标会展开专注的待办面板,收起后则会准确 +Floatick 是一款支持离线使用的 macOS 桌面 Todo 与笔记应用。它平时以一个小巧、 +可拖动的图标悬浮在工作区上方;点击图标会展开专注的面板,收起后则会准确 回到原来的锚点。面板还会根据图标附近的屏幕空间自动选择展开方向,因此放在 屏幕边缘也能自然使用。 -## 专注而轻量的 macOS 任务管理工具 +## 专注而轻量的 macOS Todo 与笔记空间 - **随时可用**——图标可以拖到任意位置,点击即可展开。 - **完整待办流程**——支持创建、编辑、完成、搜索、归档和恢复,并按天自动分组。 -- **默认仅存本地**——不需要账号、云服务或遥测,待办数据保存在 `~/.floatick`。 +- **轻量笔记空间**——记录灵感、日志和片段,支持搜索、置顶、归档、共享标签、 + 自动保存与 Markdown 预览。 +- **默认仅存本地**——不需要账号、云服务或遥测,Todo 与笔记数据保存在 `~/.floatick`。 - **贴合 macOS**——使用 AppKit 管理透明悬浮窗口,支持快捷键、右键退出和 “减少动态效果”。 - **适应不同工作环境**——支持跟随系统、浅色和深色主题,以及英文和简体中文。 @@ -64,10 +66,12 @@ Floatick 目前仍处于早期预览阶段,下载包暂未使用 Apple Develop | 操作 | 使用方式 | | --- | --- | | 调整位置 | 拖动悬浮图标 | -| 展开待办列表 | 点击悬浮图标 | +| 展开 Floatick | 点击悬浮图标 | | 收起面板 | 点击收起按钮或按 `Esc` | | 创建待办 | 按 `⌘N`,或使用顶部输入框 | -| 搜索 | 按 `⌘F` | +| 切换 Todo 与笔记 | 使用面板顶部的标签页 | +| 创建笔记 | 打开 Notes 并点击新建 | +| 搜索当前空间 | 按 `⌘F` | | 编辑待办 | 将鼠标悬浮到待办上并点击编辑 | | 完成待办 | 点击待办前的复选框 | | 归档或恢复 | 使用待办末尾的操作按钮 | @@ -80,10 +84,12 @@ Floatick 第一次启动时会自动创建工作目录: | 路径 | 用途 | | --- | --- | | `~/.floatick/todos.json` | 待办、完成状态与归档状态 | +| `~/.floatick/notes.json` | 笔记、标签、置顶与归档状态 | +| `~/.floatick/tags.json` | Todo 与笔记共享的可复用标签 | | `~/.floatick/settings.json` | 主题与语言设置 | Sparkle 会将自动更新偏好保存在 macOS 标准应用偏好中。Floatick 不需要账号, -也不会上传待办数据;网络访问仅用于检查和下载应用更新。 +也不会上传 Todo 或笔记数据;网络访问仅用于检查和下载应用更新。 ## 本地开发 @@ -128,7 +134,7 @@ Release 应用位于 lib/ app/ 应用装配与主题 core/ 共享的平台、存储和 UI 基元 - features/ Todo、设置和更新功能 + features/ Todo、笔记、设置和更新功能 l10n/ 英文与简体中文资源 macos/Runner/ AppKit 窗口外壳与 Sparkle 集成 test/ Repository、ViewModel 和 Widget 测试 @@ -137,7 +143,7 @@ tool/ 图标与发布工具 ``` Flutter 负责产品 UI 和状态;轻量 AppKit 外壳负责 macOS 专属窗口行为与 -Sparkle。待办数据不会经过平台通道。依赖边界详见 +Sparkle。产品数据不会经过平台通道。依赖边界详见 [架构说明](./docs/ARCHITECTURE.md)。 ## 开发与发布模型 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 674b369..ac5950e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -33,6 +33,10 @@ lib/ storage/ ui/ features/ + notes/ + data/ + domain/ + presentation/ settings/ data/ domain/ @@ -63,16 +67,16 @@ tool/ - `l10n`: English source copy, Simplified Chinese translations, and generated Flutter localization accessors. - `macos/Runner`: transparent floating window behavior and the Sparkle update - service; todo data does not cross either platform channel. + service; todo and note data does not cross either platform channel. ## State and persistence -`TodoViewModel`, `SettingsViewModel`, and `UpdateViewModel` own presentation -state. Repositories own file I/O, JSON compatibility, or typed platform-channel +`TodoViewModel`, `NoteViewModel`, `SettingsViewModel`, and `UpdateViewModel` own +presentation state. Repositories own file I/O, JSON compatibility, or typed platform-channel boundaries. Repositories are constructor-injected so state behavior can be tested without touching the user's home directory or launching Sparkle. -Todo data and Floatick-owned interface settings only use `~/.floatick`. +Todo data, note data, shared tags, and Floatick-owned interface settings only use `~/.floatick`. Repositories create it on first load and never read or write another hidden application directory. Sparkle owns its automatic-check preference in the standard macOS application `UserDefaults`; Floatick does not duplicate that @@ -84,9 +88,9 @@ channel to a typed, informational state in Settings; other connectivity failures remain recoverable errors. Sparkle still owns appcast parsing, signature validation, download, and installation once the feed is available. -Writes are serialized by `TodoViewModel` to prevent overlapping mutations from -losing updates. A write failure leaves the last persisted in-memory state -unchanged and exposes a recoverable UI error. +Writes are serialized by `TodoViewModel` and `NoteViewModel` to prevent +overlapping mutations from losing updates. A write failure leaves the last +persisted in-memory state unchanged and exposes a recoverable UI error. ## Testing diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 5e18a51..c3187f8 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -88,6 +88,8 @@ Download the DMG from the Draft Release and verify at least: - floating icon drag, expand direction, collapse position, and right-click Quit work; - create, edit, complete, search, archive, and restore work; +- switch to Notes, then create, edit, search, pin, tag, archive, restore, and + relaunch with note content preserved; - Chinese, English, system/light/dark themes, and Settings persistence work; - relaunch preserves `~/.floatick` data; - before the first stable release, manual update checks show the compact diff --git a/docs/TESTING.md b/docs/TESTING.md index 747ad30..4c6f341 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -33,6 +33,8 @@ tool/test/run_ui_tests.sh | 双击详情、完成、归档、恢复、搜索 | Integration | | 退出前后的本地 JSON 持久化 | Integration | | Tag 创建、Todo 关联、多选 OR 筛选和清空 | Integration | +| Notes 切换、创建、标签入口与空草稿处理 | Widget | +| Note 搜索、置顶、归档、恢复、共享标签与失败回滚 | Unit + Widget | | Sticky Board 创建、添加现有 Todo、Pin/Unpin | Integration | | 置顶、登录启动、主题等设置与原生调用边界 | Integration | | 悬浮图标的 macOS Accessibility button/press contract | XCTest | diff --git a/website/src/components/ProductHero3D.astro b/website/src/components/ProductHero3D.astro index 91b100a..4b9ab7f 100644 --- a/website/src/components/ProductHero3D.astro +++ b/website/src/components/ProductHero3D.astro @@ -10,8 +10,10 @@ const { locale } = Astro.props; const demoCopy = { en: { ariaLabel: - 'Interactive three-dimensional Floatick workspace with a todo list, a Sticky Board, an editor, and the floating icon', + 'Interactive three-dimensional Floatick workspace with Todos and Notes tabs, a Sticky Board, an editor, and the floating icon', remaining: '3 tasks remaining', + todosTab: 'Todos', + notesTab: 'Notes', search: 'Search todos', newTodo: 'New', today: 'Today', @@ -23,17 +25,17 @@ const demoCopy = { boardTitle: 'This week', boardCount: '3 todos', drawerTitle: 'New todo', - titleLabel: 'Title', titleValue: 'Prepare tomorrow’s top task', - contentLabel: 'Content', contentValue: 'Add a short note so the next step is clear.', save: 'Add todo', fallback: 'Floatick product preview', }, zh: { ariaLabel: - 'Floatick 的交互式三维工作台,包含待办列表、便利板、编辑器和圆形悬浮图标', + 'Floatick 的交互式三维工作台,包含 Todo 与 Notes 标签页、便利板、编辑器和圆形悬浮图标', remaining: '还有 3 项待完成', + todosTab: '待办', + notesTab: '笔记', search: '搜索待办', newTodo: '新建', today: '今天', @@ -45,9 +47,7 @@ const demoCopy = { boardTitle: '本周计划', boardCount: '3 项待办', drawerTitle: '新建待办', - titleLabel: '标题', titleValue: '确定明天最重要的一件事', - contentLabel: '内容', contentValue: '补充一句简短说明,让下一步足够清晰。', save: '添加待办', fallback: 'Floatick 产品预览', @@ -61,6 +61,8 @@ const copy = demoCopy[locale]; class="product-hero-stage" data-product-scene data-remaining={copy.remaining} + data-todos-tab={copy.todosTab} + data-notes-tab={copy.notesTab} data-search={copy.search} data-new-todo={copy.newTodo} data-today={copy.today} @@ -76,9 +78,7 @@ const copy = demoCopy[locale]; data-board-title={copy.boardTitle} data-board-count={copy.boardCount} data-drawer-title={copy.drawerTitle} - data-title-label={copy.titleLabel} data-title-value={copy.titleValue} - data-content-label={copy.contentLabel} data-content-value={copy.contentValue} data-save={copy.save} role="img" diff --git a/website/src/content/changelog.ts b/website/src/content/changelog.ts index 38fcf93..ac71a0d 100644 --- a/website/src/content/changelog.ts +++ b/website/src/content/changelog.ts @@ -81,6 +81,22 @@ export const changelogCopy: Record = { export const changelogEntries: Record = { en: [ + { + version: 'v0.3.3', + date: 'August 5, 2026', + dateTime: '2026-08-05', + title: 'Lightweight notes beside your todos', + summary: + 'This release adds a focused Notes workspace for ideas, logs, snippets, and other details that are not tasks.', + highlights: [ + 'Switch between Todos and Notes inside the same floating panel.', + 'Search, pin, archive, restore, and automatically save local notes.', + 'Reuse the same colored tags across todos and notes.', + 'Edit titles and Markdown content in one continuous document surface.', + ], + releaseUrl: 'https://github.com/lucaslus/floatick/releases/tag/v0.3.3', + compareUrl: 'https://github.com/lucaslus/floatick/compare/v0.3.2...v0.3.3', + }, { version: 'v0.3.2', date: 'July 31, 2026', @@ -160,6 +176,22 @@ export const changelogEntries: Record = { }, ], zh: [ + { + version: 'v0.3.3', + date: '2026 年 8 月 5 日', + dateTime: '2026-08-05', + title: 'Todo 旁边的轻量笔记', + summary: + '这个版本新增独立的 Notes 空间,用来记录灵感、日志、片段,以及那些并不是任务的内容。', + highlights: [ + '在同一个悬浮面板中随时切换 Todo 与 Notes。', + '搜索、置顶、归档、恢复并自动保存本地笔记。', + 'Todo 与笔记复用同一套彩色标签。', + '在一体式文档区域中编辑标题与 Markdown 内容。', + ], + releaseUrl: 'https://github.com/lucaslus/floatick/releases/tag/v0.3.3', + compareUrl: 'https://github.com/lucaslus/floatick/compare/v0.3.2...v0.3.3', + }, { version: 'v0.3.2', date: '2026 年 7 月 31 日', diff --git a/website/src/content/site-copy.ts b/website/src/content/site-copy.ts index 648639d..19b67de 100644 --- a/website/src/content/site-copy.ts +++ b/website/src/content/site-copy.ts @@ -119,9 +119,9 @@ export const siteCopy: Record = { en: { meta: { lang: 'en', - title: 'Floatick — Free Floating Todo List for macOS', + title: 'Floatick — Floating Todos & Notes for macOS', description: - 'A free, open-source floating todo list for macOS with local storage, Markdown notes, tags, Sticky Boards, and fast desktop capture.', + 'A free, open-source floating todo and notes app for macOS with local storage, Markdown, shared tags, Sticky Boards, and fast desktop capture.', canonicalPath: '/', alternatePath: '/zh/', }, @@ -134,11 +134,11 @@ export const siteCopy: Record = { languageLabel: 'Read in Chinese', }, hero: { - eyebrow: 'Floating todo list for macOS · Local-first · Open source', - titleBefore: 'Your next task,', + eyebrow: 'Floating todos & notes for macOS · Local-first · Open source', + titleBefore: 'Tasks and thoughts,', titleAccent: 'always one click away.', body: - 'Floatick lives on your Mac as a small, draggable icon. Click to capture a todo, add notes or tags, then collapse it back to the desktop.', + 'Floatick lives on your Mac as a small, draggable icon. Click to capture a todo or note, add context and shared tags, then collapse it back to the desktop.', download: 'Download for macOS', github: 'Explore on GitHub', compatibility: 'macOS 10.15+ · Apple silicon and Intel', @@ -146,14 +146,14 @@ export const siteCopy: Record = { proof: [ { value: 'Free', label: 'No account or subscription' }, { value: 'Open source', label: 'MIT-licensed on GitHub' }, - { value: 'Local-first', label: 'Todos stay on your Mac' }, + { value: 'Local-first', label: 'Todos and notes stay on your Mac' }, { value: 'Maintained', label: 'New releases and fixes' }, ], features: { eyebrow: 'Core features', - title: 'Everything you need, close to the desktop.', + title: 'Tasks and notes, close to the desktop.', body: - 'Capture, organize, and revisit todos without keeping a full-size task manager open.', + 'Capture, organize, and revisit work or ideas without keeping a full-size productivity app open.', items: [ { number: '01', @@ -163,15 +163,15 @@ export const siteCopy: Record = { }, { number: '02', - title: 'Titles and Markdown notes', + title: 'A lightweight Notes space', body: - 'Keep the list short with titles, then open Markdown details when a task needs more context.', + 'Switch from Todos to Notes to capture ideas, work logs, snippets, and anything worth keeping nearby.', }, { number: '03', title: 'Tags and combined filters', body: - 'Create colored tags, assign more than one, and filter the list by several tags at once.', + 'Reuse the same colored tags across todos and notes, assign more than one, and filter by several tags at once.', }, { number: '04', @@ -181,9 +181,9 @@ export const siteCopy: Record = { }, { number: '05', - title: 'Archive and restore', + title: 'Pin, archive, and restore', body: - 'Move finished work out of the main list, search the archive, and restore a todo when you need it again.', + 'Keep useful notes pinned, move finished items into the archive, and restore them whenever they matter again.', }, { number: '06', @@ -195,21 +195,21 @@ export const siteCopy: Record = { }, workflow: { eyebrow: 'From capture to action', - title: 'Start with a title. Add context when you need it.', + title: 'Start with a title. Keep writing in one surface.', body: - 'A todo can stay one line or grow into a complete Markdown brief.', + 'Todos and notes share a focused title-and-content editor, with Markdown available when you need structure.', steps: [ { label: 'Capture', - title: 'Create a todo from the desktop.', + title: 'Capture a todo or note from the desktop.', body: - 'Open Floatick, add a title, and get back to what you were doing.', + 'Open Floatick, choose the right space, add a title, and get back to what you were doing.', }, { label: 'Organize', - title: 'Use tags and Sticky Boards.', + title: 'Use shared tags and Sticky Boards.', body: - 'Group related work without moving or duplicating the original todo.', + 'Connect related todos and notes with tags, then group active tasks on desktop boards.', }, { label: 'Share', @@ -237,9 +237,9 @@ export const siteCopy: Record = { eyebrow: 'Local-first', title: 'Stored on your Mac.', body: - 'Floatick saves todos and settings as readable files in ~/.floatick. No account is required.', + 'Floatick saves todos, notes, and settings as readable files in ~/.floatick. No account is required.', points: [ - 'Your todo data stays in a folder you can inspect and back up.', + 'Your todo and note data stays in a folder you can inspect and back up.', 'No sign-up, cloud workspace, or telemetry.', 'Floatick connects to the network only to check for app updates.', ], @@ -251,12 +251,13 @@ export const siteCopy: Record = { body: 'Each release lists its new features, fixes, and behavior changes.', latestLabel: 'Latest release', - version: 'v0.3.2', - date: 'July 31, 2026', - dateTime: '2026-07-31', + version: 'v0.3.3', + date: 'August 5, 2026', + dateTime: '2026-08-05', highlights: [ - 'Restore in-app update checks after the GitHub account address changed.', - 'Validate the embedded Sparkle feed before publishing future releases.', + 'Capture lightweight notes beside your todos without leaving the floating panel.', + 'Search, pin, archive, and organize notes with the same reusable tags.', + 'Write titles and Markdown content in one continuous editor.', ], viewAll: 'Read the full changelog', }, @@ -277,17 +278,22 @@ export const siteCopy: Record = { eyebrow: 'FAQ', title: 'Before you install.', body: - 'Quick answers about storage, compatibility, Sticky Boards, and Markdown copy.', + 'Quick answers about notes, storage, compatibility, Sticky Boards, and Markdown copy.', items: [ { question: 'What is Floatick?', answer: - 'Floatick is a free, open-source floating todo list for macOS. It stays on the desktop as a draggable icon and expands into a task list when clicked.', + 'Floatick is a free, open-source floating todo and notes app for macOS. It stays on the desktop as a draggable icon and expands when clicked.', }, { - question: 'Where does Floatick store my todos?', + question: 'Where does Floatick store my data?', answer: - 'Floatick stores todos and preferences as readable files in ~/.floatick on your Mac. It does not require an account or cloud workspace.', + 'Floatick stores todos, notes, and preferences as readable files in ~/.floatick on your Mac. It does not require an account or cloud workspace.', + }, + { + question: 'Can I use Floatick for quick notes?', + answer: + 'Yes. Notes have their own searchable workspace with pinning, archiving, shared tags, automatic saving, and Markdown preview.', }, { question: 'What is a Sticky Board?', @@ -308,14 +314,14 @@ export const siteCopy: Record = { }, finalCta: { eyebrow: 'For macOS', - title: 'Keep your next todo on the desktop.', + title: 'Keep tasks and thoughts on the desktop.', body: 'Download the latest universal build, or view the source on GitHub.', download: 'Download Floatick', github: 'View source', }, footer: { - tagline: 'A local-first floating todo list for macOS.', + tagline: 'Local-first floating todos and notes for macOS.', source: 'Source', releases: 'Releases', license: 'MIT License', @@ -325,9 +331,9 @@ export const siteCopy: Record = { zh: { meta: { lang: 'zh-CN', - title: 'Floatick — 免费开源的 macOS 悬浮 Todo 清单', + title: 'Floatick — 免费开源的 macOS 悬浮 Todo 与笔记', description: - '免费开源的 macOS 桌面悬浮待办清单,支持本地存储、Markdown、标签、便利板和快速记录。', + '免费开源的 macOS 桌面悬浮 Todo 与轻量笔记,支持本地存储、Markdown、共享标签、便利板和快速记录。', canonicalPath: '/zh/', alternatePath: '/', }, @@ -340,11 +346,11 @@ export const siteCopy: Record = { languageLabel: 'Read in English', }, hero: { - eyebrow: 'macOS 悬浮待办清单 · 本地优先 · 开源', - titleBefore: '下一件事,', + eyebrow: 'macOS 悬浮 Todo 与笔记 · 本地优先 · 开源', + titleBefore: '待办和灵感,', titleAccent: '点一下就到。', body: - 'Floatick 平时是桌面上的一个可拖动图标。点击记录 Todo、补充内容或标签,用完后再收回原位。', + 'Floatick 平时是桌面上的一个可拖动图标。点击记录 Todo 或笔记、补充内容和共享标签,用完后再收回原位。', download: '下载 macOS 版', github: '在 GitHub 查看', compatibility: '支持 macOS 10.15+ · Apple 芯片与 Intel', @@ -352,14 +358,14 @@ export const siteCopy: Record = { proof: [ { value: '免费', label: '无需账号或订阅' }, { value: '开源', label: 'GitHub 上的 MIT 项目' }, - { value: '本地优先', label: 'Todo 保存在这台 Mac' }, + { value: '本地优先', label: 'Todo 和笔记保存在这台 Mac' }, { value: '持续维护', label: '持续发布功能与修复' }, ], features: { eyebrow: '核心功能', - title: '常用的 Todo 操作,就在桌面旁边。', + title: 'Todo 和笔记,就在桌面旁边。', body: - '无需常驻一个完整的任务管理器,也能随时记录、整理和找回 Todo。', + '无需常驻一个完整的效率工具,也能随时记录、整理和找回任务或想法。', items: [ { number: '01', @@ -369,15 +375,15 @@ export const siteCopy: Record = { }, { number: '02', - title: '标题与 Markdown 内容', + title: '轻量的 Notes 空间', body: - '列表只展示标题,需要更多上下文时再打开 Markdown 详情。', + '从 Todo 切换到 Notes,随手记录灵感、工作日志、片段和任何值得留下的内容。', }, { number: '03', title: '标签与组合筛选', body: - '创建彩色标签,为一个 Todo 添加多个标签,并同时按多个标签筛选。', + 'Todo 与笔记复用同一套彩色标签,支持添加多个标签并同时组合筛选。', }, { number: '04', @@ -387,9 +393,9 @@ export const siteCopy: Record = { }, { number: '05', - title: '归档与恢复', + title: '置顶、归档与恢复', body: - '把完成的工作移出主列表,继续搜索归档,并在需要时恢复。', + '置顶常用笔记,把完成的内容移入归档,并在需要时随时恢复。', }, { number: '06', @@ -401,21 +407,21 @@ export const siteCopy: Record = { }, workflow: { eyebrow: '从记录到执行', - title: '先写标题,需要时再补充上下文。', + title: '先写标题,在同一个区域继续记录。', body: - '一个 Todo 可以只有一行,也可以逐步整理成完整的 Markdown 任务。', + 'Todo 和笔记使用一致的标题与内容编辑器,需要结构时可以继续使用 Markdown。', steps: [ { label: '记录', - title: '直接从桌面新建 Todo。', + title: '直接从桌面新建 Todo 或笔记。', body: - '展开 Floatick、写下标题,然后继续手上的工作。', + '展开 Floatick、选择合适的空间、写下标题,然后继续手上的工作。', }, { label: '组织', - title: '使用标签和便利板。', + title: '使用共享标签和便利板。', body: - '把相关工作放在一起,不移动或复制原来的 Todo。', + '用标签连接相关 Todo 与笔记,再把活跃任务放进桌面便利板。', }, { label: '复制', @@ -442,9 +448,9 @@ export const siteCopy: Record = { eyebrow: '本地优先', title: '数据保存在这台 Mac。', body: - 'Floatick 将 Todo 和设置保存为 ~/.floatick 中的可读文件,无需账号。', + 'Floatick 将 Todo、笔记和设置保存为 ~/.floatick 中的可读文件,无需账号。', points: [ - 'Todo 数据可以直接查看和备份。', + 'Todo 和笔记数据可以直接查看和备份。', '无需注册、云工作区或遥测。', 'Floatick 只在检查应用更新时访问网络。', ], @@ -456,12 +462,13 @@ export const siteCopy: Record = { body: '每个版本都会列出新增功能、问题修复和行为变化。', latestLabel: '最新版本', - version: 'v0.3.2', - date: '2026 年 7 月 31 日', - dateTime: '2026-07-31', + version: 'v0.3.3', + date: '2026 年 8 月 5 日', + dateTime: '2026-08-05', highlights: [ - '修复 GitHub 账号地址变更后无法检查应用更新的问题。', - '发布前校验 App 内置的 Sparkle 更新源,避免再次断链。', + '在悬浮面板中新增独立的轻量笔记空间,与 Todo 随时切换。', + '支持搜索、置顶、归档笔记,并复用同一套彩色标签。', + '标题与 Markdown 内容采用连贯的一体式编辑体验。', ], viewAll: '查看完整更新日志', }, @@ -482,17 +489,22 @@ export const siteCopy: Record = { eyebrow: '常见问题', title: '安装前,你可能想知道这些。', body: - '快速了解数据存储、系统兼容性、便利板和 Markdown 复制。', + '快速了解笔记、数据存储、系统兼容性、便利板和 Markdown 复制。', items: [ { question: 'Floatick 是什么?', answer: - 'Floatick 是一款免费开源的 macOS 悬浮 Todo 清单。它平时是桌面上的可拖动图标,点击后展开为任务列表。', + 'Floatick 是一款免费开源的 macOS 悬浮 Todo 与笔记应用。它平时是桌面上的可拖动图标,点击后展开。', + }, + { + question: 'Todo 和笔记数据保存在哪里?', + answer: + 'Todo、笔记和偏好设置以可读文件保存在这台 Mac 的 ~/.floatick 中,无需账号或云工作区。', }, { - question: 'Todo 数据保存在哪里?', + question: '可以用 Floatick 随手记笔记吗?', answer: - 'Todo 和偏好设置以可读文件保存在这台 Mac 的 ~/.floatick 中,无需账号或云工作区。', + '可以。Notes 有独立的可搜索空间,支持置顶、归档、共享标签、自动保存和 Markdown 预览。', }, { question: '便利板是什么?', @@ -513,14 +525,14 @@ export const siteCopy: Record = { }, finalCta: { eyebrow: 'macOS', - title: '把下一件事放在桌面旁边。', + title: '把待办和灵感放在桌面旁边。', body: '下载最新 Universal 安装包,或前往 GitHub 查看源代码。', download: '下载 Floatick', github: '查看源代码', }, footer: { - tagline: '本地优先的 macOS 悬浮待办清单。', + tagline: '本地优先的 macOS 悬浮 Todo 与笔记。', source: '源代码', releases: '版本发布', license: 'MIT 许可证', diff --git a/website/src/layouts/SiteLayout.astro b/website/src/layouts/SiteLayout.astro index 4867597..2bb64eb 100644 --- a/website/src/layouts/SiteLayout.astro +++ b/website/src/layouts/SiteLayout.astro @@ -41,8 +41,8 @@ const softwareId = new URL('/#software', Astro.site).href; const webpageId = `${canonicalUrl.href}#webpage`; const socialImageAlt = lang === 'en' - ? 'Floatick floating todo list for macOS with tags, Sticky Boards, and Markdown notes' - : 'Floatick macOS 悬浮待办清单,展示标签、便利板与 Markdown 内容'; + ? 'Floatick floating todos and notes for macOS with shared tags, Sticky Boards, and Markdown' + : 'Floatick macOS 悬浮 Todo 与笔记,展示共享标签、便利板与 Markdown 内容'; const websiteSchema = { '@type': 'WebSite', @@ -81,12 +81,12 @@ const softwareSchema = { '@type': 'SoftwareApplication', '@id': softwareId, name: 'Floatick', - alternateName: 'Floatick Todo', + alternateName: 'Floatick Todo & Notes', description, applicationCategory: 'ProductivityApplication', - applicationSubCategory: 'Todo List Application', + applicationSubCategory: 'Todo List and Note-taking Application', operatingSystem: 'macOS 10.15 or later', - softwareVersion: '0.3.2', + softwareVersion: '0.3.3', isAccessibleForFree: true, url: canonicalUrl.href, downloadUrl: latestDownloadUrl, @@ -102,6 +102,8 @@ const softwareSchema = { featureList: [ 'Floating desktop todo list', 'Local-first task storage', + 'Lightweight local notes', + 'Shared tags for todos and notes', 'Markdown task notes', 'Color-coded tags and filtering', 'Pinnable Sticky Boards', diff --git a/website/src/scripts/product-hero-scene.ts b/website/src/scripts/product-hero-scene.ts index a772bf0..f5ff56f 100644 --- a/website/src/scripts/product-hero-scene.ts +++ b/website/src/scripts/product-hero-scene.ts @@ -44,6 +44,8 @@ type TaskPreview = { type ProductSceneCopy = { remaining: string; + todosTab: string; + notesTab: string; search: string; newTodo: string; today: string; @@ -51,9 +53,7 @@ type ProductSceneCopy = { boardTitle: string; boardCount: string; drawerTitle: string; - titleLabel: string; titleValue: string; - contentLabel: string; contentValue: string; save: string; }; @@ -139,6 +139,8 @@ const PRODUCT_FRAME = { const DEFAULT_COPY: ProductSceneCopy = { remaining: '3 tasks remaining', + todosTab: 'Todos', + notesTab: 'Notes', search: 'Search todos', newTodo: 'New', today: 'Today', @@ -150,9 +152,7 @@ const DEFAULT_COPY: ProductSceneCopy = { boardTitle: 'This week', boardCount: '3 todos', drawerTitle: 'New todo', - titleLabel: 'Title', titleValue: 'Prepare tomorrow’s top task', - contentLabel: 'Content', contentValue: 'Add a short note so the next step is clear.', save: 'Add todo', }; @@ -187,6 +187,8 @@ function dataValue(stage: HTMLElement, key: keyof DOMStringMap, fallback: string function readSceneCopy(stage: HTMLElement): ProductSceneCopy { return { remaining: dataValue(stage, 'remaining', DEFAULT_COPY.remaining), + todosTab: dataValue(stage, 'todosTab', DEFAULT_COPY.todosTab), + notesTab: dataValue(stage, 'notesTab', DEFAULT_COPY.notesTab), search: dataValue(stage, 'search', DEFAULT_COPY.search), newTodo: dataValue(stage, 'newTodo', DEFAULT_COPY.newTodo), today: dataValue(stage, 'today', DEFAULT_COPY.today), @@ -222,9 +224,7 @@ function readSceneCopy(stage: HTMLElement): ProductSceneCopy { boardTitle: dataValue(stage, 'boardTitle', DEFAULT_COPY.boardTitle), boardCount: dataValue(stage, 'boardCount', DEFAULT_COPY.boardCount), drawerTitle: dataValue(stage, 'drawerTitle', DEFAULT_COPY.drawerTitle), - titleLabel: dataValue(stage, 'titleLabel', DEFAULT_COPY.titleLabel), titleValue: dataValue(stage, 'titleValue', DEFAULT_COPY.titleValue), - contentLabel: dataValue(stage, 'contentLabel', DEFAULT_COPY.contentLabel), contentValue: dataValue(stage, 'contentValue', DEFAULT_COPY.contentValue), save: dataValue(stage, 'save', DEFAULT_COPY.save), }; @@ -673,32 +673,60 @@ function createMainPanelTexture(copy: ProductSceneCopy) { roundedRect( context, 58, - 232, + 218, + 886, + 88, + 24, + COLORS.panelDark, + COLORS.line, + ); + roundedRect( + context, + 64, + 224, + 431, + 76, + 20, + 'rgba(45, 212, 199, 0.16)', + ); + context.font = + '720 28px Inter, -apple-system, BlinkMacSystemFont, sans-serif'; + context.textAlign = 'center'; + context.fillStyle = COLORS.accent; + context.fillText(copy.todosTab, 279, 273); + context.fillStyle = COLORS.textMuted; + context.fillText(copy.notesTab, 716, 273); + context.textAlign = 'start'; + + roundedRect( + context, + 58, + 336, 626, 104, 27, COLORS.panelRaised, COLORS.line, ); - drawSearchIcon(context, 103, 279, 14); + drawSearchIcon(context, 103, 383, 14); context.fillStyle = COLORS.textMuted; context.font = '540 30px Inter, -apple-system, BlinkMacSystemFont, sans-serif'; - context.fillText(copy.search, 151, 291); + context.fillText(copy.search, 151, 395); roundedRect( context, 704, - 232, + 336, 104, 104, 27, COLORS.panelRaised, COLORS.line, ); - drawTagIcon(context, 756, 284, 34, COLORS.textSoft); + drawTagIcon(context, 756, 388, 34, COLORS.textSoft); - roundedRect(context, 826, 232, 118, 104, 34, '#34554f'); + roundedRect(context, 826, 336, 118, 104, 34, '#34554f'); context.fillStyle = COLORS.text; context.font = '720 28px Inter, -apple-system, BlinkMacSystemFont, sans-serif'; @@ -706,7 +734,7 @@ function createMainPanelTexture(copy: ProductSceneCopy) { context, copy.newTodo, 885, - 284, + 388, 18, 10, COLORS.text, @@ -716,12 +744,12 @@ function createMainPanelTexture(copy: ProductSceneCopy) { context.fillStyle = COLORS.textMuted; context.font = '700 26px Inter, -apple-system, BlinkMacSystemFont, sans-serif'; - context.fillText(copy.today, 64, 416); + context.fillText(copy.today, 64, 520); context.fillStyle = '#526467'; - context.fillRect(156, 399, 788, 2); + context.fillRect(156, 503, 788, 2); const tagColors = [COLORS.accent, COLORS.blue, COLORS.purple]; - const taskStarts = [456, 718, 980]; + const taskStarts = [560, 818, 1076]; copy.tasks.forEach((task, index) => { const y = taskStarts[index]; const completed = index === 1; @@ -870,45 +898,30 @@ function createDrawerTexture(copy: ProductSceneCopy) { context.fillStyle = 'rgba(205, 229, 226, 0.14)'; context.fillRect(0, 118, 720, 2); - context.fillStyle = COLORS.textMuted; - context.font = - '680 23px Inter, -apple-system, BlinkMacSystemFont, sans-serif'; - context.fillText(copy.titleLabel, 48, 184); + drawTagIcon(context, 70, 174, 30, COLORS.textSoft); roundedRect( context, 48, - 210, + 212, 624, - 110, + 534, 22, '#162426', 'rgba(45, 212, 199, 0.62)', ); context.fillStyle = COLORS.textSoft; context.font = - '590 27px Inter, -apple-system, BlinkMacSystemFont, sans-serif'; - context.fillText(copy.titleValue, 74, 276); + '700 31px Inter, -apple-system, BlinkMacSystemFont, sans-serif'; + context.fillText(copy.titleValue, 74, 282); - context.fillStyle = COLORS.textMuted; - context.font = - '680 23px Inter, -apple-system, BlinkMacSystemFont, sans-serif'; - context.fillText(copy.contentLabel, 48, 386); - roundedRect( - context, - 48, - 412, - 624, - 334, - 22, - '#162426', - COLORS.line, - ); + context.fillStyle = 'rgba(205, 229, 226, 0.18)'; + context.fillRect(74, 322, 572, 2); context.fillStyle = COLORS.textSoft; context.font = '540 25px Inter, -apple-system, BlinkMacSystemFont, sans-serif'; const words = copy.contentValue.split(' '); let line = ''; - let lineY = 466; + let lineY = 378; words.forEach((word) => { const nextLine = `${line}${word} `; if (context.measureText(nextLine).width > 540 && line) {