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:
@@ -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? ?? '',
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user