diff --git a/app/lib/features/geckoview/domain/providers/tab_session.dart b/app/lib/features/geckoview/domain/providers/tab_session.dart index 88531bbe..918c644e 100644 --- a/app/lib/features/geckoview/domain/providers/tab_session.dart +++ b/app/lib/features/geckoview/domain/providers/tab_session.dart @@ -68,6 +68,10 @@ class TabSession extends _$TabSession { return _sessionService.requestDesktopSite(enable: enable); } + Future saveToPdf() { + return _sessionService.saveToPdf(); + } + @override void build({required String? tabId}) { _sessionService = (tabId != null) diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart index 2b553504..62c69de4 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart @@ -4,10 +4,13 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; import 'package:share_plus/share_plus.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/qr_code.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; import 'package:weblibre/utils/ui_helper.dart' as ui_helper; class ShareMenuItemButton extends HookConsumerWidget { @@ -58,6 +61,126 @@ class ShowQrCodeMenuItemButton extends HookConsumerWidget { } } +class SaveToPdfMenuItemButton extends HookConsumerWidget { + const SaveToPdfMenuItemButton({super.key, required this.selectedTabId}); + + final String? selectedTabId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return MenuItemButton( + leadingIcon: const Icon(MdiIcons.filePdfBox), + closeOnActivate: false, + onPressed: () async { + await ref + .read(tabSessionProvider(tabId: selectedTabId).notifier) + .saveToPdf(); + + if (context.mounted) { + MenuController.maybeOf(context)?.close(); + } + }, + child: const Text('Export as PDF'), + ); + } +} + +class ShareMarkdownActionMenuItemButton extends HookConsumerWidget { + const ShareMarkdownActionMenuItemButton({ + super.key, + required this.selectedTabId, + required this.icon, + required this.title, + required this.shareMarkdownAction, + }); + + final String selectedTabId; + final Widget icon; + final Widget title; + final Future Function(String content, String? fileName) + shareMarkdownAction; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return MenuItemButton( + leadingIcon: icon, + closeOnActivate: false, + onPressed: () => _handleShare(context, ref), + child: title, + ); + } + + Future _handleShare(BuildContext context, WidgetRef ref) async { + final tabData = await ref + .read(tabDataRepositoryProvider.notifier) + .getTabDataById(selectedTabId); + + if (tabData == null || tabData.fullContentMarkdown.isEmpty) { + if (context.mounted) { + MenuController.maybeOf(context)?.close(); + } + return; + } + + final shouldShowDialog = + tabData.isProbablyReaderable == true && + tabData.extractedContentMarkdown.isNotEmpty; + + if (shouldShowDialog && context.mounted) { + await _showContentSelectionDialog(context, tabData); + } else { + await shareMarkdownAction( + tabData.fullContentMarkdown!, + tabData.title ?? tabData.url?.authority, + ); + } + + if (context.mounted) { + MenuController.maybeOf(context)?.close(); + } + } + + Future _showContentSelectionDialog( + BuildContext context, + TabData tabData, + ) async { + await showDialog( + context: context, + builder: (context) => SimpleDialog( + title: title, + children: [ + ListTile( + title: const Text('Extracted Content'), + subtitle: const Text( + 'Reader-optimized content without navigation and ads', + ), + onTap: () async { + Navigator.of(context).pop(); + await shareMarkdownAction( + tabData.extractedContentMarkdown!, + tabData.title ?? tabData.url?.authority, + ); + }, + ), + ListTile( + title: const Text('Full Content'), + subtitle: const Text( + 'Complete page including all elements and structure', + ), + onTap: () async { + Navigator.of(context).pop(); + await shareMarkdownAction( + tabData.fullContentMarkdown!, + tabData.title ?? tabData.url?.authority, + ); + }, + ), + ], + ), + ); + } +} + class ShareScreenshotMenuItemButton extends HookConsumerWidget { const ShareScreenshotMenuItemButton({super.key, required this.selectedTabId}); diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart index cedbd107..14ff9e92 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart @@ -19,7 +19,9 @@ */ import 'dart:convert'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; @@ -320,6 +322,43 @@ class TabMenu extends HookConsumerWidget { leadingIcon: const Icon(Icons.share), child: const Text('Share'), ), + SubmenuButton( + menuChildren: [ + ShareMarkdownActionMenuItemButton( + selectedTabId: selectedTabId, + title: const Text('Copy as Markdown'), + // ignore: deprecated_member_use + icon: const Icon(MdiIcons.languageMarkdownOutline), + shareMarkdownAction: (content, fileName) async { + await Clipboard.setData(ClipboardData(text: content)); + + if (context.mounted) { + ui_helper.showInfoMessage( + context, + 'Markdown copied to clipboard', + ); + } + }, + ), + ShareMarkdownActionMenuItemButton( + selectedTabId: selectedTabId, + title: const Text('Export as Markdown'), + // ignore: deprecated_member_use + icon: const Icon(MdiIcons.languageMarkdown), + shareMarkdownAction: (content, fileName) async { + await FilePicker.platform.saveFile( + fileName: fileName, + type: FileType.custom, + allowedExtensions: ['md'], + bytes: utf8.encode(content), + ); + }, + ), + SaveToPdfMenuItemButton(selectedTabId: selectedTabId), + ], + leadingIcon: const Icon(MdiIcons.fileExport), + child: const Text('Export'), + ), MenuItemButton( onPressed: () async { await ref diff --git a/app/lib/features/geckoview/features/tabs/domain/repositories/tab.dart b/app/lib/features/geckoview/features/tabs/domain/repositories/tab.dart index 5bea0577..72bdcde3 100644 --- a/app/lib/features/geckoview/features/tabs/domain/repositories/tab.dart +++ b/app/lib/features/geckoview/features/tabs/domain/repositories/tab.dart @@ -102,6 +102,14 @@ class TabDataRepository extends _$TabDataRepository { return filtered.length; } + Future getTabDataById(String tabId) { + return ref + .read(tabDatabaseProvider) + .tabDao + .getTabDataById(tabId) + .getSingleOrNull(); + } + Future> getContainerTabsData(String? containerId) { return ref .read(tabDatabaseProvider) diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 1ae23cd8..c8f20e2b 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -22,6 +22,7 @@ dependencies: fading_scroll: ^0.9.1 fancy_password_field: ^2.0.8 fast_equatable: ^1.3.1 + file_picker: ^10.3.8 flutter: sdk: flutter flutter_auto_size_text: ^4.1.0 diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Core.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Core.kt index b5a18b1b..d0744f3e 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Core.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Core.kt @@ -21,6 +21,7 @@ import eu.weblibre.flutter_mozilla_components.activities.NotificationActivity import eu.weblibre.flutter_mozilla_components.R import eu.weblibre.flutter_mozilla_components.ext.getPreferenceKey import eu.weblibre.flutter_mozilla_components.middleware.FlutterEventMiddleware +import eu.weblibre.flutter_mozilla_components.middleware.SaveToPDFMiddleware import eu.weblibre.flutter_mozilla_components.pigeons.BrowserExtensionEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents import kotlinx.coroutines.FlowPreview @@ -191,6 +192,7 @@ class Core( SessionPrioritizationMiddleware(), RecordingDevicesMiddleware(context, components.notificationsDelegate), PromptMiddleware(), + SaveToPDFMiddleware(), FileUploadsDirCleanerMiddleware(fileUploadsDirCleaner), LastMediaAccessMiddleware(), ) + EngineMiddleware.create( diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/SaveToPDFMiddleware.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/SaveToPDFMiddleware.kt new file mode 100644 index 00000000..5e69ff66 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/SaveToPDFMiddleware.kt @@ -0,0 +1,32 @@ +package eu.weblibre.flutter_mozilla_components.middleware + +import mozilla.components.browser.state.action.BrowserAction +import mozilla.components.browser.state.action.EngineAction +import mozilla.components.browser.state.state.BrowserState +import mozilla.components.feature.addons.logger +import mozilla.components.lib.state.Middleware +import mozilla.components.lib.state.MiddlewareContext + +class SaveToPDFMiddleware : Middleware { + override fun invoke( + context: MiddlewareContext, + next: (BrowserAction) -> Unit, + action: BrowserAction, + ) { + when (action) { + is EngineAction.SaveToPdfAction -> { + // Continue to generate the PDF, passing through here to add telemetry + next(action) + } + is EngineAction.SaveToPdfCompleteAction -> { + logger.info("Saved PDF successfully") + } + is EngineAction.SaveToPdfExceptionAction -> { + logger.error("Unable to save PDF", action.throwable) + } + else -> { + next(action) + } + } + } +} \ No newline at end of file