diff --git a/lib/main.dart b/lib/main.dart index 862a2d28..c2c63553 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,6 +4,7 @@ import 'package:adaptive_platform_ui/adaptive_platform_ui.dart'; import 'package:flutter_driver/driver_extension.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:pdfrx/pdfrx.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'core/widgets/error_boundary.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; @@ -71,6 +72,9 @@ void main() { runZonedGuarded( () async { WidgetsFlutterBinding.ensureInitialized(); + // pdfrx: warm up the PDFium engine once at startup so the inline PDF + // viewer opens without a first-use delay. + unawaited(pdfrxFlutterInitialize()); PerformanceProfiler.instance.attachFrameTimings(); // Global error handlers diff --git a/lib/shared/widgets/markdown/renderer/inline_renderer.dart b/lib/shared/widgets/markdown/renderer/inline_renderer.dart index 28c6e928..8ca3bbc3 100644 --- a/lib/shared/widgets/markdown/renderer/inline_renderer.dart +++ b/lib/shared/widgets/markdown/renderer/inline_renderer.dart @@ -10,6 +10,7 @@ import '../compiled_markdown_document.dart'; import '../citation_badge.dart'; import 'latex_preprocessor.dart'; import 'markdown_style.dart'; +import 'pdf_inline_view.dart'; /// Callback invoked when a user taps a markdown link. typedef LinkTapCallback = void Function(String url, String title); @@ -336,6 +337,24 @@ class InlineRenderer { ) { final href = element.attributes['href'] ?? ''; final title = element.attributes['title'] ?? ''; + + // Links to a PDF (a URL ending in .pdf) render as an inline PDF preview card + // with tap-to-fullscreen, instead of a plain tappable hyperlink. Works in + // every render tier because both richText and blocks route `` elements + // through this method. + if (PdfInlineView.isPdfLink(href)) { + return [ + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: PdfInlineView( + key: ValueKey(href), + url: href, + label: element.textContent, + ), + ), + ]; + } + final linkStyle = currentStyle.copyWith( color: style.linkColor, decoration: TextDecoration.underline, diff --git a/lib/shared/widgets/markdown/renderer/pdf_inline_view.dart b/lib/shared/widgets/markdown/renderer/pdf_inline_view.dart new file mode 100644 index 00000000..b8b6a573 --- /dev/null +++ b/lib/shared/widgets/markdown/renderer/pdf_inline_view.dart @@ -0,0 +1,635 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:pdfrx/pdfrx.dart'; +import 'package:share_plus/share_plus.dart'; + +/// Dedicated, explicitly-bounded disk cache for PDFs, isolated from the app-wide +/// image cache. flutter_cache_manager's DefaultCacheManager keeps up to 200 +/// objects for 30 days with no total-byte ceiling; mixing multi-MB PDFs into it +/// risks unbounded disk growth. This keeps the PDF cache small and predictable. +final BaseCacheManager _pdfCacheManager = CacheManager( + Config( + 'conduit_pdf_cache', + stalePeriod: const Duration(days: 7), + maxNrOfCacheObjects: 30, + ), +); + +/// Inline PDF card rendered inside a chat message. +/// +/// When a markdown link points at a PDF (a URL ending in `.pdf`), the markdown +/// inline renderer ([InlineRenderer._renderLink]) renders this widget instead of +/// a plain hyperlink, so PDFs sent in a conversation are viewable in place. +/// +/// When the card appears the PDF is downloaded once and cached to a local file, +/// and its first page is pre-rasterized to a held [ui.Image] (a fixed image, not +/// pdfrx's live `PdfPageView`, so the preview never flashes white on rebuild). +/// Tapping opens [_PdfFullscreenPage], which rasterizes pages lazily around the +/// viewport under a hard memory budget. +class PdfInlineView extends StatefulWidget { + const PdfInlineView({super.key, required this.url, this.label}); + + /// Absolute URL of the PDF. + final String url; + + /// Display label taken from the markdown link text (e.g. the file name). + final String? label; + + /// Returns true when [href] is a link the inline PDF viewer should render: + /// a URL whose path ends in `.pdf`. + static bool isPdfLink(String href) { + if (href.isEmpty) return false; + // Strip both query string and fragment before the extension check, so + // `report.pdf?token=…` and `report.pdf#page=3` are still detected. + return href + .split('?') + .first + .split('#') + .first + .toLowerCase() + .endsWith('.pdf'); + } + + @override + State createState() => _PdfInlineViewState(); +} + +class _PdfInlineViewState extends State { + /// Preview render width, matched to the card's on-screen footprint (the card + /// is <=560 logical px, cover-cropped) — no need to hold a screen-sized bitmap. + static const double _previewRenderWidth = 720; + + String? _filePath; + ui.Image? _previewImage; + bool _previewSettled = false; + bool _error = false; + bool _disposed = false; + + @override + void initState() { + super.initState(); + unawaited(_load()); + } + + @override + void didUpdateWidget(PdfInlineView oldWidget) { + super.didUpdateWidget(oldWidget); + // A markdown card can be re-bound to a different URL during streaming + // re-compilation; reload (and free the old preview) so we never show a + // stale document. + if (oldWidget.url != widget.url) { + _previewImage?.dispose(); + _previewImage = null; + _filePath = null; + _previewSettled = false; + _error = false; + unawaited(_load()); + } + } + + Future _load() async { + final loadingUrl = widget.url; + try { + // pdfrx engine init is idempotent; awaiting it removes a cold-start race + // where openFile runs before the PDFium engine is ready. + await pdfrxFlutterInitialize(); + final file = await _pdfCacheManager.getSingleFile(loadingUrl); + if (_disposed || loadingUrl != widget.url) return; + setState(() => _filePath = file.path); + + final doc = await PdfDocument.openFile(file.path); + try { + if (doc.pages.isEmpty) return; + final page = doc.pages.first; + final pdfImage = await page.render( + fullWidth: _previewRenderWidth, + fullHeight: _previewRenderWidth * page.height / page.width, + ); + ui.Image? image; + if (pdfImage != null) { + try { + image = await pdfImage.createImage(); + } finally { + pdfImage.dispose(); + } + } + if (_disposed || loadingUrl != widget.url) { + image?.dispose(); + return; + } + setState(() { + _previewImage = image; + _previewSettled = true; + }); + } finally { + await doc.dispose(); + } + } catch (_) { + if (!_disposed && loadingUrl == widget.url) { + setState(() { + _error = true; + _previewSettled = true; + }); + } + } + } + + void _retryLoad() { + setState(() { + _error = false; + _previewSettled = false; + }); + unawaited(_load()); + } + + @override + void dispose() { + _disposed = true; + _previewImage?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final title = _cleanPdfLabel(widget.label); + final ready = _filePath != null; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: Material( + color: scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + clipBehavior: Clip.antiAlias, + child: Semantics( + button: true, + label: ready ? 'Open PDF: $title' : 'PDF loading: $title', + child: InkWell( + onTap: ready + ? () => _openFullscreen(context, _filePath!, title) + : (_error ? _retryLoad : null), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + height: 360, + child: ColoredBox( + color: scheme.surface, + child: _buildPreview(scheme, title), + ), + ), + _buildBar(title, scheme, ready: ready), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildPreview(ColorScheme scheme, String title) { + final image = _previewImage; + final Widget content; + if (_error) { + content = Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.broken_image_outlined, color: scheme.error), + const SizedBox(height: 6), + Text( + 'Tap to retry', + style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12), + ), + ], + ), + ); + } else if (image != null) { + content = Semantics( + image: true, + label: 'Preview of first page: $title', + child: RawImage( + image: image, + fit: BoxFit.fitWidth, + alignment: Alignment.topCenter, + ), + ); + } else if (_previewSettled) { + content = Center( + child: Icon(Icons.picture_as_pdf, size: 48, color: scheme.primary), + ); + } else { + content = const Center( + child: SizedBox( + width: 26, + height: 26, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } + + return Stack( + fit: StackFit.expand, + children: [ + content, + if (image != null) + Positioned( + right: 10, + bottom: 10, + child: DecoratedBox( + decoration: BoxDecoration( + color: scheme.primary, + shape: BoxShape.circle, + ), + child: Padding( + padding: const EdgeInsets.all(7), + child: Icon( + Icons.open_in_full, + size: 16, + color: scheme.onPrimary, + ), + ), + ), + ), + ], + ); + } + + Widget _buildBar(String title, ColorScheme scheme, {required bool ready}) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(Icons.picture_as_pdf, size: 18, color: scheme.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: scheme.onSurface, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + ), + const SizedBox(width: 8), + if (ready) + IconButton( + onPressed: () => unawaited(_sharePdf(_filePath!, title)), + icon: Icon(Icons.share, size: 19, color: scheme.primary), + tooltip: 'Share', + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 34, minHeight: 34), + ), + Text( + ready ? 'Open' : 'Loading …', + style: TextStyle(color: scheme.primary, fontSize: 12), + ), + ], + ), + ); + } + + void _openFullscreen(BuildContext context, String path, String title) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => _PdfFullscreenPage(path: path, title: title), + ), + ); + } +} + +String _cleanPdfLabel(String? raw) { + final text = (raw ?? '').replaceFirst('📄', '').trim(); + return text.isEmpty ? 'PDF document' : text; +} + +/// Shares the local PDF via the OS share sheet (share_plus) under a meaningful +/// filename. +/// +/// flutter_cache_manager stores the download under a random hash filename, and +/// neither [XFile.name] nor [ShareParams.fileNameOverrides] is reliably honoured +/// on Android. To control the name robustly, the cached file is copied to a temp +/// file (in a per-share unique subdirectory, so same-titled documents never +/// collide and an in-flight share is never overwritten) whose on-disk name IS +/// the document title. Old share temp dirs are swept on each share so they do +/// not accumulate (share_plus never cleans them up). +Future _sharePdf(String path, String title) async { + final name = _pdfFileName(title); + try { + final base = await getTemporaryDirectory(); + final root = Directory('${base.path}/pdf-share'); + await _sweepOldShareTemps(root); + final shareDir = Directory( + '${root.path}/${DateTime.now().microsecondsSinceEpoch}', + ); + await shareDir.create(recursive: true); + final dest = File('${shareDir.path}/$name'); + await File(path).copy(dest.path); + await SharePlus.instance.share(ShareParams(files: [XFile(dest.path)])); + } catch (_) { + // Fall back to sharing the cached file directly if the copy fails. + await SharePlus.instance.share( + ShareParams(files: [XFile(path)], fileNameOverrides: [name]), + ); + } +} + +/// Best-effort removal of share temp dirs older than ~1h (a share sheet is never +/// open that long), so the temp area does not grow unbounded. +Future _sweepOldShareTemps(Directory root) async { + try { + if (!await root.exists()) return; + final cutoff = DateTime.now().subtract(const Duration(hours: 1)); + await for (final entry in root.list()) { + try { + if ((await entry.stat()).modified.isBefore(cutoff)) { + await entry.delete(recursive: true); + } + } catch (_) {} + } + } catch (_) {} +} + +/// Builds a readable, filesystem-friendly share filename from the document title. +String _pdfFileName(String title) { + // Keep Unicode letters/digits (umlauts, accents, CJK, …): Dart's `\w` is + // ASCII-only even with `unicode: true`, so a German/CJK title would otherwise + // be stripped to spaces. `\p{L}\p{N}` with `unicode: true` retains them. + var base = title + .replaceAll(RegExp(r'[^\p{L}\p{N}\s._\-]', unicode: true), ' ') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + if (base.length > 80) base = base.substring(0, 80).trim(); + if (base.isEmpty) base = 'document'; + return base.toLowerCase().endsWith('.pdf') ? base : '$base.pdf'; +} + +/// Fullscreen PDF route. Rasterizes pages to [ui.Image]s lazily around the +/// viewport and holds them under a hard byte budget with LRU eviction, so a +/// large document can never blow up memory while small documents stay fully +/// resident and flicker-free. +/// +/// Why not pdfrx's `PdfViewer`: it rasterizes pages lazily on paint, drawing a +/// blank white rectangle until the async render lands — very visible in a debug +/// build. Showing only already-rasterized images means a page is never white; +/// not-yet-rendered pages show a neutral surface-colored loading box, and a +/// plain [ListView] gives real momentum/fling for free. +class _PdfFullscreenPage extends StatefulWidget { + const _PdfFullscreenPage({required this.path, required this.title}); + + final String path; + final String title; + + @override + State<_PdfFullscreenPage> createState() => _PdfFullscreenPageState(); +} + +class _PdfFullscreenPageState extends State<_PdfFullscreenPage> { + /// Hard ceiling on decoded bitmap memory held at once (~10 A4 pages at panel + /// width). Small docs fit entirely → never evicted → never white. Large docs + /// stay bounded regardless of page count. + static const int _maxBitmapBytes = 64 * 1024 * 1024; + + PdfDocument? _doc; + bool _disposed = false; + bool _started = false; + Object? _error; + int _pageCount = 0; + List _aspects = const []; + double _targetWidth = 1080; + + final Map _images = {}; + final List _lru = []; // most-recently-used at front + final Set _rendering = {}; + final Set _failed = {}; + int _heldBytes = 0; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_started) return; + _started = true; + final media = MediaQuery.of(context); + // Render at panel-native pixel width (capped). Rendering wider than the + // screen only wastes memory in a no-zoom viewer. + _targetWidth = (media.size.width * media.devicePixelRatio).clamp( + 800.0, + 1080.0, + ); + unawaited(_open()); + } + + Future _open() async { + PdfDocument? doc; + try { + doc = await PdfDocument.openFile(widget.path); + if (_disposed) { + await doc.dispose(); + return; + } + final pages = doc.pages; + // Dispose any previous document first (a "Try again" re-open would + // otherwise overwrite _doc and leak the old one). + await _doc?.dispose(); + _doc = doc; + doc = null; // ownership transferred to _doc + setState(() { + _pageCount = pages.length; + _aspects = [ + for (final p in pages) p.width / p.height, + ]; + }); + } catch (error) { + // If openFile succeeded but a later step threw (e.g. doc.pages on a + // malformed file), the local doc still owns an open PDFium handle. + await doc?.dispose(); + if (mounted) setState(() => _error = error); + } + } + + Future _ensureRendered(int index) async { + final doc = _doc; + if (_disposed || doc == null) return; + if (_images.containsKey(index)) { + _touch(index); + return; + } + if (_rendering.contains(index)) return; + _rendering.add(index); + try { + final page = doc.pages[index]; + final pdfImage = await page.render( + fullWidth: _targetWidth, + fullHeight: _targetWidth * page.height / page.width, + ); + if (pdfImage == null) { + if (!_disposed && mounted) setState(() => _failed.add(index)); + return; + } + ui.Image image; + try { + image = await pdfImage.createImage(); + } finally { + pdfImage.dispose(); + } + if (_disposed) { + image.dispose(); + return; + } + _images[index] = image; + _heldBytes += image.width * image.height * 4; + _touch(index); + _evictIfNeeded(keep: index); + if (mounted) setState(() {}); + } catch (_) { + if (!_disposed && mounted) setState(() => _failed.add(index)); + } finally { + _rendering.remove(index); + } + } + + void _touch(int index) { + _lru + ..remove(index) + ..insert(0, index); + } + + void _evictIfNeeded({required int keep}) { + while (_heldBytes > _maxBitmapBytes && _lru.length > 1) { + final victim = _lru.lastWhere((i) => i != keep, orElse: () => -1); + if (victim < 0) break; + _lru.remove(victim); + final img = _images.remove(victim); + if (img != null) { + _heldBytes -= img.width * img.height * 4; + img.dispose(); + } + } + } + + @override + void dispose() { + _disposed = true; + for (final img in _images.values) { + img.dispose(); + } + _images.clear(); + unawaited(_disposeDocWhenIdle()); + super.dispose(); + } + + /// Dispose the document only once no `render()` is in flight, so PDFium is + /// never freed out from under an outstanding render call. + Future _disposeDocWhenIdle() async { + var guard = 0; + while (_rendering.isNotEmpty && guard < 600) { + await Future.delayed(const Duration(milliseconds: 16)); + guard++; + } + await _doc?.dispose(); + _doc = null; + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: scheme.surfaceContainerHighest, + appBar: AppBar( + title: Text(widget.title, maxLines: 1, overflow: TextOverflow.ellipsis), + actions: [ + IconButton( + onPressed: () => unawaited(_sharePdf(widget.path, widget.title)), + icon: const Icon(Icons.share), + tooltip: 'Share', + ), + ], + ), + body: _buildBody(scheme), + ); + } + + Widget _buildBody(ColorScheme scheme) { + if (_error != null) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, color: scheme.error, size: 40), + const SizedBox(height: 8), + const Text('Could not load the document.'), + const SizedBox(height: 12), + TextButton( + onPressed: () { + setState(() => _error = null); + unawaited(_open()); + }, + child: const Text('Try again'), + ), + ], + ), + ); + } + if (_pageCount == 0) { + return const Center(child: CircularProgressIndicator()); + } + return ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: _pageCount, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final image = _images[index]; + if (image != null) { + // Mark this visible (already-rendered) page most-recently-used so it + // is not evicted while on screen — but AFTER the frame. Mutating _lru + // directly here would make build() impure (Flutter contract) and run + // O(n) list ops for every visible item on every repaint. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_disposed && _images.containsKey(index)) _touch(index); + }); + return Semantics( + image: true, + label: 'Page ${index + 1} of $_pageCount', + child: AspectRatio( + aspectRatio: _aspects[index], + child: RawImage(image: image, fit: BoxFit.fill), + ), + ); + } + // Not yet rasterized: kick off a render for this (visible) page and show + // a neutral loading box — never a white flash. + if (!_failed.contains(index)) { + unawaited(_ensureRendered(index)); + } + return AspectRatio( + aspectRatio: _aspects[index], + child: ColoredBox( + color: scheme.surface, + child: Center( + child: _failed.contains(index) + ? Icon(Icons.broken_image_outlined, color: scheme.error) + : const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ), + ); + }, + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 3aed1626..77179688 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -229,10 +229,10 @@ packages: dependency: transitive description: name: code_assets - sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.0.0" code_builder: dependency: transitive description: @@ -947,10 +947,10 @@ packages: dependency: transitive description: name: hooks - sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "1.0.3" html: dependency: transitive description: @@ -1255,6 +1255,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + url: "https://pub.dev" + source: hosted + version: "0.17.6" nm: dependency: transitive description: @@ -1275,10 +1283,10 @@ packages: dependency: transitive description: name: objective_c - sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" url: "https://pub.dev" source: hosted - version: "9.4.1" + version: "9.3.0" octo_image: dependency: transitive description: @@ -1375,6 +1383,38 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + pdfium_dart: + dependency: transitive + description: + name: pdfium_dart + sha256: b536c19a10f8c86c160274a54ca6f29bc4c88001324ddcda4cca316033f2dc96 + url: "https://pub.dev" + source: hosted + version: "0.2.4" + pdfium_flutter: + dependency: transitive + description: + name: pdfium_flutter + sha256: "97ccb5cf207b66ba5549f09047e935376d967c6311ec3c5314405d1d04569fd2" + url: "https://pub.dev" + source: hosted + version: "0.2.1" + pdfrx: + dependency: "direct main" + description: + name: pdfrx + sha256: "6b3571565fb412fb7d0a325a76154d0685bd0a0659c3f41ee007f408d48cfd46" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + pdfrx_engine: + dependency: transitive + description: + name: pdfrx_engine + sha256: a201b11e13b6c729d731ddc96ce06394cce8503c39e4c7e9d2fa51b3e7b351a5 + url: "https://pub.dev" + source: hosted + version: "0.4.2" permission_handler: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 4f9f3ff4..1df124f0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -33,6 +33,8 @@ dependencies: # UI Components - Markdown Rendering markdown: ^7.3.0 + # PDF rendering - inline viewer for .pdf links in chat messages + pdfrx: ^2.4.3 flutter_inappwebview: ^6.1.5 socket_io_client: ^3.1.2 yaml: ^3.1.2