feat(#17): responsive settings shell + intent categories + path-hash

Replace the monolithic card+dialog settings screen with a responsive master-detail shell (rail+pane when wide, list+push when narrow; layout chosen by width via LayoutBuilder). Regroup the existing settings into intent categories and surface Path Hash Size (1/2/3-byte) in Radio and range, the first device-config-parity gap (previously repeater/CLI-only).

No settings lost: every existing control stays reachable. Phase 0 of #7.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pull/20/head
Strycher 1 month ago
parent 54d3ee4856
commit 7d0080f2cd

@ -0,0 +1,145 @@
import 'package:flutter/material.dart';
/// One entry in the responsive settings shell: an icon, a title, an optional
/// subtitle, and a builder for the category's detail pane.
class SettingsCategory {
final IconData icon;
final String title;
final String? subtitle;
final WidgetBuilder builder;
const SettingsCategory({
required this.icon,
required this.title,
this.subtitle,
required this.builder,
});
}
/// Responsive master-detail container for settings.
///
/// Wide layouts (>= [_wideBreakpoint] logical pixels) show a pinned category
/// rail beside the selected detail pane. Narrow layouts show the category list
/// and push the detail onto the navigator. Same categories, same code, every
/// platform the layout is chosen from the available width, not the OS.
class SettingsShell extends StatefulWidget {
final String title;
final List<SettingsCategory> categories;
final PreferredSizeWidget? appBarBottom;
const SettingsShell({
super.key,
required this.title,
required this.categories,
this.appBarBottom,
});
@override
State<SettingsShell> createState() => _SettingsShellState();
}
class _SettingsShellState extends State<SettingsShell> {
static const double _wideBreakpoint = 720;
static const double _railWidth = 280;
int _selected = 0;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
return constraints.maxWidth >= _wideBreakpoint
? _buildWide(context)
: _buildNarrow(context);
},
);
}
PreferredSizeWidget _appBar(String title) {
return AppBar(
title: Text(title),
centerTitle: true,
bottom: widget.appBarBottom,
);
}
Widget _buildWide(BuildContext context) {
final categories = widget.categories;
final selected = _selected.clamp(0, categories.length - 1);
return Scaffold(
appBar: _appBar(widget.title),
body: SafeArea(
top: false,
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: _railWidth,
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: categories.length,
itemBuilder: (context, i) {
final category = categories[i];
return ListTile(
leading: Icon(category.icon),
title: Text(category.title),
selected: i == selected,
selectedTileColor:
Theme.of(context).colorScheme.surfaceContainerHighest,
onTap: () => setState(() => _selected = i),
);
},
),
),
const VerticalDivider(width: 1),
Expanded(
child: KeyedSubtree(
key: ValueKey<int>(selected),
child: categories[selected].builder(context),
),
),
],
),
),
);
}
Widget _buildNarrow(BuildContext context) {
final categories = widget.categories;
return Scaffold(
appBar: _appBar(widget.title),
body: SafeArea(
top: false,
child: ListView.builder(
itemCount: categories.length,
itemBuilder: (context, i) {
final category = categories[i];
return ListTile(
leading: Icon(category.icon),
title: Text(category.title),
subtitle:
category.subtitle != null ? Text(category.subtitle!) : null,
trailing: const Icon(Icons.chevron_right),
onTap: () => _openDetail(context, category),
);
},
),
),
);
}
void _openDetail(BuildContext context, SettingsCategory category) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => Scaffold(
appBar: AppBar(
title: Text(category.title),
centerTitle: true,
bottom: widget.appBarBottom,
),
body: SafeArea(top: false, child: category.builder(context)),
),
),
);
}
}

