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>
feat/427-localize-log-share
Strycher 2 months ago
parent b6c94efd5b
commit c16f31bf6a

@ -757,9 +757,11 @@
"debugLog_copyLog": "Copy log",
"debugLog_clearLog": "Clear log",
"debugLog_shareLog": "Share logs",
"debugLog_openLogsFolder": "Open logs folder",
"debugLog_saveLog": "Save logs",
"debugLog_downloadLog": "Download logs",
"debugLog_shareSubject": "Offband Meshcore logs",
"debugLog_fileLoggingUnavailable": "File logging is unavailable on this platform",
"debugLog_logSaved": "Log saved",
"debugLog_logUnavailable": "No log available to export",
"debugLog_copied": "Debug log copied",
"debugLog_bleCopied": "BLE log copied",
"debugLog_noEntries": "No debug logs yet",

@ -2806,11 +2806,17 @@ abstract class AppLocalizations {
/// **'Share logs'**
String get debugLog_shareLog;
/// No description provided for @debugLog_openLogsFolder.
/// No description provided for @debugLog_saveLog.
///
/// In en, this message translates to:
/// **'Open logs folder'**
String get debugLog_openLogsFolder;
/// **'Save logs'**
String get debugLog_saveLog;
/// No description provided for @debugLog_downloadLog.
///
/// In en, this message translates to:
/// **'Download logs'**
String get debugLog_downloadLog;
/// No description provided for @debugLog_shareSubject.
///
@ -2818,11 +2824,17 @@ abstract class AppLocalizations {
/// **'Offband Meshcore logs'**
String get debugLog_shareSubject;
/// No description provided for @debugLog_fileLoggingUnavailable.
/// No description provided for @debugLog_logSaved.
///
/// In en, this message translates to:
/// **'Log saved'**
String get debugLog_logSaved;
/// No description provided for @debugLog_logUnavailable.
///
/// In en, this message translates to:
/// **'File logging is unavailable on this platform'**
String get debugLog_fileLoggingUnavailable;
/// **'No log available to export'**
String get debugLog_logUnavailable;
/// No description provided for @debugLog_copied.
///

@ -1524,14 +1524,19 @@ class AppLocalizationsBg extends AppLocalizations {
String get debugLog_shareLog => 'Share logs';
@override
String get debugLog_openLogsFolder => 'Open logs folder';
String get debugLog_saveLog => 'Save logs';
@override
String get debugLog_downloadLog => 'Download logs';
@override
String get debugLog_shareSubject => 'Offband Meshcore logs';
@override
String get debugLog_fileLoggingUnavailable =>
'File logging is unavailable on this platform';
String get debugLog_logSaved => 'Log saved';
@override
String get debugLog_logUnavailable => 'No log available to export';
@override
String get debugLog_copied => 'Копирано лого за отстраняване на грешки';

@ -1521,14 +1521,19 @@ class AppLocalizationsDe extends AppLocalizations {
String get debugLog_shareLog => 'Share logs';
@override
String get debugLog_openLogsFolder => 'Open logs folder';
String get debugLog_saveLog => 'Save logs';
@override
String get debugLog_downloadLog => 'Download logs';
@override
String get debugLog_shareSubject => 'Offband Meshcore logs';
@override
String get debugLog_fileLoggingUnavailable =>
'File logging is unavailable on this platform';
String get debugLog_logSaved => 'Log saved';
@override
String get debugLog_logUnavailable => 'No log available to export';
@override
String get debugLog_copied => 'Debug-Protokoll kopiert';

@ -1493,14 +1493,19 @@ class AppLocalizationsEn extends AppLocalizations {
String get debugLog_shareLog => 'Share logs';
@override
String get debugLog_openLogsFolder => 'Open logs folder';
String get debugLog_saveLog => 'Save logs';
@override
String get debugLog_downloadLog => 'Download logs';
@override
String get debugLog_shareSubject => 'Offband Meshcore logs';
@override
String get debugLog_fileLoggingUnavailable =>
'File logging is unavailable on this platform';
String get debugLog_logSaved => 'Log saved';
@override
String get debugLog_logUnavailable => 'No log available to export';
@override
String get debugLog_copied => 'Debug log copied';

@ -1520,14 +1520,19 @@ class AppLocalizationsEs extends AppLocalizations {
String get debugLog_shareLog => 'Share logs';
@override
String get debugLog_openLogsFolder => 'Open logs folder';
String get debugLog_saveLog => 'Save logs';
@override
String get debugLog_downloadLog => 'Download logs';
@override
String get debugLog_shareSubject => 'Offband Meshcore logs';
@override
String get debugLog_fileLoggingUnavailable =>
'File logging is unavailable on this platform';
String get debugLog_logSaved => 'Log saved';
@override
String get debugLog_logUnavailable => 'No log available to export';
@override
String get debugLog_copied => 'Registro de depuración copiado';

@ -1525,14 +1525,19 @@ class AppLocalizationsFr extends AppLocalizations {
String get debugLog_shareLog => 'Share logs';
@override
String get debugLog_openLogsFolder => 'Open logs folder';
String get debugLog_saveLog => 'Save logs';
@override
String get debugLog_downloadLog => 'Download logs';
@override
String get debugLog_shareSubject => 'Offband Meshcore logs';
@override
String get debugLog_fileLoggingUnavailable =>
'File logging is unavailable on this platform';
String get debugLog_logSaved => 'Log saved';
@override
String get debugLog_logUnavailable => 'No log available to export';
@override
String get debugLog_copied => 'Journal de débogage copié';

@ -1528,14 +1528,19 @@ class AppLocalizationsHu extends AppLocalizations {
String get debugLog_shareLog => 'Share logs';
@override
String get debugLog_openLogsFolder => 'Open logs folder';
String get debugLog_saveLog => 'Save logs';
@override
String get debugLog_downloadLog => 'Download logs';
@override
String get debugLog_shareSubject => 'Offband Meshcore logs';
@override
String get debugLog_fileLoggingUnavailable =>
'File logging is unavailable on this platform';
String get debugLog_logSaved => 'Log saved';
@override
String get debugLog_logUnavailable => 'No log available to export';
@override
String get debugLog_copied => 'Hibajelentő napló másolva';

@ -1522,14 +1522,19 @@ class AppLocalizationsIt extends AppLocalizations {
String get debugLog_shareLog => 'Share logs';
@override
String get debugLog_openLogsFolder => 'Open logs folder';
String get debugLog_saveLog => 'Save logs';
@override
String get debugLog_downloadLog => 'Download logs';
@override
String get debugLog_shareSubject => 'Offband Meshcore logs';
@override
String get debugLog_fileLoggingUnavailable =>
'File logging is unavailable on this platform';
String get debugLog_logSaved => 'Log saved';
@override
String get debugLog_logUnavailable => 'No log available to export';
@override
String get debugLog_copied => 'Log di debug copiato';

@ -1459,14 +1459,19 @@ class AppLocalizationsJa extends AppLocalizations {
String get debugLog_shareLog => 'Share logs';
@override
String get debugLog_openLogsFolder => 'Open logs folder';
String get debugLog_saveLog => 'Save logs';
@override
String get debugLog_downloadLog => 'Download logs';
@override
String get debugLog_shareSubject => 'Offband Meshcore logs';
@override
String get debugLog_fileLoggingUnavailable =>
'File logging is unavailable on this platform';
String get debugLog_logSaved => 'Log saved';
@override
String get debugLog_logUnavailable => 'No log available to export';
@override
String get debugLog_copied => 'デバッグログをコピー';

@ -1454,14 +1454,19 @@ class AppLocalizationsKo extends AppLocalizations {
String get debugLog_shareLog => 'Share logs';
@override
String get debugLog_openLogsFolder => 'Open logs folder';
String get debugLog_saveLog => 'Save logs';
@override
String get debugLog_downloadLog => 'Download logs';
@override
String get debugLog_shareSubject => 'Offband Meshcore logs';
@override
String get debugLog_fileLoggingUnavailable =>
'File logging is unavailable on this platform';
String get debugLog_logSaved => 'Log saved';
@override
String get debugLog_logUnavailable => 'No log available to export';
@override
String get debugLog_copied => '디버깅 로그 복사';

@ -1510,14 +1510,19 @@ class AppLocalizationsNl extends AppLocalizations {
String get debugLog_shareLog => 'Share logs';
@override
String get debugLog_openLogsFolder => 'Open logs folder';
String get debugLog_saveLog => 'Save logs';
@override
String get debugLog_downloadLog => 'Download logs';
@override
String get debugLog_shareSubject => 'Offband Meshcore logs';
@override
String get debugLog_fileLoggingUnavailable =>
'File logging is unavailable on this platform';
String get debugLog_logSaved => 'Log saved';
@override
String get debugLog_logUnavailable => 'No log available to export';
@override
String get debugLog_copied => 'Debuglog gekopieerd';

@ -1532,14 +1532,19 @@ class AppLocalizationsPl extends AppLocalizations {
String get debugLog_shareLog => 'Share logs';
@override
String get debugLog_openLogsFolder => 'Open logs folder';
String get debugLog_saveLog => 'Save logs';
@override
String get debugLog_downloadLog => 'Download logs';
@override
String get debugLog_shareSubject => 'Offband Meshcore logs';
@override
String get debugLog_fileLoggingUnavailable =>
'File logging is unavailable on this platform';
String get debugLog_logSaved => 'Log saved';
@override
String get debugLog_logUnavailable => 'No log available to export';
@override
String get debugLog_copied => 'Skopiowano dziennik debugowania';

@ -1519,14 +1519,19 @@ class AppLocalizationsPt extends AppLocalizations {
String get debugLog_shareLog => 'Share logs';
@override
String get debugLog_openLogsFolder => 'Open logs folder';
String get debugLog_saveLog => 'Save logs';
@override
String get debugLog_downloadLog => 'Download logs';
@override
String get debugLog_shareSubject => 'Offband Meshcore logs';
@override
String get debugLog_fileLoggingUnavailable =>
'File logging is unavailable on this platform';
String get debugLog_logSaved => 'Log saved';
@override
String get debugLog_logUnavailable => 'No log available to export';
@override
String get debugLog_copied => 'Log de depuração copiado';

@ -1520,14 +1520,19 @@ class AppLocalizationsRu extends AppLocalizations {
String get debugLog_shareLog => 'Share logs';
@override
String get debugLog_openLogsFolder => 'Open logs folder';
String get debugLog_saveLog => 'Save logs';
@override
String get debugLog_downloadLog => 'Download logs';
@override
String get debugLog_shareSubject => 'Offband Meshcore logs';
@override
String get debugLog_fileLoggingUnavailable =>
'File logging is unavailable on this platform';
String get debugLog_logSaved => 'Log saved';
@override
String get debugLog_logUnavailable => 'No log available to export';
@override
String get debugLog_copied => 'Журнал отладки скопирован';

@ -1510,14 +1510,19 @@ class AppLocalizationsSk extends AppLocalizations {
String get debugLog_shareLog => 'Share logs';
@override
String get debugLog_openLogsFolder => 'Open logs folder';
String get debugLog_saveLog => 'Save logs';
@override
String get debugLog_downloadLog => 'Download logs';
@override
String get debugLog_shareSubject => 'Offband Meshcore logs';
@override
String get debugLog_fileLoggingUnavailable =>
'File logging is unavailable on this platform';
String get debugLog_logSaved => 'Log saved';
@override
String get debugLog_logUnavailable => 'No log available to export';
@override
String get debugLog_copied => 'Záznam ladenia skopírovaný';

@ -1508,14 +1508,19 @@ class AppLocalizationsSl extends AppLocalizations {
String get debugLog_shareLog => 'Share logs';
@override
String get debugLog_openLogsFolder => 'Open logs folder';
String get debugLog_saveLog => 'Save logs';
@override
String get debugLog_downloadLog => 'Download logs';
@override
String get debugLog_shareSubject => 'Offband Meshcore logs';
@override
String get debugLog_fileLoggingUnavailable =>
'File logging is unavailable on this platform';
String get debugLog_logSaved => 'Log saved';
@override
String get debugLog_logUnavailable => 'No log available to export';
@override
String get debugLog_copied => 'Beležka kopirana.';

@ -1502,14 +1502,19 @@ class AppLocalizationsSv extends AppLocalizations {
String get debugLog_shareLog => 'Share logs';
@override
String get debugLog_openLogsFolder => 'Open logs folder';
String get debugLog_saveLog => 'Save logs';
@override
String get debugLog_downloadLog => 'Download logs';
@override
String get debugLog_shareSubject => 'Offband Meshcore logs';
@override
String get debugLog_fileLoggingUnavailable =>
'File logging is unavailable on this platform';
String get debugLog_logSaved => 'Log saved';
@override
String get debugLog_logUnavailable => 'No log available to export';
@override
String get debugLog_copied => 'Felsökningslogg kopierad';

@ -1514,14 +1514,19 @@ class AppLocalizationsUk extends AppLocalizations {
String get debugLog_shareLog => 'Share logs';
@override
String get debugLog_openLogsFolder => 'Open logs folder';
String get debugLog_saveLog => 'Save logs';
@override
String get debugLog_downloadLog => 'Download logs';
@override
String get debugLog_shareSubject => 'Offband Meshcore logs';
@override
String get debugLog_fileLoggingUnavailable =>
'File logging is unavailable on this platform';
String get debugLog_logSaved => 'Log saved';
@override
String get debugLog_logUnavailable => 'No log available to export';
@override
String get debugLog_copied => 'Журнал налагодження скопійовано';

@ -1440,14 +1440,19 @@ class AppLocalizationsZh extends AppLocalizations {
String get debugLog_shareLog => 'Share logs';
@override
String get debugLog_openLogsFolder => 'Open logs folder';
String get debugLog_saveLog => 'Save logs';
@override
String get debugLog_downloadLog => 'Download logs';
@override
String get debugLog_shareSubject => 'Offband Meshcore logs';
@override
String get debugLog_fileLoggingUnavailable =>
'File logging is unavailable on this platform';
String get debugLog_logSaved => 'Log saved';
@override
String get debugLog_logUnavailable => 'No log available to export';
@override
String get debugLog_copied => '调试日志已复制';

@ -26,7 +26,15 @@ class AppDebugLogScreen extends StatelessWidget {
IconButton(
tooltip: LogExport.tooltip(context),
icon: Icon(LogExport.icon),
onPressed: () => LogExport.shareLogs(context),
onPressed: () => LogExport.shareLogs(
context,
webContent: () => entries
.map(
(entry) =>
'[${entry.formattedTime}] [${entry.levelLabel}] [${entry.tag}] ${entry.message}',
)
.join('\n'),
),
),
IconButton(
tooltip: context.l10n.debugLog_copyLog,

@ -37,7 +37,15 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
IconButton(
tooltip: LogExport.tooltip(context),
icon: Icon(LogExport.icon),
onPressed: () => LogExport.shareLogs(context),
onPressed: () => LogExport.shareLogs(
context,
webContent: () => entries
.map(
(entry) =>
'${entry.description}\n${entry.hexPreview}\n',
)
.join('\n'),
),
),
IconButton(
tooltip: context.l10n.debugLog_copyLog,

@ -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;
static String tooltip(BuildContext context) => _isMobile
? context.l10n.debugLog_shareLog
: context.l10n.debugLog_openLogsFolder;
/// 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;
/// 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) {
if (kIsWeb) return context.l10n.debugLog_downloadLog;
return _isMobile
? context.l10n.debugLog_shareLog
: context.l10n.debugLog_saveLog;
}
/// 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);
}

@ -345,6 +345,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: "1d45e9910f68c16eb0c74f0b10097ad81aed516ea28054c027137e8f7d75e840"
url: "https://pub.dev"
source: hosted
version: "0.5.2+9"
file_selector_ios:
dependency: transitive
description:
name: file_selector_ios
sha256: e2ecf2885c121691ce13b60db3508f53c01f869fb6e8dc5c1cfa771e4c46aeca
url: "https://pub.dev"
source: hosted
version: "0.5.3+5"
file_selector_linux:
dependency: transitive
description:
name: file_selector_linux
sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
url: "https://pub.dev"
source: hosted
version: "0.9.4"
file_selector_macos:
dependency: transitive
description:
name: file_selector_macos
sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
url: "https://pub.dev"
source: hosted
version: "0.9.5"
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: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
url: "https://pub.dev"
source: hosted
version: "0.9.3+5"
fixnum:
dependency: transitive
description:

@ -66,6 +66,7 @@ dependencies:
yaml: ^3.1.2 # Parse importable device config profiles (#403)
path_provider: ^2.1.6
share_plus: ^12.0.1
file_selector: ^1.0.3 # Native Save As dialog for log export on desktop (#433)
build_pipe: ^0.3.1
material_symbols_icons: ^4.2906.0
web: ^1.1.1

Loading…
Cancel
Save

Powered by TurnKey Linux.