diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 137c297..ff04f66 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -74,5 +74,19 @@ "current": { "type": "int" }, "total": { "type": "int" } } - } + }, + "board": "Board", + "boardTitle": "Sticky Board", + "boardOpen": "Sticky note board", + "boardAddCard": "Add card", + "boardNewCardText": "New note", + "boardDeleteCard": "Delete card", + "boardDeleteCardTitle": "Delete this card?", + "boardBacklinks": "Linked from", + "boardNoBacklinks": "Nothing links here yet", + "boardDanglingLink": "No card named \"{target}\"", + "@boardDanglingLink": { + "placeholders": { "target": { "type": "String" } } + }, + "close": "Close" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index d1bb63a..1fb02d2 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -439,6 +439,72 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'{current} / {total}'** String pageOfPages(int current, int total); + + /// No description provided for @board. + /// + /// In en, this message translates to: + /// **'Board'** + String get board; + + /// No description provided for @boardTitle. + /// + /// In en, this message translates to: + /// **'Sticky Board'** + String get boardTitle; + + /// No description provided for @boardOpen. + /// + /// In en, this message translates to: + /// **'Sticky note board'** + String get boardOpen; + + /// No description provided for @boardAddCard. + /// + /// In en, this message translates to: + /// **'Add card'** + String get boardAddCard; + + /// No description provided for @boardNewCardText. + /// + /// In en, this message translates to: + /// **'New note'** + String get boardNewCardText; + + /// No description provided for @boardDeleteCard. + /// + /// In en, this message translates to: + /// **'Delete card'** + String get boardDeleteCard; + + /// No description provided for @boardDeleteCardTitle. + /// + /// In en, this message translates to: + /// **'Delete this card?'** + String get boardDeleteCardTitle; + + /// No description provided for @boardBacklinks. + /// + /// In en, this message translates to: + /// **'Linked from'** + String get boardBacklinks; + + /// No description provided for @boardNoBacklinks. + /// + /// In en, this message translates to: + /// **'Nothing links here yet'** + String get boardNoBacklinks; + + /// No description provided for @boardDanglingLink. + /// + /// In en, this message translates to: + /// **'No card named \"{target}\"'** + String boardDanglingLink(String target); + + /// No description provided for @close. + /// + /// In en, this message translates to: + /// **'Close'** + String get close; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 1fb674e..4265534 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -188,4 +188,39 @@ class AppLocalizationsEn extends AppLocalizations { String pageOfPages(int current, int total) { return '$current / $total'; } + + @override + String get board => 'Board'; + + @override + String get boardTitle => 'Sticky Board'; + + @override + String get boardOpen => 'Sticky note board'; + + @override + String get boardAddCard => 'Add card'; + + @override + String get boardNewCardText => 'New note'; + + @override + String get boardDeleteCard => 'Delete card'; + + @override + String get boardDeleteCardTitle => 'Delete this card?'; + + @override + String get boardBacklinks => 'Linked from'; + + @override + String get boardNoBacklinks => 'Nothing links here yet'; + + @override + String boardDanglingLink(String target) { + return 'No card named \"$target\"'; + } + + @override + String get close => 'Close'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 1856be5..ee357e9 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -188,4 +188,39 @@ class AppLocalizationsZh extends AppLocalizations { String pageOfPages(int current, int total) { return '$current / $total'; } + + @override + String get board => '便利贴板'; + + @override + String get boardTitle => '便利贴板'; + + @override + String get boardOpen => '便利贴板'; + + @override + String get boardAddCard => '添加便利贴'; + + @override + String get boardNewCardText => '新便利贴'; + + @override + String get boardDeleteCard => '删除便利贴'; + + @override + String get boardDeleteCardTitle => '删除这张便利贴?'; + + @override + String get boardBacklinks => '哪些链接到这里'; + + @override + String get boardNoBacklinks => '暂无其他便利贴链接到这里'; + + @override + String boardDanglingLink(String target) { + return '没有名为“$target”的便利贴'; + } + + @override + String get close => '关闭'; } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 2b76738..927cea0 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -56,5 +56,16 @@ "actionHighlightSelection": "高亮所选", "failedToOpenPdf": "打开 PDF 失败:\n{error}", "pdfNoPages": "PDF 没有任何页面。", - "pageOfPages": "{current} / {total}" + "pageOfPages": "{current} / {total}", + "board": "便利贴板", + "boardTitle": "便利贴板", + "boardOpen": "便利贴板", + "boardAddCard": "添加便利贴", + "boardNewCardText": "新便利贴", + "boardDeleteCard": "删除便利贴", + "boardDeleteCardTitle": "删除这张便利贴?", + "boardBacklinks": "哪些链接到这里", + "boardNoBacklinks": "暂无其他便利贴链接到这里", + "boardDanglingLink": "没有名为“{target}”的便利贴", + "close": "关闭" } diff --git a/lib/screens/board_screen.dart b/lib/screens/board_screen.dart new file mode 100644 index 0000000..365c4ef --- /dev/null +++ b/lib/screens/board_screen.dart @@ -0,0 +1,650 @@ +// lib/screens/board_screen.dart +// +// F7 — 双链 + 无限便利贴. The reachable, persisted UI over the (already +// unit-tested) pure [Board] model. An infinite InteractiveViewer canvas hosts a +// Stack of sticky cards; cards are draggable, their text is editable, and +// `[[link]]` spans render as tappable chips that pan/center the board on the +// matching card (matched by card id — see _navigateToTarget). Backlinks +// ("什么链接到这里 / Linked from") come straight from board.backlinksOf(id). +// +// Persistence: the whole board is saved (cards as rows) via +// DatabaseService.saveBoardCards, debounced 800ms like the ink editors, and +// reloaded on open so it survives a restart. +// +// OUT OF SCOPE (left as TODOs): +// TODO(board-ink): ink/handwriting ON cards (the model mentions a per-card +// StrokeHost). Not wired here. +// TODO(board-multi): multiple-boards management UI. A single default board id +// is used when [boardId] is null; the param is kept for later. +// TODO(board-link-autocomplete): real-time `[[` autocomplete of card ids. + +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../editor/board/board.dart'; +import '../l10n/app_localizations.dart'; +import '../services/database_service.dart'; + +/// Default board id used when no [boardId] is supplied. One shared board is in +/// scope for increment 1; the param survives for future multi-board support. +const String kDefaultBoardId = 'default'; + +/// Default size of a freshly added sticky card (board content coordinates). +const Size _kNewCardSize = Size(180, 140); + +/// The logical extent of the (conceptually infinite) board content. The Stack +/// needs a finite size; InteractiveViewer's infinite boundaryMargin lets the +/// user pan far beyond it, and cards are centered around the middle so there is +/// room to grow in every direction. +const double _kCanvasExtent = 100000; +const Offset _kCanvasCenter = Offset(_kCanvasExtent / 2, _kCanvasExtent / 2); + +class BoardScreen extends StatefulWidget { + const BoardScreen({super.key, this.boardId}); + + /// Which board to open. Null → [kDefaultBoardId]. + final String? boardId; + + @override + State createState() => _BoardScreenState(); +} + +class _BoardScreenState extends State { + final TransformationController _transform = TransformationController(); + + /// The viewport size of the InteractiveViewer (used to center on a card). + final GlobalKey _viewportKey = GlobalKey(); + + Board _board = Board.empty; + bool _loading = true; + + /// Currently selected card (drives the backlinks panel + delete affordance). + String? _selectedId; + + /// The card whose text is being edited inline (null = none). + String? _editingId; + + DatabaseService? _db; + Timer? _saveTimer; + + String get _boardId => widget.boardId ?? kDefaultBoardId; + + @override + void initState() { + super.initState(); + // Center the initial viewport on the canvas center so newly added cards + // (placed at viewport center, which starts near _kCanvasCenter) are onscreen. + WidgetsBinding.instance.addPostFrameCallback((_) => _centerInitially()); + _load(); + } + + @override + void dispose() { + // Flush any pending debounced save synchronously-ish before tearing down. + _saveTimer?.cancel(); + if (_dirty) { + // Fire-and-forget: the future completes after dispose but the write still + // lands (DatabaseService is a singleton independent of this widget). + unawaited(_db?.saveBoardCards(_boardId, _board.cards) ?? Future.value()); + } + _transform.dispose(); + super.dispose(); + } + + bool _dirty = false; + + Future _load() async { + final db = await DatabaseService.getInstance(); + final board = await db.loadBoard(_boardId); + if (!mounted) return; + setState(() { + _db = db; + _board = board; + _loading = false; + }); + } + + void _centerInitially() { + final ctx = _viewportKey.currentContext; + if (ctx == null) return; + final size = ctx.size; + if (size == null) return; + // Translate so that _kCanvasCenter sits at the middle of the viewport. + _transform.value = Matrix4.identity() + ..translateByDouble( + size.width / 2 - _kCanvasCenter.dx, + size.height / 2 - _kCanvasCenter.dy, + 0, + 1, + ); + } + + // ── Mutations (each schedules a debounced save) ──────────────────────── + + void _mutate(Board next) { + setState(() => _board = next); + _dirty = true; + _scheduleSave(); + } + + void _scheduleSave() { + _saveTimer?.cancel(); + // Capture the snapshot synchronously (Board is immutable, so _board is a + // stable value) — same discipline as SaveScheduler. + final snapshot = _board; + final boardId = _boardId; + _saveTimer = Timer(const Duration(milliseconds: 800), () async { + await _db?.saveBoardCards(boardId, snapshot.cards); + _dirty = false; + }); + } + + String _newCardId() => 'card-${DateTime.now().microsecondsSinceEpoch}'; + + /// The board-content point currently at the center of the viewport. + Offset _viewportCenterInContent() { + final ctx = _viewportKey.currentContext; + final size = ctx?.size ?? const Size(400, 600); + final viewportCenter = Offset(size.width / 2, size.height / 2); + // Inverse-map the viewport-center screen point into content coordinates. + final inv = Matrix4.inverted(_transform.value); + return MatrixUtils.transformPoint(inv, viewportCenter); + } + + void _addCard() { + final center = _viewportCenterInContent(); + final pos = center - + Offset(_kNewCardSize.width / 2, _kNewCardSize.height / 2); + final l = AppLocalizations.of(context); + final card = BoardCard( + id: _newCardId(), + position: pos, + size: _kNewCardSize, + text: l.boardNewCardText, + ); + _mutate(_board.add(card)); + setState(() { + _selectedId = card.id; + _editingId = card.id; + }); + } + + void _deleteCard(String id) { + _mutate(_board.removeById(id)); + setState(() { + if (_selectedId == id) _selectedId = null; + if (_editingId == id) _editingId = null; + }); + } + + /// Pan/center the board on the card matching [target] (matched by card id). + /// Returns false when no such card exists (a dangling link). + bool _navigateToTarget(String target) { + final card = _board.cardById(target); + if (card == null) return false; + _centerOnCard(card); + setState(() => _selectedId = card.id); + return true; + } + + void _centerOnCard(BoardCard card) { + final ctx = _viewportKey.currentContext; + final size = ctx?.size ?? const Size(400, 600); + final scale = _transform.value.getMaxScaleOnAxis(); + final cardCenter = card.position + + Offset(card.size.width / 2, card.size.height / 2); + // We want: screen = scale * content + translation == viewportCenter. + final tx = size.width / 2 - scale * cardCenter.dx; + final ty = size.height / 2 - scale * cardCenter.dy; + _transform.value = Matrix4.identity() + ..translateByDouble(tx, ty, 0, 1) + ..scaleByDouble(scale, scale, scale, 1); + } + + // ── Build ────────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context); + final cs = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar(title: Text(l.boardTitle), centerTitle: true), + floatingActionButton: FloatingActionButton( + onPressed: _loading ? null : _addCard, + tooltip: l.boardAddCard, + child: const Icon(Icons.add), + ), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : Stack( + children: [ + InteractiveViewer( + key: _viewportKey, + transformationController: _transform, + boundaryMargin: const EdgeInsets.all(double.infinity), + minScale: 0.2, + maxScale: 4, + // A finite, very large content area for the card Stack. + constrained: false, + child: GestureDetector( + // Tap on empty canvas → deselect / stop editing. + behavior: HitTestBehavior.translucent, + onTap: () => setState(() { + _selectedId = null; + _editingId = null; + }), + child: SizedBox( + width: _kCanvasExtent, + height: _kCanvasExtent, + child: Stack( + children: [ + for (final card in _board.cards) + _BoardCardWidget( + key: ValueKey(card.id), + card: card, + board: _board, + selected: card.id == _selectedId, + editing: card.id == _editingId, + onSelect: () => + setState(() => _selectedId = card.id), + onStartEdit: () => + setState(() => _editingId = card.id), + onTextChanged: (t) => + _mutate(_board.setText(card.id, t)), + onMoved: (pos) => + _mutate(_board.moveCard(card.id, pos)), + onDelete: () => _confirmDelete(card.id), + onTapLink: _onTapLink, + scale: _transform.value.getMaxScaleOnAxis(), + ), + ], + ), + ), + ), + ), + if (_selectedId != null) + _BacklinksPanel( + board: _board, + selectedId: _selectedId!, + onTapCard: (id) { + final c = _board.cardById(id); + if (c != null) _centerOnCard(c); + setState(() => _selectedId = id); + }, + onClose: () => setState(() => _selectedId = null), + background: cs.surfaceContainerHigh, + ), + ], + ), + ); + } + + void _onTapLink(String target) { + final l = AppLocalizations.of(context); + final ok = _navigateToTarget(target); + if (!ok) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l.boardDanglingLink(target))), + ); + } + } + + Future _confirmDelete(String id) async { + final l = AppLocalizations.of(context); + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(l.boardDeleteCardTitle), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(l.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(l.delete), + ), + ], + ), + ); + if (confirmed == true) _deleteCard(id); + } +} + +// ─────────────────────────────────────────────────────────────────────────── +// One sticky card. + +class _BoardCardWidget extends StatefulWidget { + const _BoardCardWidget({ + super.key, + required this.card, + required this.board, + required this.selected, + required this.editing, + required this.onSelect, + required this.onStartEdit, + required this.onTextChanged, + required this.onMoved, + required this.onDelete, + required this.onTapLink, + required this.scale, + }); + + final BoardCard card; + final Board board; + final bool selected; + final bool editing; + final VoidCallback onSelect; + final VoidCallback onStartEdit; + final ValueChanged onTextChanged; + final ValueChanged onMoved; + final VoidCallback onDelete; + final ValueChanged onTapLink; + + /// Current board zoom — used to convert screen drag deltas into content space. + final double scale; + + @override + State<_BoardCardWidget> createState() => _BoardCardWidgetState(); +} + +class _BoardCardWidgetState extends State<_BoardCardWidget> { + TextEditingController? _controller; + + @override + void didUpdateWidget(_BoardCardWidget old) { + super.didUpdateWidget(old); + // Keep the live editing controller in sync if the card text changes + // underneath us (e.g. external mutation) while NOT editing. + if (!widget.editing && _controller != null) { + _controller!.dispose(); + _controller = null; + } + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final card = widget.card; + + return Positioned( + left: card.position.dx, + top: card.position.dy, + width: card.size.width, + height: card.size.height, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onSelect, + onLongPress: widget.onDelete, + // Dragging the card body moves it (content delta = screen delta / scale). + onPanStart: (_) => widget.onSelect(), + onPanUpdate: (d) { + final scale = widget.scale == 0 ? 1.0 : widget.scale; + widget.onMoved(card.position + d.delta / scale); + }, + child: Material( + elevation: widget.selected ? 6 : 2, + color: cs.secondaryContainer, + surfaceTintColor: cs.surfaceTint, + borderRadius: BorderRadius.circular(12), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: widget.selected + ? Border.all(color: cs.primary, width: 2) + : null, + ), + padding: const EdgeInsets.all(10), + child: widget.editing + ? _buildEditor(cs) + : _buildBody(context, cs), + ), + ), + ), + ); + } + + Widget _buildEditor(ColorScheme cs) { + _controller ??= TextEditingController(text: widget.card.text); + return TextField( + controller: _controller, + autofocus: true, + maxLines: null, + expands: true, + textAlignVertical: TextAlignVertical.top, + style: TextStyle(color: cs.onSecondaryContainer, fontSize: 14), + decoration: const InputDecoration( + border: InputBorder.none, + isCollapsed: true, + ), + onChanged: widget.onTextChanged, + ); + } + + Widget _buildBody(BuildContext context, ColorScheme cs) { + return GestureDetector( + // A tap that is NOT on a link enters edit mode. + onTap: widget.onStartEdit, + child: SizedBox.expand( + child: SingleChildScrollView( + child: _LinkedText( + text: widget.card.text, + board: widget.board, + onTapLink: widget.onTapLink, + baseColor: cs.onSecondaryContainer, + ), + ), + ), + ); + } +} + +// ─────────────────────────────────────────────────────────────────────────── +// Renders card text with [[link]] spans as tappable inline chips. Existing card +// ids are styled as live links; unknown targets are dangling (dashed/red). + +class _LinkedText extends StatelessWidget { + const _LinkedText({ + required this.text, + required this.board, + required this.onTapLink, + required this.baseColor, + }); + + final String text; + final Board board; + final ValueChanged onTapLink; + final Color baseColor; + + static final RegExp _linkPattern = RegExp(r'\[\[([^\]]*)\]\]'); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final spans = []; + var last = 0; + for (final m in _linkPattern.allMatches(text)) { + if (m.start > last) { + spans.add(TextSpan(text: text.substring(last, m.start))); + } + final target = (m.group(1) ?? '').trim(); + final exists = target.isNotEmpty && board.cardById(target) != null; + spans.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: _LinkChip( + label: target.isEmpty ? '[[]]' : target, + exists: exists, + onTap: target.isEmpty ? null : () => onTapLink(target), + cs: cs, + ), + ), + ); + last = m.end; + } + if (last < text.length) { + spans.add(TextSpan(text: text.substring(last))); + } + return Text.rich( + TextSpan( + style: TextStyle(color: baseColor, fontSize: 14), + children: spans, + ), + ); + } +} + +class _LinkChip extends StatelessWidget { + const _LinkChip({ + required this.label, + required this.exists, + required this.onTap, + required this.cs, + }); + + final String label; + final bool exists; + final VoidCallback? onTap; + final ColorScheme cs; + + @override + Widget build(BuildContext context) { + final color = exists ? cs.primary : cs.error; + return GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 1), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: color, + width: 1, + // Dangling links get a visibly different (no fill, error) look. + style: BorderStyle.solid, + ), + ), + child: Text( + label, + style: TextStyle( + color: color, + fontSize: 13, + fontWeight: FontWeight.w600, + decoration: + exists ? TextDecoration.none : TextDecoration.lineThrough, + ), + ), + ), + ); + } +} + +// ─────────────────────────────────────────────────────────────────────────── +// "什么链接到这里 / Linked from" panel for the selected card. + +class _BacklinksPanel extends StatelessWidget { + const _BacklinksPanel({ + required this.board, + required this.selectedId, + required this.onTapCard, + required this.onClose, + required this.background, + }); + + final Board board; + final String selectedId; + final ValueChanged onTapCard; + final VoidCallback onClose; + final Color background; + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context); + final backlinks = board.backlinksOf(selectedId).toList()..sort(); + + return Positioned( + right: 12, + top: 12, + bottom: 12, + width: 240, + child: Material( + elevation: 4, + color: background, + borderRadius: BorderRadius.circular(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 8, 4), + child: Row( + children: [ + Expanded( + child: Text( + l.boardBacklinks, + style: Theme.of(context).textTheme.titleSmall, + ), + ), + IconButton( + icon: const Icon(Icons.close), + tooltip: l.close, + onPressed: onClose, + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: backlinks.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + l.boardNoBacklinks, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ) + : ListView( + children: [ + for (final id in backlinks) + ListTile( + dense: true, + leading: const Icon(Icons.link, size: 18), + title: Text( + _preview(board, id), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + id, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onTap: () => onTapCard(id), + ), + ], + ), + ), + ], + ), + ), + ); + } + + String _preview(Board board, String id) { + final c = board.cardById(id); + if (c == null) return id; + final text = c.text.trim(); + return text.isEmpty ? id : text.replaceAll('\n', ' '); + } +} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 12dd86a..6ad2905 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -11,6 +11,7 @@ import '../services/pdf_service.dart'; import '../services/pptx_service.dart'; import '../editor/canvas/pen_note_screen.dart'; import '../editor/canvas/pen_slide_screen.dart'; +import 'board_screen.dart'; import 'search_screen.dart'; import 'settings_screen.dart'; import 'split_view_screen.dart'; @@ -60,6 +61,15 @@ class HomeScreen extends ConsumerWidget { tooltip: l.importPpt, onPressed: () => _importPptx(context), ), + IconButton( + icon: const Icon(Icons.dashboard_customize), + tooltip: l.boardOpen, + onPressed: () { + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const BoardScreen())); + }, + ), IconButton( icon: const Icon(Icons.search), tooltip: l.search, diff --git a/lib/services/database_service.dart b/lib/services/database_service.dart index 8557ee6..6d6c769 100644 --- a/lib/services/database_service.dart +++ b/lib/services/database_service.dart @@ -1,11 +1,14 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:ui' show Offset, Size; +import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:uuid/uuid.dart'; +import '../editor/board/board.dart'; import '../models/bookmark.dart'; import '../models/document.dart' as doc; import '../models/ink_point.dart'; @@ -28,6 +31,18 @@ class DatabaseService { return service; } + /// Test-only: drop the cached singleton so the next [getInstance] re-opens a + /// fresh database (e.g. after pointing PathProviderPlatform at a new temp + /// dir). Closes the current handle if one is open. + @visibleForTesting + static Future resetForTest() async { + final existing = _instance; + _instance = null; + if (existing != null) { + await existing._database.close(); + } + } + Database get database => _database; Future _initialize() async { @@ -41,7 +56,7 @@ class DatabaseService { _database = await openDatabase( dbPath, - version: 6, + version: 7, onCreate: _onCreate, onUpgrade: _onUpgrade, ); @@ -184,6 +199,31 @@ class DatabaseService { created_at INTEGER NOT NULL ) '''); + + // Sticky-note board cards (v7): F7 双链 + 无限便利贴. + await _createBoardCardsTable(db); + } + + /// Sticky-note board cards table (F7). One row per [BoardCard]; a board is the + /// set of rows sharing a [board_id]. Geometry is stored as plain columns + /// (rows, not a blob) so a board round-trips and could be queried later. + Future _createBoardCardsTable(DatabaseExecutor db) async { + await db.execute(''' + CREATE TABLE board_cards ( + id TEXT PRIMARY KEY, + board_id TEXT NOT NULL, + x REAL NOT NULL, + y REAL NOT NULL, + w REAL NOT NULL, + h REAL NOT NULL, + text TEXT NOT NULL DEFAULT '', + ordinal INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL + ) + '''); + await db.execute( + 'CREATE INDEX idx_board_cards_board ON board_cards(board_id)', + ); } Future _onUpgrade(Database db, int oldVersion, int newVersion) async { @@ -191,6 +231,13 @@ class DatabaseService { if (oldVersion < 4) {} // v3->v4: version boundary (no-op schema) if (oldVersion < 5) await _migrateV4toV5(db); if (oldVersion < 6) await _migrateV5toV6(db); + if (oldVersion < 7) await _migrateV6toV7(db); + } + + Future _migrateV6toV7(Database db) async { + await db.transaction((txn) async { + await _createBoardCardsTable(txn); + }); } Future _migrateV5toV6(Database db) async { @@ -897,4 +944,63 @@ class DatabaseService { .map((s) => InkStroke.fromJson(s as Map)) .toList(); } + + // ── Board cards CRUD (F7 双链 + 无限便利贴) ────────────────────────── + + /// Replace ALL cards for [boardId] with [cards]. Geometry is persisted as + /// rows (id, x, y, w, h, text) so the board survives restart. The whole + /// replace runs in one transaction so a crash mid-save cannot leave a + /// half-written board. + Future saveBoardCards( + String boardId, + List cards, + ) async { + final now = DateTime.now().millisecondsSinceEpoch; + await _database.transaction((txn) async { + await txn.delete( + 'board_cards', + where: 'board_id = ?', + whereArgs: [boardId], + ); + for (var i = 0; i < cards.length; i++) { + final c = cards[i]; + await txn.insert('board_cards', { + 'id': c.id, + 'board_id': boardId, + 'x': c.position.dx, + 'y': c.position.dy, + 'w': c.size.width, + 'h': c.size.height, + 'text': c.text, + 'ordinal': i, + 'updated_at': now, + }); + } + }); + } + + /// Load the [Board] for [boardId] (empty board when nothing is stored). + Future loadBoard(String boardId) async { + final rows = await _database.query( + 'board_cards', + where: 'board_id = ?', + whereArgs: [boardId], + orderBy: 'ordinal ASC', + ); + return Board([ + for (final row in rows) + BoardCard( + id: row['id'] as String, + position: Offset( + (row['x'] as num).toDouble(), + (row['y'] as num).toDouble(), + ), + size: Size( + (row['w'] as num).toDouble(), + (row['h'] as num).toDouble(), + ), + text: row['text'] as String? ?? '', + ), + ]); + } } diff --git a/test/board_screen_test.dart b/test/board_screen_test.dart new file mode 100644 index 0000000..cf61734 --- /dev/null +++ b/test/board_screen_test.dart @@ -0,0 +1,150 @@ +// test/board_screen_test.dart +// +// Screen-level + persistence guards for F7 (双链 + 无限便利贴): +// 1. BoardScreen pumps, "Add card" inserts a card, typing text with a +// [[link]] renders the card body and the link chip. +// 2. saveBoardCards / loadBoard round-trip a board through DatabaseService +// (cards survive a reload — proves the screen's persistence is real). +// +// DatabaseService.getInstance() needs getApplicationDocumentsDirectory(); we +// mock PathProviderPlatform to a temp dir so the singleton opens a real (ffi) +// sqlite DB on disk. + +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +// ignore: depend_on_referenced_packages +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +// ignore: depend_on_referenced_packages +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:badnote/editor/board/board.dart'; +import 'package:badnote/l10n/app_localizations.dart'; +import 'package:badnote/screens/board_screen.dart'; +import 'package:badnote/services/database_service.dart'; + +class _FakePathProvider extends PathProviderPlatform + with MockPlatformInterfaceMixin { + _FakePathProvider(this.dir); + final String dir; + @override + Future getApplicationDocumentsPath() async => dir; +} + +Future _pumpBoard(WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const BoardScreen(boardId: 'test-board'), + ), + ), + ); + // The board loads from a real (ffi) sqlite file in initState. That I/O only + // completes on the real event loop, so drive it via runAsync, then pump to + // surface the loaded state. (pumpAndSettle is unusable here: the loading + // CircularProgressIndicator animates forever and never settles.) + await tester.runAsync(() async { + // Give the DB open + loadBoard future real wall-clock time to resolve. + await Future.delayed(const Duration(milliseconds: 200)); + }); + await tester.pump(); + await tester.pump(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + + late Directory tmp; + + setUp(() async { + tmp = await Directory.systemTemp.createTemp('board_screen_test_'); + // Fresh DB file per test → clean board state. + final dbFile = File('${tmp.path}/badnote.db'); + if (dbFile.existsSync()) dbFile.deleteSync(); + PathProviderPlatform.instance = _FakePathProvider(tmp.path); + DatabaseService.resetForTest(); + }); + + tearDown(() async { + try { + tmp.deleteSync(recursive: true); + } catch (_) {} + }); + + testWidgets('add a card, type [[link]] text, card + link chip render', + (tester) async { + await _pumpBoard(tester); + + // Empty board: no cards yet. + expect(find.byType(TextField), findsNothing); + + // "Add card" FAB → inserts a card that opens straight into inline edit. + await tester.tap(find.byType(FloatingActionButton)); + await tester.pump(); + await tester.pump(); + expect(find.byType(TextField), findsOneWidget, + reason: 'a new card opens in inline-edit mode'); + + // Type a body containing a [[link]] to a (dangling) target. + await tester.enterText(find.byType(TextField), 'hello [[other]]'); + await tester.pump(); + + // Tap empty canvas (top-left, away from the centered card) to leave edit + // mode and render the body. + await tester.tapAt(const Offset(10, 100)); + await tester.pump(); + await tester.pump(); + + // The non-link text and the link chip both render. + expect(find.textContaining('hello'), findsWidgets); + expect(find.text('other'), findsOneWidget, + reason: 'the [[other]] link renders as a tappable chip'); + + // Tear the screen down so its debounced save timer is cancelled and any + // pending flush lands while the DB is still open (avoids a post-test write + // against a torn-down DB). Disposal runs synchronously on pumpWidget. + await tester.pumpWidget(const SizedBox()); + await tester.runAsync(() async { + await Future.delayed(const Duration(milliseconds: 50)); + }); + }); + + test('persistence round-trips a board through DatabaseService', () async { + final db = await DatabaseService.getInstance(); + + final board = Board.empty + .add(BoardCard( + id: 'a', + position: const Offset(10, 20), + size: const Size(180, 140), + text: 'see [[b]]', + )) + .add(BoardCard( + id: 'b', + position: const Offset(300, 50), + size: const Size(180, 140), + text: 'leaf', + )); + + await db.saveBoardCards('test-board', board.cards); + + final reloaded = await db.loadBoard('test-board'); + expect(reloaded.length, 2); + expect(reloaded.cardById('a')!.position, const Offset(10, 20)); + expect(reloaded.cardById('a')!.text, 'see [[b]]'); + expect(reloaded.cardById('b')!.size, const Size(180, 140)); + // Backlinks survive because the [[b]] link is preserved in text. + expect(reloaded.backlinksOf('b'), {'a'}); + + // A board id without rows loads as empty (default-board fallback path). + expect((await db.loadBoard('nonexistent')).length, 0); + }); +}