import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:uuid/uuid.dart'; import '../diagnostics/badnote_log.dart'; import '../editor/board/board.dart'; import '../l10n/app_localizations.dart'; import '../services/database_service.dart'; import '../theme/app_theme.dart'; const _kDefaultBoardId = 'main'; /// Infinite sticky-note board — first-class shell destination (F7). class BoardScreen extends ConsumerStatefulWidget { const BoardScreen({super.key}); @override ConsumerState createState() => _BoardScreenState(); } class _BoardScreenState extends ConsumerState { Board _board = Board.empty; bool _loading = true; String? _selectedId; final _transform = TransformationController(); Timer? _saveDebounce; @override void initState() { super.initState(); _load(); } @override void dispose() { _saveDebounce?.cancel(); _transform.dispose(); super.dispose(); } Future _load() async { final db = await DatabaseService.getInstance(); final board = await db.loadBoard(_kDefaultBoardId); if (!mounted) return; setState(() { _board = board; _loading = false; }); BadNoteLog.instance.info( LogSubsystem.board, 'board_loaded', fields: {'cards': board.length}, ); } void _scheduleSave() { _saveDebounce?.cancel(); _saveDebounce = Timer(const Duration(milliseconds: 400), () async { final db = await DatabaseService.getInstance(); await db.saveBoardCards(_kDefaultBoardId, _board.cards); BadNoteLog.instance.debug( LogSubsystem.board, 'board_saved', fields: {'cards': _board.length}, ); }); } void _addCard() { final l = AppLocalizations.of(context); final id = const Uuid().v4(); // Place near viewport center in scene coords. final matrix = _transform.value; final inv = Matrix4.inverted(matrix); final center = MatrixUtils.transformPoint( inv, Offset( MediaQuery.sizeOf(context).width / 2, MediaQuery.sizeOf(context).height / 2, ), ); setState(() { _board = _board.add( BoardCard( id: id, position: center - const Offset(120, 80), size: const Size(240, 160), text: l.boardNewCardText, ), ); _selectedId = id; }); _scheduleSave(); } void _deleteSelected() { final id = _selectedId; if (id == null) return; final l = AppLocalizations.of(context); 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.boardDeleteCard), ), ], ), ).then((ok) { if (ok != true) return; setState(() { _board = _board.removeById(id); _selectedId = null; }); _scheduleSave(); }); } @override Widget build(BuildContext context) { final l = AppLocalizations.of(context); if (_loading) { return const Center(child: CircularProgressIndicator()); } final selected = _selectedId != null ? _board.cardById(_selectedId!) : null; final backlinks = selected != null ? _board.backlinksOf(selected.id) : {}; return Scaffold( backgroundColor: Theme.of(context).colorScheme.surface, appBar: AppBar( title: Text(l.boardTitle), actions: [ IconButton( tooltip: l.boardAddCard, onPressed: _addCard, icon: const Icon(Icons.add), ), if (_selectedId != null) IconButton( tooltip: l.boardDeleteCard, onPressed: _deleteSelected, icon: const Icon(Icons.delete_outline), ), ], ), body: Row( children: [ Expanded( child: InteractiveViewer( transformationController: _transform, constrained: false, boundaryMargin: const EdgeInsets.all(2000), minScale: 0.25, maxScale: 3, child: SizedBox( width: 4000, height: 3000, child: CustomPaint( painter: _BoardGridPainter( color: AppTokens.rule.withValues(alpha: 0.45), ), child: Stack( children: [ for (final card in _board.cards) Positioned( left: card.position.dx, top: card.position.dy, width: card.size.width, height: card.size.height, child: _StickyCard( card: card, selected: card.id == _selectedId, onTap: () => setState(() => _selectedId = card.id), onDrag: (delta) { setState(() { _board = _board.moveCard( card.id, card.position + delta, ); }); _scheduleSave(); }, onTextChanged: (text) { setState(() { _board = _board.setText(card.id, text); }); _scheduleSave(); }, ), ), ], ), ), ), ), ), if (selected != null) SizedBox( width: 260, child: Material( elevation: 1, color: Theme.of(context).colorScheme.surface, child: Padding( padding: const EdgeInsets.all(AppTokens.chromePad), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l.boardBacklinks, style: Theme.of(context).textTheme.titleSmall, ), const SizedBox(height: 8), if (backlinks.isEmpty) Text( l.boardNoBacklinks, style: Theme.of(context).textTheme.bodySmall, ) else ...backlinks.map((id) { final c = _board.cardById(id); return ListTile( dense: true, contentPadding: EdgeInsets.zero, title: Text(c?.text.split('\n').first ?? id), onTap: () => setState(() => _selectedId = id), ); }), const Divider(), Text( '[[links]]', style: Theme.of(context).textTheme.labelMedium, ), const SizedBox(height: 4), Text( '在便利贴正文里写 [[另一张卡片id]] 建立双链', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: AppTokens.inkMuted, ), ), ], ), ), ), ), ], ), floatingActionButton: FloatingActionButton.extended( onPressed: _addCard, icon: const Icon(Icons.sticky_note_2), label: Text(l.boardAddCard), ), ); } } class _StickyCard extends StatefulWidget { const _StickyCard({ required this.card, required this.selected, required this.onTap, required this.onDrag, required this.onTextChanged, }); final BoardCard card; final bool selected; final VoidCallback onTap; final ValueChanged onDrag; final ValueChanged onTextChanged; @override State<_StickyCard> createState() => _StickyCardState(); } class _StickyCardState extends State<_StickyCard> { late final TextEditingController _controller = TextEditingController(text: widget.card.text); @override void didUpdateWidget(covariant _StickyCard oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.card.text != widget.card.text && _controller.text != widget.card.text) { _controller.text = widget.card.text; } } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return GestureDetector( onTap: widget.onTap, onPanUpdate: (d) => widget.onDrag(d.delta), child: AnimatedContainer( duration: const Duration(milliseconds: 120), padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: AppTokens.sticky, borderRadius: BorderRadius.circular(AppTokens.radiusSm), border: Border.all( color: widget.selected ? AppTokens.copper : AppTokens.rule, width: widget.selected ? 2 : 1, ), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.08), blurRadius: 8, offset: const Offset(0, 3), ), ], ), child: TextField( controller: _controller, maxLines: null, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: AppTokens.ink, ), decoration: const InputDecoration( border: InputBorder.none, isDense: true, contentPadding: EdgeInsets.zero, ), onChanged: widget.onTextChanged, ), ), ); } } class _BoardGridPainter extends CustomPainter { _BoardGridPainter({required this.color}); final Color color; @override void paint(Canvas canvas, Size size) { final paint = Paint() ..color = color ..strokeWidth = 1; const step = 48.0; for (double x = 0; x < size.width; x += step) { canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint); } for (double y = 0; y < size.height; y += step) { canvas.drawLine(Offset(0, y), Offset(size.width, y), paint); } } @override bool shouldRepaint(covariant _BoardGridPainter oldDelegate) => oldDelegate.color != color; }