feat(#574): stock config export screen
Mirrors stock's Export Config screen: section list, Select All and Deselect All, everything checked by default. The asymmetry with import, which will start with nothing checked, is stock's own and is deliberate: opt out on the way out, opt in on the way in. A section the user ticked that could not be gathered is shown before the file is written, with its specific reason, and the user chooses whether to export anyway. Firmware-built-without-it reads differently from device-did-not-answer because only one of them is worth retrying. The screen also states plainly that this is the stock format and cannot carry Offband-only data, so importing the file back will not restore it. File naming follows stock, <name>_meshcore_config_<stamp>.json, and is sanitized for the filesystem without touching the name written inside the file, which has to round-trip exactly. Real device names in the reference corpus contain emoji and a trailing space, both covered by tests. Writing reuses LogExport for the per-platform mechanism (share sheet on mobile, Save As on desktop, download on web) rather than adding a second export path that could drift. The temp file can contain the node private key, so it is written under the app's own temp directory. The public key is abbreviated on screen; the private key is never rendered. 6 tests on the file name.epic/568-export-import
parent
ee0f7a7d52
commit
ba37deffaf
@ -0,0 +1,310 @@
|
||||
/// Stock-compatible config export (#574, epic #568).
|
||||
///
|
||||
/// Mirrors the stock app's Export Config screen: a list of sections, Select All
|
||||
/// and Deselect All, and **everything checked by default**. The asymmetry with
|
||||
/// the import screen (which starts with nothing checked) is deliberate and
|
||||
/// copied from stock: opt out on the way out, opt in on the way in.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../services/stock_config_export_service.dart';
|
||||
import '../utils/stock_config_file.dart';
|
||||
import '../widgets/adaptive_app_bar_title.dart';
|
||||
|
||||
class ExportConfigScreen extends StatefulWidget {
|
||||
const ExportConfigScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ExportConfigScreen> createState() => _ExportConfigScreenState();
|
||||
}
|
||||
|
||||
class _ExportConfigScreenState extends State<ExportConfigScreen> {
|
||||
/// Every section starts selected, matching stock.
|
||||
final Set<StockConfigSection> _selected = StockConfigSection.values.toSet();
|
||||
bool _busy = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: AdaptiveAppBarTitle(l10n.exportConfig_title),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.check),
|
||||
onPressed: _busy ? null : () => _export(connector),
|
||||
tooltip: l10n.exportConfig_title,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
_Banner(text: l10n.exportConfig_instruction),
|
||||
_Banner(text: l10n.exportConfig_lossyNotice, subdued: true),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
setState(() => _selected.addAll(StockConfigSection.values)),
|
||||
child: Text(l10n.stockConfig_selectAll),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => setState(_selected.clear),
|
||||
child: Text(l10n.stockConfig_deselectAll),
|
||||
),
|
||||
],
|
||||
),
|
||||
for (final section in StockConfigSection.values)
|
||||
_SectionTile(
|
||||
title: sectionLabel(context, section, connector),
|
||||
subtitle: _subtitleFor(section, connector),
|
||||
selected: _selected.contains(section),
|
||||
onChanged: (value) => setState(() {
|
||||
if (value) {
|
||||
_selected.add(section);
|
||||
} else {
|
||||
_selected.remove(section);
|
||||
}
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String? _subtitleFor(
|
||||
StockConfigSection section,
|
||||
MeshCoreConnector connector,
|
||||
) {
|
||||
final l10n = context.l10n;
|
||||
switch (section) {
|
||||
case StockConfigSection.name:
|
||||
return connector.selfName;
|
||||
case StockConfigSection.identity:
|
||||
final key = connector.selfPublicKeyHex;
|
||||
final abbreviated = key.length > 12
|
||||
? '${key.substring(0, 6)}...${key.substring(key.length - 6)}'
|
||||
: key;
|
||||
// The private key is never rendered, not even abbreviated.
|
||||
return '${l10n.stockConfig_identityWarning}\n'
|
||||
'${l10n.stockConfig_publicKeyLabel(abbreviated)}\n'
|
||||
'${l10n.stockConfig_privateKeyHidden}';
|
||||
case StockConfigSection.radioSettings:
|
||||
final freq = connector.currentFreqHz;
|
||||
final bw = connector.currentBwHz;
|
||||
if (freq == null || bw == null) return null;
|
||||
// currentFreqHz is kHz despite its name; currentBwHz really is Hz.
|
||||
return '${l10n.settings_frequency}: ${freq / 1000} MHz\n'
|
||||
'${l10n.settings_bandwidth}: ${bw / 1000} kHz\n'
|
||||
'${l10n.settings_spreadingFactor}: ${connector.currentSf}\n'
|
||||
'${l10n.settings_codingRate}: ${connector.currentCr}\n'
|
||||
'${l10n.settings_txPower}: ${connector.currentTxPower}';
|
||||
case StockConfigSection.positionSettings:
|
||||
return '${connector.selfLatitude ?? 0}, ${connector.selfLongitude ?? 0}';
|
||||
case StockConfigSection.otherSettings:
|
||||
return null;
|
||||
case StockConfigSection.autoAddSettings:
|
||||
return null;
|
||||
case StockConfigSection.channels:
|
||||
return l10n.exportConfig_allChannels;
|
||||
case StockConfigSection.contacts:
|
||||
return l10n.exportConfig_allContacts;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _export(MeshCoreConnector connector) async {
|
||||
final l10n = context.l10n;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
|
||||
if (!connector.isConnected) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.exportConfig_notConnected)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (_selected.isEmpty) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.exportConfig_nothingSelected)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
final result = await StockConfigExportService(
|
||||
connector,
|
||||
).build(sections: Set.of(_selected));
|
||||
|
||||
// A section the user asked for that could not be gathered is surfaced
|
||||
// before the file is written, never dropped quietly.
|
||||
if (!result.isComplete) {
|
||||
if (!mounted) return;
|
||||
final proceed = await _confirmOmissions(result);
|
||||
if (proceed != true) return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
|
||||
await saveStockConfigFile(
|
||||
context,
|
||||
json: result.config.encode(),
|
||||
fileName: stockConfigFileName(connector.selfName, DateTime.now()),
|
||||
subject: l10n.exportConfig_shareSubject,
|
||||
);
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.exportConfig_failed('$e'))),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool?> _confirmOmissions(StockConfigExportResult result) {
|
||||
final l10n = context.l10n;
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(l10n.exportConfig_omittedTitle),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final entry in result.omitted.entries)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Text(
|
||||
_omissionText(
|
||||
context,
|
||||
sectionLabel(context, entry.key, connector),
|
||||
entry.value,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(l10n.exportConfig_cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(l10n.exportConfig_exportAnyway),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _omissionText(
|
||||
BuildContext context,
|
||||
String section,
|
||||
StockConfigOmission reason,
|
||||
) {
|
||||
final l10n = context.l10n;
|
||||
switch (reason) {
|
||||
case StockConfigOmission.unsupported:
|
||||
return l10n.exportConfig_omittedUnsupported(section);
|
||||
case StockConfigOmission.noReply:
|
||||
return l10n.exportConfig_omittedNoReply(section);
|
||||
case StockConfigOmission.rejected:
|
||||
return l10n.exportConfig_omittedRejected(section);
|
||||
case StockConfigOmission.unavailable:
|
||||
return l10n.exportConfig_omittedUnavailable(section);
|
||||
}
|
||||
}
|
||||
|
||||
/// Section titles, shared with the import screen so the two cannot drift.
|
||||
String sectionLabel(
|
||||
BuildContext context,
|
||||
StockConfigSection section,
|
||||
MeshCoreConnector connector,
|
||||
) {
|
||||
final l10n = context.l10n;
|
||||
switch (section) {
|
||||
case StockConfigSection.name:
|
||||
return l10n.stockConfig_sectionName;
|
||||
case StockConfigSection.identity:
|
||||
return l10n.stockConfig_sectionIdentity;
|
||||
case StockConfigSection.radioSettings:
|
||||
return l10n.stockConfig_sectionRadio;
|
||||
case StockConfigSection.positionSettings:
|
||||
return l10n.stockConfig_sectionPosition;
|
||||
case StockConfigSection.otherSettings:
|
||||
return l10n.stockConfig_sectionOther;
|
||||
case StockConfigSection.autoAddSettings:
|
||||
return l10n.stockConfig_sectionAutoAdd;
|
||||
case StockConfigSection.channels:
|
||||
return l10n.stockConfig_sectionChannels(
|
||||
connector.channels.where((c) => !c.isEmpty).length,
|
||||
);
|
||||
case StockConfigSection.contacts:
|
||||
return l10n.stockConfig_sectionContacts(connector.contacts.length);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionTile extends StatelessWidget {
|
||||
const _SectionTile({
|
||||
required this.title,
|
||||
required this.selected,
|
||||
required this.onChanged,
|
||||
this.subtitle,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final bool selected;
|
||||
final ValueChanged<bool> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CheckboxListTile(
|
||||
value: selected,
|
||||
onChanged: (value) => onChanged(value ?? false),
|
||||
controlAffinity: ListTileControlAffinity.trailing,
|
||||
isThreeLine: subtitle != null,
|
||||
title: Text(title),
|
||||
subtitle: subtitle == null ? null : Text(subtitle!),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Banner extends StatelessWidget {
|
||||
const _Banner({required this.text, this.subdued = false});
|
||||
|
||||
final String text;
|
||||
final bool subdued;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 18, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: subdued ? theme.textTheme.bodySmall : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,70 @@
|
||||
/// Naming and writing of stock-compatible config files (#574, epic #568).
|
||||
library;
|
||||
|
||||
import 'dart:io' show File;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'log_export.dart';
|
||||
import 'log_web_download.dart';
|
||||
|
||||
/// Characters Windows forbids in a file name, plus the path separators.
|
||||
final _illegalFileNameChars = RegExp(r'[<>:"/\\|?*\x00-\x1F]');
|
||||
|
||||
/// Builds the file name stock uses: `<device name>_meshcore_config_<stamp>`.
|
||||
///
|
||||
/// Real device names contain emoji and, in at least one observed case, a
|
||||
/// trailing space, so the name is sanitized for the filesystem here. The name
|
||||
/// written *inside* the file is never touched: it must round-trip exactly.
|
||||
String stockConfigFileName(String? deviceName, DateTime at) {
|
||||
final safeName = _sanitizeForFileName(deviceName ?? '');
|
||||
final stamp = [
|
||||
at.year.toString().padLeft(4, '0'),
|
||||
at.month.toString().padLeft(2, '0'),
|
||||
at.day.toString().padLeft(2, '0'),
|
||||
].join('-');
|
||||
final time = [
|
||||
at.hour.toString().padLeft(2, '0'),
|
||||
at.minute.toString().padLeft(2, '0'),
|
||||
at.second.toString().padLeft(2, '0'),
|
||||
].join();
|
||||
final prefix = safeName.isEmpty ? '' : '${safeName}_';
|
||||
return '${prefix}meshcore_config_$stamp-$time.json';
|
||||
}
|
||||
|
||||
String _sanitizeForFileName(String name) {
|
||||
final cleaned = name.replaceAll(_illegalFileNameChars, '');
|
||||
// Windows also rejects names ending in a space or a dot.
|
||||
return cleaned.replaceAll(RegExp(r'[ .]+$'), '').trim();
|
||||
}
|
||||
|
||||
/// Writes [json] out as [fileName], using each platform's normal mechanism:
|
||||
/// the share sheet on mobile, a Save As dialog on desktop, a browser download
|
||||
/// on web. Reuses [LogExport], which already owns that per-platform logic, so
|
||||
/// there is only one place where export behavior can drift.
|
||||
Future<void> saveStockConfigFile(
|
||||
BuildContext context, {
|
||||
required String json,
|
||||
required String fileName,
|
||||
String? subject,
|
||||
}) async {
|
||||
if (kIsWeb) {
|
||||
downloadTextFile(fileName, json);
|
||||
return;
|
||||
}
|
||||
// A temp file is the handoff format LogExport works in. It holds the node's
|
||||
// private key when the identity section was included, so it is written under
|
||||
// the app's own temp directory and never anywhere shared.
|
||||
final dir = await getTemporaryDirectory();
|
||||
final file = File('${dir.path}/$fileName');
|
||||
await file.writeAsString(json, flush: true);
|
||||
if (!context.mounted) return;
|
||||
await LogExport.shareFile(
|
||||
context,
|
||||
file,
|
||||
subject: subject,
|
||||
fileName: fileName,
|
||||
);
|
||||
}
|
||||
Loading…
Reference in new issue