fix(#433): log share = Save As on desktop, download on web
The share helper only invoked the OS share sheet on mobile; on all desktop it opened the containing folder, which for a temp-dir capture dumped the user into TEMP amid unrelated files. Now: - Android/iOS: OS share sheet (unchanged). - Windows/macOS/Linux: native Save As dialog (file_selector) writing the file to a user-chosen location. - Web: browser download of the log text (no on-disk file on web). Adds file_selector; web download via a js_interop helper behind a conditional import. Folds in #427 (localized share strings). LogExport.shareFile keeps a compatible signature for the serial-capture screen (#430). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>pull/436/head
parent
2565f0a880
commit
9aa83da995
@ -1,54 +1,126 @@
|
||||
import 'dart:io' show File;
|
||||
|
||||
import 'package:file_selector/file_selector.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../l10n/l10n.dart';
|
||||
import '../services/file_log_service.dart';
|
||||
import 'log_web_download.dart';
|
||||
|
||||
/// Shared log export for the App-log and BLE-log screens (#393, #97).
|
||||
/// Shared log export for the App-log and BLE-log screens (#393, #97) and any
|
||||
/// other on-disk log file (e.g. a serial capture). Per platform:
|
||||
///
|
||||
/// - Android / iOS: OS share sheet ([SharePlus]) with the file.
|
||||
/// - Windows / macOS / Linux: native "Save As" dialog ([getSaveLocation]),
|
||||
/// writing the file to a location the user picks.
|
||||
/// - Web: browser download of the log text (there is no on-disk file on web).
|
||||
///
|
||||
/// The on-disk log file ([FileLogService]) holds both the app log and BLE
|
||||
/// frames as one combined stream, so either screen exports the same complete
|
||||
/// file. Both screens use these helpers so their Share action can't drift.
|
||||
/// Both log screens use these helpers so their Share action can't drift. #433
|
||||
class LogExport {
|
||||
const LogExport._();
|
||||
|
||||
static const _defaultFileName = 'offband-log.txt';
|
||||
|
||||
static bool get _isMobile =>
|
||||
defaultTargetPlatform == TargetPlatform.android ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS;
|
||||
|
||||
/// Platform-standard glyph: Android share (connected nodes), iOS square+arrow,
|
||||
/// desktop reveals the saved file (folder). #396
|
||||
static IconData get icon => defaultTargetPlatform == TargetPlatform.android
|
||||
? Icons.share
|
||||
: _isMobile
|
||||
? Icons.ios_share
|
||||
: Icons.folder_open;
|
||||
/// Platform-standard action glyph: share (mobile), save (desktop),
|
||||
/// download (web).
|
||||
static IconData get icon {
|
||||
if (kIsWeb) return Icons.download;
|
||||
if (defaultTargetPlatform == TargetPlatform.android) return Icons.share;
|
||||
if (_isMobile) return Icons.ios_share;
|
||||
return Icons.save_alt;
|
||||
}
|
||||
|
||||
static String tooltip(BuildContext context) => _isMobile
|
||||
static String tooltip(BuildContext context) {
|
||||
if (kIsWeb) return context.l10n.debugLog_downloadLog;
|
||||
return _isMobile
|
||||
? context.l10n.debugLog_shareLog
|
||||
: context.l10n.debugLog_openLogsFolder;
|
||||
: context.l10n.debugLog_saveLog;
|
||||
}
|
||||
|
||||
/// Flush the on-disk log and hand it to the OS share sheet (mobile), or open
|
||||
/// the logs folder in the file manager (desktop). Shows a snackbar if file
|
||||
/// logging is unavailable (e.g. web).
|
||||
static Future<void> shareLogs(BuildContext context) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final unavailableMessage = context.l10n.debugLog_fileLoggingUnavailable;
|
||||
final subject = context.l10n.debugLog_shareSubject;
|
||||
/// Export the combined on-disk app+BLE log (the App/BLE log screens). On web
|
||||
/// there is no on-disk file, so [webContent] supplies the text to download.
|
||||
static Future<void> shareLogs(
|
||||
BuildContext context, {
|
||||
String Function()? webContent,
|
||||
}) async {
|
||||
if (kIsWeb) {
|
||||
await _downloadOnWeb(
|
||||
context,
|
||||
content: webContent?.call() ?? '',
|
||||
fileName: _defaultFileName,
|
||||
);
|
||||
return;
|
||||
}
|
||||
final file = await FileLogService.instance.flushAndGetActiveFile();
|
||||
if (!context.mounted) return;
|
||||
await _exportFile(
|
||||
context,
|
||||
file: file,
|
||||
fileName: _defaultFileName,
|
||||
subject: context.l10n.debugLog_shareSubject,
|
||||
);
|
||||
}
|
||||
|
||||
/// Export an already-written file (e.g. a serial capture dump). Native only;
|
||||
/// callers on web should use [shareLogs] with a `webContent` builder instead.
|
||||
static Future<void> shareFile(
|
||||
BuildContext context,
|
||||
File file, {
|
||||
String? subject,
|
||||
String? fileName,
|
||||
}) async {
|
||||
await _exportFile(
|
||||
context,
|
||||
file: file,
|
||||
fileName: fileName ?? file.uri.pathSegments.last,
|
||||
subject: subject ?? context.l10n.debugLog_shareSubject,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> _exportFile(
|
||||
BuildContext context, {
|
||||
required File? file,
|
||||
required String fileName,
|
||||
required String subject,
|
||||
}) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final unavailable = context.l10n.debugLog_logUnavailable;
|
||||
final savedMessage = context.l10n.debugLog_logSaved;
|
||||
if (file == null) {
|
||||
messenger.showSnackBar(SnackBar(content: Text(unavailableMessage)));
|
||||
messenger.showSnackBar(SnackBar(content: Text(unavailable)));
|
||||
return;
|
||||
}
|
||||
if (_isMobile) {
|
||||
await SharePlus.instance.share(
|
||||
ShareParams(subject: subject, files: [XFile(file.path)]),
|
||||
);
|
||||
} else {
|
||||
final dir = FileLogService.instance.logDir;
|
||||
if (dir != null) await launchUrl(Uri.file(dir.path));
|
||||
return;
|
||||
}
|
||||
// Desktop: let the user pick where to save the file.
|
||||
final location = await getSaveLocation(suggestedName: fileName);
|
||||
if (location == null) return; // user cancelled
|
||||
await file.copy(location.path);
|
||||
messenger.showSnackBar(SnackBar(content: Text(savedMessage)));
|
||||
}
|
||||
|
||||
static Future<void> _downloadOnWeb(
|
||||
BuildContext context, {
|
||||
required String content,
|
||||
required String fileName,
|
||||
}) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
if (content.isEmpty) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.debugLog_logUnavailable)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
downloadTextFile(fileName, content);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
// Trigger a browser download of `content` as `fileName`. Web only; the native
|
||||
// stub is a no-op and is never reached (callers guard with `kIsWeb`). #433
|
||||
export 'log_web_download_stub.dart'
|
||||
if (dart.library.js_interop) 'log_web_download_web.dart';
|
||||
@ -0,0 +1,3 @@
|
||||
/// Native no-op: browser download only exists on web. Never called on native
|
||||
/// (callers guard with `kIsWeb`). #433
|
||||
void downloadTextFile(String fileName, String content) {}
|
||||
@ -0,0 +1,18 @@
|
||||
import 'dart:js_interop';
|
||||
|
||||
import 'package:web/web.dart' as web;
|
||||
|
||||
/// Trigger a browser download of [content] (UTF-8 text) as [fileName] by
|
||||
/// creating an in-memory Blob and clicking a transient anchor. #433
|
||||
void downloadTextFile(String fileName, String content) {
|
||||
final blob = web.Blob(
|
||||
<JSAny>[content.toJS].toJS,
|
||||
web.BlobPropertyBag(type: 'text/plain;charset=utf-8'),
|
||||
);
|
||||
final url = web.URL.createObjectURL(blob);
|
||||
final anchor = web.document.createElement('a') as web.HTMLAnchorElement
|
||||
..href = url
|
||||
..download = fileName;
|
||||
anchor.click();
|
||||
web.URL.revokeObjectURL(url);
|
||||
}
|
||||
Loading…
Reference in new issue