feat(board): sticky-note board with backlinks
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:
2026-06-24 17:15:39 +08:00
parent 0feca74278
commit f757701391
9 changed files with 1080 additions and 3 deletions

View File

@@ -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? ?? '',
),
]);
}
}