Fix bugs across app + server, optimize UI/UX, add Gitea CI
Bug fixes (Flutter): - Wrap multi-statement DB writes (insert/update/delete note, deleteDocument, deletePageData, OCR FTS merge, migrations) in transactions to prevent data loss on interruption and a read-modify-write FTS race. - Fix PdfDocument leaks on exception (try/finally dispose) and preserve image aspect ratio when stamping images onto PDF pages. - Guard file-picker against empty selection (was .single -> crash). - Fix eraser ConcurrentModificationError and unmodifiable-list crash on PDF pages; capture page synchronously on save to stop wrong-page data loss. - Fix Riverpod DB-not-ready races, broken pull-to-refresh, settings load race, and search N+1; transform stored annotations on PDF page rotation. - Normalize pen pressure for devices without a pressure range. - PPT: single source of truth for slide strokes so ink displays and exports. UI/UX: - Material 3 typography, theme-aware colors (dark-mode fixes), hover cursors and right-click/visible actions on desktop, keyboard shortcuts (undo/redo/ save/find), toolbar overflow handling, friendlier empty states, semantic OCR status badges, relative timestamps, 1-based page indicators, large-deck PPT navigation, and a scratchpad-scope label in split view. Server (optional backend): - Persist JWT secret (was per-process random), block path traversal in storage, fix CORS '*'+credentials, add OCR job ownership checks, last-writer-wins sync guard, constant-time login, and split out heavy OCR deps so the API/tests run without them. CI: Gitea workflows for format+analyze+test (Linux, system sqlite) and a Windows release build; pristine `flutter analyze`, all Flutter and server tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
469
lib/screens/split_view_screen.dart
Normal file
469
lib/screens/split_view_screen.dart
Normal file
@@ -0,0 +1,469 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
||||
|
||||
import '../models/ink_stroke.dart';
|
||||
import '../models/pen_tool.dart';
|
||||
import '../models/pressure_curve.dart';
|
||||
import '../services/database_service.dart';
|
||||
import '../services/undo_manager.dart';
|
||||
import '../utils/stroke_stabilizer.dart';
|
||||
import '../widgets/annotation_toolbar.dart';
|
||||
import '../widgets/ink_canvas.dart';
|
||||
|
||||
/// Split-view derivation mode: left pane = reference PDF, right pane = infinite
|
||||
/// scratchpad for formula derivation. Scratchpad strokes are persisted per
|
||||
/// document via [DatabaseService.saveScratchpad] / [DatabaseService.loadScratchpad].
|
||||
class SplitViewScreen extends StatefulWidget {
|
||||
final String filePath;
|
||||
final String documentId;
|
||||
|
||||
const SplitViewScreen({
|
||||
super.key,
|
||||
required this.filePath,
|
||||
required this.documentId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SplitViewScreen> createState() => _SplitViewState();
|
||||
}
|
||||
|
||||
class _SplitViewState extends State<SplitViewScreen> {
|
||||
// -- PDF (left pane) --
|
||||
final PdfViewerController _pdfController = PdfViewerController();
|
||||
int _currentPage = 0;
|
||||
int _pageCount = 0;
|
||||
String _fileName = '';
|
||||
|
||||
// -- Split divider --
|
||||
double _leftPaneFraction = 0.5;
|
||||
bool _isDraggingDivider = false;
|
||||
|
||||
// -- Scratchpad (right pane) --
|
||||
final UndoManager _undoManager = UndoManager();
|
||||
List<InkStroke> _strokes = [];
|
||||
double _canvasWidth = 4000;
|
||||
double _canvasHeight = 4000;
|
||||
|
||||
// -- Tool state --
|
||||
PenTool _currentTool = PenTool.pen;
|
||||
Color _currentColor = Colors.black;
|
||||
double _currentStrokeWidth = 2.0;
|
||||
bool _filled = false;
|
||||
PressureCurveType _pressureCurveType = PressureCurveType.linear;
|
||||
StabilizationLevel _stabilizationLevel = StabilizationLevel.none;
|
||||
|
||||
// -- Auto-save debounce --
|
||||
Timer? _saveTimer;
|
||||
bool _dirty = false;
|
||||
|
||||
// -- Page link markers (optional feature) --
|
||||
final List<_PageLink> _pageLinks = [];
|
||||
|
||||
static const double _edgeThreshold = 200.0;
|
||||
static const double _expandAmount = 1000.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadScratchpad();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_saveTimer?.cancel();
|
||||
_saveImmediate();
|
||||
_pdfController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// -- Persistence --
|
||||
|
||||
Future<void> _loadScratchpad() async {
|
||||
final db = await DatabaseService.getInstance();
|
||||
final strokes = await db.loadScratchpad(widget.documentId);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_strokes = strokes;
|
||||
for (final s in strokes) {
|
||||
_undoManager.addStroke(s);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleSave() {
|
||||
_dirty = true;
|
||||
_saveTimer?.cancel();
|
||||
_saveTimer = Timer(const Duration(seconds: 3), _saveImmediate);
|
||||
}
|
||||
|
||||
Future<void> _saveImmediate() async {
|
||||
if (!_dirty) return;
|
||||
_dirty = false;
|
||||
final db = await DatabaseService.getInstance();
|
||||
final json = jsonEncode(_strokes.map((s) => s.toJson()).toList());
|
||||
await db.saveScratchpad(widget.documentId, json);
|
||||
}
|
||||
|
||||
// -- Scratchpad stroke callbacks --
|
||||
|
||||
void _onStrokeComplete(InkStroke stroke) {
|
||||
setState(() {
|
||||
_strokes.add(stroke);
|
||||
_undoManager.addStroke(stroke);
|
||||
_checkCanvasExpansion(stroke);
|
||||
});
|
||||
_scheduleSave();
|
||||
}
|
||||
|
||||
void _onErase(String strokeId, List<InkStroke> replacements) {
|
||||
setState(() {
|
||||
final original = _strokes.where((s) => s.id == strokeId).firstOrNull;
|
||||
if (original != null) {
|
||||
_undoManager.removeStroke(original, replacements: replacements);
|
||||
_strokes = List.from(_undoManager.currentStrokes);
|
||||
}
|
||||
});
|
||||
_scheduleSave();
|
||||
}
|
||||
|
||||
void _undo() {
|
||||
setState(() {
|
||||
_undoManager.undo();
|
||||
_strokes = List.from(_undoManager.currentStrokes);
|
||||
});
|
||||
_scheduleSave();
|
||||
}
|
||||
|
||||
void _redo() {
|
||||
setState(() {
|
||||
_undoManager.redo();
|
||||
_strokes = List.from(_undoManager.currentStrokes);
|
||||
});
|
||||
_scheduleSave();
|
||||
}
|
||||
|
||||
// -- Auto-expand canvas --
|
||||
|
||||
void _checkCanvasExpansion(InkStroke stroke) {
|
||||
double maxRight = 0;
|
||||
double maxBottom = 0;
|
||||
for (final p in stroke.points) {
|
||||
if (p.x > maxRight) maxRight = p.x;
|
||||
if (p.y > maxBottom) maxBottom = p.y;
|
||||
}
|
||||
bool expanded = false;
|
||||
if (maxRight > _canvasWidth - _edgeThreshold) {
|
||||
_canvasWidth += _expandAmount;
|
||||
expanded = true;
|
||||
}
|
||||
if (maxBottom > _canvasHeight - _edgeThreshold) {
|
||||
_canvasHeight += _expandAmount;
|
||||
expanded = true;
|
||||
}
|
||||
if (expanded) setState(() {});
|
||||
}
|
||||
|
||||
// -- Divider drag --
|
||||
|
||||
void _onDividerDragStart(DragStartDetails details) {
|
||||
setState(() => _isDraggingDivider = true);
|
||||
}
|
||||
|
||||
void _onDividerDragUpdate(
|
||||
DragUpdateDetails details,
|
||||
BoxConstraints constraints,
|
||||
) {
|
||||
final renderWidth = constraints.maxWidth;
|
||||
if (renderWidth <= 0) return;
|
||||
final delta = details.delta.dx / renderWidth;
|
||||
setState(() {
|
||||
_leftPaneFraction = (_leftPaneFraction + delta).clamp(0.2, 0.8);
|
||||
});
|
||||
}
|
||||
|
||||
void _onDividerDragEnd(DragEndDetails details) {
|
||||
setState(() => _isDraggingDivider = false);
|
||||
}
|
||||
|
||||
// -- PDF page navigation --
|
||||
|
||||
void _prevPage() {
|
||||
if (_currentPage > 0) {
|
||||
_pdfController.previousPage();
|
||||
}
|
||||
}
|
||||
|
||||
void _nextPage() {
|
||||
if (_currentPage < _pageCount - 1) {
|
||||
_pdfController.nextPage();
|
||||
}
|
||||
}
|
||||
|
||||
// -- Page link creation (long-press on left pane) --
|
||||
|
||||
void _onPdfLongPress(int pageNumber) {
|
||||
// Place a page link marker at the current scratchpad viewport center.
|
||||
// We approximate the viewport center as (0, 0) since InteractiveViewer
|
||||
// manages its own transform — the user can reposition by panning.
|
||||
setState(() {
|
||||
_pageLinks.add(
|
||||
_PageLink(
|
||||
pageNumber: pageNumber,
|
||||
position: const Offset(100, 100), // default top-left area
|
||||
),
|
||||
);
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Page link marker added for page $pageNumber')),
|
||||
);
|
||||
}
|
||||
|
||||
void _onPageLinkTap(_PageLink link) {
|
||||
_pdfController.jumpToPage(link.pageNumber);
|
||||
setState(() {
|
||||
_currentPage = link.pageNumber - 1;
|
||||
});
|
||||
}
|
||||
|
||||
void _deletePageLink(_PageLink link) {
|
||||
setState(() {
|
||||
_pageLinks.remove(link);
|
||||
});
|
||||
}
|
||||
|
||||
// -- Build --
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
_fileName.isEmpty ? 'Split View' : _fileName,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () {
|
||||
_saveImmediate();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
// Left pane page navigation
|
||||
IconButton(
|
||||
icon: const Icon(Icons.navigate_before),
|
||||
tooltip: 'Previous page (PDF)',
|
||||
onPressed: _currentPage > 0 ? _prevPage : null,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${_currentPage + 1} / $_pageCount',
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.navigate_next),
|
||||
tooltip: 'Next page (PDF)',
|
||||
onPressed: _currentPage < _pageCount - 1 ? _nextPage : null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Canvas info
|
||||
Tooltip(
|
||||
message:
|
||||
'Scratchpad size: ${_canvasWidth.round()} x ${_canvasHeight.round()}',
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${_canvasWidth.round()}x${_canvasHeight.round()}',
|
||||
style: const TextStyle(fontSize: 11, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Label clarifying that the toolbar controls the scratchpad pane.
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 12, top: 4),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'Scratchpad tools',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Toolbar (applies to scratchpad only)
|
||||
AnnotationToolbar(
|
||||
currentTool: _currentTool,
|
||||
currentColor: _currentColor,
|
||||
currentStrokeWidth: _currentStrokeWidth,
|
||||
filled: _filled,
|
||||
pressureCurveType: _pressureCurveType,
|
||||
stabilizationLevel: _stabilizationLevel,
|
||||
canUndo: _undoManager.canUndo,
|
||||
canRedo: _undoManager.canRedo,
|
||||
onToolChanged: (tool) => setState(() => _currentTool = tool),
|
||||
onColorChanged: (color) => setState(() => _currentColor = color),
|
||||
onStrokeWidthChanged: (w) =>
|
||||
setState(() => _currentStrokeWidth = w),
|
||||
onFilledChanged: (f) => setState(() => _filled = f),
|
||||
onPressureCurveChanged: (v) =>
|
||||
setState(() => _pressureCurveType = v),
|
||||
onStabilizationChanged: (v) =>
|
||||
setState(() => _stabilizationLevel = v),
|
||||
onUndo: _undo,
|
||||
onRedo: _redo,
|
||||
),
|
||||
// Split view body
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final totalWidth = constraints.maxWidth;
|
||||
final leftWidth = totalWidth * _leftPaneFraction;
|
||||
final rightWidth =
|
||||
totalWidth - leftWidth - 12; // 12px divider hit area
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
// Left pane: PDF reference (read-only)
|
||||
SizedBox(width: leftWidth, child: _buildPdfPane()),
|
||||
// Draggable divider: 12px hit area, 4px visual strip.
|
||||
GestureDetector(
|
||||
onHorizontalDragStart: _onDividerDragStart,
|
||||
onHorizontalDragUpdate: (d) =>
|
||||
_onDividerDragUpdate(d, constraints),
|
||||
onHorizontalDragEnd: _onDividerDragEnd,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.resizeColumn,
|
||||
child: SizedBox(
|
||||
width: 12,
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 4,
|
||||
color: _isDraggingDivider
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).dividerColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Right pane: Infinite scratchpad
|
||||
SizedBox(width: rightWidth, child: _buildScratchpadPane()),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPdfPane() {
|
||||
return Stack(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onLongPress: () {
|
||||
// Long-press on PDF to create page link marker
|
||||
_onPdfLongPress(_currentPage + 1);
|
||||
},
|
||||
child: SfPdfViewer.file(
|
||||
File(widget.filePath),
|
||||
controller: _pdfController,
|
||||
canShowScrollHead: true,
|
||||
canShowScrollStatus: true,
|
||||
onPageChanged: (PdfPageChangedDetails details) {
|
||||
setState(() {
|
||||
_currentPage = details.newPageNumber - 1;
|
||||
});
|
||||
},
|
||||
onDocumentLoaded: (PdfDocumentLoadedDetails details) {
|
||||
setState(() {
|
||||
_pageCount = details.document.pages.count;
|
||||
_fileName = widget.filePath.split(Platform.pathSeparator).last;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
// Page link markers overlay (on PDF pane, showing linked pages)
|
||||
if (_pageLinks.isNotEmpty)
|
||||
Positioned(bottom: 8, left: 8, child: _buildPageLinkChips()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPageLinkChips() {
|
||||
return Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: _pageLinks.map((link) {
|
||||
return GestureDetector(
|
||||
onTap: () => _onPageLinkTap(link),
|
||||
onLongPress: () => _deletePageLink(link),
|
||||
child: Chip(
|
||||
avatar: const Icon(Icons.link, size: 14, color: Colors.white),
|
||||
label: Text(
|
||||
'p${link.pageNumber}',
|
||||
style: const TextStyle(fontSize: 11, color: Colors.white),
|
||||
),
|
||||
backgroundColor: Colors.blue.shade600,
|
||||
padding: EdgeInsets.zero,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScratchpadPane() {
|
||||
return Container(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: InteractiveViewer(
|
||||
constrained: false,
|
||||
minScale: 0.25,
|
||||
maxScale: 8.0,
|
||||
boundaryMargin: const EdgeInsets.all(double.infinity),
|
||||
child: SizedBox(
|
||||
width: _canvasWidth,
|
||||
height: _canvasHeight,
|
||||
child: InkCanvas(
|
||||
strokes: _strokes,
|
||||
onStrokeComplete: _onStrokeComplete,
|
||||
onErase: _onErase,
|
||||
tool: _currentTool,
|
||||
color: _currentColor,
|
||||
strokeWidth: _currentStrokeWidth,
|
||||
pressureCurve: PressureCurve(type: _pressureCurveType),
|
||||
stabilizationLevel: _stabilizationLevel,
|
||||
filled: _filled,
|
||||
interactionMode: InteractionMode.draw,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A marker linking a scratchpad position to a specific PDF page.
|
||||
class _PageLink {
|
||||
final int pageNumber;
|
||||
final Offset position;
|
||||
|
||||
const _PageLink({required this.pageNumber, required this.position});
|
||||
}
|
||||
Reference in New Issue
Block a user