@ -10,8 +10,8 @@ import '../connector/meshcore_protocol.dart';
import '../l10n/l10n.dart';
import '../models/radio_settings.dart';
import '../services/app_debug_log_service.dart';
import '../widgets/app_bar.dart';
import '../helpers/snack_bar_builder.dart';
import 'settings/settings_shell.dart';
import 'app_settings_screen.dart';
import 'app_debug_log_screen.dart';
import 'ble_debug_log_screen.dart';
@ -61,40 +61,226 @@ class _SettingsScreenState extends State<SettingsScreen> {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Scaffold(
appBar: AppBar(
title: AppBarTitle(
l10n.settings_title,
indicators: false,
subtitle: false,
),
bottom: const SyncProgressAppBarBottom(),
return SettingsShell(
title: l10n.settings_title,
appBarBottom: const SyncProgressAppBarBottom(),
categories: _categories(context),
);
}
List<SettingsCategory> _categories(BuildContext context) {
final l10n = context.l10n;
return [
SettingsCategory(
icon: Icons.settings_input_antenna,
title: l10n.settings_radioSettings,
subtitle: l10n.settings_radioSettingsSubtitle,
builder: _radioRangePane,
),
body: SafeArea(
top: false,
child: Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
_buildDeviceInfoCard(context, connector),
const SizedBox(height: 16),
_buildAppSettingsCard(context),
const SizedBox(height: 16),
_buildNodeSettingsCard(context, connector),
const SizedBox(height: 16),
_buildActionsCard(context, connector),
const SizedBox(height: 16),
_buildDebugCard(context),
const SizedBox(height: 16),
_buildExportCard(connector),
const SizedBox(height: 16),
_buildAboutCard(context),
SettingsCategory(
icon: Icons.badge_outlined,
title: l10n.settings_nodeSettings,
builder: _identityPane,
),
SettingsCategory(
icon: Icons.shield_outlined,
title: l10n.settings_privacy,
subtitle: l10n.settings_privacySubtitle,
builder: _privacyPane,
),
SettingsCategory(
icon: Icons.forum_outlined,
title: l10n.settings_contactSettings,
subtitle: l10n.settings_contactSettingsSubtitle,
builder: _messagesContactsPane,
),
SettingsCategory(
icon: Icons.info_outline,
title: l10n.settings_deviceInfo,
builder: _devicePane,
),
SettingsCategory(
icon: Icons.tune,
title: l10n.settings_appSettings,
subtitle: l10n.settings_appSettingsSubtitle,
builder: _appPane,
),
SettingsCategory(
icon: Icons.bolt_outlined,
title: l10n.settings_actions,
builder: _actionsPane,
),
SettingsCategory(
icon: Icons.build_outlined,
title: l10n.settings_debug,
builder: _diagnosticsPane,
),
];
}
Widget _radioRangePane(BuildContext context) {
final l10n = context.l10n;
final connector = context.watch<MeshCoreConnector>();
return ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
leading: const Icon(Icons.radio),
title: Text(l10n.settings_radioSettings),
subtitle: Text(l10n.settings_radioSettingsSubtitle),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showRadioSettings(context, connector),
),
const Divider(height: 1),
_PathHashSizeTile(connector: connector),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.sensors_outlined),
title: Text(l10n.radioStats_settingsTile),
subtitle: Text(l10n.radioStats_settingsSubtitle),
trailing: const Icon(Icons.chevron_right),
enabled: connector.isConnected &&
connector.supportsCompanionRadioStats,
onTap: () => pushCompanionRadioStatsScreen(context),
),
],
),
),
],
);
}
Widget _identityPane(BuildContext context) {
final l10n = context.l10n;
final connector = context.watch<MeshCoreConnector>();
return ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
leading: const Icon(Icons.person_outline),
title: Text(l10n.settings_nodeName),
subtitle:
Text(connector.selfName ?? l10n.settings_nodeNameNotSet),
trailing: const Icon(Icons.chevron_right),
onTap: () => _editNodeName(context, connector),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.location_on_outlined),
title: Text(l10n.settings_location),
subtitle: Text(l10n.settings_locationSubtitle),
trailing: const Icon(Icons.chevron_right),
onTap: () => _editLocation(context, connector),
),
if (connector.currentCustomVars?.containsKey('gps') ?? false) ...[
const Divider(height: 1),
SwitchListTile(
secondary: const Icon(Icons.gps_fixed),
title: Text(l10n.settings_locationGPSEnable),
subtitle: Text(l10n.settings_locationGPSEnableSubtitle),
value: connector.currentCustomVars?['gps'] == '1',
onChanged: (value) async {
await connector.setCustomVar(value ? 'gps:1' : 'gps:0');
},
),
],
if (connector.selfPublicKey != null) ...[
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: _buildInfoRow(
l10n.settings_infoPublicKey,
'${pubKeyToHex(connector.selfPublicKey!).substring(0, 16)}...',
),
),
],
);
},
],
),
),
),
],
);
}
Widget _privacyPane(BuildContext context) {
final l10n = context.l10n;
final connector = context.watch<MeshCoreConnector>();
return ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: ListTile(
leading: const Icon(Icons.visibility_off_outlined),
title: Text(l10n.settings_privacy),
subtitle: Text(l10n.settings_privacySubtitle),
trailing: const Icon(Icons.chevron_right),
onTap: () => _privacySettings(context, connector),
),
),
],
);
}
Widget _messagesContactsPane(BuildContext context) {
final l10n = context.l10n;
final connector = context.watch<MeshCoreConnector>();
return ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: ListTile(
leading: const Icon(Icons.group_add_outlined),
title: Text(l10n.settings_contactSettings),
subtitle: Text(l10n.settings_contactSettingsSubtitle),
trailing: const Icon(Icons.chevron_right),
onTap: () => _editAutoAddConfig(context, connector),
),
),
],
);
}
Widget _devicePane(BuildContext context) {
final connector = context.watch<MeshCoreConnector>();
return ListView(
padding: const EdgeInsets.all(16),
children: [_buildDeviceInfoCard(context, connector)],
);
}
Widget _appPane(BuildContext context) {
return ListView(
padding: const EdgeInsets.all(16),
children: [_buildAppSettingsCard(context)],
);
}
Widget _actionsPane(BuildContext context) {
final connector = context.watch<MeshCoreConnector>();
return ListView(
padding: const EdgeInsets.all(16),
children: [_buildActionsCard(context, connector)],
);
}
Widget _diagnosticsPane(BuildContext context) {
final connector = context.watch<MeshCoreConnector>();
return ListView(
padding: const EdgeInsets.all(16),
children: [
_buildDebugCard(context),
const SizedBox(height: 16),
_buildExportCard(connector),
const SizedBox(height: 16),
_buildAboutCard(context),
],
);
}
@ -258,88 +444,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
Widget _buildNodeSettingsCard(
BuildContext context,
MeshCoreConnector connector,
) {
final l10n = context.l10n;
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
l10n.settings_nodeSettings,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
ListTile(
leading: const Icon(Icons.person_outline),
title: Text(l10n.settings_nodeName),
subtitle: Text(connector.selfName ?? l10n.settings_nodeNameNotSet),
trailing: const Icon(Icons.chevron_right),
onTap: () => _editNodeName(context, connector),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.radio),
title: Text(l10n.settings_radioSettings),
subtitle: Text(l10n.settings_radioSettingsSubtitle),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showRadioSettings(context, connector),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.sensors_outlined),
title: Text(l10n.radioStats_settingsTile),
subtitle: Text(l10n.radioStats_settingsSubtitle),
trailing: const Icon(Icons.chevron_right),
enabled:
connector.isConnected && connector.supportsCompanionRadioStats,
onTap: () => pushCompanionRadioStatsScreen(context),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.location_on_outlined),
title: Text(l10n.settings_location),
subtitle: Text(l10n.settings_locationSubtitle),
trailing: const Icon(Icons.chevron_right),
onTap: () => _editLocation(context, connector),
),
if (connector.currentCustomVars?.containsKey('gps') ?? false) ...[
const Divider(height: 1),
SwitchListTile(
secondary: const Icon(Icons.gps_fixed),
title: Text(l10n.settings_locationGPSEnable),
subtitle: Text(l10n.settings_locationGPSEnableSubtitle),
value: connector.currentCustomVars?['gps'] == '1',
onChanged: (value) async {
await connector.setCustomVar(value ? 'gps:1' : 'gps:0');
},
),
],
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.group_add_outlined),
title: Text(l10n.settings_contactSettings),
subtitle: Text(l10n.settings_contactSettingsSubtitle),
trailing: const Icon(Icons.chevron_right),
onTap: () => _editAutoAddConfig(context, connector),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.visibility_off_outlined),
title: Text(l10n.settings_privacy),
subtitle: Text(l10n.settings_privacySubtitle),
trailing: const Icon(Icons.chevron_right),
onTap: () => _privacySettings(context, connector),
),
],
),
);
}
Widget _buildActionsCard(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
return Card(
@ -1108,6 +1212,69 @@ void _privacySettings(BuildContext context, MeshCoreConnector connector) {
);
}
/// Inline path-hash size selector for the connected device.
///
/// 1/2/3-byte hop hashes trade repeater-ID space against the maximum hop
/// count. The width a region standardises on (commonly 2-byte) belongs here
/// in Radio & range, not buried under "experimental". Reads the current width
/// from device info and writes via [MeshCoreConnector.setPathHashMode].
class _PathHashSizeTile extends StatefulWidget {
final MeshCoreConnector connector;
const _PathHashSizeTile({required this.connector});
@override
State<_PathHashSizeTile> createState() => _PathHashSizeTileState();
}
class _PathHashSizeTileState extends State<_PathHashSizeTile> {
// Hop ceilings per hash width (1/2/3 bytes), from the firmware path-hash spec.
static const List<int> _maxHopsPerWidth = [64, 32, 21];
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final connector = widget.connector;
final mode = (connector.pathHashByteWidth - 1).clamp(0, 2);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: DropdownButtonFormField<int>(
key: ValueKey<int>(mode),
initialValue: mode,
isExpanded: true,
decoration: InputDecoration(
labelText: l10n.repeater_pathHashMode,
helperText: l10n.repeater_pathHashModeHelper,
helperMaxLines: 5,
prefixIcon: const Icon(Icons.alt_route),
border: const OutlineInputBorder(),
),
items: [
for (var m = 0; m < 3; m++)
DropdownMenuItem<int>(
value: m,
child: Text('${m + 1}-byte · max ${_maxHopsPerWidth[m]} hops'),
),
],
onChanged: connector.isConnected
? (value) async {
if (value == null) return;
await connector.setPathHashMode(value);
await connector.refreshDeviceInfo();
if (!context.mounted) return;
showDismissibleSnackBar(
context,
content: Text(
'${l10n.repeater_pathHashMode}: ${value + 1}-byte',
),
);
}
: null,
),
);
}
}
class _RadioSettingsDialog extends StatefulWidget {
final MeshCoreConnector connector;

Loading…
Cancel
Save

Powered by TurnKey Linux.