From d5cda2bdb290448d0d97d1bc8220e9fd3664e41c Mon Sep 17 00:00:00 2001 From: BunnyPraneeth5 Date: Fri, 4 Sep 2026 23:22:13 +0530 Subject: [PATCH 1/2] Fixed UI part in Smart Send, Updated with the better user experience by adding file_selector for native file picking --- lib/app.dart | 209 ++++++++++++++---- .../join/native_file_picker_service.dart | 29 +++ linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 64 ++++++ pubspec.yaml | 1 + test/widget_test.dart | 68 +++++- .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 10 files changed, 337 insertions(+), 45 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index d0eda12..fe61fb8 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1544,16 +1544,13 @@ class _DropHomeScreenState extends State ), ); final destination = _smartDestinationCard(); - const features = _FeatureGrid( - items: [ - ('Share Sheet', Icons.ios_share_rounded, 'From any app'), - ('Drop files', Icons.file_download_outlined, 'Desktop'), - ('Links', Icons.link_rounded, 'Send as text'), - ], + final actions = _SmartSendActions( + onAddFiles: () => unawaited(_pickAndAddFiles()), + onSendLink: () => unawaited(_showAddLinkDialog()), ); final contextPanel = Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [destination, const SizedBox(height: 12), features], + children: [destination, const SizedBox(height: 12), actions], ); return LayoutBuilder( builder: (context, constraints) { @@ -1565,7 +1562,7 @@ class _DropHomeScreenState extends State const SizedBox(height: 12), composer, const SizedBox(height: 12), - features, + actions, ], ); } @@ -1738,7 +1735,7 @@ class _DropHomeScreenState extends State ), if (_gatewayUploadedCid?.isNotEmpty == true) const SizedBox(height: 12), PrimaryButton( - label: _gatewayUploading ? 'Uploading…' : 'Choose file & upload', + label: _gatewayUploading ? 'Uploading…' : 'Add files', icon: Icons.cloud_upload_rounded, busy: _gatewayUploading, onPressed: selected != null && !_gatewayUploading @@ -4679,6 +4676,108 @@ class _DropHomeScreenState extends State _smartText.text = data.text!; } + Future _pickAndAddFiles() async { + final picked = await _nativeFilePickerService.pickFilesForUpload(); + final paths = picked + .map((file) => file.path) + .where((path) => path.isNotEmpty) + .toList(); + if (paths.isEmpty) return; + + if (_smartSendScopeIndex == 1) { + await _uploadDroppedFilesToGateway(paths); + } else { + await _handleSharedPayload(SharedPayload(filePaths: paths)); + } + } + + String? _extractUrl(String value) { + final match = RegExp(r'https?://[^\s]+').firstMatch(value.trim()); + if (match == null) return null; + final url = match.group(0); + if (url == null) return null; + final uri = Uri.tryParse(url); + if (uri == null || (uri.scheme != 'http' && uri.scheme != 'https')) { + return null; + } + return url; + } + + Future _showAddLinkDialog() async { + final data = await Clipboard.getData(Clipboard.kTextPlain); + if (!mounted) return; + + final clipboardUrl = _extractUrl(data?.text ?? ''); + final controller = TextEditingController(text: clipboardUrl ?? ''); + + final result = await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setDialogState) { + final hasClipboardUrl = _extractUrl(data?.text ?? '') != null; + return AlertDialog( + backgroundColor: DropTheme.black, + title: const Text('Send a link'), + content: TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration( + hintText: 'https://...', + labelText: 'URL', + ), + onChanged: (_) => setDialogState(() {}), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: hasClipboardUrl + ? () { + final pasted = _extractUrl(data?.text ?? ''); + if (pasted != null) { + controller.text = pasted; + setDialogState(() {}); + } + } + : null, + child: const Text('Paste from clipboard'), + ), + FilledButton( + onPressed: controller.text.trim().isNotEmpty + ? () => Navigator.of(ctx).pop(controller.text.trim()) + : null, + child: const Text('Add to composer'), + ), + ], + ); + }, + ), + ); + + controller.dispose(); + if (result == null || !mounted) return; + + final url = _extractUrl(result); + if (url == null) { + _snack('Enter a valid http or https URL'); + return; + } + + if (_smartText.text.trim().isEmpty) { + _smartTitle.text = 'Link'; + _smartText.text = url; + } else { + if (_smartTitle.text.trim().isEmpty) { + _smartTitle.text = 'Link'; + } + final current = _smartText.text.trim(); + _smartText.text = '$current\n$url'; + } + _snack('Link added to composer'); + } + Future _handleSharedPayload(SharedPayload payload) async { if (payload.isEmpty || !mounted) return; if (_loadingHostFolderSelection) { @@ -5961,11 +6060,14 @@ class _InfoCard extends StatelessWidget { } } -class _FeatureGrid extends StatelessWidget { - const _FeatureGrid({required this.items}); +class _SmartSendActions extends StatelessWidget { + const _SmartSendActions({ + required this.onAddFiles, + required this.onSendLink, + }); - /// (title, icon, sub) — all features render a success "Ready" caption. - final List<(String, IconData, String)> items; + final VoidCallback onAddFiles; + final VoidCallback onSendLink; @override Widget build(BuildContext context) { @@ -5973,61 +6075,80 @@ class _FeatureGrid extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - for (var i = 0; i < items.length; i++) ...[ - if (i > 0) const SizedBox(width: 10), - Expanded(child: _featureCard(context, items[i])), - ], + Expanded( + child: _SmartSendActionCard( + icon: Icons.file_upload_outlined, + title: 'Add files', + subtitle: isDesktopPlatform + ? 'Drop files here or click to browse' + : 'Tap to choose files', + onTap: onAddFiles, + ), + ), + const SizedBox(width: 10), + Expanded( + child: _SmartSendActionCard( + icon: Icons.add_link_rounded, + title: 'Send a link', + subtitle: 'Tap to paste a URL into the composer', + onTap: onSendLink, + ), + ), ], ), ); } +} - Widget _featureCard(BuildContext context, (String, IconData, String) item) { +class _SmartSendActionCard extends StatelessWidget { + const _SmartSendActionCard({ + required this.icon, + required this.title, + required this.subtitle, + required this.onTap, + }); + + final IconData icon; + final String title; + final String subtitle; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { return DropCard( + onTap: onTap, padding: const EdgeInsets.all(14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(item.$2, color: DropTheme.orange, size: 22), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, color: DropTheme.orange, size: 22), + const Spacer(), + const Icon( + Icons.chevron_right_rounded, + color: DropTheme.faint, + size: 18, + ), + ], + ), const SizedBox(height: 12), Text( - item.$1, + title, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleSmall, ), const SizedBox(height: 2), Text( - item.$3, + subtitle, maxLines: 2, overflow: TextOverflow.ellipsis, style: Theme.of( context, ).textTheme.bodySmall?.copyWith(fontSize: 11), ), - const SizedBox(height: 10), - Row( - children: [ - Container( - width: 6, - height: 6, - decoration: const BoxDecoration( - color: DropTheme.success, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 6), - Text( - 'Ready', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - fontSize: 10.5, - fontWeight: FontWeight.w700, - color: DropTheme.success, - letterSpacing: 0, - ), - ), - ], - ), ], ), ); diff --git a/lib/features/join/native_file_picker_service.dart b/lib/features/join/native_file_picker_service.dart index 05565fe..2292710 100644 --- a/lib/features/join/native_file_picker_service.dart +++ b/lib/features/join/native_file_picker_service.dart @@ -1,5 +1,9 @@ +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import '../../core/platform_capabilities.dart'; + class PickedUploadFile { const PickedUploadFile({ required this.path, @@ -31,6 +35,31 @@ class NativeFilePickerService { } Future> pickFilesForUpload() async { + if (kIsWeb) return []; + + // On desktop, file_selector uses the native file dialog on all three + // platforms and avoids the need for per-platform method channel code. + if (isDesktopPlatform) { + final picked = await openFiles( + acceptedTypeGroups: const [XTypeGroup()], + ); + return Future.wait( + picked.map((file) async { + var sizeBytes = 0; + try { + sizeBytes = await file.length(); + } catch (_) { + // The platform may not always report a length; fall back to 0. + } + return PickedUploadFile( + path: file.path, + name: file.name, + sizeBytes: sizeBytes, + ); + }), + ); + } + final result = await _channel.invokeMethod('pickFilesForUpload'); if (result is List) { return result diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 3e9b616..2c5ddb5 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,6 +7,7 @@ #include "generated_plugin_registrant.h" #include +#include #include #include #include @@ -17,6 +18,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) desktop_drop_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopDropPlugin"); desktop_drop_plugin_register_with_registrar(desktop_drop_registrar); + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); g_autoptr(FlPluginRegistrar) gtk_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); gtk_plugin_register_with_registrar(gtk_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 8ad8ba6..94965b3 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST desktop_drop + file_selector_linux gtk screen_retriever_linux tray_manager diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 53b41e5..69967b7 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,6 +10,7 @@ import bonsoir_darwin import connectivity_plus import desktop_drop import device_info_plus +import file_selector_macos import google_sign_in_ios import package_info_plus import screen_retriever_macos @@ -27,6 +28,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 294cc9f..2546ae7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -425,6 +425,70 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector: + dependency: "direct main" + description: + name: file_selector + sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a + url: "https://pub.dev" + source: hosted + version: "1.1.0" + file_selector_android: + dependency: transitive + description: + name: file_selector_android + sha256: "7c76473740e33a11343c8fce88166049230850d2f19cd8a652ea935fcb8c9206" + url: "https://pub.dev" + source: hosted + version: "0.5.2+10" + file_selector_ios: + dependency: transitive + description: + name: file_selector_ios + sha256: "97269e5307a0ab813b1fa2430bada0a96e0afb74848417f8676f64ba5de0051c" + url: "https://pub.dev" + source: hosted + version: "0.5.3+6" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab + url: "https://pub.dev" + source: hosted + version: "0.9.4+1" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4 + url: "https://pub.dev" + source: hosted + version: "0.9.5+1" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_web: + dependency: transitive + description: + name: file_selector_web + sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec + url: "https://pub.dev" + source: hosted + version: "0.9.3+6" fixnum: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 995b2ac..4bc407f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,6 +53,7 @@ dependencies: url_launcher: ^6.3.1 app_links: ^6.3.3 desktop_drop: ^0.7.1 + file_selector: ^1.1.0 dev_dependencies: flutter_test: sdk: flutter diff --git a/test/widget_test.dart b/test/widget_test.dart index dbf6f66..9b86a69 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -77,7 +77,9 @@ void main() { await tester.tap(find.byIcon(Icons.bolt_outlined)); await tester.pump(); - expect(find.text('Drop files'), findsOneWidget); + expect(find.text('Add files'), findsOneWidget); + expect(find.text('Send a link'), findsOneWidget); + expect(find.text('Share Sheet'), findsNothing); expect(tester.takeException(), isNull); debugDefaultTargetPlatformOverride = null; @@ -234,4 +236,68 @@ void main() { expect(find.text('http://192.168.1.24:8787'), findsOneWidget); expect(tester.takeException(), isNull); }); + + testWidgets('Send a link opens a dialog and adds a URL to the composer', ( + tester, + ) async { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.getData') { + return {'text': 'https://example.com'}; + } + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + + debugDefaultTargetPlatformOverride = TargetPlatform.android; + await tester.binding.setSurfaceSize(const Size(393, 851)); + tester.view.devicePixelRatio = 1; + + await tester.pumpWidget(const ErebrusDropApp(skipOnboarding: true)); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.bolt_outlined)); + await tester.pump(); + expect(find.text('Add files'), findsOneWidget); + expect(find.text('Send a link'), findsOneWidget); + expect(find.text('Share Sheet'), findsNothing); + + await tester.tap(find.text('Send a link')); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.byType(AlertDialog), findsOneWidget); + + final urlField = find.descendant( + of: find.byType(AlertDialog), + matching: find.byType(TextField), + ); + expect(urlField, findsOneWidget); + expect( + find.descendant( + of: find.byType(AlertDialog), + matching: find.text('https://example.com'), + ), + findsOneWidget, + ); + + await tester.enterText(urlField, 'https://example.org'); + await tester.pump(); + + await tester.tap(find.text('Add to composer')); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.byType(AlertDialog), findsNothing); + expect(find.text('https://example.org'), findsOneWidget); + + debugDefaultTargetPlatformOverride = null; + tester.view.resetDevicePixelRatio(); + await tester.binding.setSurfaceSize(null); + }); } diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index bc61b65..293eeae 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -24,6 +25,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); DesktopDropPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("DesktopDropPlugin")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); TrayManagerPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 25fbcc7..0a5215c 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST bonsoir_windows connectivity_plus desktop_drop + file_selector_windows screen_retriever_windows tray_manager url_launcher_windows From 3ae176e8a51fd26c6b34417c99122d287665577f Mon Sep 17 00:00:00 2001 From: BunnyPraneeth5 Date: Sat, 5 Sep 2026 13:42:10 +0530 Subject: [PATCH 2/2] fix: prevent controller disposal during exit animations --- lib/app.dart | 538 ++++++++++++++++++++++++++---------------- test/widget_test.dart | 452 +++++++++++++++++++++++++++++++++++ 2 files changed, 783 insertions(+), 207 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index fe61fb8..9044cee 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1900,66 +1900,16 @@ class _DropHomeScreenState extends State /// Shows a dialog for entering or loading an encryption key. /// Returns the key text, or null if cancelled. - Future _promptEncryptionKey(String filename) async { - final ctrl = TextEditingController(); - await showDialog( - context: context, - barrierDismissible: false, - builder: (ctx) => AlertDialog( - backgroundColor: DropTheme.black, - title: Text('Decryption key for $filename'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Enter the key used to encrypt this file, or load it from a text/key file.', - ), - const SizedBox(height: 12), - TextField( - controller: ctrl, - obscureText: true, - decoration: const InputDecoration( - hintText: 'Passphrase or base64/hex key', - ), - ), - ], - ), - actions: [ - TextButton( - onPressed: () { - ctrl.clear(); - Navigator.of(ctx).pop(); - }, - child: const Text('Cancel'), - ), - TextButton( - onPressed: () async { - final picked = await _nativeFilePickerService.pickFileForUpload(); - if (picked == null || picked.path.isEmpty) return; - try { - final text = await File(picked.path).readAsString(); - ctrl.text = text.trim(); - } catch (e) { - if (ctx.mounted) { - ScaffoldMessenger.of(ctx).showSnackBar( - SnackBar(content: Text('Could not read key file: $e')), - ); - } - } - }, - child: const Text('Load key file'), - ), - FilledButton( - onPressed: () => Navigator.of(ctx).pop(), - child: const Text('Download'), - ), - ], - ), + Future _promptEncryptionKey(String filename) { + return showEncryptionKeyDialog( + context, + filename: filename, + loadKeyFile: () async { + final picked = await _nativeFilePickerService.pickFileForUpload(); + if (picked == null || picked.path.isEmpty) return null; + return File(picked.path).readAsString(); + }, ); - final result = ctrl.text.trim(); - ctrl.dispose(); - return result.isEmpty ? null : result; } Widget _smartDestinationCard() { @@ -2468,102 +2418,12 @@ class _DropHomeScreenState extends State ); } - Future _showIpfsGatewayPicker() async { - const presets = [ - 'https://ipfs.erebrus.io', - 'https://cloudflare-ipfs.com', - 'https://gateway.pinata.cloud', - 'https://ipfs.io', - ]; - final initial = _ipfsGatewayUrl; - String? selected = presets.contains(initial) ? initial : null; - final customCtrl = TextEditingController( - text: presets.contains(initial) ? '' : initial, - ); - - await showDialog( - context: context, - builder: (ctx) => StatefulBuilder( - builder: (ctx, setDialogState) { - final effectiveUrl = selected ?? customCtrl.text.trim(); - return AlertDialog( - backgroundColor: DropTheme.black, - title: const Text('IPFS Gateway'), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Choose the gateway used to fetch files by CID.'), - const SizedBox(height: 12), - for (final preset in presets) - ListTile( - contentPadding: EdgeInsets.zero, - dense: true, - leading: Icon( - selected == preset - ? Icons.radio_button_checked_rounded - : Icons.radio_button_unchecked_rounded, - color: selected == preset - ? DropTheme.orange - : DropTheme.faint, - ), - title: Text(preset), - onTap: () { - setDialogState(() { - selected = preset; - customCtrl.clear(); - }); - }, - ), - ListTile( - contentPadding: EdgeInsets.zero, - dense: true, - leading: Icon( - selected == null - ? Icons.radio_button_checked_rounded - : Icons.radio_button_unchecked_rounded, - color: selected == null - ? DropTheme.orange - : DropTheme.faint, - ), - title: const Text('Custom'), - onTap: () => setDialogState(() => selected = null), - ), - Padding( - padding: const EdgeInsets.only(left: 34), - child: TextField( - controller: customCtrl, - enabled: selected == null, - decoration: const InputDecoration( - hintText: 'https://your-gateway.tld', - ), - onChanged: (_) => setDialogState(() {}), - ), - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(), - child: const Text('Cancel'), - ), - TextButton( - onPressed: effectiveUrl.isEmpty - ? null - : () { - Navigator.of(ctx).pop(); - unawaited(_saveIpfsGatewayUrl(effectiveUrl)); - }, - child: const Text('Save'), - ), - ], - ); - }, - ), + Future _showIpfsGatewayPicker() { + return showIpfsGatewayPickerDialog( + context, + initialUrl: _ipfsGatewayUrl, + onSave: _saveIpfsGatewayUrl, ); - customCtrl.dispose(); } Widget _settingsFooter() { @@ -4708,55 +4568,7 @@ class _DropHomeScreenState extends State if (!mounted) return; final clipboardUrl = _extractUrl(data?.text ?? ''); - final controller = TextEditingController(text: clipboardUrl ?? ''); - - final result = await showDialog( - context: context, - builder: (ctx) => StatefulBuilder( - builder: (ctx, setDialogState) { - final hasClipboardUrl = _extractUrl(data?.text ?? '') != null; - return AlertDialog( - backgroundColor: DropTheme.black, - title: const Text('Send a link'), - content: TextField( - controller: controller, - autofocus: true, - decoration: const InputDecoration( - hintText: 'https://...', - labelText: 'URL', - ), - onChanged: (_) => setDialogState(() {}), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(), - child: const Text('Cancel'), - ), - TextButton( - onPressed: hasClipboardUrl - ? () { - final pasted = _extractUrl(data?.text ?? ''); - if (pasted != null) { - controller.text = pasted; - setDialogState(() {}); - } - } - : null, - child: const Text('Paste from clipboard'), - ), - FilledButton( - onPressed: controller.text.trim().isNotEmpty - ? () => Navigator.of(ctx).pop(controller.text.trim()) - : null, - child: const Text('Add to composer'), - ), - ], - ); - }, - ), - ); - - controller.dispose(); + final result = await showAddLinkDialog(context, clipboardUrl: clipboardUrl); if (result == null || !mounted) return; final url = _extractUrl(result); @@ -6060,12 +5872,324 @@ class _InfoCard extends StatelessWidget { } } -class _SmartSendActions extends StatelessWidget { - const _SmartSendActions({ - required this.onAddFiles, - required this.onSendLink, +Future showAddLinkDialog( + BuildContext context, { + required String? clipboardUrl, +}) { + return showDialog( + context: context, + builder: (_) => _AddLinkDialog( + key: const ValueKey('send-link-dialog'), + clipboardUrl: clipboardUrl, + ), + ); +} + +class _AddLinkDialog extends StatefulWidget { + const _AddLinkDialog({required this.clipboardUrl, super.key}); + + final String? clipboardUrl; + + @override + State<_AddLinkDialog> createState() => _AddLinkDialogState(); +} + +class _AddLinkDialogState extends State<_AddLinkDialog> { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.clipboardUrl ?? ''); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + backgroundColor: DropTheme.black, + title: const Text('Send a link'), + content: TextField( + controller: _controller, + autofocus: true, + decoration: const InputDecoration( + hintText: 'https://...', + labelText: 'URL', + ), + onChanged: (_) => setState(() {}), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: widget.clipboardUrl != null + ? () { + _controller.text = widget.clipboardUrl!; + setState(() {}); + } + : null, + child: const Text('Paste from clipboard'), + ), + FilledButton( + onPressed: _controller.text.trim().isNotEmpty + ? () => Navigator.of(context).pop(_controller.text.trim()) + : null, + child: const Text('Add to composer'), + ), + ], + ); + } +} + +Future showEncryptionKeyDialog( + BuildContext context, { + required String filename, + required Future Function() loadKeyFile, +}) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (_) => _EncryptionKeyDialog( + key: const ValueKey('encryption-key-dialog'), + filename: filename, + loadKeyFile: loadKeyFile, + ), + ); +} + +class _EncryptionKeyDialog extends StatefulWidget { + const _EncryptionKeyDialog({ + required this.filename, + required this.loadKeyFile, + super.key, + }); + + final String filename; + final Future Function() loadKeyFile; + + @override + State<_EncryptionKeyDialog> createState() => _EncryptionKeyDialogState(); +} + +class _EncryptionKeyDialogState extends State<_EncryptionKeyDialog> { + final TextEditingController _controller = TextEditingController(); + + String? get _result { + final text = _controller.text.trim(); + return text.isEmpty ? null : text; + } + + Future _loadKeyFile() async { + try { + final text = await widget.loadKeyFile(); + if (!mounted || text == null) return; + _controller.text = text.trim(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Could not read key file: $e'))); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) { + if (!didPop) Navigator.of(context).pop(_result); + }, + child: AlertDialog( + backgroundColor: DropTheme.black, + title: Text('Decryption key for ${widget.filename}'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Enter the key used to encrypt this file, or load it from a text/key file.', + ), + const SizedBox(height: 12), + TextField( + controller: _controller, + obscureText: true, + decoration: const InputDecoration( + hintText: 'Passphrase or base64/hex key', + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + _controller.clear(); + Navigator.of(context).pop(); + }, + child: const Text('Cancel'), + ), + TextButton( + onPressed: _loadKeyFile, + child: const Text('Load key file'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(_result), + child: const Text('Download'), + ), + ], + ), + ); + } +} + +Future showIpfsGatewayPickerDialog( + BuildContext context, { + required String initialUrl, + required Future Function(String) onSave, +}) { + return showDialog( + context: context, + builder: (_) => _IpfsGatewayPickerDialog( + key: const ValueKey('ipfs-gateway-dialog'), + initialUrl: initialUrl, + onSave: onSave, + ), + ); +} + +class _IpfsGatewayPickerDialog extends StatefulWidget { + const _IpfsGatewayPickerDialog({ + required this.initialUrl, + required this.onSave, + super.key, }); + final String initialUrl; + final Future Function(String) onSave; + + @override + State<_IpfsGatewayPickerDialog> createState() => + _IpfsGatewayPickerDialogState(); +} + +class _IpfsGatewayPickerDialogState extends State<_IpfsGatewayPickerDialog> { + static const _presets = [ + 'https://ipfs.erebrus.io', + 'https://cloudflare-ipfs.com', + 'https://gateway.pinata.cloud', + 'https://ipfs.io', + ]; + + late String? _selected; + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + final initial = widget.initialUrl; + _selected = _presets.contains(initial) ? initial : null; + _controller = TextEditingController(text: _selected == null ? initial : ''); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final effectiveUrl = _selected ?? _controller.text.trim(); + return AlertDialog( + backgroundColor: DropTheme.black, + title: const Text('IPFS Gateway'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Choose the gateway used to fetch files by CID.'), + const SizedBox(height: 12), + for (final preset in _presets) + ListTile( + contentPadding: EdgeInsets.zero, + dense: true, + leading: Icon( + _selected == preset + ? Icons.radio_button_checked_rounded + : Icons.radio_button_unchecked_rounded, + color: _selected == preset + ? DropTheme.orange + : DropTheme.faint, + ), + title: Text(preset), + onTap: () { + setState(() { + _selected = preset; + _controller.clear(); + }); + }, + ), + ListTile( + contentPadding: EdgeInsets.zero, + dense: true, + leading: Icon( + _selected == null + ? Icons.radio_button_checked_rounded + : Icons.radio_button_unchecked_rounded, + color: _selected == null ? DropTheme.orange : DropTheme.faint, + ), + title: const Text('Custom'), + onTap: () => setState(() => _selected = null), + ), + Padding( + padding: const EdgeInsets.only(left: 34), + child: TextField( + controller: _controller, + enabled: _selected == null, + decoration: const InputDecoration( + hintText: 'https://your-gateway.tld', + ), + onChanged: (_) => setState(() {}), + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: effectiveUrl.isEmpty + ? null + : () { + Navigator.of(context).pop(); + unawaited(widget.onSave(effectiveUrl)); + }, + child: const Text('Save'), + ), + ], + ); + } +} + +class _SmartSendActions extends StatelessWidget { + const _SmartSendActions({required this.onAddFiles, required this.onSendLink}); + final VoidCallback onAddFiles; final VoidCallback onSendLink; diff --git a/test/widget_test.dart b/test/widget_test.dart index 9b86a69..9a8ed4c 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:erebrus_drop/app.dart'; import 'package:erebrus_drop/features/gateway/gateway_sheets.dart'; import 'package:erebrus_drop/features/onboarding/onboarding_screen.dart'; @@ -8,7 +10,457 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +final _dialogField = find.descendant( + of: find.byType(AlertDialog), + matching: find.byType(TextField), +); + +Future _pumpDialogFrames(WidgetTester tester, {int count = 20}) async { + for (var frame = 0; frame < count; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + expect(tester.takeException(), isNull, reason: 'Dialog frame $frame'); + } +} + +Future _mountDialogHarness( + WidgetTester tester, + Future Function(BuildContext) open, +) async { + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetViewInsets); + await tester.pumpWidget( + MaterialApp( + theme: DropTheme.dark(), + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => unawaited(open(context)), + child: const Text('Open test dialog'), + ), + ), + ), + ), + ); + expect(tester.takeException(), isNull); +} + +Future _openTestDialog(WidgetTester tester) async { + await tester.tap(find.text('Open test dialog')); + await tester.pump(); + expect(tester.takeException(), isNull); + await _pumpDialogFrames(tester); + expect(find.byType(AlertDialog), findsOneWidget); + expect(_dialogField, findsOneWidget); + return tester.widget(_dialogField).controller!; +} + +Future _editDialog(WidgetTester tester, String text) async { + await tester.enterText(_dialogField, text); + await tester.pump(); + expect(tester.takeException(), isNull); +} + +void _expectButtonEnabled(WidgetTester tester, String text, bool enabled) { + final button = tester.widget( + find.ancestor( + of: find.text(text), + matching: find.bySubtype(), + ), + ); + expect(button.onPressed, enabled ? isNotNull : isNull); +} + +Future _dismissTestDialog(WidgetTester tester, String exit) async { + if (exit == 'back') { + await tester.binding.handlePopRoute(); + } else if (exit == 'barrier') { + await tester.tapAt(const Offset(5, 5)); + } else { + await tester.tap(find.text(exit)); + } +} + +Future _finishDialogDismissal( + WidgetTester tester, + TextEditingController controller, +) async { + final fieldElement = tester.element(_dialogField); + final editableElement = tester.element( + find.descendant(of: _dialogField, matching: find.byType(EditableText)), + ); + final route = ModalRoute.of(fieldElement)!; + await tester.pump(); + expect(tester.takeException(), isNull); + expect(route.isCurrent, isFalse); + expect(fieldElement.mounted, isTrue); + expect(find.byType(AlertDialog), findsOneWidget); + tester.view.viewInsets = const FakeViewPadding(bottom: 48); + fieldElement.markNeedsBuild(); + editableElement.markNeedsBuild(); + await _pumpDialogFrames(tester, count: 1); + expect(fieldElement.mounted, isTrue); + void listener() {} + expect(() => controller.addListener(listener), returnsNormally); + controller.removeListener(listener); + await _pumpDialogFrames(tester); + expect(fieldElement.mounted, isFalse); + expect(find.byType(AlertDialog), findsNothing); + expect(() => controller.addListener(listener), throwsFlutterError); + tester.view.resetViewInsets(); + await tester.pump(); + expect(tester.takeException(), isNull); +} + void main() { + for (final clipboardUrl in [null, 'https://clipboard.example']) { + for (final exit in ['Cancel', 'back', 'barrier', 'Add to composer']) { + testWidgets('link dialog $exit with clipboard $clipboardUrl', ( + tester, + ) async { + final results = []; + await _mountDialogHarness(tester, (context) async { + results.add( + await showAddLinkDialog(context, clipboardUrl: clipboardUrl), + ); + }); + TextEditingController? previous; + for (var repeat = 0; repeat < 2; repeat++) { + final controller = await _openTestDialog(tester); + expect(find.text('Send a link'), findsOneWidget); + expect(controller, isNot(same(previous))); + expect(controller.text, clipboardUrl ?? ''); + _expectButtonEnabled( + tester, + 'Paste from clipboard', + clipboardUrl != null, + ); + _expectButtonEnabled(tester, 'Add to composer', clipboardUrl != null); + await _editDialog(tester, ' '); + _expectButtonEnabled(tester, 'Add to composer', false); + if (clipboardUrl != null) { + await tester.tap(find.text('Paste from clipboard')); + await tester.pump(); + expect(tester.takeException(), isNull); + expect(controller.text, clipboardUrl); + _expectButtonEnabled(tester, 'Add to composer', true); + } + await _editDialog(tester, ' https://edited.example/path '); + _expectButtonEnabled(tester, 'Add to composer', true); + await _dismissTestDialog(tester, exit); + await _finishDialogDismissal(tester, controller); + expect(results.length, repeat + 1); + expect( + results.last, + exit == 'Add to composer' ? 'https://edited.example/path' : isNull, + ); + previous = controller; + } + }); + } + } + + for (final exit in [ + 'Cancel', + 'back', + 'barrier', + 'Download', + 'empty Download', + ]) { + testWidgets( + 'encryption dialog $exit preserves result and controller lifetime', + (tester) async { + final results = []; + await _mountDialogHarness(tester, (context) async { + results.add( + await showEncryptionKeyDialog( + context, + filename: 'secret.bin', + loadKeyFile: () async => null, + ), + ); + }); + TextEditingController? previous; + for (var repeat = 0; repeat < 2; repeat++) { + final controller = await _openTestDialog(tester); + expect(find.text('Decryption key for secret.bin'), findsOneWidget); + expect(controller, isNot(same(previous))); + expect(controller.text, isEmpty); + expect(tester.widget(_dialogField).obscureText, isTrue); + await _editDialog( + tester, + exit == 'empty Download' ? ' ' : ' typed key ', + ); + if (exit == 'barrier') { + await _dismissTestDialog(tester, 'barrier'); + await _pumpDialogFrames(tester); + expect(find.byType(AlertDialog), findsOneWidget); + expect( + ModalRoute.of(tester.element(_dialogField))!.isCurrent, + isTrue, + ); + expect(controller.text, ' typed key '); + expect(results.length, repeat); + await _dismissTestDialog(tester, 'Cancel'); + } else { + await _dismissTestDialog( + tester, + exit == 'empty Download' ? 'Download' : exit, + ); + } + await _finishDialogDismissal(tester, controller); + expect(results.length, repeat + 1); + expect( + results.last, + exit == 'back' || exit == 'Download' ? 'typed key' : isNull, + ); + previous = controller; + } + }, + ); + } + + const presets = [ + 'https://ipfs.erebrus.io', + 'https://cloudflare-ipfs.com', + 'https://gateway.pinata.cloud', + 'https://ipfs.io', + ]; + for (final initialUrl in [...presets, 'https://custom.example', '']) { + for (final exit in [ + 'Cancel', + 'back', + 'barrier', + 'Save custom', + 'Save preset', + ]) { + testWidgets('IPFS dialog $exit from "$initialUrl"', (tester) async { + final saves = []; + var completions = 0; + ModalRoute? dialogRoute; + await _mountDialogHarness(tester, (context) async { + await showIpfsGatewayPickerDialog( + context, + initialUrl: initialUrl, + onSave: (url) async { + expect(dialogRoute!.isCurrent, isFalse); + saves.add(url); + }, + ); + completions++; + }); + TextEditingController? previous; + for (var repeat = 0; repeat < 2; repeat++) { + final controller = await _openTestDialog(tester); + dialogRoute = ModalRoute.of(tester.element(_dialogField)); + expect(find.text('IPFS Gateway'), findsOneWidget); + expect(controller, isNot(same(previous))); + expect( + controller.text, + presets.contains(initialUrl) ? '' : initialUrl, + ); + expect( + tester.widget(_dialogField).enabled, + !presets.contains(initialUrl), + ); + _expectButtonEnabled(tester, 'Save', initialUrl.isNotEmpty); + for (final preset in presets) { + expect(find.text(preset), findsOneWidget); + } + await tester.tap(find.text('Custom')); + await tester.pump(); + expect(tester.takeException(), isNull); + expect(tester.widget(_dialogField).enabled, isTrue); + await _editDialog(tester, ' '); + _expectButtonEnabled(tester, 'Save', false); + await _editDialog(tester, ' https://edited-gateway.example '); + _expectButtonEnabled(tester, 'Save', true); + for (final preset in presets) { + await tester.tap(find.text(preset)); + await tester.pump(); + expect(tester.takeException(), isNull); + expect(controller.text, isEmpty); + expect(tester.widget(_dialogField).enabled, isFalse); + _expectButtonEnabled(tester, 'Save', true); + await tester.tap(find.text('Custom')); + await tester.pump(); + expect(tester.takeException(), isNull); + expect(tester.widget(_dialogField).enabled, isTrue); + _expectButtonEnabled(tester, 'Save', false); + await _editDialog(tester, ' https://edited-gateway.example '); + } + if (exit == 'Save preset') { + await tester.tap( + find.text( + initialUrl.isNotEmpty && presets.contains(initialUrl) + ? initialUrl + : presets[repeat], + ), + ); + await tester.pump(); + expect(tester.takeException(), isNull); + } + await _dismissTestDialog( + tester, + exit.startsWith('Save') ? 'Save' : exit, + ); + await _finishDialogDismissal(tester, controller); + expect(completions, repeat + 1); + if (exit.startsWith('Save')) { + expect(saves.length, repeat + 1); + expect( + saves.last, + exit == 'Save custom' + ? 'https://edited-gateway.example' + : presets.contains(initialUrl) + ? initialUrl + : presets[repeat], + ); + } else { + expect(saves, isEmpty); + } + previous = controller; + } + }); + } + } + + for (final outcome in ['success', 'cancel', 'error']) { + testWidgets('key file $outcome while encryption dialog is mounted', ( + tester, + ) async { + late Completer pending; + var calls = 0; + final results = []; + await _mountDialogHarness(tester, (context) async { + results.add( + await showEncryptionKeyDialog( + context, + filename: 'loaded.bin', + loadKeyFile: () { + calls++; + return pending.future; + }, + ), + ); + }); + TextEditingController? previous; + for (var repeat = 0; repeat < 2; repeat++) { + pending = Completer(); + final controller = await _openTestDialog(tester); + expect(controller, isNot(same(previous))); + expect(controller.text, isEmpty); + await _editDialog(tester, ' original key '); + await tester.tap(find.text('Load key file')); + await tester.pump(); + expect(tester.takeException(), isNull); + expect(calls, repeat + 1); + expect(controller.text, ' original key '); + expect(results.length, repeat); + if (outcome == 'error') { + pending.completeError(StateError('key read failed')); + } else { + pending.complete(outcome == 'success' ? ' \n loaded key \n ' : null); + } + await tester.pump(); + expect(tester.takeException(), isNull); + await _pumpDialogFrames(tester); + expect( + controller.text, + outcome == 'success' ? 'loaded key' : ' original key ', + ); + if (outcome == 'error') { + expect(find.byType(SnackBar), findsOneWidget); + expect( + find.textContaining('Could not read key file:'), + findsOneWidget, + ); + expect(find.textContaining('key read failed'), findsOneWidget); + ScaffoldMessenger.of( + tester.element(_dialogField), + ).removeCurrentSnackBar(); + await tester.pump(); + expect(tester.takeException(), isNull); + } else { + expect(find.byType(SnackBar), findsNothing); + } + await _dismissTestDialog(tester, 'Download'); + await _finishDialogDismissal(tester, controller); + expect( + results.last, + outcome == 'success' ? 'loaded key' : 'original key', + ); + previous = controller; + } + }); + + for (final reopenBeforeCompletion in [false, true]) { + testWidgets( + 'late key file $outcome after disposal, next dialog open: $reopenBeforeCompletion', + (tester) async { + late Completer pending; + final results = []; + await _mountDialogHarness(tester, (context) async { + results.add( + await showEncryptionKeyDialog( + context, + filename: 'late.bin', + loadKeyFile: () => pending.future, + ), + ); + }); + for (var repeat = 0; repeat < 2; repeat++) { + pending = Completer(); + final oldController = await _openTestDialog(tester); + expect(oldController.text, isEmpty); + await _editDialog(tester, 'old key'); + await tester.tap(find.text('Load key file')); + await tester.pump(); + expect(tester.takeException(), isNull); + await _dismissTestDialog(tester, 'Cancel'); + await _finishDialogDismissal(tester, oldController); + expect(results.last, isNull); + TextEditingController? newController; + if (reopenBeforeCompletion) { + newController = await _openTestDialog(tester); + expect(newController, isNot(same(oldController))); + expect(newController.text, isEmpty); + await _editDialog(tester, ' next key '); + } + if (outcome == 'error') { + pending.completeError(StateError('late read failure')); + } else { + pending.complete( + outcome == 'success' ? ' stale loaded key ' : null, + ); + } + await tester.pump(); + expect(tester.takeException(), isNull); + await _pumpDialogFrames(tester); + expect(find.byType(SnackBar), findsNothing); + if (!reopenBeforeCompletion) { + expect(find.byType(AlertDialog), findsNothing); + newController = await _openTestDialog(tester); + expect(newController, isNot(same(oldController))); + expect(newController.text, isEmpty); + await _editDialog(tester, ' next key '); + } + expect(newController!.text, ' next key '); + await _dismissTestDialog(tester, 'Download'); + await _finishDialogDismissal(tester, newController); + expect( + results, + List.generate( + (repeat + 1) * 2, + (index) => index.isEven ? null : 'next key', + ), + ); + } + }, + ); + } + } + testWidgets('onboarding adapts to landscape Android screens', (tester) async { await tester.binding.setSurfaceSize(const Size(872, 393)); tester.view.devicePixelRatio = 1;