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.
248 lines
8.5 KiB
248 lines
8.5 KiB
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../../connector/meshcore_connector.dart';
|
|
import '../../connector/offband_device_ui.dart';
|
|
|
|
/// Settings for a headless device's physical UI: the button-action matrix
|
|
/// (#474) and the device notification scope (#475).
|
|
///
|
|
/// The whole pane is capability-gated. A radio that does not advertise the bit
|
|
/// gets no screen and never sees the command, so stock and older firmware
|
|
/// degrade silently with nothing shown and no error.
|
|
///
|
|
/// English-only for now, matching the serial-capture pane (#430); localization
|
|
/// is a follow-up rather than a blocker on shipping the capability.
|
|
class DeviceUiView extends StatefulWidget {
|
|
const DeviceUiView({super.key});
|
|
|
|
@override
|
|
State<DeviceUiView> createState() => _DeviceUiViewState();
|
|
}
|
|
|
|
class _DeviceUiViewState extends State<DeviceUiView> {
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// Re-read on open as well as on device-info: the user may have changed the
|
|
// scope by triple-pressing the device since the last refresh.
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
final c = context.read<MeshCoreConnector>();
|
|
c.requestButtonMatrix();
|
|
c.requestNotifyScope();
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Consumer<MeshCoreConnector>(
|
|
builder: (context, connector, _) {
|
|
final showButtons = connector.supportsButtonMatrix;
|
|
final showScope = connector.supportsNotifyScope;
|
|
// A radio advertising neither bit has no button and no buzzer to
|
|
// configure, so it gets nothing. The tile that leads here is gated the
|
|
// same way, so this is a belt-and-braces guard rather than a path a
|
|
// user can reach. (#474 negative test)
|
|
if (!showButtons && !showScope) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
// The radio advertises the capability but shipped firmware has no
|
|
// get/set command yet, so it can be detected and not queried. Say that
|
|
// plainly instead of spinning forever on a read that never returns.
|
|
if (!connector.supportsDeviceUiCommand) {
|
|
return ListView(
|
|
children: [
|
|
const _SectionHeader('Supported by this radio'),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
|
child: Text(
|
|
'This radio reports that it supports '
|
|
'${showScope && showButtons
|
|
? 'notification scope and button actions'
|
|
: showScope
|
|
? 'the notification scope'
|
|
: 'button actions'}.\n\n'
|
|
'Reading and changing it from the app needs a firmware '
|
|
'update that is still in progress, so there is nothing to '
|
|
'set here yet.',
|
|
),
|
|
),
|
|
if (showScope)
|
|
const ListTile(
|
|
leading: Icon(Icons.touch_app_outlined),
|
|
title: Text('Change it on the device'),
|
|
subtitle: Text(
|
|
'Triple-press the button to cycle All, Self, then None. '
|
|
'The default is All.',
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
return ListView(
|
|
children: [
|
|
if (connector.deviceUiError != null)
|
|
_ErrorBanner(
|
|
message: connector.deviceUiError!,
|
|
onDismiss: connector.clearDeviceUiError,
|
|
),
|
|
if (showScope) ...[
|
|
const _SectionHeader('Device notification scope'),
|
|
const Padding(
|
|
padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
|
|
child: Text(
|
|
'Controls whether this radio\'s buzzer sounds. This is '
|
|
'separate from per-channel notifications, which control '
|
|
'whether this app notifies you.',
|
|
),
|
|
),
|
|
..._scopeTiles(connector),
|
|
const Divider(height: 24),
|
|
],
|
|
if (showButtons) ...[
|
|
const _SectionHeader('Button actions'),
|
|
const Padding(
|
|
padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
|
|
child: Text(
|
|
'Assign what each button press does. Long press is reserved '
|
|
'by the firmware for CLI rescue and power off, so it cannot '
|
|
'be reassigned.',
|
|
),
|
|
),
|
|
..._buttonTiles(connector),
|
|
],
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
List<Widget> _scopeTiles(MeshCoreConnector connector) {
|
|
final current = connector.deviceNotifyScope;
|
|
if (current == null) {
|
|
return [
|
|
const ListTile(
|
|
leading: SizedBox(
|
|
width: 24,
|
|
height: 24,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
title: Text('Reading scope from the radio'),
|
|
),
|
|
];
|
|
}
|
|
// Plain ListTiles rather than RadioListTile: the Radio group API is
|
|
// deprecated in this Flutter version and the replacement needs a
|
|
// RadioGroup ancestor, which buys nothing for three mutually exclusive
|
|
// rows.
|
|
return DeviceNotifyScope.values
|
|
.map(
|
|
(scope) => ListTile(
|
|
leading: Icon(
|
|
scope == current
|
|
? Icons.radio_button_checked
|
|
: Icons.radio_button_unchecked,
|
|
color: scope == current
|
|
? Theme.of(context).colorScheme.primary
|
|
: null,
|
|
),
|
|
title: Text(scope.label),
|
|
subtitle: Text(scope.description),
|
|
selected: scope == current,
|
|
onTap: () => connector.setNotifyScope(scope),
|
|
),
|
|
)
|
|
.toList();
|
|
}
|
|
|
|
List<Widget> _buttonTiles(MeshCoreConnector connector) {
|
|
final matrix = connector.buttonMatrix;
|
|
if (matrix == null) {
|
|
return [
|
|
const ListTile(
|
|
leading: SizedBox(
|
|
width: 24,
|
|
height: 24,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
title: Text('Reading button configuration from the radio'),
|
|
),
|
|
];
|
|
}
|
|
// Only actions the radio said it can perform are offered, so a board with
|
|
// no buzzer or no GPS never shows a choice it would reject. (#474)
|
|
final actions = matrix.availableActions;
|
|
return ButtonSequence.values.map((seq) {
|
|
final assigned = matrix.assignments[seq] ?? ButtonAction.none;
|
|
return ListTile(
|
|
title: Text(seq.label),
|
|
subtitle: Text(assigned.label),
|
|
trailing: DropdownButton<ButtonAction>(
|
|
value: actions.contains(assigned) ? assigned : ButtonAction.none,
|
|
onChanged: (value) {
|
|
if (value != null) connector.setButtonAction(seq, value);
|
|
},
|
|
items: actions
|
|
.map(
|
|
(a) => DropdownMenuItem<ButtonAction>(
|
|
value: a,
|
|
child: Text(a.label),
|
|
),
|
|
)
|
|
.toList(),
|
|
),
|
|
);
|
|
}).toList();
|
|
}
|
|
}
|
|
|
|
class _SectionHeader extends StatelessWidget {
|
|
const _SectionHeader(this.title);
|
|
final String title;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
|
child: Text(title, style: Theme.of(context).textTheme.titleMedium),
|
|
);
|
|
}
|
|
|
|
/// Persistent error banner. Stays until dismissed rather than flashing, per the
|
|
/// error-visibility rule: an error the user cannot finish reading is not a
|
|
/// surfaced error.
|
|
class _ErrorBanner extends StatelessWidget {
|
|
const _ErrorBanner({required this.message, required this.onDismiss});
|
|
final String message;
|
|
final VoidCallback onDismiss;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
return Container(
|
|
margin: const EdgeInsets.all(12),
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: scheme.errorContainer,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.error_outline, color: scheme.onErrorContainer),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
message,
|
|
style: TextStyle(color: scheme.onErrorContainer),
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: Icon(Icons.close, color: scheme.onErrorContainer),
|
|
onPressed: onDismiss,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|