feat(board): sticky-note board with backlinks
All checks were successful
CI / Windows build (push) Successful in 20m32s
All checks were successful
CI / Windows build (push) Successful in 20m32s
Wire the F7 双链 + 无限便利贴 model (Board/LinkGraph) into a reachable screen. Previously the model existed but had no UI and no entry point. board_screen.dart: an infinite InteractiveViewer canvas of draggable, editable sticky cards. Card text renders [[links]] as tappable chips that pan to the target card (dangling links styled apart). A backlinks panel lists "linked from" via backlinksOf. "Add card" FAB drops a card at the viewport center. Persistence: a board_cards table (DB v7), one row per card, debounced 800ms like the ink editors, loaded on open — boards survive restart. Entry added to the home screen app bar (dashboard_customize icon). Ink-on-cards, multi-board management and link autocomplete are deferred (TODO board-ink / board-multi / board-link-autocomplete). analyze clean, 285 tests green.
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 => '关闭';
|
||||
}
|
||||
|
||||
@@ -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": "关闭"
|
||||
}
|
||||
|
||||
650
lib/screens/board_screen.dart
Normal file
650
lib/screens/board_screen.dart
Normal file
@@ -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<BoardScreen> createState() => _BoardScreenState();
|
||||
}
|
||||
|
||||
class _BoardScreenState extends State<BoardScreen> {
|
||||
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<void> _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<void> _confirmDelete(String id) async {
|
||||
final l = AppLocalizations.of(context);
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<String> onTextChanged;
|
||||
final ValueChanged<Offset> onMoved;
|
||||
final VoidCallback onDelete;
|
||||
final ValueChanged<String> 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<String> onTapLink;
|
||||
final Color baseColor;
|
||||
|
||||
static final RegExp _linkPattern = RegExp(r'\[\[([^\]]*)\]\]');
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final spans = <InlineSpan>[];
|
||||
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<String> 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', ' ');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> resetForTest() async {
|
||||
final existing = _instance;
|
||||
_instance = null;
|
||||
if (existing != null) {
|
||||
await existing._database.close();
|
||||
}
|
||||
}
|
||||
|
||||
Database get database => _database;
|
||||
|
||||
Future<void> _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<void> _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<void> _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<void> _migrateV6toV7(Database db) async {
|
||||
await db.transaction((txn) async {
|
||||
await _createBoardCardsTable(txn);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _migrateV5toV6(Database db) async {
|
||||
@@ -897,4 +944,63 @@ class DatabaseService {
|
||||
.map((s) => InkStroke.fromJson(s as Map<String, dynamic>))
|
||||
.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<void> saveBoardCards(
|
||||
String boardId,
|
||||
List<BoardCard> 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<Board> 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? ?? '',
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
150
test/board_screen_test.dart
Normal file
150
test/board_screen_test.dart
Normal file
@@ -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<String?> getApplicationDocumentsPath() async => dir;
|
||||
}
|
||||
|
||||
Future<void> _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<void>.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<void>.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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user