From 8b336337b178b4a973b2a3ab6015b4a11840a6fb Mon Sep 17 00:00:00 2001 From: Scriptbash <98601298+Scriptbash@users.noreply.github.com> Date: Sun, 15 Mar 2026 14:06:33 -0400 Subject: [PATCH] Add pdf overlay --- lib/l10n/app_en.arb | 12 ++ lib/screens/article_website.dart | 40 ++++-- lib/screens/pdf_reader.dart | 70 +++++++++- lib/widgets/pdf_control_overlay.dart | 199 +++++++++++++++++++++++++++ 4 files changed, 304 insertions(+), 17 deletions(-) create mode 100644 lib/widgets/pdf_control_overlay.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a96ba533..52fadcd7 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -561,6 +561,18 @@ } } }, + "zoomOut":"Zoom out", + "@zoomOut":{}, + "zoomIn":"Zoom in", + "@zoomIn":{}, + "goToFirstPage":"Go to first page", + "@goToFirstPage":{}, + "goToLastPage":"Go to last page", + "@goToLastPage":{}, + "nextMatch":"Next match", + "@nextMatch":{}, + "previousMatch":"Previous match", + "@previousMatch":{}, "sourceCode": "Source code", "@sourceCode": {}, "reportIssue": "Report an issue", diff --git a/lib/screens/article_website.dart b/lib/screens/article_website.dart index 1e663516..4cc7f394 100644 --- a/lib/screens/article_website.dart +++ b/lib/screens/article_website.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:http/io_client.dart'; import 'package:wispar/generated_l10n/app_localizations.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:wispar/services/unpaywall_api.dart'; @@ -8,7 +9,7 @@ import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; -import 'dart:io'; +import 'dart:io' as io; import 'package:http/http.dart' as http; import 'package:wispar/services/logs_helper.dart'; import 'package:wispar/webview_env.dart'; @@ -58,7 +59,7 @@ class ArticleWebsiteState extends State { _initWebViewSettings(); checkUnpaywallAvailability(); - pullToRefreshController = Platform.isAndroid || Platform.isIOS + pullToRefreshController = io.Platform.isAndroid || io.Platform.isIOS ? PullToRefreshController( settings: PullToRefreshSettings( color: Colors.deepPurple, @@ -97,15 +98,15 @@ class ArticleWebsiteState extends State { } String _getPlatformUserAgent() { - if (Platform.isAndroid) { + if (io.Platform.isAndroid) { return "Mozilla/5.0 (Android 16; Mobile; LG-M255; rv:140.0) Gecko/140.0 Firefox/140.0"; - } else if (Platform.isIOS) { + } else if (io.Platform.isIOS) { return "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile Safari/604.1"; - } else if (Platform.isMacOS) { + } else if (io.Platform.isMacOS) { return "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko)"; - } else if (Platform.isWindows) { + } else if (io.Platform.isWindows) { return "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:142.0) Gecko/20100101 Firefox/142.0"; - } else if (Platform.isLinux) { + } else if (io.Platform.isLinux) { return "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.3"; } else { return "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0 Mobile Safari/537.36"; @@ -309,7 +310,7 @@ class ArticleWebsiteState extends State { ? InAppWebView( key: webViewKey, webViewEnvironment: - Platform.isWindows ? webViewEnvironment : null, + io.Platform.isWindows ? webViewEnvironment : null, initialUrlRequest: URLRequest(url: WebUri(pdfUrl)), initialSettings: settings, pullToRefreshController: pullToRefreshController, @@ -403,7 +404,7 @@ class ArticleWebsiteState extends State { return ServerTrustAuthResponse( action: ServerTrustAuthResponseAction.PROCEED); }, - onDownloadStartRequest: (controller, urlInfo) async { + onDownloadStarting: (controller, urlInfo) async { final Uri downloadUri = urlInfo.url; final String? mimeType = urlInfo.mimeType; final String? suggestedFilename = @@ -946,7 +947,22 @@ class ArticleWebsiteState extends State { logger.info( 'Full HTTP Request Headers being sent: $headers'); - final client = http.Client(); + final io.HttpClient innerHttpClient = io.HttpClient() + ..badCertificateCallback = + ((io.X509Certificate cert, String host, int port) { + final configuredProxyHost = + Uri.tryParse(proxyUrl)?.host; + + if (configuredProxyHost != null && + host.contains(configuredProxyHost)) { + logger.info( + 'Trusting certificate for proxy host: $host'); + return true; + } + return false; + }); + + final client = IOClient(innerHttpClient); final request = http.Request('GET', finalDownloadUri) ..headers.addAll(headers) @@ -982,7 +998,7 @@ class ArticleWebsiteState extends State { if (useCustomPath && customPath != null) { baseDirPath = customPath; - } else if (Platform.isWindows) { + } else if (io.Platform.isWindows) { final defaultAppDir = await getApplicationSupportDirectory(); baseDirPath = defaultAppDir.path; @@ -1001,7 +1017,7 @@ class ArticleWebsiteState extends State { } final fileName = '$cleanedDoi.pdf'; - final pdfFile = File('$baseDirPath/$fileName'); + final pdfFile = io.File('$baseDirPath/$fileName'); await pdfFile.writeAsBytes(response.bodyBytes); if (mounted) { Navigator.of(this.context).push(MaterialPageRoute( diff --git a/lib/screens/pdf_reader.dart b/lib/screens/pdf_reader.dart index 856f2e22..c7ab259e 100644 --- a/lib/screens/pdf_reader.dart +++ b/lib/screens/pdf_reader.dart @@ -1,16 +1,17 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import '../generated_l10n/app_localizations.dart'; +import 'package:wispar/generated_l10n/app_localizations.dart'; import 'package:pdfrx/pdfrx.dart'; -import '../widgets/publication_card/publication_card.dart'; -import '../services/database_helper.dart'; +import 'package:wispar/widgets/pdf_control_overlay.dart'; +import 'package:wispar/widgets/publication_card/publication_card.dart'; +import 'package:wispar/services/database_helper.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:open_filex/open_filex.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as p; import 'package:shared_preferences/shared_preferences.dart'; -import '../services/logs_helper.dart'; -import '../screens/chat_screen.dart'; +import 'package:wispar/services/logs_helper.dart'; +import 'package:wispar/screens/chat_screen.dart'; import 'dart:math'; class PdfReader extends StatefulWidget { @@ -38,9 +39,14 @@ class PdfReaderState extends State { bool _darkPdfTheme = false; int _pdfOrientation = 0; bool _isZoomed = false; + bool _overlayVisible = true; + + PdfTextSearcher? textSearcher; @override void dispose() { + textSearcher?.removeListener(_update); + textSearcher?.dispose(); super.dispose(); } @@ -53,6 +59,10 @@ class PdfReaderState extends State { }); } + void _update() { + if (mounted) setState(() {}); + } + Future _loadPreferences() async { final prefs = await SharedPreferences.getInstance(); final pdfThemeOption = prefs.getInt('pdfThemeOption') ?? 0; @@ -189,6 +199,16 @@ class PdfReaderState extends State { child: PdfViewer.file(resolvedPdfPath, controller: controller, params: PdfViewerParams( + onViewerReady: (document, controller) { + setState(() { + textSearcher = PdfTextSearcher(controller) + ..addListener(_update); + }); + }, + pagePaintCallbacks: [ + if (textSearcher != null) + textSearcher!.pageTextMatchPaintCallback, + ], layoutPages: _pdfOrientation == 1 ? (pages, params) { final height = pages.fold( @@ -254,12 +274,52 @@ class PdfReaderState extends State { }, onTapUp: (details) { handleLinkTap(details.localPosition); + + setState(() { + _overlayVisible = !_overlayVisible; + }); }, child: IgnorePointer( child: SizedBox( width: size.width, height: size.height), ), ), + PdfViewerScrollThumb( + controller: controller, + orientation: _pdfOrientation == 1 + ? ScrollbarOrientation.bottom + : ScrollbarOrientation.right, + thumbSize: _pdfOrientation == 1 + ? const Size(60, 26) + : const Size(40, 26), + thumbBuilder: + (context, thumbSize, pageNumber, controller) { + return AnimatedOpacity( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + opacity: _overlayVisible ? 1 : 0, + child: Container( + decoration: BoxDecoration( + color: + Colors.black.withValues(alpha: 0.75), + borderRadius: BorderRadius.circular(12), + ), + alignment: Alignment.center, + child: Text( + pageNumber.toString(), + style: const TextStyle( + color: Colors.white, + ), + ), + ), + ); + }, + ), + PdfControlOverlay( + controller: controller, + textSearcher: textSearcher, + overlayVisible: _overlayVisible, + ), ], ))) ]) diff --git a/lib/widgets/pdf_control_overlay.dart b/lib/widgets/pdf_control_overlay.dart new file mode 100644 index 00000000..7c4170d0 --- /dev/null +++ b/lib/widgets/pdf_control_overlay.dart @@ -0,0 +1,199 @@ +import 'package:flutter/material.dart'; +import 'package:pdfrx/pdfrx.dart'; +import 'package:flutter/services.dart'; +import 'package:wispar/generated_l10n/app_localizations.dart'; + +class PdfControlOverlay extends StatefulWidget { + final PdfViewerController controller; + final PdfTextSearcher? textSearcher; + final bool overlayVisible; + + const PdfControlOverlay({ + super.key, + required this.controller, + required this.textSearcher, + required this.overlayVisible, + }); + + @override + State createState() => _PdfControlOverlayState(); +} + +class _PdfControlOverlayState extends State { + bool _isSearching = false; + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocusNode = FocusNode(); + + @override + void dispose() { + _searchController.dispose(); + _searchFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Positioned( + bottom: 28, + left: 0, + right: 0, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + opacity: widget.overlayVisible ? 1 : 0, + child: IgnorePointer( + ignoring: !widget.overlayVisible, + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 320), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_isSearching && widget.textSearcher != null) + _buildSearchBar(), + _buildBottomBar(), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildSearchBar() { + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Expanded( + child: CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.enter, shift: true): + () { + widget.textSearcher?.goToPrevMatch(); + }, + const SingleActivator(LogicalKeyboardKey.enter): () { + widget.textSearcher?.goToNextMatch(); + }, + }, + child: TextField( + controller: _searchController, + focusNode: _searchFocusNode, + autofocus: true, + style: const TextStyle(color: Colors.white), + textInputAction: TextInputAction.search, + onSubmitted: (_) { + if (widget.textSearcher?.matches.isNotEmpty ?? false) { + widget.textSearcher?.goToNextMatch(); + _searchFocusNode.requestFocus(); + } + }, + decoration: InputDecoration( + hintText: AppLocalizations.of(context)!.searchPlaceholder, + hintStyle: + TextStyle(color: Colors.white.withValues(alpha: 0.5)), + border: InputBorder.none, + ), + onChanged: (v) => v.isEmpty + ? widget.textSearcher?.resetTextSearch() + : widget.textSearcher + ?.startTextSearch(v, caseInsensitive: true), + ), + ), + ), + if (widget.textSearcher?.matches.isNotEmpty ?? false) ...[ + Text( + '${(widget.textSearcher!.currentIndex ?? 0) + 1}/${widget.textSearcher!.matches.length}', + style: const TextStyle(color: Colors.white70, fontSize: 12), + ), + IconButton( + tooltip: AppLocalizations.of(context)!.previousMatch, + icon: const Icon(Icons.keyboard_arrow_up, color: Colors.white), + onPressed: () => widget.textSearcher?.goToPrevMatch(), + ), + IconButton( + icon: + const Icon(Icons.keyboard_arrow_down, color: Colors.white), + tooltip: AppLocalizations.of(context)!.nextMatch, + onPressed: () => widget.textSearcher?.goToNextMatch(), + ), + ], + IconButton( + icon: const Icon(Icons.close, color: Colors.white70, size: 20), + tooltip: AppLocalizations.of(context)!.cancel, + onPressed: () { + setState(() => _isSearching = false); + widget.textSearcher?.resetTextSearch(); + _searchController.clear(); + }, + ), + ], + ), + ), + ); + } + + Widget _buildBottomBar() { + return Container( + height: 42, + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.75), + borderRadius: BorderRadius.circular(28), + ), + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + IconButton( + icon: const Icon(Icons.zoom_out, color: Colors.white), + tooltip: AppLocalizations.of(context)!.zoomOut, + onPressed: () => widget.controller.zoomDown(), + ), + IconButton( + icon: const Icon(Icons.zoom_in, color: Colors.white), + tooltip: AppLocalizations.of(context)!.zoomIn, + onPressed: () => widget.controller.zoomUp(), + ), + const VerticalDivider(color: Colors.white54, indent: 8, endIndent: 8), + IconButton( + icon: const Icon(Icons.first_page, color: Colors.white), + tooltip: AppLocalizations.of(context)!.goToFirstPage, + onPressed: () => widget.controller.goToPage(pageNumber: 1), + ), + IconButton( + icon: const Icon(Icons.last_page, color: Colors.white), + tooltip: AppLocalizations.of(context)!.goToLastPage, + onPressed: () => widget.controller.goToPage( + pageNumber: widget.controller.document.pages.length, + ), + ), + const VerticalDivider(color: Colors.white54, indent: 8, endIndent: 8), + IconButton( + icon: Icon(_isSearching ? Icons.search_off : Icons.search, + color: Colors.white), + tooltip: AppLocalizations.of(context)!.search, + onPressed: () { + setState(() => _isSearching = !_isSearching); + if (!_isSearching) { + widget.textSearcher?.resetTextSearch(); + _searchController.clear(); + } + if (_isSearching) { + Future.delayed(Duration.zero, () { + _searchFocusNode.requestFocus(); + }); + } + }, + ), + ], + ), + ); + } +}