You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
meshcore-client/lib/screens/import_config_screen.dart

354 lines
12 KiB

/// Stock-compatible config import (#576, epic #568).
///
/// Mirrors the stock app's Import Config screen, including the detail that
/// **nothing is checked by default**. Export opts out, import opts in; that
/// asymmetry is stock's and it is the safe default in each direction.
///
/// The per-section wording is stock's own, because the behavior is stock's own:
/// contacts are upserted, existing channels are never overwritten, and
/// importing an identity is destructive.
library;
import 'dart:convert';
import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../models/stock_config.dart';
import '../services/stock_config_export_service.dart' show StockConfigSection;
import '../services/stock_config_import_service.dart';
import '../widgets/adaptive_app_bar_title.dart';
import 'export_config_screen.dart' show sectionLabel;
class ImportConfigScreen extends StatefulWidget {
const ImportConfigScreen({super.key});
@override
State<ImportConfigScreen> createState() => _ImportConfigScreenState();
}
class _ImportConfigScreenState extends State<ImportConfigScreen> {
StockConfig? _config;
/// Starts empty: import is opt-in, matching stock.
final Set<StockConfigSection> _selected = {};
bool _busy = false;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final connector = context.watch<MeshCoreConnector>();
final config = _config;
return Scaffold(
appBar: AppBar(
title: AdaptiveAppBarTitle(l10n.importConfig_title),
centerTitle: true,
actions: [
if (config != null)
IconButton(
icon: const Icon(Icons.check),
onPressed: _busy ? null : () => _import(connector, config),
tooltip: l10n.importConfig_title,
),
],
),
body: config == null
? Center(
child: FilledButton.icon(
onPressed: _busy ? null : _pickFile,
icon: const Icon(Icons.folder_open),
label: Text(l10n.importConfig_chooseFile),
),
)
: ListView(
children: [
_Banner(text: l10n.importConfig_instruction),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextButton(
onPressed: () =>
setState(() => _selected.addAll(_present(config))),
child: Text(l10n.stockConfig_selectAll),
),
TextButton(
onPressed: () => setState(_selected.clear),
child: Text(l10n.stockConfig_deselectAll),
),
],
),
// Only sections the file actually carries are offered.
for (final section in _present(config))
CheckboxListTile(
value: _selected.contains(section),
controlAffinity: ListTileControlAffinity.trailing,
isThreeLine: _noteFor(section) != null,
title: Text(_titleFor(section, config, connector)),
subtitle: _noteFor(section) == null
? null
: Text(_noteFor(section)!),
onChanged: (value) => setState(() {
if (value ?? false) {
_selected.add(section);
} else {
_selected.remove(section);
}
}),
),
],
),
);
}
/// The sections this particular file carries. A stock export omits whatever
/// the exporting user deselected, so an absent section is normal.
List<StockConfigSection> _present(StockConfig config) {
return [
if (config.name != null) StockConfigSection.name,
if (config.privateKey != null) StockConfigSection.identity,
if (config.radioSettings != null) StockConfigSection.radioSettings,
if (config.positionSettings != null) StockConfigSection.positionSettings,
if (config.otherSettings != null) StockConfigSection.otherSettings,
if (config.autoAddSettings != null) StockConfigSection.autoAddSettings,
if (config.channels != null) StockConfigSection.channels,
if (config.contacts != null) StockConfigSection.contacts,
];
}
String _titleFor(
StockConfigSection section,
StockConfig config,
MeshCoreConnector connector,
) {
final l10n = context.l10n;
// Channel and contact counts come from the FILE here, not the device.
switch (section) {
case StockConfigSection.channels:
return l10n.stockConfig_sectionChannels(config.channels?.length ?? 0);
case StockConfigSection.contacts:
return l10n.stockConfig_sectionContacts(config.contacts?.length ?? 0);
default:
return sectionLabel(context, section, connector);
}
}
String? _noteFor(StockConfigSection section) {
final l10n = context.l10n;
switch (section) {
case StockConfigSection.identity:
return l10n.importConfig_identityWarning;
case StockConfigSection.channels:
return l10n.importConfig_channelsNote;
case StockConfigSection.contacts:
return l10n.importConfig_contactsNote;
default:
return null;
}
}
Future<void> _pickFile() async {
final l10n = context.l10n;
final messenger = ScaffoldMessenger.of(context);
setState(() => _busy = true);
try {
final file = await openFile(
acceptedTypeGroups: const [
XTypeGroup(label: 'JSON', extensions: ['json']),
],
);
if (file == null) return;
final source = utf8.decode(
await file.readAsBytes(),
allowMalformed: true,
);
final parsed = StockConfig.parse(source);
if (!mounted) return;
setState(() {
_config = parsed;
_selected.clear();
});
} on StockConfigFormatException catch (e) {
// The exception carries the offending JSON path, so the user is told
// which part of the file is wrong rather than just "invalid".
messenger.showSnackBar(
SnackBar(content: Text(l10n.importConfig_parseFailed(e.toString()))),
);
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text(l10n.importConfig_parseFailed('$e'))),
);
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _import(MeshCoreConnector connector, StockConfig config) async {
final l10n = context.l10n;
final messenger = ScaffoldMessenger.of(context);
if (!connector.isConnected) {
messenger.showSnackBar(
SnackBar(content: Text(l10n.importConfig_notConnected)),
);
return;
}
if (_selected.isEmpty) {
messenger.showSnackBar(
SnackBar(content: Text(l10n.importConfig_nothingSelected)),
);
return;
}
// Replacing the node identity is irreversible, so it gets its own explicit
// confirmation rather than riding along with the rest of the selection.
if (_selected.contains(StockConfigSection.identity)) {
final confirmed = await _confirmIdentityOverwrite();
if (confirmed != true) return;
if (!mounted) return;
}
setState(() => _busy = true);
try {
final result = await StockConfigImportService(
connector,
).apply(config: config, sections: Set.of(_selected));
if (!mounted) return;
await _showResult(result, connector);
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<bool?> _confirmIdentityOverwrite() {
final l10n = context.l10n;
return showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.importConfig_confirmIdentityTitle),
content: Text(l10n.importConfig_confirmIdentityBody),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(l10n.importConfig_cancel),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(l10n.importConfig_confirmIdentityAccept),
),
],
),
);
}
/// Shows what actually happened. Skipped channels and unapplied sections are
/// listed by name with a reason; an import that quietly did less than the
/// user asked for is the silent failure SAFELANE section 6 forbids.
Future<void> _showResult(
StockConfigImportResult result,
MeshCoreConnector connector,
) {
final l10n = context.l10n;
return showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.importConfig_resultTitle),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.importConfig_resultCounts(
result.contactsWritten,
result.channelsAdded,
),
),
if (result.failed.isNotEmpty) ...[
const SizedBox(height: 12),
Text(
l10n.importConfig_resultFailedHeading,
style: Theme.of(context).textTheme.titleSmall,
),
for (final entry in result.failed.entries)
Text(
l10n.importConfig_entryLine(
sectionLabel(context, entry.key, connector),
_reasonText(context, entry.value),
),
),
],
if (result.skippedChannels.isNotEmpty) ...[
const SizedBox(height: 12),
Text(
l10n.importConfig_resultSkippedHeading,
style: Theme.of(context).textTheme.titleSmall,
),
for (final skipped in result.skippedChannels)
Text(
l10n.importConfig_entryLine(
skipped.name,
_reasonText(context, skipped.reason),
),
),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.importConfig_close),
),
],
),
);
}
}
String _reasonText(BuildContext context, StockConfigImportIssue reason) {
final l10n = context.l10n;
switch (reason) {
case StockConfigImportIssue.absentFromFile:
return l10n.importConfig_reasonAbsentFromFile;
case StockConfigImportIssue.noReply:
return l10n.importConfig_reasonNoReply;
case StockConfigImportIssue.unsupported:
return l10n.importConfig_reasonUnsupported;
case StockConfigImportIssue.rejected:
return l10n.importConfig_reasonRejected;
case StockConfigImportIssue.alreadyPresent:
return l10n.importConfig_reasonAlreadyPresent;
case StockConfigImportIssue.noFreeSlot:
return l10n.importConfig_reasonNoFreeSlot;
case StockConfigImportIssue.notWritable:
return l10n.importConfig_reasonNotWritable;
}
}
class _Banner extends StatelessWidget {
const _Banner({required this.text});
final String text;
@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)),
],
),
);
}
}

Powered by TurnKey Linux.