diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 553932a5..25d5e9f6 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -6,7 +6,7 @@ import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart' show SystemNavigator; import 'package:flutter/widgets.dart' - show WidgetsBinding, WidgetsBindingObserver, AppLifecycleState; + show WidgetsBinding, WidgetsBindingObserver, AppLifecycleState, Rect; import 'package:geolocator/geolocator.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:app_links/app_links.dart'; @@ -12035,12 +12035,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Share a debug log file /// /// Uses the native share sheet to allow users to share logs via email, messaging, etc. - Future shareDebugLog(File file) async { + /// [sharePositionOrigin] is the tapped button's global rect, which iPad needs + /// (see [shareOfflineSession]). + Future shareDebugLog(File file, {Rect? sharePositionOrigin}) async { try { final result = await SharePlus.instance.share( ShareParams( files: [XFile(file.path)], subject: 'MeshMapper Debug Log', + sharePositionOrigin: sharePositionOrigin, ), ); debugLog('[DEBUG] Shared log: ${file.path}, status: ${result.status}'); @@ -12053,7 +12056,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// to Files, Drive, email, …) — the mobile equivalent of the web JSON /// download. Mirrors [shareDebugLog]; writes the same pretty JSON the web /// path produces. Throws on failure so the caller can surface an error. - Future shareOfflineSession(String filename) async { + /// + /// [sharePositionOrigin] is the global rect of the tapped button. iPad + /// anchors the share popover there, and share_plus refuses to open the + /// sheet on iPad without one. + Future shareOfflineSession(String filename, + {Rect? sharePositionOrigin}) async { final data = _offlineSessionService.getSessionData(filename); if (data == null) { throw Exception('Session "$filename" not found'); @@ -12066,6 +12074,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ShareParams( files: [XFile(file.path)], subject: 'MeshMapper Offline Session', + sharePositionOrigin: sharePositionOrigin, ), ); debugLog('[OFFLINE] Shared session: $filename, status: ${result.status}'); diff --git a/lib/screens/settings/about_settings_page.dart b/lib/screens/settings/about_settings_page.dart index 9396275e..7ff5cb41 100644 --- a/lib/screens/settings/about_settings_page.dart +++ b/lib/screens/settings/about_settings_page.dart @@ -12,6 +12,7 @@ import 'package:intl/intl.dart'; import '../../providers/app_state_provider.dart'; import '../../services/debug_file_logger.dart'; import '../../utils/constants.dart'; +import '../../utils/share_origin.dart'; import '../../widgets/app_toast.dart'; import '../../widgets/bug_report_dialog.dart'; import '../../widgets/upload_logs_dialog.dart'; @@ -292,10 +293,14 @@ class _AboutSettingsPageState extends State { _showLogViewer(context, appState, file), tooltip: 'View', ), - IconButton( - icon: const Icon(Icons.share, size: 20), - onPressed: () => appState.shareDebugLog(file), - tooltip: 'Share', + Builder( + builder: (buttonContext) => IconButton( + icon: const Icon(Icons.share, size: 20), + onPressed: () => appState.shareDebugLog(file, + sharePositionOrigin: + globalRectOf(buttonContext)), + tooltip: 'Share', + ), ), ], ), diff --git a/lib/screens/settings/data_settings_page.dart b/lib/screens/settings/data_settings_page.dart index e60ea4fe..8dacaa04 100644 --- a/lib/screens/settings/data_settings_page.dart +++ b/lib/screens/settings/data_settings_page.dart @@ -10,6 +10,8 @@ import '../../utils/web_file_helpers_stub.dart' import '../../providers/app_state_provider.dart'; import '../../services/offline_session_service.dart'; +import '../../utils/debug_logger_io.dart'; +import '../../utils/share_origin.dart'; import 'settings_section_card.dart'; /// Settings folder: Data. @@ -83,8 +85,8 @@ class DataSettingsPage extends StatelessWidget { context, appState, session.filename), onDelete: () => _confirmDeleteOfflineSession( context, appState, session.filename), - onDownload: () => _downloadOfflineSession( - context, appState, session.filename), + onDownload: (origin) => _downloadOfflineSession( + context, appState, session.filename, origin), )), ]), ], @@ -278,8 +280,8 @@ class DataSettingsPage extends StatelessWidget { ); } - Future _downloadOfflineSession( - BuildContext context, AppStateProvider appState, String filename) async { + Future _downloadOfflineSession(BuildContext context, + AppStateProvider appState, String filename, Rect? origin) async { try { final sessionData = appState.offlineSessionService.getSessionData(filename); @@ -317,9 +319,11 @@ class DataSettingsPage extends StatelessWidget { } else { // Mobile: write the JSON to a temp file and open the native share // sheet (Save to Files, Drive, email, …) — mirrors the debug-log share. - await appState.shareOfflineSession(filename); + await appState.shareOfflineSession(filename, + sharePositionOrigin: origin); } } catch (e) { + debugError('[OFFLINE] Failed to download session $filename: $e'); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -338,7 +342,9 @@ class _OfflineSessionTile extends StatelessWidget { final bool uploadEnabled; final VoidCallback onUpload; final VoidCallback onDelete; - final VoidCallback onDownload; + + /// Called with the download button's global rect (the iPad share anchor). + final ValueChanged onDownload; const _OfflineSessionTile({ required this.session, @@ -454,11 +460,13 @@ class _OfflineSessionTile extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ // Download button - always available - IconButton( - icon: const Icon(Icons.download), - onPressed: onDownload, - tooltip: 'Download JSON', - color: Colors.blue, + Builder( + builder: (buttonContext) => IconButton( + icon: const Icon(Icons.download), + onPressed: () => onDownload(globalRectOf(buttonContext)), + tooltip: 'Download JSON', + color: Colors.blue, + ), ), // Upload button - only when not uploaded if (!isUploaded) diff --git a/lib/utils/share_origin.dart b/lib/utils/share_origin.dart new file mode 100644 index 00000000..5350f19f --- /dev/null +++ b/lib/utils/share_origin.dart @@ -0,0 +1,11 @@ +import 'package:flutter/widgets.dart'; + +/// Global rect of the widget behind [context], or null before layout. +/// +/// Pass it as share_plus's `sharePositionOrigin`: iPad anchors the share +/// popover there and refuses to open the sheet without one. +Rect? globalRectOf(BuildContext context) { + final box = context.findRenderObject() as RenderBox?; + if (box == null || !box.hasSize) return null; + return box.localToGlobal(Offset.zero) & box.size; +}