dart_pdf_editor 1.1.0
dart_pdf_editor: ^1.1.0 copied to clipboard
Flutter PDF viewer and editor rendered natively in Dart — zooming viewer, text selection and search, annotation authoring, form filling, signatures, and page management. No platform plugins.
import 'dart:async';
import 'package:file_selector/file_selector.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pdf_document/pdf_document.dart';
import 'package:dart_pdf_editor/dart_pdf_editor.dart';
import 'package:pdf_ocr_vlm/pdf_ocr_vlm.dart';
import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher.dart';
import 'demo_document.dart';
/// The project's source repository, opened from the AppBar links menu.
final _githubUrl = Uri.parse('https://github.com/ben-milanko/dart-pdf');
/// The published Flutter package the example is built on.
final _pubDevUrl = Uri.parse('https://pub.dev/packages/dart_pdf_editor');
/// One filter, every platform: desktop and web match on the extension,
/// Android on the MIME type, iOS/macOS on the uniform type identifier —
/// a type group missing the field a platform filters by throws there.
const _pdfTypeGroup = XTypeGroup(
label: 'PDF documents',
extensions: ['pdf'],
mimeTypes: ['application/pdf'],
uniformTypeIdentifiers: ['com.adobe.pdf'],
);
/// Images the form tool's push-button fill accepts.
const _imageTypeGroup = XTypeGroup(
label: 'Images',
extensions: ['png', 'jpg', 'jpeg'],
mimeTypes: ['image/png', 'image/jpeg'],
uniformTypeIdentifiers: ['public.png', 'public.jpeg'],
);
/// The form tool's image picker: tapped push-button fields (signature
/// and logo slots in templates) fill with the chosen PNG or JPEG.
Future<Uint8List?> _pickFormImage(BuildContext context, PdfFormField field) =>
openFile(acceptedTypeGroups: const [_imageTypeGroup])
.then((file) => file?.readAsBytes());
/// The image tool's picker: inserts the chosen PNG or JPEG as a stamp
/// annotation the user can move, resize, and rotate.
Future<Uint8List?> _pickImage(BuildContext context) =>
openFile(acceptedTypeGroups: const [_imageTypeGroup])
.then((file) => file?.readAsBytes());
void main() {
// On web, point the render worker at its compiled script so the heavy page
// interpretation + image decode run in a dedicated Web Worker instead of on
// the UI thread (the deploy workflow compiles it with
// `dart run dart_pdf_editor:build_web_worker`). With no script present the
// worker degrades to local rendering, so this is safe before a worker build.
if (kIsWeb) {
pdfRenderWorkerScriptUrl = 'pdf_render_worker.dart.js';
}
runApp(const ViewerApp());
}
class ViewerApp extends StatefulWidget {
const ViewerApp({super.key});
@override
State<ViewerApp> createState() => _ViewerAppState();
}
class _ViewerAppState extends State<ViewerApp> {
/// UI preferences saved on this device — tool styles, which panels
/// are open, and the theme mode. Owned here so the MaterialApp can
/// follow the persisted light/dark choice; the screen below shares
/// the same instance with every editing session.
final _prefs = PdfEditingPreferences();
@override
void dispose() {
_prefs.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _prefs,
builder: (context, _) => MaterialApp(
title: 'dart-pdf viewer',
theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
darkTheme: ThemeData(
colorSchemeSeed: Colors.indigo,
brightness: Brightness.dark,
useMaterial3: true,
),
themeMode: _prefs.themeMode,
home: ViewerScreen(prefs: _prefs),
),
);
}
}
class ViewerScreen extends StatefulWidget {
const ViewerScreen({super.key, required this.prefs});
final PdfEditingPreferences prefs;
@override
State<ViewerScreen> createState() => _ViewerScreenState();
}
class _ViewerScreenState extends State<ViewerScreen> {
PdfEditingPreferences get _prefs => widget.prefs;
/// One entry per open document. Each tab owns its own edit session and
/// viewer controller, so switching tabs preserves each document's
/// edits, undo history, and any demo-specific state.
final List<_DocumentTab> _tabs = [];
int _activeIndex = 0;
_DocumentTab? get _active =>
_tabs.isEmpty ? null : _tabs[_activeIndex.clamp(0, _tabs.length - 1)];
/// Demo of the two drop-in widgets: the toggle swaps the full
/// [PdfEditorView] for the view-only [PdfReader]. App-wide.
bool _readOnly = false;
/// OCR connection settings, supplied through the credentials dialog and
/// remembered for the app's lifetime (the API key is deliberately kept in
/// memory only — the example never writes a secret to disk). Defaults to a
/// local vLLM/dots.ocr server; see the pdf_ocr_vlm README to run one.
String _ocrEndpoint = 'http://localhost:8000/v1/chat/completions';
String _ocrModel = 'model';
String? _ocrApiKey;
/// GoTo and the standard named page actions never get here (the viewer
/// follows them itself). Custom-scheme URIs are dispatched as app
/// commands — the conventional way a PDF drives its host app — and
/// anything else just gets described in a snackbar.
void _onAction(PdfAction action, PdfAnnotation annotation) {
final tab = _active;
if (action is PdfUriAction) {
final uri = Uri.tryParse(action.uri);
if (uri?.scheme == 'app') {
switch (uri!.host) {
case 'counter':
if (tab != null) setState(() => tab.counter++);
return;
case 'message':
_toast(uri.queryParameters['text'] ?? 'No message');
return;
}
}
}
_toast(switch (action) {
PdfUriAction(:final uri) => 'Link: $uri',
PdfJavaScriptAction(:final script) =>
'JavaScript surfaced to the app: $script',
PdfNamedAction(:final name) => 'Named action: $name',
PdfUnknownAction(:final type) => 'Unhandled action type: $type',
PdfGoToAction() => 'GoTo', // unreachable
});
}
/// The app's own entries in the annotation right-click menu — here a
/// "Copy text" action when the clicked annotation carries any.
List<PdfAnnotationMenuItem> _annotationMenuActions(
BuildContext context, PdfAnnotationMenuRequest request) {
final contents = request.primary?.contents;
if (contents == null || contents.isEmpty) return const [];
return [
PdfAnnotationMenuItem(
label: 'Copy text',
icon: Icons.copy_outlined,
onSelected: (request) {
Clipboard.setData(ClipboardData(text: contents));
_toast('Annotation text copied');
},
),
];
}
Future<void> _openLink(Uri url) async {
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
_toast('Could not open $url');
}
}
void _toast(String message) {
// Floating in the bottom-right corner on desktop, so toasts stay a
// compact pill off to the side and never cover the chrome; a
// near-full-width pill on narrow windows. pdfFloatingToastMargin lifts
// it clear of the editing toolbar dock and the device safe-area inset.
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(SnackBar(
content: Text(message),
behavior: SnackBarBehavior.floating,
margin: pdfFloatingToastMargin(context),
duration: const Duration(seconds: 2),
));
}
/// Opens [bytes] in a brand-new tab and makes it the active one.
void _openBytes(Uint8List bytes, String title, {bool isDemo = false}) {
setState(() {
_tabs.add(_DocumentTab.document(
title: title,
bytes: bytes,
preferences: _prefs,
isDemo: isDemo,
));
_activeIndex = _tabs.length - 1;
});
}
/// Adds a tab that just reports an open failure.
void _openError(String title, String error) {
setState(() {
_tabs.add(_DocumentTab.error(title: title, error: error));
_activeIndex = _tabs.length - 1;
});
}
void _openDemo() =>
_openBytes(buildDemoPdf(), 'Feature showcase', isDemo: true);
/// Disposes the tab at [index] and drops it, keeping a sensible tab
/// active. The controllers are torn down after the frame so the
/// outgoing viewer can detach from them cleanly first.
void _closeTab(int index) {
final tab = _tabs[index];
setState(() {
_tabs.removeAt(index);
if (_activeIndex >= _tabs.length) _activeIndex = _tabs.length - 1;
if (_activeIndex < 0) _activeIndex = 0;
});
WidgetsBinding.instance.addPostFrameCallback((_) => tab.dispose());
}
/// Pins [child] into a page slot at its design size in PDF points and
/// lets it scale with the page, so the overlays hold together at any
/// zoom level and on any screen size.
Widget _slot(PdfPageGeometry geometry, PdfRect rect, Widget child) =>
Positioned.fromRect(
rect: geometry.toViewRect(rect),
child: FittedBox(
child: SizedBox(width: rect.width, height: rect.height, child: child),
),
);
/// Flutter widgets pinned into the slots the demo document draws.
List<Widget> _demoOverlays(
BuildContext context, int pageIndex, PdfPageGeometry geometry) {
final tab = _active;
if (tab == null) return const [];
switch (pageIndex) {
case 0:
return [
_slot(geometry, DemoLayout.counterBadge,
_CounterBadge(count: tab.counter)),
];
case 1:
return [
_slot(geometry, DemoLayout.clock, const _ClockTile()),
_slot(
geometry,
DemoLayout.counter,
_CounterControl(
count: tab.counter,
onChanged: (value) => setState(() => tab.counter = value),
),
),
_slot(
geometry,
DemoLayout.toggle,
FittedBox(
child: Switch(
value: tab.switchOn,
onChanged: (value) => setState(() => tab.switchOn = value),
),
),
),
_slot(
geometry,
DemoLayout.note,
Material(
color: const Color(0xF2FFFFFF),
shape: RoundedRectangleBorder(
side: BorderSide(color: Colors.indigo.shade200),
borderRadius: BorderRadius.circular(4),
),
child: TextField(
key: const ValueKey('demo-note'),
controller: tab.noteField,
decoration: const InputDecoration(
hintText: 'Type here - this text box floats above the page',
isDense: true,
contentPadding: EdgeInsets.all(10),
border: InputBorder.none,
),
),
),
),
];
default:
return const [];
}
}
@override
void initState() {
super.initState();
// open a file straight away with:
// flutter run -d macos --dart-define=PDF=/path/to/file.pdf
const preset = String.fromEnvironment('PDF');
if (preset.isNotEmpty) {
_openPath(preset);
} else {
_openDemo();
}
}
@override
void dispose() {
for (final tab in _tabs) {
tab.dispose();
}
super.dispose();
}
Future<void> _pickFile() async {
final file = await openFile(acceptedTypeGroups: const [_pdfTypeGroup]);
if (file == null) return;
try {
_openBytes(await file.readAsBytes(), file.name);
} catch (e) {
_openError(file.name, 'Could not open ${file.name}\n$e');
}
}
/// Picks a PDF and returns its bytes (null when cancelled) — the source
/// for the editor's "Insert PDF…" action.
Future<Uint8List?> _pickPdfBytes() async {
final file = await openFile(acceptedTypeGroups: const [_pdfTypeGroup]);
return file?.readAsBytes();
}
/// Opens a second PDF and compares it against the active document in a
/// new tab ([PdfComparisonView]). The active document is the "before".
Future<void> _compareWith() async {
final tab = _active;
final current = tab?.session?.bytes;
if (current == null) return;
final file = await openFile(acceptedTypeGroups: const [_pdfTypeGroup]);
if (file == null) return;
try {
final other = await file.readAsBytes();
setState(() {
_tabs.add(_DocumentTab.comparison(
title: 'Compare: ${tab!.title} ↔ ${file.name}',
before: current,
after: other,
));
_activeIndex = _tabs.length - 1;
});
} catch (e) {
_openError(file.name, 'Could not open ${file.name}\n$e');
}
}
Future<void> _openPath(String path) async {
final name = path.split(RegExp(r'[/\\]')).last;
try {
_openBytes(await XFile(path).readAsBytes(), name);
} catch (e) {
_openError(name, 'Could not open $path\n$e');
}
}
/// The suggested save name — the active tab's title (the opened file's
/// name, or the demo's title), with a `.pdf` extension guaranteed.
String _saveFileName() {
var name = (_active?.title ?? '').trim();
if (name.isEmpty) name = 'document';
if (!name.toLowerCase().endsWith('.pdf')) name = '$name.pdf';
return name;
}
/// Saves with whatever the platform offers: a save dialog on desktop,
/// a browser download on the web, the share sheet on phones and
/// tablets (where apps can't write outside their sandbox directly).
Future<void> _saveAs(Uint8List bytes) async {
final name = _saveFileName();
final file = XFile.fromData(bytes, mimeType: 'application/pdf');
if (kIsWeb) {
await file.saveTo(name);
_toast('Downloaded $name');
return;
}
switch (defaultTargetPlatform) {
case TargetPlatform.android || TargetPlatform.iOS:
final box = context.findRenderObject() as RenderBox?;
final origin =
box == null ? null : box.localToGlobal(Offset.zero) & box.size;
await SharePlus.instance.share(ShareParams(
files: [file],
fileNameOverrides: [name],
// required on iPad: the share popover anchors to this rect
sharePositionOrigin: origin ?? const Rect.fromLTWH(0, 0, 1, 1),
));
default:
final location = await getSaveLocation(
suggestedName: name,
acceptedTypeGroups: const [_pdfTypeGroup],
);
if (location == null) return;
try {
await file.saveTo(location.path);
_toast('Saved to ${location.path}');
} catch (e) {
_toast('Save failed: $e');
}
}
}
/// Adds an invisible, selectable/searchable OCR text layer over the
/// active document using a self-hosted vision-language OCR model
/// (pdf_ocr_vlm). Prompts for the service endpoint and an optional API
/// key/token first, then runs every page and opens the result in a new
/// tab. The original is left untouched.
Future<void> _runOcr() async {
final tab = _active;
final bytes = tab?.session?.bytes;
if (tab == null || bytes == null) {
_toast('Open a document before running OCR');
return;
}
// Supply / confirm the OCR service credentials.
final settings = await showDialog<_OcrSettings>(
context: context,
builder: (_) => _OcrSettingsDialog(
endpoint: _ocrEndpoint,
model: _ocrModel,
apiKey: _ocrApiKey,
onOpenDocs: () => _openLink(Uri.parse(
'https://github.com/ben-milanko/dart-pdf/tree/main/packages/pdf_ocr_vlm')),
),
);
if (settings == null) return; // cancelled
setState(() {
_ocrEndpoint = settings.endpoint;
_ocrModel = settings.model;
_ocrApiKey = settings.apiKey;
});
final progress = ValueNotifier<String>('Preparing…');
if (!mounted) return;
unawaited(showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => _OcrProgressDialog(progress: progress),
));
final engine = VlmOcrEngine.dotsOcr(
endpoint: Uri.parse(settings.endpoint),
model: settings.model.isEmpty ? 'model' : settings.model,
apiKey: (settings.apiKey?.isNotEmpty ?? false) ? settings.apiKey : null,
);
try {
final editor = PdfEditor(PdfDocument.open(bytes));
final count = editor.document.pageCount;
var spans = 0;
for (var i = 0; i < count; i++) {
progress.value = 'Recognising page ${i + 1} of $count…';
spans += await editor.applyOcr(i, engine, pixelRatio: 2);
}
final result = editor.save();
if (!mounted) return;
Navigator.of(context, rootNavigator: true).pop(); // dismiss progress
_openBytes(result, '${tab.title} (OCR)');
_toast('OCR added $spans text spans — the page text is now selectable');
} on VlmOcrException catch (e) {
if (!mounted) return;
Navigator.of(context, rootNavigator: true).pop();
_toast('OCR failed: ${e.message}');
} catch (e) {
if (!mounted) return;
Navigator.of(context, rootNavigator: true).pop();
_toast('OCR failed: $e');
} finally {
engine.close();
progress.dispose();
}
}
@override
Widget build(BuildContext context) {
final tab = _active;
return Scaffold(
appBar: AppBar(
title: Text(
tab == null || tab.title.isEmpty ? 'dart-pdf viewer' : tab.title,
overflow: TextOverflow.ellipsis),
// a browser-style tab strip under the title; hidden until the
// first document is open
bottom: _tabs.isEmpty
? null
: PreferredSize(
preferredSize: const Size.fromHeight(_tabStripHeight),
child: _buildTabStrip(),
),
actions: [
if (tab?.viewer != null)
ListenableBuilder(
listenable: tab!.viewer!,
builder: (context, _) => !tab.viewer!.hasSelection
? const SizedBox.shrink()
: IconButton(
icon: const Icon(Icons.copy),
tooltip: 'Copy selected text (⌘C)',
onPressed: () async {
await tab.viewer!.copySelection();
if (!context.mounted) return;
_toast('Copied to clipboard');
},
),
),
// every plain action is compact: the row overflows an 800px
// window (the widget-test viewport included) at full density
IconButton(
visualDensity: VisualDensity.compact,
icon: Icon(_readOnly ? Icons.edit_off : Icons.edit),
tooltip: _readOnly
? 'Read-only (PdfReader) — tap to edit'
: 'Editing (PdfEditorView) — tap for read-only',
onPressed: () => setState(() => _readOnly = !_readOnly),
),
ListenableBuilder(
listenable: _prefs,
builder: (context, _) => IconButton(
visualDensity: VisualDensity.compact,
icon: Icon(switch (_prefs.themeMode) {
ThemeMode.system => Icons.brightness_auto,
ThemeMode.light => Icons.light_mode,
ThemeMode.dark => Icons.dark_mode,
}),
tooltip: switch (_prefs.themeMode) {
ThemeMode.system => 'Theme: system — tap for light',
ThemeMode.light => 'Theme: light — tap for dark',
ThemeMode.dark => 'Theme: dark — tap for system',
},
onPressed: () => _prefs.themeMode = switch (_prefs.themeMode) {
ThemeMode.system => ThemeMode.light,
ThemeMode.light => ThemeMode.dark,
ThemeMode.dark => ThemeMode.system,
},
),
),
IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.auto_awesome),
tooltip: 'Open the interactive demo in a new tab',
onPressed: _openDemo,
),
IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.compare_arrows),
tooltip: 'Compare with another PDF…',
onPressed: _compareWith,
),
IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.folder_open),
tooltip: 'Open PDF in a new tab',
onPressed: _pickFile,
),
// Compare + project links share one overflow slot so the action
// row stays inside the 800px test window (every standalone
// button would push it over).
PopupMenuButton<VoidCallback>(
icon: const Icon(Icons.more_vert),
tooltip: 'More actions',
onSelected: (action) => action(),
itemBuilder: (context) => [
PopupMenuItem(
value: () => unawaited(_runOcr()),
enabled: tab?.session != null,
child: const ListTile(
leading: Icon(Icons.document_scanner_outlined),
title: Text('Add OCR text layer…'),
contentPadding: EdgeInsets.zero,
),
),
const PopupMenuDivider(),
PopupMenuItem(
value: () => _openLink(_githubUrl),
child: const ListTile(
leading: Icon(Icons.code),
title: Text('View source on GitHub'),
contentPadding: EdgeInsets.zero,
),
),
PopupMenuItem(
value: () => _openLink(_pubDevUrl),
child: const ListTile(
leading: Icon(Icons.inventory_2_outlined),
title: Text('dart_pdf_editor on pub.dev'),
contentPadding: EdgeInsets.zero,
),
),
],
),
],
),
// each tab is keyed so switching rebuilds against its own
// controllers (which keep the edits and scroll position alive);
// only the active tab is mounted, so there's one viewer at a time
body: tab == null
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
FilledButton.icon(
onPressed: _pickFile,
icon: const Icon(Icons.folder_open),
label: const Text('Open a PDF'),
),
const SizedBox(height: 12),
FilledButton.tonalIcon(
onPressed: _openDemo,
icon: const Icon(Icons.auto_awesome),
label: const Text('Try the interactive demo'),
),
],
),
)
: tab.error != null
? Center(child: Text(tab.error!, textAlign: TextAlign.center))
: tab.isComparison
? PdfComparisonView(
key: ValueKey(tab),
before: tab.compareBefore!,
after: tab.compareAfter!,
)
// the two drop-in widgets carry all the PDF chrome (search,
// page number, panels, toolbar) — the app supplies the edit
// session, its file handling, and the demo's app-side wiring
: _readOnly
? PdfReader(
key: ValueKey(tab),
bytes: tab.session!.bytes,
// a stable id per document so reopening it (across
// app restarts) restores its scroll position and zoom
documentId: tab.title,
controller: tab.viewer,
preferences: _prefs,
onAction: _onAction,
pageOverlayBuilder: tab.isDemo ? _demoOverlays : null,
)
: PdfEditorView(
key: ValueKey(tab),
documentId: tab.title,
controller: tab.session,
viewerController: tab.viewer,
onSave: (saved) => unawaited(_saveAs(saved)),
onPickPdfToInsert: _pickPdfBytes,
onExportPages: (bytes) => unawaited(_saveAs(bytes)),
onAction: _onAction,
pageOverlayBuilder: tab.isDemo ? _demoOverlays : null,
annotationMenuBuilder: _annotationMenuActions,
formImagePicker: _pickFormImage,
imagePicker: _pickImage,
),
);
}
/// The horizontally scrolling row of open-document tabs plus the
/// new-tab button.
Widget _buildTabStrip() {
final scheme = Theme.of(context).colorScheme;
return Material(
color: scheme.surface,
child: SizedBox(
height: _tabStripHeight,
// the new-tab button is the last item in the scrolling row, so it
// always rides immediately after the final tab
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 4),
itemCount: _tabs.length + 1,
itemBuilder: (context, i) => i < _tabs.length
? _buildTab(i)
: IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.add),
tooltip: 'Open PDF in a new tab',
onPressed: _pickFile,
),
),
),
);
}
Widget _buildTab(int index) {
final tab = _tabs[index];
final selected = index == _activeIndex;
final scheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 5),
child: Material(
color: selected
? scheme.secondaryContainer
: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => setState(() => _activeIndex = index),
child: Padding(
padding: const EdgeInsets.only(left: 12, right: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 160),
child: Text(
tab.title.isEmpty ? 'Untitled' : tab.title,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight:
selected ? FontWeight.w600 : FontWeight.normal,
color: selected
? scheme.onSecondaryContainer
: scheme.onSurfaceVariant,
),
),
),
IconButton(
icon: const Icon(Icons.close, size: 16),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints:
const BoxConstraints(minWidth: 30, minHeight: 30),
tooltip: 'Close tab',
onPressed: () => _closeTab(index),
),
],
),
),
),
),
);
}
}
/// Height of the AppBar's tab strip.
const double _tabStripHeight = 42;
/// One open document. Holds its own edit session and viewer controller
/// so switching tabs preserves edits, undo history, scroll position,
/// and any demo-specific overlay state.
class _DocumentTab {
_DocumentTab.document({
required this.title,
required Uint8List bytes,
required PdfEditingPreferences preferences,
this.isDemo = false,
}) : session = PdfEditingController(bytes, preferences: preferences),
viewer = PdfViewerController(),
error = null,
compareBefore = null,
compareAfter = null;
_DocumentTab.error({required this.title, required this.error})
: session = null,
viewer = null,
isDemo = false,
compareBefore = null,
compareAfter = null;
/// A document-comparison tab: hosts a [PdfComparisonView] over two
/// files. No edit session or viewer controller of its own.
_DocumentTab.comparison({
required this.title,
required Uint8List before,
required Uint8List after,
}) : session = null,
viewer = null,
isDemo = false,
error = null,
compareBefore = before,
compareAfter = after;
final String title;
final String? error;
final bool isDemo;
/// The two documents a comparison tab diffs; null on every other tab.
final Uint8List? compareBefore;
final Uint8List? compareAfter;
bool get isComparison => compareAfter != null;
/// Null for an error tab. Shared preferences are owned by the app, so
/// they outlive the tab.
final PdfEditingController? session;
final PdfViewerController? viewer;
// demo-specific state the PDF links and overlays drive, per document
int counter = 0;
bool switchOn = false;
final noteField = TextEditingController();
void dispose() {
session?.dispose();
viewer?.dispose();
noteField.dispose();
}
}
/// The OCR service connection the credentials dialog returns.
class _OcrSettings {
const _OcrSettings({required this.endpoint, required this.model, this.apiKey});
final String endpoint;
final String model;
final String? apiKey;
}
/// Collects the OCR service endpoint, model name, and an optional API
/// key/token before a run — the "supply credentials / login" step. The key
/// is sent as an `Authorization: Bearer …` header by the engine.
class _OcrSettingsDialog extends StatefulWidget {
const _OcrSettingsDialog({
required this.endpoint,
required this.model,
required this.apiKey,
required this.onOpenDocs,
});
final String endpoint;
final String model;
final String? apiKey;
final VoidCallback onOpenDocs;
@override
State<_OcrSettingsDialog> createState() => _OcrSettingsDialogState();
}
class _OcrSettingsDialogState extends State<_OcrSettingsDialog> {
late final _endpoint = TextEditingController(text: widget.endpoint);
late final _model = TextEditingController(text: widget.model);
late final _apiKey = TextEditingController(text: widget.apiKey ?? '');
bool _obscureKey = true;
@override
void dispose() {
_endpoint.dispose();
_model.dispose();
_apiKey.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Run OCR'),
content: SizedBox(
width: 460,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Adds a selectable, searchable text layer over scanned pages '
'using a vision-language OCR model you host (dots.ocr on vLLM, '
'or any OpenAI-compatible OCR endpoint).',
),
const SizedBox(height: 16),
TextField(
key: const ValueKey('ocr-endpoint'),
controller: _endpoint,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Service endpoint',
hintText: 'http://localhost:8000/v1/chat/completions',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
key: const ValueKey('ocr-model'),
controller: _model,
decoration: const InputDecoration(
labelText: 'Model name',
hintText: 'model',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
key: const ValueKey('ocr-api-key'),
controller: _apiKey,
obscureText: _obscureKey,
decoration: InputDecoration(
labelText: 'API key / token (optional)',
helperText: 'Sent as Authorization: Bearer …',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(
_obscureKey ? Icons.visibility : Icons.visibility_off),
tooltip: _obscureKey ? 'Show' : 'Hide',
onPressed: () => setState(() => _obscureKey = !_obscureKey),
),
),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
icon: const Icon(Icons.help_outline, size: 18),
label: const Text('How to set up an OCR server'),
onPressed: widget.onOpenDocs,
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton.icon(
key: const ValueKey('ocr-run'),
icon: const Icon(Icons.document_scanner_outlined),
label: const Text('Run OCR'),
onPressed: () {
final endpoint = _endpoint.text.trim();
if (endpoint.isEmpty) return;
final key = _apiKey.text.trim();
Navigator.of(context).pop(_OcrSettings(
endpoint: endpoint,
model: _model.text.trim(),
apiKey: key.isEmpty ? null : key,
));
},
),
],
);
}
}
/// Modal shown while OCR runs; [progress] reports the current page.
class _OcrProgressDialog extends StatelessWidget {
const _OcrProgressDialog({required this.progress});
final ValueListenable<String> progress;
@override
Widget build(BuildContext context) {
return AlertDialog(
content: Row(
children: [
const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 3),
),
const SizedBox(width: 16),
Expanded(
child: ValueListenableBuilder<String>(
valueListenable: progress,
builder: (context, value, _) => Text(value),
),
),
],
),
);
}
}
/// Shows the counter the PDF's "Increment" link annotation drives —
/// PDF → app state → widget, completing the loop on the same page.
class _CounterBadge extends StatelessWidget {
const _CounterBadge({required this.count});
final int count;
@override
Widget build(BuildContext context) {
return Material(
color: Colors.indigo,
borderRadius: BorderRadius.circular(6),
child: Center(
child: Text(
'$count',
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
);
}
}
/// Ticks every second — proof the overlay is a live widget, not artwork.
class _ClockTile extends StatefulWidget {
const _ClockTile();
@override
State<_ClockTile> createState() => _ClockTileState();
}
class _ClockTileState extends State<_ClockTile> {
late final Timer _timer;
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(seconds: 1), (_) => setState(() {}));
}
@override
void dispose() {
_timer.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final now = DateTime.now();
String pad(int v) => v.toString().padLeft(2, '0');
return Material(
color: Colors.black87,
borderRadius: BorderRadius.circular(6),
child: Center(
child: Text(
'${pad(now.hour)}:${pad(now.minute)}:${pad(now.second)}',
style: const TextStyle(
color: Colors.greenAccent,
fontSize: 18,
fontFeatures: [FontFeature.tabularFigures()],
),
),
),
);
}
}
/// Edits the same counter the PDF link on page 1 increments.
class _CounterControl extends StatelessWidget {
const _CounterControl({required this.count, required this.onChanged});
final int count;
final ValueChanged<int> onChanged;
@override
Widget build(BuildContext context) {
return Material(
color: const Color(0xF2FFFFFF),
shape: RoundedRectangleBorder(
side: BorderSide(color: Colors.indigo.shade200),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
IconButton(
icon: const Icon(Icons.remove),
onPressed: () => onChanged(count - 1),
),
Text('$count', style: Theme.of(context).textTheme.titleMedium),
IconButton(
icon: const Icon(Icons.add),
onPressed: () => onChanged(count + 1),
),
],
),
);
}
}