// 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', ' '); } }