Builds the owner-approved parity design (docs/initiatives/standalone/975-settings-restructure/tones-screen-parity-mockups.html) against meshcore-firmware 3e788e2a: - #986: the six event tones (DM, Channel, Room, New contact, Ack, Sent) on Buzzer and tones, one shared picker with a play button per row (0x0F/0x10/0x11). DM is one tone for every direct message. - #970: all eight custom slots listed; the editor reads the stored tune back (0x12), checks typing with a port of firmware's rtttlProblem(), and saves in transport-sized chunks (0x0A), restarting once if displaced. - #971: Radio tone in the channel sheet and chat menu, Default naming what it plays. - #987: Screen (text sizes, Screen off, time zone with a phone-matched suggestion) and Battery (full point, set here, back to auto) under Node Settings, gated on caps2 0x40. Caps2 0x10 is now TUNES (slots, channel tone, event tones, preview). Also corrects the mockup's zone count: 40 zones plus not set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>pull/994/head
parent
054c7ddba2
commit
1827308339
@ -0,0 +1,150 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'offband_device_ui.dart';
|
||||
|
||||
/// The badge's own display and battery settings over `0xC5` (#987): both
|
||||
/// text sizes, Screen off, time zone and the battery full point. Gated on caps
|
||||
/// byte 2 `0x40` SCREEN_SETTINGS (meshcore-firmware `3e788e2a`).
|
||||
///
|
||||
/// Every reply, GET or SET, is the whole set re-read after the write:
|
||||
/// `[0xC5][sub][nav][msg][scr_stored:2][scr_secs:2][tz][batt_stored:2]
|
||||
/// [batt_full:2][batt_pinned]`, 14 bytes, all little-endian.
|
||||
|
||||
const int offbandUiScreenGet = 0x13;
|
||||
const int offbandUiTextSet = 0x14;
|
||||
const int offbandUiScreenOffSet = 0x15;
|
||||
const int offbandUiTimeZoneSet = 0x16;
|
||||
const int offbandUiBatterySet = 0x17;
|
||||
const int _screenReplyLength = 14;
|
||||
|
||||
/// The three text sizes, in firmware's encoding (`BadgeLayout.h`).
|
||||
enum BadgeTextSize {
|
||||
large(0, 'Large'),
|
||||
medium(1, 'Medium'),
|
||||
small(2, 'Small');
|
||||
|
||||
const BadgeTextSize(this.code, this.label);
|
||||
final int code;
|
||||
final String label;
|
||||
|
||||
static BadgeTextSize? fromCode(int code) {
|
||||
for (final s in BadgeTextSize.values) {
|
||||
if (s.code == code) return s;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Which text size a TEXT_SET changes: the badge's Nav text or Message text.
|
||||
enum BadgeTextTarget {
|
||||
navigation(0),
|
||||
messages(1);
|
||||
|
||||
const BadgeTextTarget(this.code);
|
||||
final int code;
|
||||
}
|
||||
|
||||
/// The Screen off steps the badge's row cycles through (`kScreenOffSteps`).
|
||||
/// Firmware accepts exactly these and nothing else.
|
||||
const List<int> screenOffSteps = [15, 30, 60, 120, 300];
|
||||
|
||||
String screenOffLabel(int secs) => secs < 60
|
||||
? '$secs s'
|
||||
: '${secs ~/ 60} m${secs % 60 == 0 ? '' : ' ${secs % 60} s'}';
|
||||
|
||||
/// The radio's screen and battery settings, from any `0x13` to `0x17` reply.
|
||||
class ScreenSettingsState {
|
||||
const ScreenSettingsState({
|
||||
required this.navText,
|
||||
required this.messageText,
|
||||
required this.screenOffStored,
|
||||
required this.screenOffSeconds,
|
||||
required this.timeZone,
|
||||
required this.batteryFullStored,
|
||||
required this.batteryFullMv,
|
||||
required this.batteryPinned,
|
||||
});
|
||||
|
||||
/// Null when a newer firmware reports a size this client does not know.
|
||||
final BadgeTextSize? navText;
|
||||
final BadgeTextSize? messageText;
|
||||
|
||||
/// The Screen off preference in seconds; 0 means the board's compiled
|
||||
/// timeout, which [screenOffSeconds] then reports.
|
||||
final int screenOffStored;
|
||||
final int screenOffSeconds;
|
||||
|
||||
/// Index into the badge's zone table; 0 is not set.
|
||||
final int timeZone;
|
||||
|
||||
/// The learned or pinned full point in mV, 0 when there is neither.
|
||||
final int batteryFullStored;
|
||||
|
||||
/// The mV the radio's battery bar treats as 100%.
|
||||
final int batteryFullMv;
|
||||
|
||||
/// Set by hand, which auto-learn leaves alone.
|
||||
final bool batteryPinned;
|
||||
|
||||
bool get screenOffIsDefault => screenOffStored == 0;
|
||||
}
|
||||
|
||||
Uint8List buildScreenSettingsGetFrame() =>
|
||||
Uint8List.fromList([cmdOffbandDeviceUi, offbandUiScreenGet]);
|
||||
|
||||
Uint8List buildTextSizeSetFrame(BadgeTextTarget target, BadgeTextSize size) =>
|
||||
Uint8List.fromList([
|
||||
cmdOffbandDeviceUi,
|
||||
offbandUiTextSet,
|
||||
target.code,
|
||||
size.code,
|
||||
]);
|
||||
|
||||
Uint8List buildScreenOffSetFrame(int seconds) {
|
||||
if (seconds < 0 || seconds > 0xFFFF) {
|
||||
throw ArgumentError.value(seconds, 'seconds', 'must be 0 to 65535');
|
||||
}
|
||||
return Uint8List.fromList([
|
||||
cmdOffbandDeviceUi,
|
||||
offbandUiScreenOffSet,
|
||||
seconds & 0xFF,
|
||||
seconds >> 8,
|
||||
]);
|
||||
}
|
||||
|
||||
Uint8List buildTimeZoneSetFrame(int index) {
|
||||
if (index < 0 || index > 0xFF) {
|
||||
throw ArgumentError.value(index, 'index', 'must be 0 to 255');
|
||||
}
|
||||
return Uint8List.fromList([cmdOffbandDeviceUi, offbandUiTimeZoneSet, index]);
|
||||
}
|
||||
|
||||
/// `pin` true pins the radio's own current reading as 100%; false hands the
|
||||
/// full point back to auto-learn.
|
||||
Uint8List buildBatteryFullSetFrame({required bool pin}) =>
|
||||
Uint8List.fromList([cmdOffbandDeviceUi, offbandUiBatterySet, pin ? 1 : 0]);
|
||||
|
||||
/// Parse a screen-settings reply. Null if this is not one, or if it is
|
||||
/// shorter than the 14 bytes the contract fixes. The shared 0x7F error is
|
||||
/// owned by [parseButtonMatrixReply].
|
||||
OffbandUiReply? parseScreenSettingsReply(Uint8List frame) {
|
||||
if (frame.length < 2 || frame[0] != respCodeOffbandDeviceUi) return null;
|
||||
final sub = frame[1];
|
||||
if (sub < offbandUiScreenGet || sub > offbandUiBatterySet) return null;
|
||||
if (frame.length < _screenReplyLength) return null;
|
||||
final data = ByteData.sublistView(frame);
|
||||
return OffbandUiReply(
|
||||
command: respCodeOffbandDeviceUi,
|
||||
sub: sub,
|
||||
screen: ScreenSettingsState(
|
||||
navText: BadgeTextSize.fromCode(frame[2]),
|
||||
messageText: BadgeTextSize.fromCode(frame[3]),
|
||||
screenOffStored: data.getUint16(4, Endian.little),
|
||||
screenOffSeconds: data.getUint16(6, Endian.little),
|
||||
timeZone: frame[8],
|
||||
batteryFullStored: data.getUint16(9, Endian.little),
|
||||
batteryFullMv: data.getUint16(11, Endian.little),
|
||||
batteryPinned: frame[13] != 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,415 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'offband_device_ui.dart';
|
||||
|
||||
/// The radio's tune system over `0xC5` (#970, #971, #986): the built-in tune
|
||||
/// table, the eight custom RTTTL slots, the six event tones and preview.
|
||||
///
|
||||
/// Gated on caps byte 2 `0x10` TUNES (meshcore-firmware `3e788e2a`). The
|
||||
/// contract lives in firmware's `OffbandConfigProtocol.h`; the tables and the
|
||||
/// validator below are ports of `Tunes.h` and `TuneSlots.h` at that commit.
|
||||
|
||||
const int offbandUiSlotGet = 0x09;
|
||||
const int offbandUiSlotSet = 0x0A;
|
||||
const int offbandUiEventToneGet = 0x0F;
|
||||
const int offbandUiEventToneSet = 0x10;
|
||||
const int offbandUiTunePlay = 0x11;
|
||||
const int offbandUiSlotRead = 0x12;
|
||||
|
||||
/// The built-in tunes, by id. Firmware's table is append-only, so this copy
|
||||
/// can only fall behind, never misname an id. A null name is a tune firmware
|
||||
/// keeps out of its own picker (id 7, Trigger), so it is never offered here.
|
||||
const List<String?> builtinTuneNames = [
|
||||
'Silent',
|
||||
'Chime',
|
||||
'Kerplop',
|
||||
'Tick',
|
||||
'Bloop',
|
||||
'Drop',
|
||||
'Knock',
|
||||
null,
|
||||
];
|
||||
|
||||
const int tuneSilent = 0;
|
||||
const int builtinTuneCount = 8;
|
||||
const int tuneSlotCount = 8;
|
||||
const int firstSlotTuneId = builtinTuneCount;
|
||||
|
||||
/// The longest RTTTL a slot holds (`kMaxRtttl`) and the longest name a slot
|
||||
/// shows (`kMaxSlotName`).
|
||||
const int maxSlotRtttl = 240;
|
||||
const int maxSlotName = 12;
|
||||
|
||||
bool isSlotTuneId(int id) =>
|
||||
id >= firstSlotTuneId && id < firstSlotTuneId + tuneSlotCount;
|
||||
|
||||
int slotTuneId(int slot) => firstSlotTuneId + slot;
|
||||
|
||||
/// Built-in ids the badge's picker lists, in its order.
|
||||
List<int> get listedBuiltinTuneIds => [
|
||||
for (var id = 0; id < builtinTuneNames.length; id++)
|
||||
if (builtinTuneNames[id] != null) id,
|
||||
];
|
||||
|
||||
/// One of the six events the badge's Tones screen sets. Values are firmware's
|
||||
/// `offband::ToneEvent`, and the labels are the badge's own (`kToneEventRows`).
|
||||
enum ToneEvent {
|
||||
dm(0, 'DM'),
|
||||
channel(1, 'Channel'),
|
||||
room(2, 'Room'),
|
||||
newContact(3, 'New contact'),
|
||||
ack(4, 'Ack'),
|
||||
sent(5, 'Sent');
|
||||
|
||||
const ToneEvent(this.code, this.label);
|
||||
final int code;
|
||||
final String label;
|
||||
|
||||
static ToneEvent? fromCode(int code) {
|
||||
for (final e in ToneEvent.values) {
|
||||
if (e.code == code) return e;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Stored byte meaning "nothing chosen": the board's compiled default plays.
|
||||
const int tuneUnset = 0xFF;
|
||||
|
||||
/// An event's tune as the radio reports it. [effective] is what plays.
|
||||
class EventToneState {
|
||||
const EventToneState({required this.stored, required this.effective});
|
||||
final int stored;
|
||||
final int effective;
|
||||
|
||||
bool get isRadioDefault => stored == tuneUnset;
|
||||
}
|
||||
|
||||
class EventToneReply {
|
||||
const EventToneReply({required this.event, required this.state});
|
||||
final ToneEvent event;
|
||||
final EventToneState state;
|
||||
}
|
||||
|
||||
/// What a custom slot holds, from `SLOT_GET`: its length and derived name,
|
||||
/// never the tune itself. Length 0 is an empty slot.
|
||||
class TuneSlotInfo {
|
||||
const TuneSlotInfo({required this.length, required this.name});
|
||||
final int length;
|
||||
final String name;
|
||||
|
||||
bool get isEmpty => length == 0;
|
||||
}
|
||||
|
||||
class TuneSlotReply {
|
||||
const TuneSlotReply({required this.slot, required this.info});
|
||||
final int slot;
|
||||
final TuneSlotInfo info;
|
||||
}
|
||||
|
||||
/// `SLOT_SET` progress: how many bytes the radio holds for the push in flight.
|
||||
class TuneSlotAccepted {
|
||||
const TuneSlotAccepted({required this.slot, required this.accepted});
|
||||
final int slot;
|
||||
final int accepted;
|
||||
}
|
||||
|
||||
/// One `SLOT_READ` chunk.
|
||||
class TuneSlotChunk {
|
||||
const TuneSlotChunk({
|
||||
required this.slot,
|
||||
required this.offset,
|
||||
required this.more,
|
||||
required this.bytes,
|
||||
});
|
||||
final int slot;
|
||||
final int offset;
|
||||
final bool more;
|
||||
final Uint8List bytes;
|
||||
}
|
||||
|
||||
/// Why a preview did or did not sound (`offband::TonePlay`).
|
||||
enum TunePlayResult {
|
||||
played(0),
|
||||
busy(1),
|
||||
soundOff(2);
|
||||
|
||||
const TunePlayResult(this.code);
|
||||
final int code;
|
||||
|
||||
static TunePlayResult? fromCode(int code) {
|
||||
for (final r in TunePlayResult.values) {
|
||||
if (r.code == code) return r;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class TunePlayReply {
|
||||
const TunePlayReply({required this.tuneId, required this.result});
|
||||
final int tuneId;
|
||||
|
||||
/// Null when a newer firmware reports a result this client does not know.
|
||||
final TunePlayResult? result;
|
||||
}
|
||||
|
||||
/// What the client knows of a radio's tunes, for naming an id.
|
||||
///
|
||||
/// [slots] holds the `SLOT_GET` replies read so far, by slot index.
|
||||
String? tuneLabel(int id, Map<int, TuneSlotInfo> slots) {
|
||||
if (id >= 0 && id < builtinTuneNames.length) return builtinTuneNames[id];
|
||||
if (isSlotTuneId(id)) {
|
||||
final info = slots[id - firstSlotTuneId];
|
||||
if (info == null || info.isEmpty) return null;
|
||||
return info.name.isEmpty ? 'Slot ${id - firstSlotTuneId + 1}' : info.name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The ids a picker may offer: firmware's rule, a named built-in or a filled
|
||||
/// slot. Firmware refuses everything else, so nothing else is shown.
|
||||
List<int> offeredTuneIds(Map<int, TuneSlotInfo> slots) => [
|
||||
...listedBuiltinTuneIds,
|
||||
for (var s = 0; s < tuneSlotCount; s++)
|
||||
if (slots[s] != null && !slots[s]!.isEmpty) slotTuneId(s),
|
||||
];
|
||||
|
||||
Uint8List buildEventToneGetFrame(ToneEvent event) =>
|
||||
Uint8List.fromList([cmdOffbandDeviceUi, offbandUiEventToneGet, event.code]);
|
||||
|
||||
Uint8List buildEventToneSetFrame(ToneEvent event, int tuneId) =>
|
||||
Uint8List.fromList([
|
||||
cmdOffbandDeviceUi,
|
||||
offbandUiEventToneSet,
|
||||
event.code,
|
||||
_byte(tuneId, 'tuneId'),
|
||||
]);
|
||||
|
||||
Uint8List buildTunePlayFrame(int tuneId) => Uint8List.fromList([
|
||||
cmdOffbandDeviceUi,
|
||||
offbandUiTunePlay,
|
||||
_byte(tuneId, 'tuneId'),
|
||||
]);
|
||||
|
||||
Uint8List buildSlotGetFrame(int slot) =>
|
||||
Uint8List.fromList([cmdOffbandDeviceUi, offbandUiSlotGet, _slotByte(slot)]);
|
||||
|
||||
Uint8List buildSlotReadFrame(int slot, int offset) => Uint8List.fromList([
|
||||
cmdOffbandDeviceUi,
|
||||
offbandUiSlotRead,
|
||||
_slotByte(slot),
|
||||
_byte(offset, 'offset'),
|
||||
]);
|
||||
|
||||
/// `[0xC5][0x0A][slot][offset][more][bytes...]`. An empty final chunk at
|
||||
/// offset 0 clears the slot.
|
||||
Uint8List buildSlotSetFrame(int slot, int offset, bool more, List<int> bytes) =>
|
||||
Uint8List.fromList([
|
||||
cmdOffbandDeviceUi,
|
||||
offbandUiSlotSet,
|
||||
_slotByte(slot),
|
||||
_byte(offset, 'offset'),
|
||||
more ? 1 : 0,
|
||||
...bytes,
|
||||
]);
|
||||
|
||||
int _slotByte(int slot) {
|
||||
if (slot < 0 || slot >= tuneSlotCount) {
|
||||
throw ArgumentError.value(
|
||||
slot,
|
||||
'slot',
|
||||
'must be 0 to ${tuneSlotCount - 1}',
|
||||
);
|
||||
}
|
||||
return slot;
|
||||
}
|
||||
|
||||
int _byte(int value, String name) {
|
||||
if (value < 0 || value > 0xFF) {
|
||||
throw ArgumentError.value(value, name, 'must be 0 to 255');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/// Parse a tune reply (0x09, 0x0A, 0x0F, 0x10, 0x11, 0x12). Null if this is
|
||||
/// not one, or if it is truncated. The shared 0x7F error is owned by
|
||||
/// [parseButtonMatrixReply].
|
||||
OffbandUiReply? parseTuneReply(Uint8List frame) {
|
||||
if (frame.length < 2 || frame[0] != respCodeOffbandDeviceUi) return null;
|
||||
final sub = frame[1];
|
||||
OffbandUiReply reply({
|
||||
EventToneReply? eventTone,
|
||||
TuneSlotReply? slot,
|
||||
TuneSlotAccepted? accepted,
|
||||
TuneSlotChunk? chunk,
|
||||
TunePlayReply? play,
|
||||
}) => OffbandUiReply(
|
||||
command: respCodeOffbandDeviceUi,
|
||||
sub: sub,
|
||||
eventTone: eventTone,
|
||||
tuneSlot: slot,
|
||||
slotAccepted: accepted,
|
||||
slotChunk: chunk,
|
||||
tunePlay: play,
|
||||
);
|
||||
|
||||
switch (sub) {
|
||||
case offbandUiEventToneGet:
|
||||
case offbandUiEventToneSet:
|
||||
if (frame.length < 5) return null;
|
||||
final event = ToneEvent.fromCode(frame[2]);
|
||||
if (event == null) {
|
||||
return OffbandUiReply(
|
||||
command: respCodeOffbandDeviceUi,
|
||||
sub: sub,
|
||||
errorMessage:
|
||||
'The radio reported an unknown tone event (${frame[2]})',
|
||||
);
|
||||
}
|
||||
return reply(
|
||||
eventTone: EventToneReply(
|
||||
event: event,
|
||||
state: EventToneState(stored: frame[3], effective: frame[4]),
|
||||
),
|
||||
);
|
||||
case offbandUiSlotGet:
|
||||
if (frame.length < 4) return null;
|
||||
// The name is the rest of the frame, at most what the transport carried.
|
||||
final name = String.fromCharCodes(frame.sublist(4));
|
||||
return reply(
|
||||
slot: TuneSlotReply(
|
||||
slot: frame[2],
|
||||
info: TuneSlotInfo(length: frame[3], name: name),
|
||||
),
|
||||
);
|
||||
case offbandUiSlotSet:
|
||||
if (frame.length < 4) return null;
|
||||
return reply(
|
||||
accepted: TuneSlotAccepted(slot: frame[2], accepted: frame[3]),
|
||||
);
|
||||
case offbandUiSlotRead:
|
||||
if (frame.length < 5) return null;
|
||||
return reply(
|
||||
chunk: TuneSlotChunk(
|
||||
slot: frame[2],
|
||||
offset: frame[3],
|
||||
more: frame[4] != 0,
|
||||
bytes: Uint8List.fromList(frame.sublist(5)),
|
||||
),
|
||||
);
|
||||
case offbandUiTunePlay:
|
||||
if (frame.length < 4) return null;
|
||||
return reply(
|
||||
play: TunePlayReply(
|
||||
tuneId: frame[2],
|
||||
result: TunePlayResult.fromCode(frame[3]),
|
||||
),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Why an RTTTL string is not safe to play, as firmware's `rtttlProblem()`
|
||||
/// judges it. The same rule runs on the badge's editor and on every pushed
|
||||
/// tune, so the app checks with it before sending: the radio can only answer
|
||||
/// "malformed", and this says why.
|
||||
enum RtttlProblem {
|
||||
ok('OK'),
|
||||
empty('Empty'),
|
||||
noName('Needs a name'),
|
||||
missingColon('Needs name:notes'),
|
||||
badDefault('Bad d=, o= or b='),
|
||||
zeroTempo('b= must be 1 or more'),
|
||||
noNotes('No notes'),
|
||||
ambiguousNote('Add d=, o= or b= first'),
|
||||
strayColon('Bad name or d=/o=/b=');
|
||||
|
||||
const RtttlProblem(this.text);
|
||||
|
||||
/// Firmware's own words (`rtttlProblemText`), so the app and the badge
|
||||
/// describe the same problem the same way.
|
||||
final String text;
|
||||
}
|
||||
|
||||
bool _isDigit(int c) => c >= 0x30 && c <= 0x39;
|
||||
bool _isDefaultsSep(int c) => c == 0x2C || c == 0x3A;
|
||||
|
||||
const int _colon = 0x3A;
|
||||
|
||||
/// Port of firmware `rtttlProblem()` (`Tunes.h`, `3e788e2a`), step for step.
|
||||
RtttlProblem rtttlProblem(String rtttl) {
|
||||
final s = rtttl.codeUnits;
|
||||
if (s.isEmpty) return RtttlProblem.empty;
|
||||
int at(int i) => i < s.length ? s[i] : 0;
|
||||
|
||||
var p = 0;
|
||||
while (at(p) != 0 && at(p) != _colon) {
|
||||
p++;
|
||||
}
|
||||
if (at(p) == 0) return RtttlProblem.missingColon;
|
||||
if (p == 0) return RtttlProblem.noName;
|
||||
p++;
|
||||
|
||||
var sawDefault = false;
|
||||
|
||||
if (at(p) == 0x64 && at(p + 1) == 0x3D) {
|
||||
p += 2;
|
||||
var digits = 0;
|
||||
while (_isDigit(at(p))) {
|
||||
p++;
|
||||
digits++;
|
||||
}
|
||||
if (digits == 0 || digits > 4) return RtttlProblem.badDefault;
|
||||
if (!_isDefaultsSep(at(p))) return RtttlProblem.badDefault;
|
||||
p++;
|
||||
sawDefault = true;
|
||||
}
|
||||
|
||||
if (at(p) == 0x6F && at(p + 1) == 0x3D) {
|
||||
if (!_isDigit(at(p + 2))) return RtttlProblem.badDefault;
|
||||
if (!_isDefaultsSep(at(p + 3))) return RtttlProblem.badDefault;
|
||||
p += 4;
|
||||
sawDefault = true;
|
||||
}
|
||||
|
||||
if (at(p) == 0x62 && at(p + 1) == 0x3D) {
|
||||
p += 2;
|
||||
var digits = 0;
|
||||
var bpm = 0;
|
||||
while (_isDigit(at(p))) {
|
||||
if (digits < 5) bpm = bpm * 10 + (at(p) - 0x30);
|
||||
p++;
|
||||
digits++;
|
||||
}
|
||||
if (digits == 0) return RtttlProblem.zeroTempo;
|
||||
if (digits > 4) return RtttlProblem.badDefault;
|
||||
if (bpm == 0) return RtttlProblem.zeroTempo;
|
||||
if (!_isDefaultsSep(at(p))) return RtttlProblem.badDefault;
|
||||
p++;
|
||||
sawDefault = true;
|
||||
}
|
||||
|
||||
if (at(p) == 0) return RtttlProblem.noNotes;
|
||||
|
||||
final c = at(p);
|
||||
if (!sawDefault && (c == 0x64 || c == 0x6F || c == 0x62)) {
|
||||
return RtttlProblem.ambiguousNote;
|
||||
}
|
||||
|
||||
for (var q = p; q < s.length; q++) {
|
||||
if (s[q] == _colon) return RtttlProblem.strayColon;
|
||||
}
|
||||
return RtttlProblem.ok;
|
||||
}
|
||||
|
||||
/// Port of firmware `deriveSlotName()`: the text before the first colon,
|
||||
/// trimmed and cut to [maxSlotName]. Empty when there is no usable name.
|
||||
String deriveSlotName(String rtttl) {
|
||||
final colon = rtttl.indexOf(':');
|
||||
if (colon < 0) return '';
|
||||
var name = rtttl
|
||||
.substring(0, colon)
|
||||
.replaceAll(RegExp(r'^[ \t]+|[ \t]+$'), '');
|
||||
if (name.length > maxSlotName) name = name.substring(0, maxSlotName);
|
||||
return name;
|
||||
}
|
||||
@ -0,0 +1,159 @@
|
||||
/// The badge's time zone table, a copy of firmware `src/helpers/TimeZones.h`
|
||||
/// at meshcore-firmware `3e788e2a` (#987).
|
||||
///
|
||||
/// The radio stores a zone as an index into this list, so the order is the
|
||||
/// contract. Firmware keeps it append-only ("Append only; never reorder"), so
|
||||
/// a copy can only fall behind; an index past the end is shown by number.
|
||||
library;
|
||||
|
||||
enum DstRule { none, us, eu }
|
||||
|
||||
class BadgeTimeZone {
|
||||
const BadgeTimeZone(this.name, this.standardMinutes, this.rule);
|
||||
final String name;
|
||||
final int standardMinutes;
|
||||
final DstRule rule;
|
||||
}
|
||||
|
||||
const int timeZoneNotSet = 0;
|
||||
|
||||
const List<BadgeTimeZone> badgeTimeZones = [
|
||||
BadgeTimeZone('not set', 0, DstRule.none),
|
||||
BadgeTimeZone('UTC', 0, DstRule.none),
|
||||
BadgeTimeZone('Eastern', -300, DstRule.us),
|
||||
BadgeTimeZone('Central', -360, DstRule.us),
|
||||
BadgeTimeZone('Mountain', -420, DstRule.us),
|
||||
BadgeTimeZone('Arizona', -420, DstRule.none),
|
||||
BadgeTimeZone('Pacific', -480, DstRule.us),
|
||||
BadgeTimeZone('Alaska', -540, DstRule.us),
|
||||
BadgeTimeZone('Hawaii', -600, DstRule.none),
|
||||
BadgeTimeZone('UK', 0, DstRule.eu),
|
||||
BadgeTimeZone('Central EU', 60, DstRule.eu),
|
||||
BadgeTimeZone('Eastern EU', 120, DstRule.eu),
|
||||
BadgeTimeZone('UTC-12', -720, DstRule.none),
|
||||
BadgeTimeZone('UTC-11', -660, DstRule.none),
|
||||
BadgeTimeZone('UTC-10', -600, DstRule.none),
|
||||
BadgeTimeZone('UTC-9', -540, DstRule.none),
|
||||
BadgeTimeZone('UTC-8', -480, DstRule.none),
|
||||
BadgeTimeZone('UTC-7', -420, DstRule.none),
|
||||
BadgeTimeZone('UTC-6', -360, DstRule.none),
|
||||
BadgeTimeZone('UTC-5', -300, DstRule.none),
|
||||
BadgeTimeZone('UTC-4', -240, DstRule.none),
|
||||
BadgeTimeZone('UTC-3', -180, DstRule.none),
|
||||
BadgeTimeZone('UTC-2', -120, DstRule.none),
|
||||
BadgeTimeZone('UTC-1', -60, DstRule.none),
|
||||
BadgeTimeZone('UTC+1', 60, DstRule.none),
|
||||
BadgeTimeZone('UTC+2', 120, DstRule.none),
|
||||
BadgeTimeZone('UTC+3', 180, DstRule.none),
|
||||
BadgeTimeZone('UTC+4', 240, DstRule.none),
|
||||
BadgeTimeZone('UTC+5', 300, DstRule.none),
|
||||
BadgeTimeZone('UTC+6', 360, DstRule.none),
|
||||
BadgeTimeZone('UTC+7', 420, DstRule.none),
|
||||
BadgeTimeZone('UTC+8', 480, DstRule.none),
|
||||
BadgeTimeZone('UTC+9', 540, DstRule.none),
|
||||
BadgeTimeZone('UTC+10', 600, DstRule.none),
|
||||
BadgeTimeZone('UTC+11', 660, DstRule.none),
|
||||
BadgeTimeZone('UTC+12', 720, DstRule.none),
|
||||
BadgeTimeZone('UTC+13', 780, DstRule.none),
|
||||
BadgeTimeZone('UTC+14', 840, DstRule.none),
|
||||
BadgeTimeZone('UTC-3:30', -210, DstRule.none),
|
||||
BadgeTimeZone('UTC+5:30', 330, DstRule.none),
|
||||
BadgeTimeZone('UTC+9:30', 570, DstRule.none),
|
||||
];
|
||||
|
||||
/// The first fixed-offset zone (`kFirstFixed`).
|
||||
const int _firstFixedZone = 12;
|
||||
|
||||
bool _isSet(int index) => index > 0 && index < badgeTimeZones.length;
|
||||
|
||||
/// The zone's name, or the raw index for one this client does not know.
|
||||
String timeZoneName(int index) => index >= 0 && index < badgeTimeZones.length
|
||||
? badgeTimeZones[index].name
|
||||
: 'Zone $index';
|
||||
|
||||
int _nthSunday(int year, int month, int n) {
|
||||
final first = DateTime.utc(year, month, 1);
|
||||
final toSunday = (DateTime.sunday - first.weekday) % 7;
|
||||
return 1 + toSunday + 7 * (n - 1);
|
||||
}
|
||||
|
||||
int _lastSunday(int year, int month) {
|
||||
final last = DateTime.utc(year, month + 1, 0);
|
||||
return last.day - (last.weekday % 7);
|
||||
}
|
||||
|
||||
/// Port of firmware `inDaylightSaving()`: US from 02:00 local on the second
|
||||
/// Sunday of March to 02:00 local daylight time on the first Sunday of
|
||||
/// November; EU from 01:00 UTC on the last Sunday of March to 01:00 UTC on
|
||||
/// the last Sunday of October.
|
||||
bool _inDaylightSaving(BadgeTimeZone z, DateTime utc) {
|
||||
final y = utc.year;
|
||||
switch (z.rule) {
|
||||
case DstRule.us:
|
||||
final start = DateTime.utc(
|
||||
y,
|
||||
3,
|
||||
_nthSunday(y, 3, 2),
|
||||
2,
|
||||
).subtract(Duration(minutes: z.standardMinutes));
|
||||
final end = DateTime.utc(
|
||||
y,
|
||||
11,
|
||||
_nthSunday(y, 11, 1),
|
||||
2,
|
||||
).subtract(Duration(minutes: z.standardMinutes + 60));
|
||||
return !utc.isBefore(start) && utc.isBefore(end);
|
||||
case DstRule.eu:
|
||||
final start = DateTime.utc(y, 3, _lastSunday(y, 3), 1);
|
||||
final end = DateTime.utc(y, 10, _lastSunday(y, 10), 1);
|
||||
return !utc.isBefore(start) && utc.isBefore(end);
|
||||
case DstRule.none:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Minutes to add to UTC at [utc] in zone [index], daylight saving included.
|
||||
/// Null for "not set" and for an index this client does not know.
|
||||
int? timeZoneOffsetMinutes(int index, DateTime utc) {
|
||||
if (!_isSet(index)) return null;
|
||||
final z = badgeTimeZones[index];
|
||||
return z.standardMinutes + (_inDaylightSaving(z, utc.toUtc()) ? 60 : 0);
|
||||
}
|
||||
|
||||
/// "UTC-4", "UTC+5:30", "UTC+0".
|
||||
String formatUtcOffset(int minutes) {
|
||||
final sign = minutes < 0 ? '-' : '+';
|
||||
final a = minutes.abs();
|
||||
final mins = a % 60;
|
||||
return 'UTC$sign${a ~/ 60}${mins == 0 ? '' : ':${mins.toString().padLeft(2, '0')}'}';
|
||||
}
|
||||
|
||||
/// The zone that matches the phone, as the badge suggests one from GPS.
|
||||
///
|
||||
/// A named zone matches when its offset equals the phone's now, in January and
|
||||
/// in July, so its daylight rule agrees as well as its current offset. Failing
|
||||
/// that, the fixed zone for the phone's current offset. Null when nothing fits.
|
||||
///
|
||||
/// [offsetAt] is the phone's UTC offset at a local instant; injectable so the
|
||||
/// match can be tested without depending on the machine's own zone.
|
||||
int? suggestTimeZone({
|
||||
required DateTime now,
|
||||
required Duration Function(DateTime local) offsetAt,
|
||||
}) {
|
||||
final year = now.year;
|
||||
final probes = [now, DateTime(year, 1, 15, 12), DateTime(year, 7, 15, 12)];
|
||||
// From 2: UTC (1) is the fallback below, so a UK phone in winter is not
|
||||
// offered plain UTC.
|
||||
for (var i = 2; i < _firstFixedZone; i++) {
|
||||
final matches = probes.every(
|
||||
(t) => timeZoneOffsetMinutes(i, t.toUtc()) == offsetAt(t).inMinutes,
|
||||
);
|
||||
if (matches) return i;
|
||||
}
|
||||
final current = offsetAt(now).inMinutes;
|
||||
if (current == 0) return 1;
|
||||
for (var i = _firstFixedZone; i < badgeTimeZones.length; i++) {
|
||||
if (badgeTimeZones[i].standardMinutes == current) return i;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@ -0,0 +1,125 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../connector/meshcore_connector.dart';
|
||||
import '../../connector/offband_device_ui.dart';
|
||||
import '../../connector/offband_screen_settings.dart';
|
||||
import '../../widgets/device_ui_error_banner.dart';
|
||||
|
||||
/// Where the badge's battery bar puts 100%, and how it got there (#987):
|
||||
/// the badge's own Battery screen, with its two actions. Gated on caps byte 2
|
||||
/// `0x40`. English-only, matching [DeviceUiView].
|
||||
class BadgeBatteryView extends StatefulWidget {
|
||||
const BadgeBatteryView({super.key});
|
||||
|
||||
@override
|
||||
State<BadgeBatteryView> createState() => _BadgeBatteryViewState();
|
||||
}
|
||||
|
||||
/// Where the full point came from, in the badge's terms (default, learned,
|
||||
/// set). Shared with the Node Settings row.
|
||||
String batteryFullSource(ScreenSettingsState s) => s.batteryFullStored == 0
|
||||
? 'Default for this board'
|
||||
: s.batteryPinned
|
||||
? 'Set by hand'
|
||||
: 'Learned after a full charge';
|
||||
|
||||
class _BadgeBatteryViewState extends State<BadgeBatteryView> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) context.read<MeshCoreConnector>().requestScreenSettings();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pin(MeshCoreConnector connector) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Set 100% here?'),
|
||||
content: const Text(
|
||||
'Only do this with the radio fully charged. The radio uses its '
|
||||
'current reading as full, and stops learning it.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: const Text('Set 100% here'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true) await connector.setBatteryFull(pin: true);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
final s = connector.screenSettings;
|
||||
final error = connector.deviceUiError;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final mv = connector.batteryMillivolts;
|
||||
final pct = connector.batteryPercent;
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
if (error != null &&
|
||||
connector.deviceUiErrorSurface == DeviceUiSurface.screenSettings)
|
||||
DeviceUiErrorBanner(
|
||||
message: error,
|
||||
onDismiss: connector.clearDeviceUiError,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
),
|
||||
if (pct != null) Text('$pct%', style: textTheme.headlineMedium),
|
||||
if (mv != null) Text('Reading $mv mV'),
|
||||
const SizedBox(height: 12),
|
||||
if (s == null)
|
||||
const ListTile(
|
||||
leading: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
title: Text('Reading battery settings from the radio'),
|
||||
)
|
||||
else ...[
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.battery_std),
|
||||
title: Text('100% at ${s.batteryFullMv} mV'),
|
||||
subtitle: Text(batteryFullSource(s)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'The radio learns where full is after a complete charge. Set it by '
|
||||
'hand only if the bar never reaches 100% when charged.',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => _pin(connector),
|
||||
icon: const Icon(Icons.bolt),
|
||||
label: const Text('Set 100% here'),
|
||||
),
|
||||
// Only when there is something to hand back, as on the badge.
|
||||
if (s.batteryFullStored != 0)
|
||||
TextButton(
|
||||
onPressed: () => connector.setBatteryFull(pin: false),
|
||||
child: const Text('Back to auto'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,262 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../connector/meshcore_connector.dart';
|
||||
import '../../connector/offband_device_ui.dart';
|
||||
import '../../connector/offband_screen_settings.dart';
|
||||
import '../../models/badge_time_zones.dart';
|
||||
import '../../widgets/device_ui_error_banner.dart';
|
||||
|
||||
/// The badge's own screen settings (#987): its Nav text, Message text, Screen
|
||||
/// off and Time zone rows, with the same choices and no others. Gated on caps
|
||||
/// byte 2 `0x40`; the Node Settings row that leads here is gated the same way.
|
||||
///
|
||||
/// Every control draws from the radio's re-read reply, never from the tap.
|
||||
/// English-only, matching [DeviceUiView].
|
||||
class BadgeScreenView extends StatefulWidget {
|
||||
const BadgeScreenView({super.key});
|
||||
|
||||
@override
|
||||
State<BadgeScreenView> createState() => _BadgeScreenViewState();
|
||||
}
|
||||
|
||||
class _BadgeScreenViewState extends State<BadgeScreenView> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) context.read<MeshCoreConnector>().requestScreenSettings();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
final s = connector.screenSettings;
|
||||
final error = connector.deviceUiError;
|
||||
return ListView(
|
||||
children: [
|
||||
if (error != null &&
|
||||
connector.deviceUiErrorSurface == DeviceUiSurface.screenSettings)
|
||||
DeviceUiErrorBanner(
|
||||
message: error,
|
||||
onDismiss: connector.clearDeviceUiError,
|
||||
),
|
||||
if (s == null)
|
||||
const ListTile(
|
||||
leading: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
title: Text('Reading screen settings from the radio'),
|
||||
)
|
||||
else ...[
|
||||
const _Header('Text size'),
|
||||
// The badge's Nav text and Message text, named for what they govern
|
||||
// (owner decision 2026-10-25).
|
||||
_TextSizeRow(
|
||||
label: 'Menus and lists',
|
||||
value: s.navText,
|
||||
onChanged: (v) =>
|
||||
connector.setTextSize(BadgeTextTarget.navigation, v),
|
||||
),
|
||||
_TextSizeRow(
|
||||
label: 'Messages',
|
||||
value: s.messageText,
|
||||
onChanged: (v) =>
|
||||
connector.setTextSize(BadgeTextTarget.messages, v),
|
||||
),
|
||||
const _Header('Screen off'),
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: Text(
|
||||
'How long the screen stays on after the last key press.',
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SegmentedButton<int>(
|
||||
segments: [
|
||||
for (final secs in screenOffSteps)
|
||||
ButtonSegment(value: secs, label: Text(screenOffLabel(secs))),
|
||||
],
|
||||
selected: {
|
||||
if (screenOffSteps.contains(s.screenOffStored))
|
||||
s.screenOffStored,
|
||||
},
|
||||
emptySelectionAllowed: true,
|
||||
onSelectionChanged: (v) {
|
||||
if (v.isNotEmpty) connector.setScreenOff(v.first);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (!screenOffSteps.contains(s.screenOffStored))
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Text(
|
||||
s.screenOffIsDefault
|
||||
? 'Board default: ${screenOffLabel(s.screenOffSeconds)}'
|
||||
: 'Set to ${screenOffLabel(s.screenOffStored)} elsewhere',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
const _Header('Time zone'),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.public),
|
||||
title: Text(timeZoneName(s.timeZone)),
|
||||
subtitle: Text(_zoneSubtitle(s.timeZone)),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => TimeZonePickerScreen(current: s.timeZone),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _zoneSubtitle(int index) {
|
||||
final offset = timeZoneOffsetMinutes(index, DateTime.now().toUtc());
|
||||
if (offset == null) {
|
||||
return index == timeZoneNotSet
|
||||
? 'Messages show their age'
|
||||
: 'A zone this app does not know';
|
||||
}
|
||||
return '${formatUtcOffset(offset)} now. Message times show as clock times.';
|
||||
}
|
||||
}
|
||||
|
||||
class _Header extends StatelessWidget {
|
||||
const _Header(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),
|
||||
);
|
||||
}
|
||||
|
||||
class _TextSizeRow extends StatelessWidget {
|
||||
const _TextSizeRow({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final BadgeTextSize? value;
|
||||
final ValueChanged<BadgeTextSize> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label),
|
||||
const SizedBox(height: 4),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: SegmentedButton<BadgeTextSize>(
|
||||
segments: [
|
||||
for (final size in BadgeTextSize.values)
|
||||
ButtonSegment(value: size, label: Text(size.label)),
|
||||
],
|
||||
selected: {?value},
|
||||
emptySelectionAllowed: true,
|
||||
onSelectionChanged: (v) {
|
||||
if (v.isNotEmpty) onChanged(v.first);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The badge's zone list, with "not set" and a suggestion from the phone's
|
||||
/// own zone, as the badge suggests one from GPS.
|
||||
class TimeZonePickerScreen extends StatefulWidget {
|
||||
const TimeZonePickerScreen({super.key, required this.current});
|
||||
|
||||
final int current;
|
||||
|
||||
@override
|
||||
State<TimeZonePickerScreen> createState() => _TimeZonePickerScreenState();
|
||||
}
|
||||
|
||||
class _TimeZonePickerScreenState extends State<TimeZonePickerScreen> {
|
||||
String _query = '';
|
||||
|
||||
void _choose(int index) {
|
||||
context.read<MeshCoreConnector>().setTimeZone(index);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final now = DateTime.now();
|
||||
final suggested = suggestTimeZone(
|
||||
now: now,
|
||||
offsetAt: (local) => local.timeZoneOffset,
|
||||
);
|
||||
final q = _query.trim().toLowerCase();
|
||||
bool shown(int i) => q.isEmpty || timeZoneName(i).toLowerCase().contains(q);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
Widget zoneTile(int i, {bool suggestion = false}) {
|
||||
final offset = timeZoneOffsetMinutes(i, now.toUtc());
|
||||
final selected = !suggestion && i == widget.current;
|
||||
return ListTile(
|
||||
leading: suggestion
|
||||
? const Icon(Icons.smartphone)
|
||||
: Icon(
|
||||
selected
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_unchecked,
|
||||
color: selected ? scheme.primary : null,
|
||||
),
|
||||
title: Text(timeZoneName(i)),
|
||||
subtitle: suggestion
|
||||
? const Text('Matches this phone')
|
||||
: selected
|
||||
? const Text('Current')
|
||||
: i == timeZoneNotSet
|
||||
? const Text('Messages show their age')
|
||||
: null,
|
||||
trailing: offset == null ? null : Text(formatUtcOffset(offset)),
|
||||
selected: selected,
|
||||
onTap: () => _choose(i),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Time zone'), centerTitle: true),
|
||||
body: ListView(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: SearchBar(
|
||||
hintText: 'Search zones',
|
||||
leading: const Icon(Icons.search),
|
||||
onChanged: (v) => setState(() => _query = v),
|
||||
),
|
||||
),
|
||||
if (suggested != null && shown(suggested)) ...[
|
||||
zoneTile(suggested, suggestion: true),
|
||||
const Divider(height: 1),
|
||||
],
|
||||
for (var i = 0; i < badgeTimeZones.length; i++)
|
||||
if (shown(i)) zoneTile(i),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,305 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../connector/meshcore_connector.dart';
|
||||
import '../../connector/offband_device_ui.dart';
|
||||
import '../../connector/offband_tones.dart';
|
||||
import '../../widgets/device_ui_error_banner.dart';
|
||||
import '../../widgets/radio_tone.dart';
|
||||
|
||||
/// Edit one custom tune slot on the radio (#970).
|
||||
///
|
||||
/// Opens with the stored tune read back (`SLOT_READ`), so a filled slot can be
|
||||
/// changed rather than only replaced. Typing is checked with firmware's own
|
||||
/// validator and words before anything is sent, since the radio can only
|
||||
/// answer "malformed". Saving is the chunked `SLOT_SET`; the radio replaces
|
||||
/// the slot only on the final chunk, so a failed save changes nothing.
|
||||
///
|
||||
/// English-only, matching [DeviceUiView].
|
||||
class TuneSlotEditorScreen extends StatefulWidget {
|
||||
const TuneSlotEditorScreen({
|
||||
super.key,
|
||||
required this.slot,
|
||||
required this.info,
|
||||
});
|
||||
|
||||
final int slot;
|
||||
final TuneSlotInfo info;
|
||||
|
||||
@override
|
||||
State<TuneSlotEditorScreen> createState() => _TuneSlotEditorScreenState();
|
||||
}
|
||||
|
||||
class _TuneSlotEditorScreenState extends State<TuneSlotEditorScreen> {
|
||||
final _controller = TextEditingController();
|
||||
|
||||
/// What the radio holds, once read. Null while reading, or if the read
|
||||
/// failed: then the slot's content is unknown and Play saved is off.
|
||||
String? _saved;
|
||||
bool _reading = false;
|
||||
int _readBytes = 0;
|
||||
bool _saving = false;
|
||||
int _sentBytes = 0;
|
||||
int _sendTotal = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller.addListener(() => setState(() {}));
|
||||
if (widget.info.isEmpty) {
|
||||
_saved = '';
|
||||
} else {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _read());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _read() async {
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
setState(() => _reading = true);
|
||||
final text = await connector.readTuneSlot(
|
||||
widget.slot,
|
||||
onProgress: (n) {
|
||||
if (mounted) setState(() => _readBytes = n);
|
||||
},
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_reading = false;
|
||||
_saved = text;
|
||||
if (text != null) _controller.text = text;
|
||||
});
|
||||
}
|
||||
|
||||
bool get _busy => _reading || _saving;
|
||||
String get _text => _controller.text;
|
||||
bool get _dirty => _text != (_saved ?? '');
|
||||
|
||||
Future<void> _save() async {
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final navigator = Navigator.of(context);
|
||||
final text = _text;
|
||||
setState(() {
|
||||
_saving = true;
|
||||
_sentBytes = 0;
|
||||
_sendTotal = text.length;
|
||||
});
|
||||
final ok = await connector.saveTuneSlot(
|
||||
widget.slot,
|
||||
text,
|
||||
onProgress: (sent, total) {
|
||||
if (mounted) setState(() => _sentBytes = sent);
|
||||
},
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_saving = false;
|
||||
if (ok) _saved = text;
|
||||
});
|
||||
if (!ok) return;
|
||||
// Pop after the rebuild, so PopScope sees the saved state and does not
|
||||
// ask to discard a tune that was just saved.
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
if (mounted) navigator.pop();
|
||||
}
|
||||
|
||||
Future<void> _clear() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text('Clear slot ${widget.slot + 1}?'),
|
||||
content: const Text(
|
||||
'The tune is removed from the radio. Events and channels using it '
|
||||
'stop playing it.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
_controller.text = '';
|
||||
await _save();
|
||||
}
|
||||
|
||||
Future<void> _playSaved() async {
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final note = describeTunePlay(
|
||||
await connector.playTune(slotTuneId(widget.slot)),
|
||||
);
|
||||
if (note == null) return;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(note),
|
||||
duration: const Duration(days: 1),
|
||||
action: SnackBarAction(label: 'OK', onPressed: () {}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _confirmDiscard() async {
|
||||
final discard = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Discard changes?'),
|
||||
content: const Text('This tune has not been saved to the radio.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: const Text('Keep editing'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: const Text('Discard'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return discard == true;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final problem = rtttlProblem(_text);
|
||||
final emptyText = _text.isEmpty;
|
||||
final helper = emptyText
|
||||
? (_saved == null ? '' : 'Type or paste an RTTTL tune')
|
||||
: problem == RtttlProblem.ok
|
||||
? 'Plays as "${deriveSlotName(_text)}"'
|
||||
: problem.text;
|
||||
final helperIsProblem = !emptyText && problem != RtttlProblem.ok;
|
||||
final savedHasTune = _saved != null && _saved!.isNotEmpty;
|
||||
final error = connector.deviceUiError;
|
||||
final showError =
|
||||
error != null &&
|
||||
(connector.deviceUiErrorSurface == DeviceUiSurface.tuneSlot ||
|
||||
connector.deviceUiErrorSurface == DeviceUiSurface.tunePlay);
|
||||
|
||||
return PopScope(
|
||||
// Held while a read or save is in flight: leaving would abandon it
|
||||
// halfway, and the radio would keep the old tune.
|
||||
canPop: !_dirty && !_busy,
|
||||
onPopInvokedWithResult: (didPop, _) async {
|
||||
if (didPop || _busy) return;
|
||||
final navigator = Navigator.of(context);
|
||||
if (await _confirmDiscard()) navigator.pop();
|
||||
},
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Slot ${widget.slot + 1}'),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
if (showError)
|
||||
DeviceUiErrorBanner(
|
||||
message: error,
|
||||
onDismiss: connector.clearDeviceUiError,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
readOnly: _busy,
|
||||
minLines: 4,
|
||||
maxLines: 8,
|
||||
maxLength: maxSlotRtttl,
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
// RTTTL is plain ASCII on one line; the radio stores bytes.
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'[\x20-\x7E]')),
|
||||
],
|
||||
style: const TextStyle(fontFamily: 'monospace'),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'RTTTL',
|
||||
border: const OutlineInputBorder(),
|
||||
helperText: helperIsProblem ? null : helper,
|
||||
errorText: helperIsProblem ? helper : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
alignment: WrapAlignment.end,
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _busy || !savedHasTune ? null : _clear,
|
||||
style: TextButton.styleFrom(foregroundColor: scheme.error),
|
||||
child: const Text('Clear slot'),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _busy || !savedHasTune || _dirty
|
||||
? null
|
||||
: _playSaved,
|
||||
icon: const Icon(Icons.play_circle_outline),
|
||||
label: const Text('Play saved'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed:
|
||||
_busy ||
|
||||
!_dirty ||
|
||||
emptyText ||
|
||||
problem != RtttlProblem.ok
|
||||
? null
|
||||
: _save,
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_reading) ...[
|
||||
const SizedBox(height: 16),
|
||||
LinearProgressIndicator(
|
||||
value: widget.info.length == 0
|
||||
? null
|
||||
: _readBytes / widget.info.length,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Reading from the radio: $_readBytes of '
|
||||
'${widget.info.length} characters',
|
||||
),
|
||||
],
|
||||
if (_saving) ...[
|
||||
const SizedBox(height: 16),
|
||||
LinearProgressIndicator(
|
||||
value: _sendTotal == 0 ? null : _sentBytes / _sendTotal,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
_sendTotal == 0
|
||||
? 'Clearing the slot'
|
||||
: 'Sending to the radio: $_sentBytes of $_sendTotal '
|
||||
'characters',
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Play saved plays what is on the radio, so it is off while there '
|
||||
'are unsaved changes.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// The radio's refusal of a device-UI write, shown until dismissed rather than
|
||||
/// flashed: an error the user cannot finish reading is not a surfaced error.
|
||||
class DeviceUiErrorBanner extends StatelessWidget {
|
||||
const DeviceUiErrorBanner({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.onDismiss,
|
||||
this.margin = const EdgeInsets.all(12),
|
||||
});
|
||||
|
||||
final String message;
|
||||
final VoidCallback onDismiss;
|
||||
final EdgeInsetsGeometry margin;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
margin: margin,
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,332 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../connector/offband_device_ui.dart';
|
||||
import '../connector/offband_tones.dart';
|
||||
import '../models/channel.dart';
|
||||
|
||||
/// The radio's tune picker (#971, #986), shared by the six event tones and a
|
||||
/// channel's own tone, so every entry point offers the same list the badge's
|
||||
/// picker does. A channel's "Radio tone" row lives here too, reachable from
|
||||
/// the Channels sheet and the channel chat menu, so the two cannot drift.
|
||||
///
|
||||
/// English-only, matching [DeviceUiView] which hosts the event rows.
|
||||
|
||||
IconData tuneIcon(int? id) => id == null
|
||||
? Icons.sync
|
||||
: id == tuneSilent
|
||||
? Icons.volume_off_outlined
|
||||
: Icons.music_note_outlined;
|
||||
|
||||
/// The radio's reason a preview made no sound, or null when it played.
|
||||
String? describeTunePlay(TunePlayResult? result) => switch (result) {
|
||||
TunePlayResult.played => null,
|
||||
TunePlayResult.busy =>
|
||||
'The radio is playing a notification. Try again in a moment.',
|
||||
TunePlayResult.soundOff =>
|
||||
'Sound is off on this radio. Set the scope to All or Self to hear it.',
|
||||
null => 'The radio did not play that tune.',
|
||||
};
|
||||
|
||||
/// A tune choice from [showTunePickerDialog].
|
||||
sealed class TuneChoice {
|
||||
const TuneChoice();
|
||||
}
|
||||
|
||||
/// "Default": no choice, follow the radio's tone. Offered only to a channel.
|
||||
class DefaultTuneChoice extends TuneChoice {
|
||||
const DefaultTuneChoice();
|
||||
}
|
||||
|
||||
class TuneIdChoice extends TuneChoice {
|
||||
const TuneIdChoice(this.id);
|
||||
final int id;
|
||||
}
|
||||
|
||||
/// Show the picker. [current] is the row to mark; null marks Default. Returns
|
||||
/// the choice, or null when the user cancels.
|
||||
///
|
||||
/// The play button auditions on the radio and never chooses; tapping a name
|
||||
/// chooses (owner decision 2026-10-25). Only what firmware accepts is listed:
|
||||
/// named built-ins and filled slots.
|
||||
Future<TuneChoice?> showTunePickerDialog(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
String? description,
|
||||
required int? current,
|
||||
String? defaultSubtitle,
|
||||
bool withDefault = false,
|
||||
}) {
|
||||
return showDialog<TuneChoice>(
|
||||
context: context,
|
||||
builder: (dialogContext) => _TunePickerDialog(
|
||||
title: title,
|
||||
description: description,
|
||||
current: current,
|
||||
defaultSubtitle: defaultSubtitle,
|
||||
withDefault: withDefault,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _TunePickerDialog extends StatefulWidget {
|
||||
const _TunePickerDialog({
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.current,
|
||||
required this.defaultSubtitle,
|
||||
required this.withDefault,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String? description;
|
||||
final int? current;
|
||||
final String? defaultSubtitle;
|
||||
final bool withDefault;
|
||||
|
||||
@override
|
||||
State<_TunePickerDialog> createState() => _TunePickerDialogState();
|
||||
}
|
||||
|
||||
class _TunePickerDialogState extends State<_TunePickerDialog> {
|
||||
/// A preview's refusal, shown inside the dialog: a snackbar would sit
|
||||
/// behind the dialog's scrim.
|
||||
String? _playNote;
|
||||
|
||||
Future<void> _play(MeshCoreConnector connector, int id) async {
|
||||
setState(() => _playNote = null);
|
||||
final result = await connector.playTune(id);
|
||||
if (!mounted) return;
|
||||
setState(() => _playNote = describeTunePlay(result));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, _) {
|
||||
final slots = connector.tuneSlots;
|
||||
final ids = offeredTuneIds(slots);
|
||||
final builtins = ids.where((id) => !isSlotTuneId(id)).toList();
|
||||
final custom = ids.where(isSlotTuneId).toList();
|
||||
|
||||
Widget row(int id) {
|
||||
final selected = id == widget.current;
|
||||
return ListTile(
|
||||
leading: Icon(tuneIcon(id)),
|
||||
title: Text(tuneLabel(id, slots) ?? 'Tune $id'),
|
||||
subtitle: selected ? const Text('Current') : null,
|
||||
selected: selected,
|
||||
trailing: id == tuneSilent
|
||||
? null
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.play_circle_outline),
|
||||
color: scheme.primary,
|
||||
tooltip: 'Play on the radio',
|
||||
onPressed: () => _play(connector, id),
|
||||
),
|
||||
onTap: () => Navigator.pop(context, TuneIdChoice(id)),
|
||||
);
|
||||
}
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(widget.title),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
if (widget.description != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
|
||||
child: Text(widget.description!),
|
||||
),
|
||||
if (_playNote != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
|
||||
child: Text(
|
||||
_playNote!,
|
||||
style: TextStyle(color: scheme.error),
|
||||
),
|
||||
),
|
||||
if (widget.withDefault) ...[
|
||||
ListTile(
|
||||
leading: Icon(tuneIcon(null)),
|
||||
title: const Text('Default'),
|
||||
subtitle: widget.defaultSubtitle == null
|
||||
? null
|
||||
: Text(widget.defaultSubtitle!),
|
||||
selected: widget.current == null,
|
||||
trailing: widget.current == null
|
||||
? const Icon(Icons.check)
|
||||
: null,
|
||||
onTap: () =>
|
||||
Navigator.pop(context, const DefaultTuneChoice()),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
],
|
||||
for (final id in builtins) row(id),
|
||||
if (custom.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 4),
|
||||
child: Text(
|
||||
'Your tunes',
|
||||
style: Theme.of(context).textTheme.labelMedium,
|
||||
),
|
||||
),
|
||||
for (final id in custom) row(id),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// What a channel's Default plays: the Channel event's effective tune, when
|
||||
/// the radio has reported it.
|
||||
String _defaultSubtitle(MeshCoreConnector connector) {
|
||||
final effective = connector.eventTone(ToneEvent.channel)?.effective;
|
||||
final name = effective == null
|
||||
? null
|
||||
: tuneLabel(effective, connector.tuneSlots);
|
||||
return name == null
|
||||
? 'The radio\'s channel tone'
|
||||
: '$name, the radio\'s channel tone';
|
||||
}
|
||||
|
||||
/// The subtitle for a channel's Radio tone row.
|
||||
String channelRadioToneLabel(MeshCoreConnector connector, int channel) {
|
||||
final tune = connector.channelTuneFor(channel);
|
||||
if (tune == null) return 'Reading from the radio';
|
||||
if (tune.inherits) return 'Default';
|
||||
return tuneLabel(tune.tuneId!, connector.tuneSlots) ?? 'Tune ${tune.tuneId}';
|
||||
}
|
||||
|
||||
/// Read what the Radio tone row and its picker need.
|
||||
void requestChannelRadioTone(MeshCoreConnector connector, int channel) {
|
||||
connector.requestChannelTune(channel);
|
||||
connector.requestEventTones();
|
||||
connector.requestTuneSlots();
|
||||
}
|
||||
|
||||
/// Show the radio's refusal of a write to [surface] as a snackbar that stays
|
||||
/// until dismissed, for screens with no banner of their own. Watches for a few
|
||||
/// seconds after the write; a confirmed write shows nothing.
|
||||
void showRefusalWhenItArrives(
|
||||
BuildContext context,
|
||||
MeshCoreConnector connector,
|
||||
DeviceUiSurface surface,
|
||||
) {
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
if (messenger == null) return;
|
||||
late final VoidCallback listener;
|
||||
listener = () {
|
||||
final error = connector.deviceUiError;
|
||||
if (connector.deviceUiErrorSurface != surface || error == null) return;
|
||||
connector.removeListener(listener);
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(error),
|
||||
duration: const Duration(days: 1),
|
||||
action: SnackBarAction(
|
||||
label: 'OK',
|
||||
onPressed: connector.clearDeviceUiError,
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
connector.addListener(listener);
|
||||
Future.delayed(
|
||||
const Duration(seconds: 5),
|
||||
() => connector.removeListener(listener),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showChannelRadioToneDialog(
|
||||
BuildContext context,
|
||||
Channel channel,
|
||||
) async {
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final tune = connector.channelTuneFor(channel.index);
|
||||
final choice = await showTunePickerDialog(
|
||||
context,
|
||||
title: 'Radio tone',
|
||||
description: channel.name,
|
||||
current: tune?.tuneId,
|
||||
defaultSubtitle: _defaultSubtitle(connector),
|
||||
withDefault: true,
|
||||
);
|
||||
if (choice != null && context.mounted) {
|
||||
showRefusalWhenItArrives(context, connector, DeviceUiSurface.channelTune);
|
||||
}
|
||||
switch (choice) {
|
||||
case DefaultTuneChoice():
|
||||
await connector.setChannelTune(
|
||||
channel.index,
|
||||
const ChannelTune.inherit(),
|
||||
);
|
||||
case TuneIdChoice(:final id):
|
||||
await connector.setChannelTune(channel.index, ChannelTune.tune(id));
|
||||
case null:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// The channel sheet's Radio tone row. Reads the channel's tune when shown, so
|
||||
/// its subtitle is the radio's answer. Shown only with caps byte 2 `0x10`.
|
||||
class ChannelRadioToneTile extends StatefulWidget {
|
||||
const ChannelRadioToneTile({
|
||||
super.key,
|
||||
required this.channel,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final Channel channel;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
State<ChannelRadioToneTile> createState() => _ChannelRadioToneTileState();
|
||||
}
|
||||
|
||||
class _ChannelRadioToneTileState extends State<ChannelRadioToneTile> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final c = context.read<MeshCoreConnector>();
|
||||
if (c.supportsTunes) requestChannelRadioTone(c, widget.channel.index);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
if (!connector.supportsTunes) return const SizedBox.shrink();
|
||||
final tune = connector.channelTuneFor(widget.channel.index);
|
||||
return ListTile(
|
||||
leading: Icon(tune == null ? Icons.sync : tuneIcon(tune.tuneId)),
|
||||
title: const Text('Radio tone'),
|
||||
subtitle: Text(channelRadioToneLabel(connector, widget.channel.index)),
|
||||
onTap: widget.onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether [surface]'s refusal belongs on a tone screen.
|
||||
bool isToneSurface(DeviceUiSurface? surface) =>
|
||||
surface == DeviceUiSurface.channelTune ||
|
||||
surface == DeviceUiSurface.eventTone ||
|
||||
surface == DeviceUiSurface.tunePlay ||
|
||||
surface == DeviceUiSurface.tuneSlot;
|
||||
@ -0,0 +1,146 @@
|
||||
// #987: the badge's screen and battery settings over 0xC5 0x13 to 0x17, and
|
||||
// the time zone table, against meshcore-firmware 3e788e2a.
|
||||
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_open/connector/offband_screen_settings.dart';
|
||||
import 'package:meshcore_open/models/badge_time_zones.dart';
|
||||
|
||||
void main() {
|
||||
group('frames', () {
|
||||
test('get and each set encode as documented', () {
|
||||
expect(buildScreenSettingsGetFrame(), equals([0xC5, 0x13]));
|
||||
expect(
|
||||
buildTextSizeSetFrame(BadgeTextTarget.messages, BadgeTextSize.small),
|
||||
equals([0xC5, 0x14, 1, 2]),
|
||||
);
|
||||
expect(buildScreenOffSetFrame(300), equals([0xC5, 0x15, 0x2C, 0x01]));
|
||||
expect(buildTimeZoneSetFrame(2), equals([0xC5, 0x16, 2]));
|
||||
expect(buildBatteryFullSetFrame(pin: true), equals([0xC5, 0x17, 1]));
|
||||
expect(buildBatteryFullSetFrame(pin: false), equals([0xC5, 0x17, 0]));
|
||||
});
|
||||
});
|
||||
|
||||
group('reply', () {
|
||||
test('all fourteen bytes, little-endian', () {
|
||||
final r = parseScreenSettingsReply(
|
||||
Uint8List.fromList([
|
||||
0xC5, 0x15, //
|
||||
0, 1, // nav large, messages medium
|
||||
0x3C, 0x00, // stored 60 s
|
||||
0x3C, 0x00, // waits 60 s
|
||||
2, // Eastern
|
||||
0x36, 0x10, // 4150 mV stored
|
||||
0x36, 0x10, // 4150 mV in use
|
||||
0, // learned
|
||||
]),
|
||||
)!;
|
||||
final s = r.screen!;
|
||||
expect(s.navText, BadgeTextSize.large);
|
||||
expect(s.messageText, BadgeTextSize.medium);
|
||||
expect(s.screenOffStored, 60);
|
||||
expect(s.screenOffIsDefault, isFalse);
|
||||
expect(s.timeZone, 2);
|
||||
expect(s.batteryFullStored, 4150);
|
||||
expect(s.batteryPinned, isFalse);
|
||||
});
|
||||
|
||||
test('a short reply is never shown as the radio\'s state', () {
|
||||
expect(
|
||||
parseScreenSettingsReply(Uint8List.fromList([0xC5, 0x13, 0, 1])),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('screen off labels', () {
|
||||
expect(screenOffSteps.map(screenOffLabel), [
|
||||
'15 s',
|
||||
'30 s',
|
||||
'1 m',
|
||||
'2 m',
|
||||
'5 m',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
group('time zones', () {
|
||||
test('the table matches firmware: 41 entries, indices pinned', () {
|
||||
expect(badgeTimeZones.length, 41);
|
||||
expect(badgeTimeZones[0].name, 'not set');
|
||||
expect(badgeTimeZones[2].name, 'Eastern');
|
||||
expect(badgeTimeZones[12].name, 'UTC-12');
|
||||
expect(badgeTimeZones.last.name, 'UTC+9:30');
|
||||
});
|
||||
|
||||
test('US daylight time, both edges', () {
|
||||
// 2026: DST runs Mar 8 07:00 UTC to Nov 1 06:00 UTC for Eastern.
|
||||
expect(timeZoneOffsetMinutes(2, DateTime.utc(2026, 3, 8, 6, 59)), -300);
|
||||
expect(timeZoneOffsetMinutes(2, DateTime.utc(2026, 3, 8, 7)), -240);
|
||||
expect(timeZoneOffsetMinutes(2, DateTime.utc(2026, 11, 1, 5, 59)), -240);
|
||||
expect(timeZoneOffsetMinutes(2, DateTime.utc(2026, 11, 1, 6)), -300);
|
||||
// Arizona keeps standard time all year.
|
||||
expect(timeZoneOffsetMinutes(5, DateTime.utc(2026, 7, 1)), -420);
|
||||
});
|
||||
|
||||
test('EU summer time, both edges', () {
|
||||
// 2026: last Sundays are Mar 29 and Oct 25, at 01:00 UTC.
|
||||
expect(timeZoneOffsetMinutes(10, DateTime.utc(2026, 3, 29, 0, 59)), 60);
|
||||
expect(timeZoneOffsetMinutes(10, DateTime.utc(2026, 3, 29, 1)), 120);
|
||||
expect(timeZoneOffsetMinutes(10, DateTime.utc(2026, 10, 25, 1)), 60);
|
||||
});
|
||||
|
||||
test('not set and unknown indices have no offset', () {
|
||||
expect(timeZoneOffsetMinutes(0, DateTime.utc(2026)), isNull);
|
||||
expect(timeZoneOffsetMinutes(99, DateTime.utc(2026)), isNull);
|
||||
expect(timeZoneName(99), 'Zone 99');
|
||||
});
|
||||
|
||||
test('offset labels', () {
|
||||
expect(formatUtcOffset(-240), 'UTC-4');
|
||||
expect(formatUtcOffset(330), 'UTC+5:30');
|
||||
expect(formatUtcOffset(0), 'UTC+0');
|
||||
});
|
||||
|
||||
// A phone whose offset follows the US Eastern rule.
|
||||
Duration eastern(DateTime local) =>
|
||||
Duration(minutes: timeZoneOffsetMinutes(2, local.toUtc())!);
|
||||
|
||||
test('a phone on Eastern is offered Eastern, not UTC-4', () {
|
||||
expect(
|
||||
suggestTimeZone(now: DateTime(2026, 10, 25, 12), offsetAt: eastern),
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test('a phone with no daylight rule gets the fixed offset', () {
|
||||
expect(
|
||||
suggestTimeZone(
|
||||
now: DateTime(2026, 10, 25, 12),
|
||||
offsetAt: (_) => const Duration(minutes: 330),
|
||||
),
|
||||
39,
|
||||
);
|
||||
});
|
||||
|
||||
test('a phone on UTC gets UTC, not the UK', () {
|
||||
expect(
|
||||
suggestTimeZone(
|
||||
now: DateTime(2026, 10, 25, 12),
|
||||
offsetAt: (_) => Duration.zero,
|
||||
),
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('an offset with no zone suggests nothing', () {
|
||||
expect(
|
||||
suggestTimeZone(
|
||||
now: DateTime(2026, 10, 25, 12),
|
||||
offsetAt: (_) => const Duration(minutes: 345),
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,139 @@
|
||||
// #970, #986: the tune codec over 0xC5, against the contract in
|
||||
// meshcore-firmware 3e788e2a OffbandConfigProtocol.h, and the ports of its
|
||||
// RTTTL validator and slot-name rule.
|
||||
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_open/connector/offband_tones.dart';
|
||||
|
||||
void main() {
|
||||
group('frames', () {
|
||||
test('event tone get and set', () {
|
||||
expect(buildEventToneGetFrame(ToneEvent.dm), equals([0xC5, 0x0F, 0]));
|
||||
expect(
|
||||
buildEventToneSetFrame(ToneEvent.sent, 9),
|
||||
equals([0xC5, 0x10, 5, 9]),
|
||||
);
|
||||
});
|
||||
|
||||
test('play, slot get and slot read', () {
|
||||
expect(buildTunePlayFrame(8), equals([0xC5, 0x11, 8]));
|
||||
expect(buildSlotGetFrame(7), equals([0xC5, 0x09, 7]));
|
||||
expect(buildSlotReadFrame(2, 15), equals([0xC5, 0x12, 2, 15]));
|
||||
});
|
||||
|
||||
test('slot set carries offset, more and the bytes', () {
|
||||
expect(
|
||||
buildSlotSetFrame(1, 15, true, [0x61, 0x62]),
|
||||
equals([0xC5, 0x0A, 1, 15, 1, 0x61, 0x62]),
|
||||
);
|
||||
// The empty final chunk at offset 0 is how a slot is cleared.
|
||||
expect(buildSlotSetFrame(1, 0, false, []), equals([0xC5, 0x0A, 1, 0, 0]));
|
||||
});
|
||||
|
||||
test('a slot past the eight is refused before it is sent', () {
|
||||
expect(() => buildSlotGetFrame(8), throwsArgumentError);
|
||||
expect(() => buildSlotReadFrame(-1, 0), throwsArgumentError);
|
||||
});
|
||||
});
|
||||
|
||||
group('replies', () {
|
||||
Uint8List f(List<int> b) => Uint8List.fromList(b);
|
||||
|
||||
test('event tone keeps stored and effective apart', () {
|
||||
final r = parseTuneReply(f([0xC5, 0x0F, 0, 0xFF, 1]))!;
|
||||
expect(r.eventTone!.event, ToneEvent.dm);
|
||||
expect(r.eventTone!.state.isRadioDefault, isTrue);
|
||||
expect(r.eventTone!.state.effective, 1);
|
||||
});
|
||||
|
||||
test('an unknown event is an error, not a guess', () {
|
||||
expect(parseTuneReply(f([0xC5, 0x10, 9, 1, 1]))!.isError, isTrue);
|
||||
});
|
||||
|
||||
test('slot get: length and name, and an empty slot', () {
|
||||
final full = parseTuneReply(
|
||||
f([0xC5, 0x09, 0, 142, ...'Mario'.codeUnits]),
|
||||
);
|
||||
expect(full!.tuneSlot!.info.length, 142);
|
||||
expect(full.tuneSlot!.info.name, 'Mario');
|
||||
final empty = parseTuneReply(f([0xC5, 0x09, 3, 0]));
|
||||
expect(empty!.tuneSlot!.info.isEmpty, isTrue);
|
||||
});
|
||||
|
||||
test('slot read chunk and play result', () {
|
||||
final c = parseTuneReply(f([0xC5, 0x12, 1, 15, 1, 0x61, 0x62]))!;
|
||||
expect(c.slotChunk!.offset, 15);
|
||||
expect(c.slotChunk!.more, isTrue);
|
||||
expect(c.slotChunk!.bytes, equals([0x61, 0x62]));
|
||||
final p = parseTuneReply(f([0xC5, 0x11, 8, 2]))!;
|
||||
expect(p.tunePlay!.result, TunePlayResult.soundOff);
|
||||
});
|
||||
|
||||
test('truncated replies are dropped', () {
|
||||
expect(parseTuneReply(f([0xC5, 0x0F, 0, 1])), isNull);
|
||||
expect(parseTuneReply(f([0xC5, 0x12, 1, 0])), isNull);
|
||||
expect(parseTuneReply(f([0xC5, 0x11, 8])), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('the picker rule', () {
|
||||
test('named built-ins plus filled slots, never id 7 or empty slots', () {
|
||||
final slots = {
|
||||
0: const TuneSlotInfo(length: 142, name: 'Mario'),
|
||||
1: const TuneSlotInfo(length: 0, name: ''),
|
||||
};
|
||||
expect(offeredTuneIds(slots), equals([0, 1, 2, 3, 4, 5, 6, 8]));
|
||||
});
|
||||
|
||||
test('labels', () {
|
||||
final slots = {
|
||||
0: const TuneSlotInfo(length: 142, name: 'Mario'),
|
||||
4: const TuneSlotInfo(length: 20, name: ''),
|
||||
};
|
||||
expect(tuneLabel(2, slots), 'Kerplop');
|
||||
expect(tuneLabel(7, slots), isNull);
|
||||
expect(tuneLabel(8, slots), 'Mario');
|
||||
expect(tuneLabel(12, slots), 'Slot 5');
|
||||
expect(tuneLabel(9, slots), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('rtttlProblem, ported from firmware', () {
|
||||
final cases = <String, RtttlProblem>{
|
||||
'Mario:d=4,o=5,b=100:16e6,16e6': RtttlProblem.ok,
|
||||
'MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7': RtttlProblem.ok,
|
||||
'ack:d=32,o=8,b=120:c': RtttlProblem.ok,
|
||||
'': RtttlProblem.empty,
|
||||
'no colon here': RtttlProblem.missingColon,
|
||||
':d=4:c': RtttlProblem.noName,
|
||||
'x:d=:c': RtttlProblem.badDefault,
|
||||
'x:o=55:c': RtttlProblem.badDefault,
|
||||
'Doorbell:d=4,o=5,b=0:8e6': RtttlProblem.zeroTempo,
|
||||
'x:b=:c': RtttlProblem.zeroTempo,
|
||||
'x:d=4:': RtttlProblem.noNotes,
|
||||
'x:b8,c': RtttlProblem.ambiguousNote,
|
||||
'x:c:d': RtttlProblem.strayColon,
|
||||
'x: d=4,o=5,b=100:c': RtttlProblem.strayColon,
|
||||
};
|
||||
cases.forEach((tune, want) {
|
||||
test('"$tune" is ${want.name}', () {
|
||||
expect(rtttlProblem(tune), want);
|
||||
});
|
||||
});
|
||||
|
||||
test('every problem has firmware\'s words', () {
|
||||
expect(RtttlProblem.zeroTempo.text, 'b= must be 1 or more');
|
||||
expect(RtttlProblem.missingColon.text, 'Needs name:notes');
|
||||
});
|
||||
});
|
||||
|
||||
group('deriveSlotName', () {
|
||||
test('the text before the first colon, trimmed and capped at 12', () {
|
||||
expect(deriveSlotName(' Mario :d=4:c'), 'Mario');
|
||||
expect(deriveSlotName('ABCDEFGHIJKLMNOP:c'), 'ABCDEFGHIJKL');
|
||||
expect(deriveSlotName('no colon'), '');
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,323 @@
|
||||
// #970, #986, #987: the connector side of tunes and screen settings over 0xC5.
|
||||
// Pins the gates (no bit, no frame), that state follows only the radio's
|
||||
// replies, the chunked slot read and save, the one restart a displaced save
|
||||
// gets, and that refusals land on the right surface.
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_open/connector/meshcore_connector.dart';
|
||||
import 'package:meshcore_open/connector/offband_device_ui.dart';
|
||||
import 'package:meshcore_open/connector/offband_tones.dart';
|
||||
import 'package:meshcore_open/storage/drift/blob_store.dart';
|
||||
import 'package:meshcore_open/storage/drift/offband_database.dart';
|
||||
import 'package:meshcore_open/storage/prefs_manager.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// RESP_CODE_DEVICE_INFO from an Offband radio, caps byte 2 at offset 84.
|
||||
List<int> deviceInfo({required int caps2}) {
|
||||
final v = ascii.encode('1.5.0-1.17.0');
|
||||
final f = Uint8List(85);
|
||||
f[0] = 13;
|
||||
f[1] = 22;
|
||||
f.setRange(60, 60 + v.length, v);
|
||||
f[84] = caps2;
|
||||
return f;
|
||||
}
|
||||
|
||||
void main() {
|
||||
late OffbandDatabase db;
|
||||
late MeshCoreConnector connector;
|
||||
late List<List<int>> sent;
|
||||
|
||||
/// Replies the fake radio sends for each frame the client writes.
|
||||
List<List<int>> Function(List<int> frame)? radio;
|
||||
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
PrefsManager.reset();
|
||||
await PrefsManager.initialize();
|
||||
db = OffbandDatabase(NativeDatabase.memory());
|
||||
BlobStore.overrideForTest(BlobStore(db));
|
||||
sent = [];
|
||||
radio = null;
|
||||
connector = MeshCoreConnector();
|
||||
connector.sendFrameOverrideForTest = (data) {
|
||||
final frame = data.toList();
|
||||
sent.add(frame);
|
||||
final replies = radio?.call(frame) ?? const [];
|
||||
// Replies arrive after the write returns, as they would over a link.
|
||||
scheduleMicrotask(() {
|
||||
for (final r in replies) {
|
||||
connector.handleFrameForTest(r);
|
||||
}
|
||||
});
|
||||
};
|
||||
connector.setConnectedForTest();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
BlobStore.clearTestOverride();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
bool sentSub(Set<int> subs) => sent.any(
|
||||
(f) => f.length >= 2 && f[0] == cmdOffbandDeviceUi && subs.contains(f[1]),
|
||||
);
|
||||
|
||||
group('gates', () {
|
||||
test('no 0x10: no tune frame of any kind', () async {
|
||||
connector.handleFrameForTest(deviceInfo(caps2: 0x01));
|
||||
sent.clear();
|
||||
await connector.requestEventTones();
|
||||
await connector.setEventTone(ToneEvent.dm, 1);
|
||||
await connector.requestTuneSlots();
|
||||
expect(await connector.playTune(1), isNull);
|
||||
expect(await connector.readTuneSlot(0), isNull);
|
||||
expect(await connector.saveTuneSlot(0, 'x:d=4:c'), isFalse);
|
||||
expect(sentSub({0x09, 0x0A, 0x0F, 0x10, 0x11, 0x12}), isFalse);
|
||||
});
|
||||
|
||||
test('no 0x40: no screen frame, and none on connect', () async {
|
||||
connector.handleFrameForTest(deviceInfo(caps2: 0x10));
|
||||
await connector.setTimeZone(2);
|
||||
await connector.setScreenOff(60);
|
||||
expect(sentSub({0x13, 0x14, 0x15, 0x16, 0x17}), isFalse);
|
||||
});
|
||||
|
||||
test('with 0x40 the settings are read on connect', () {
|
||||
connector.handleFrameForTest(deviceInfo(caps2: 0x40));
|
||||
expect(sent, contains(equals([0xC5, 0x13])));
|
||||
});
|
||||
});
|
||||
|
||||
group('event tones', () {
|
||||
test('reads all six, and a write shows only once confirmed', () async {
|
||||
connector.handleFrameForTest(deviceInfo(caps2: 0x10));
|
||||
sent.clear();
|
||||
await connector.requestEventTones();
|
||||
expect(sent, [
|
||||
for (var ev = 0; ev < 6; ev++) [0xC5, 0x0F, ev],
|
||||
]);
|
||||
|
||||
await connector.setEventTone(ToneEvent.room, 8);
|
||||
expect(connector.eventTone(ToneEvent.room), isNull);
|
||||
connector.handleFrameForTest([0xC5, 0x10, 2, 8, 8]);
|
||||
expect(connector.eventTone(ToneEvent.room)!.stored, 8);
|
||||
});
|
||||
|
||||
test('a refused event tone is the event tone surface\'s', () async {
|
||||
connector.handleFrameForTest(deviceInfo(caps2: 0x10));
|
||||
await connector.setEventTone(ToneEvent.dm, 9);
|
||||
connector.handleFrameForTest([0xC5, 0x7F, 0x05]);
|
||||
expect(connector.deviceUiErrorSurface, DeviceUiSurface.eventTone);
|
||||
expect(connector.eventTone(ToneEvent.dm), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('preview', () {
|
||||
test('returns the radio\'s result', () async {
|
||||
connector.handleFrameForTest(deviceInfo(caps2: 0x10));
|
||||
radio = (f) => [
|
||||
[0xC5, 0x11, f[2], 2],
|
||||
];
|
||||
expect(await connector.playTune(3), TunePlayResult.soundOff);
|
||||
});
|
||||
|
||||
test('a refused id comes back null and is shown', () async {
|
||||
connector.handleFrameForTest(deviceInfo(caps2: 0x10));
|
||||
radio = (f) => [
|
||||
[0xC5, 0x7F, 0x05],
|
||||
];
|
||||
expect(await connector.playTune(12), isNull);
|
||||
expect(connector.deviceUiErrorSurface, DeviceUiSurface.tunePlay);
|
||||
});
|
||||
});
|
||||
|
||||
group('slot read', () {
|
||||
test('follows more until the radio says done', () async {
|
||||
connector.handleFrameForTest(deviceInfo(caps2: 0x10));
|
||||
final tune = ascii.encode('Mario:d=4,o=5,b=100:16e6,16e6,32p');
|
||||
radio = (f) {
|
||||
final offset = f[3];
|
||||
final end = (offset + 10).clamp(0, tune.length);
|
||||
return [
|
||||
[
|
||||
0xC5,
|
||||
0x12,
|
||||
f[2],
|
||||
offset,
|
||||
end < tune.length ? 1 : 0,
|
||||
...tune.sublist(offset, end),
|
||||
],
|
||||
];
|
||||
};
|
||||
final progress = <int>[];
|
||||
final text = await connector.readTuneSlot(1, onProgress: progress.add);
|
||||
expect(text, ascii.decode(tune));
|
||||
expect(progress.last, tune.length);
|
||||
expect(connector.tuneSlotTransferActive, isFalse);
|
||||
});
|
||||
|
||||
test('an empty slot reads as empty text', () async {
|
||||
connector.handleFrameForTest(deviceInfo(caps2: 0x10));
|
||||
radio = (f) => [
|
||||
[0xC5, 0x12, f[2], 0, 0],
|
||||
];
|
||||
expect(await connector.readTuneSlot(4), '');
|
||||
});
|
||||
|
||||
test('a chunk that says more but carries nothing stops the read', () async {
|
||||
connector.handleFrameForTest(deviceInfo(caps2: 0x10));
|
||||
radio = (f) => [
|
||||
[0xC5, 0x12, f[2], f[3], 1],
|
||||
];
|
||||
expect(await connector.readTuneSlot(0), isNull);
|
||||
expect(connector.deviceUiErrorSurface, DeviceUiSurface.tuneSlot);
|
||||
});
|
||||
});
|
||||
|
||||
group('slot save', () {
|
||||
/// A radio that assembles pushes the way firmware does, optionally
|
||||
/// displacing the first push after its first chunk.
|
||||
List<List<int>> Function(List<int>) assembler({bool displaceOnce = false}) {
|
||||
var held = <int>[];
|
||||
var displaced = !displaceOnce;
|
||||
return (f) {
|
||||
if (f[1] == 0x09) {
|
||||
return [
|
||||
[0xC5, 0x09, f[2], held.length, ...ascii.encode('Mario')],
|
||||
];
|
||||
}
|
||||
if (f[1] != 0x0A) return const [];
|
||||
final offset = f[3];
|
||||
final more = f[4] != 0;
|
||||
if (offset == 0) held = [];
|
||||
if (offset != held.length) {
|
||||
return [
|
||||
[0xC5, 0x7F, 0x07, held.length],
|
||||
];
|
||||
}
|
||||
held.addAll(f.sublist(5));
|
||||
if (!displaced && more) {
|
||||
displaced = true;
|
||||
held = [];
|
||||
return [
|
||||
[0xC5, 0x7F, 0x07, 0],
|
||||
];
|
||||
}
|
||||
return [
|
||||
[0xC5, 0x0A, f[2], more ? held.length : 0],
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
test('sends chunks with running offsets and commits on the last', () async {
|
||||
connector.handleFrameForTest(deviceInfo(caps2: 0x10));
|
||||
radio = assembler();
|
||||
sent.clear();
|
||||
final tune = 'Mario:d=4,o=5,b=100:${List.filled(40, '8e6').join(',')}';
|
||||
final ok = await connector.saveTuneSlot(0, tune);
|
||||
expect(ok, isTrue);
|
||||
|
||||
final pushes = sent.where((f) => f[1] == 0x0A).toList();
|
||||
expect(pushes.length, greaterThan(1));
|
||||
var expectOffset = 0;
|
||||
for (final p in pushes) {
|
||||
expect(p[3], expectOffset);
|
||||
expectOffset += p.length - 5;
|
||||
}
|
||||
expect(pushes.last[4], 0);
|
||||
expect(
|
||||
pushes.sublist(0, pushes.length - 1).every((p) => p[4] == 1),
|
||||
isTrue,
|
||||
);
|
||||
// Chunks respect the transport's frame budget.
|
||||
expect(
|
||||
pushes.every((p) => p.length <= connector.effectiveMaxFrameSize),
|
||||
isTrue,
|
||||
);
|
||||
// Re-read after the commit, and the list shows the radio's answer.
|
||||
expect(sent.last, equals([0xC5, 0x09, 0]));
|
||||
expect(connector.tuneSlots[0]!.length, tune.length);
|
||||
});
|
||||
|
||||
test('a displaced push restarts from 0 once and succeeds', () async {
|
||||
connector.handleFrameForTest(deviceInfo(caps2: 0x10));
|
||||
radio = assembler(displaceOnce: true);
|
||||
final tune = 'Mario:d=4,o=5,b=100:${List.filled(40, '8e6').join(',')}';
|
||||
expect(await connector.saveTuneSlot(0, tune), isTrue);
|
||||
final restarts = sent.where((f) => f[1] == 0x0A && f[3] == 0).length;
|
||||
expect(restarts, 2);
|
||||
expect(connector.deviceUiError, isNull);
|
||||
});
|
||||
|
||||
test('displaced twice: stops and says so', () async {
|
||||
connector.handleFrameForTest(deviceInfo(caps2: 0x10));
|
||||
radio = (f) => f[1] == 0x0A
|
||||
? [
|
||||
[0xC5, 0x7F, 0x07, 0],
|
||||
]
|
||||
: const [];
|
||||
final tune = 'Mario:d=4,o=5,b=100:${List.filled(40, '8e6').join(',')}';
|
||||
expect(await connector.saveTuneSlot(0, tune), isFalse);
|
||||
expect(sent.where((f) => f[1] == 0x0A).length, 2);
|
||||
expect(connector.deviceUiErrorSurface, DeviceUiSurface.tuneSlot);
|
||||
expect(connector.deviceUiError, ButtonConfigError.slotOffset.message);
|
||||
});
|
||||
|
||||
test('clearing sends the one empty commit', () async {
|
||||
connector.handleFrameForTest(deviceInfo(caps2: 0x10));
|
||||
radio = assembler();
|
||||
sent.clear();
|
||||
expect(await connector.saveTuneSlot(3, ''), isTrue);
|
||||
expect(sent.first, equals([0xC5, 0x0A, 3, 0, 0]));
|
||||
});
|
||||
|
||||
test('a tune over 240 characters is never sent', () async {
|
||||
connector.handleFrameForTest(deviceInfo(caps2: 0x10));
|
||||
sent.clear();
|
||||
expect(await connector.saveTuneSlot(0, 'x:${'c' * 240}'), isFalse);
|
||||
expect(sent, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('screen settings', () {
|
||||
List<int> reply(int sub, {int tz = 2}) => [
|
||||
0xC5, sub, 0, 1, 60, 0, 60, 0, tz, 0x36, 0x10, 0x36, 0x10, 0, //
|
||||
];
|
||||
|
||||
test('state follows the re-read reply', () async {
|
||||
connector.handleFrameForTest(deviceInfo(caps2: 0x40));
|
||||
await connector.setTimeZone(9);
|
||||
expect(sent.last, equals([0xC5, 0x16, 9]));
|
||||
connector.handleFrameForTest(reply(0x16, tz: 9));
|
||||
expect(connector.screenSettings!.timeZone, 9);
|
||||
});
|
||||
|
||||
test('a battery refusal keeps the radio\'s reason', () async {
|
||||
connector.handleFrameForTest(deviceInfo(caps2: 0x40));
|
||||
connector.handleFrameForTest(reply(0x13));
|
||||
await connector.setBatteryFull(pin: true);
|
||||
connector.handleFrameForTest([0xC5, 0x7F, 0x09]);
|
||||
expect(connector.deviceUiErrorSurface, DeviceUiSurface.screenSettings);
|
||||
expect(
|
||||
connector.deviceUiError,
|
||||
ButtonConfigError.batteryNotCharged.message,
|
||||
);
|
||||
expect(connector.screenSettings!.batteryFullStored, 4150);
|
||||
});
|
||||
|
||||
test('a new radio does not inherit the previous one\'s settings', () {
|
||||
connector.handleFrameForTest(deviceInfo(caps2: 0x50));
|
||||
connector.handleFrameForTest(reply(0x13));
|
||||
connector.handleFrameForTest([0xC5, 0x0F, 0, 1, 1]);
|
||||
connector.handleFrameForTest(deviceInfo(caps2: 0x50));
|
||||
expect(connector.screenSettings, isNull);
|
||||
expect(connector.eventTone(ToneEvent.dm), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,397 @@
|
||||
// #970, #971, #986, #987: the approved parity screens
|
||||
// (docs/initiatives/standalone/975-settings-restructure/
|
||||
// tones-screen-parity-mockups.html). Pins the gates, that each list offers
|
||||
// exactly what firmware accepts, that play never chooses, and that each
|
||||
// control sends the documented request.
|
||||
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_open/connector/meshcore_connector.dart';
|
||||
import 'package:meshcore_open/connector/offband_device_ui.dart';
|
||||
import 'package:meshcore_open/connector/offband_screen_settings.dart';
|
||||
import 'package:meshcore_open/connector/offband_tones.dart';
|
||||
import 'package:meshcore_open/l10n/app_localizations.dart';
|
||||
import 'package:meshcore_open/models/channel.dart';
|
||||
import 'package:meshcore_open/screens/settings_screen.dart';
|
||||
import 'package:meshcore_open/services/app_settings_service.dart';
|
||||
import 'package:meshcore_open/storage/drift/blob_store.dart';
|
||||
import 'package:meshcore_open/storage/drift/offband_database.dart';
|
||||
import 'package:meshcore_open/storage/prefs_manager.dart';
|
||||
import 'package:meshcore_open/widgets/radio_tone.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'dart:typed_data';
|
||||
|
||||
class _Radio extends MeshCoreConnector {
|
||||
_Radio({this.tunes = true, this.screen = true, this.playResult});
|
||||
final bool tunes;
|
||||
final bool screen;
|
||||
final TunePlayResult? playResult;
|
||||
final List<String> calls = [];
|
||||
|
||||
ScreenSettingsState settings = const ScreenSettingsState(
|
||||
navText: BadgeTextSize.large,
|
||||
messageText: BadgeTextSize.medium,
|
||||
screenOffStored: 60,
|
||||
screenOffSeconds: 60,
|
||||
timeZone: 2,
|
||||
batteryFullStored: 0,
|
||||
batteryFullMv: 4200,
|
||||
batteryPinned: false,
|
||||
);
|
||||
|
||||
@override
|
||||
bool get supportsNotifyScope => true;
|
||||
@override
|
||||
bool get supportsButtonMatrix => false;
|
||||
@override
|
||||
bool get supportsTunes => tunes;
|
||||
@override
|
||||
bool get supportsScreenSettings => screen;
|
||||
@override
|
||||
DeviceNotifyScope? get deviceNotifyScope => DeviceNotifyScope.all;
|
||||
|
||||
@override
|
||||
EventToneState? eventTone(ToneEvent event) => switch (event) {
|
||||
ToneEvent.dm => const EventToneState(stored: 0xFF, effective: 1),
|
||||
ToneEvent.channel => const EventToneState(stored: 0xFF, effective: 2),
|
||||
ToneEvent.room => const EventToneState(stored: 8, effective: 8),
|
||||
_ => const EventToneState(stored: 0, effective: 0),
|
||||
};
|
||||
|
||||
@override
|
||||
Map<int, TuneSlotInfo> get tuneSlots => {
|
||||
0: const TuneSlotInfo(length: 34, name: 'Mario'),
|
||||
for (var s = 1; s < tuneSlotCount; s++)
|
||||
s: const TuneSlotInfo(length: 0, name: ''),
|
||||
};
|
||||
|
||||
@override
|
||||
ScreenSettingsState? get screenSettings => screen ? settings : null;
|
||||
|
||||
@override
|
||||
ChannelTune? channelTuneFor(int channel) => const ChannelTune.inherit();
|
||||
|
||||
@override
|
||||
Future<void> requestNotifyScope() async => calls.add('scope');
|
||||
@override
|
||||
Future<void> requestEventTones() async => calls.add('evtones');
|
||||
@override
|
||||
Future<void> requestTuneSlots() async => calls.add('slots');
|
||||
@override
|
||||
Future<void> requestScreenSettings() async => calls.add('screen');
|
||||
@override
|
||||
Future<void> requestChannelTune(int channel) async =>
|
||||
calls.add('chtune $channel');
|
||||
@override
|
||||
Future<void> setEventTone(ToneEvent event, int tuneId) async =>
|
||||
calls.add('evtone ${event.name} $tuneId');
|
||||
@override
|
||||
Future<void> setChannelTune(int channel, ChannelTune tune) async =>
|
||||
calls.add('chtune set $channel ${tune.stored}');
|
||||
@override
|
||||
Future<TunePlayResult?> playTune(int tuneId) async {
|
||||
calls.add('play $tuneId');
|
||||
return playResult ?? TunePlayResult.played;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> readTuneSlot(
|
||||
int slot, {
|
||||
void Function(int bytesRead)? onProgress,
|
||||
}) async {
|
||||
calls.add('read $slot');
|
||||
return 'Mario:d=4,o=5,b=100:16e6,16e6,32p';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setTextSize(BadgeTextTarget target, BadgeTextSize size) async =>
|
||||
calls.add('text ${target.name} ${size.name}');
|
||||
@override
|
||||
Future<void> setScreenOff(int seconds) async => calls.add('scroff $seconds');
|
||||
@override
|
||||
Future<void> setTimeZone(int index) async => calls.add('tz $index');
|
||||
@override
|
||||
Future<void> setBatteryFull({required bool pin}) async =>
|
||||
calls.add('batt ${pin ? 'pin' : 'auto'}');
|
||||
}
|
||||
|
||||
void main() {
|
||||
late OffbandDatabase db;
|
||||
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
PrefsManager.reset();
|
||||
await PrefsManager.initialize();
|
||||
db = OffbandDatabase(NativeDatabase.memory());
|
||||
BlobStore.overrideForTest(BlobStore(db));
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
BlobStore.clearTestOverride();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
Future<void> pump(WidgetTester tester, _Radio radio, Widget home) async {
|
||||
radio.sendFrameOverrideForTest = (_) {};
|
||||
tester.view.physicalSize = const Size(400, 1000);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<MeshCoreConnector>.value(value: radio),
|
||||
ChangeNotifierProvider<AppSettingsService>(
|
||||
create: (_) => AppSettingsService(),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: home,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> tapText(WidgetTester tester, String text) async {
|
||||
await tester.scrollUntilVisible(
|
||||
find.text(text),
|
||||
200,
|
||||
scrollable: find.byType(Scrollable).last,
|
||||
);
|
||||
// scrollUntilVisible can stop with the row at the very edge, where a tap
|
||||
// lands on whatever is drawn over it.
|
||||
await tester.ensureVisible(find.text(text));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text(text));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> openBuzzer(WidgetTester tester, _Radio radio) async {
|
||||
await pump(tester, radio, const SettingsScreen());
|
||||
await tapText(tester, 'Message Settings');
|
||||
await tapText(tester, 'Buzzer and tones');
|
||||
}
|
||||
|
||||
group('Buzzer and tones', () {
|
||||
testWidgets('scope, the six tones and all eight slots', (tester) async {
|
||||
final radio = _Radio();
|
||||
await openBuzzer(tester, radio);
|
||||
expect(radio.calls, containsAll(['scope', 'evtones', 'slots']));
|
||||
for (final e in ToneEvent.values) {
|
||||
await tester.scrollUntilVisible(
|
||||
find.text(e.label),
|
||||
100,
|
||||
scrollable: find.byType(Scrollable).last,
|
||||
);
|
||||
expect(find.text(e.label), findsOneWidget);
|
||||
}
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('8 (empty)'),
|
||||
100,
|
||||
scrollable: find.byType(Scrollable).last,
|
||||
);
|
||||
expect(find.text('8 (empty)'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a default and a choice never read alike', (tester) async {
|
||||
await openBuzzer(tester, _Radio());
|
||||
expect(find.text('Chime (radio default)'), findsOneWidget);
|
||||
expect(find.text('Mario'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('no 0x10: the scope alone, as before', (tester) async {
|
||||
final radio = _Radio(tunes: false);
|
||||
await openBuzzer(tester, radio);
|
||||
expect(find.text('Device notification scope'), findsOneWidget);
|
||||
expect(find.text('Tones'), findsNothing);
|
||||
expect(find.text('Custom tunes'), findsNothing);
|
||||
expect(radio.calls, isNot(contains('evtones')));
|
||||
});
|
||||
|
||||
testWidgets('the picker lists what firmware accepts; play never chooses', (
|
||||
tester,
|
||||
) async {
|
||||
final radio = _Radio();
|
||||
await openBuzzer(tester, radio);
|
||||
await tapText(tester, 'DM');
|
||||
|
||||
final dialog = find.byType(AlertDialog);
|
||||
for (final name in [
|
||||
'Silent',
|
||||
'Chime',
|
||||
'Kerplop',
|
||||
'Tick',
|
||||
'Bloop',
|
||||
'Drop',
|
||||
'Knock',
|
||||
'Mario',
|
||||
]) {
|
||||
expect(
|
||||
find.descendant(of: dialog, matching: find.text(name)),
|
||||
findsOneWidget,
|
||||
);
|
||||
}
|
||||
// No Default row for an event (owner decision 3), no empty slot.
|
||||
expect(
|
||||
find.descendant(of: dialog, matching: find.text('Default')),
|
||||
findsNothing,
|
||||
);
|
||||
expect(
|
||||
find.descendant(of: dialog, matching: find.textContaining('empty')),
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
final kerplop = find.ancestor(
|
||||
of: find.descendant(of: dialog, matching: find.text('Kerplop')),
|
||||
matching: find.byType(ListTile),
|
||||
);
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: kerplop,
|
||||
matching: find.byIcon(Icons.play_circle_outline),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(radio.calls, contains('play 2'));
|
||||
expect(radio.calls.where((c) => c.startsWith('evtone ')), isEmpty);
|
||||
|
||||
await tester.tap(
|
||||
find.descendant(of: dialog, matching: find.text('Bloop')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(radio.calls, contains('evtone dm 4'));
|
||||
});
|
||||
|
||||
testWidgets('a silent preview says why', (tester) async {
|
||||
final radio = _Radio(playResult: TunePlayResult.soundOff);
|
||||
await openBuzzer(tester, radio);
|
||||
await tapText(tester, 'DM');
|
||||
final dialog = find.byType(AlertDialog);
|
||||
final chime = find.ancestor(
|
||||
of: find.descendant(of: dialog, matching: find.text('Chime')),
|
||||
matching: find.byType(ListTile),
|
||||
);
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: chime,
|
||||
matching: find.byIcon(Icons.play_circle_outline),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('Sound is off on this radio'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('slot editor', () {
|
||||
testWidgets('opens with the stored tune and checks typing', (tester) async {
|
||||
final radio = _Radio();
|
||||
await openBuzzer(tester, radio);
|
||||
await tapText(tester, '1 Mario');
|
||||
expect(radio.calls, contains('read 0'));
|
||||
expect(find.text('Mario:d=4,o=5,b=100:16e6,16e6,32p'), findsOneWidget);
|
||||
expect(find.text('Plays as "Mario"'), findsOneWidget);
|
||||
|
||||
await tester.enterText(
|
||||
find.byType(TextField),
|
||||
'Doorbell:d=4,o=5,b=0:8e6',
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.text('b= must be 1 or more'), findsOneWidget);
|
||||
final save = tester.widget<FilledButton>(
|
||||
find.widgetWithText(FilledButton, 'Save'),
|
||||
);
|
||||
expect(save.onPressed, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('Node Settings', () {
|
||||
testWidgets('Screen and Battery only with 0x40', (tester) async {
|
||||
await pump(tester, _Radio(screen: false), const SettingsScreen());
|
||||
await tapText(tester, 'Node Settings');
|
||||
expect(find.text('Screen'), findsNothing);
|
||||
expect(find.text('Battery'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('the Screen page sends each control\'s request', (
|
||||
tester,
|
||||
) async {
|
||||
final radio = _Radio();
|
||||
await pump(tester, radio, const SettingsScreen());
|
||||
await tapText(tester, 'Node Settings');
|
||||
expect(find.text('100% at 4200 mV'), findsOneWidget);
|
||||
await tapText(tester, 'Screen');
|
||||
|
||||
await tester.tap(find.text('Small').last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(radio.calls, contains('text messages small'));
|
||||
|
||||
await tester.tap(find.text('5 m'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(radio.calls, contains('scroff 300'));
|
||||
|
||||
await tapText(tester, 'Eastern');
|
||||
await tapText(tester, 'Central');
|
||||
expect(radio.calls, contains('tz 3'));
|
||||
});
|
||||
|
||||
testWidgets('Battery: pin asks first; Back to auto only when set', (
|
||||
tester,
|
||||
) async {
|
||||
final radio = _Radio();
|
||||
await pump(tester, radio, const SettingsScreen());
|
||||
await tapText(tester, 'Node Settings');
|
||||
await tapText(tester, 'Battery');
|
||||
expect(find.text('Default for this board'), findsOneWidget);
|
||||
expect(find.text('Back to auto'), findsNothing);
|
||||
|
||||
await tester.tap(find.text('Set 100% here'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(radio.calls, isNot(contains('batt pin')));
|
||||
await tester.tap(find.text('Set 100% here').last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(radio.calls, contains('batt pin'));
|
||||
});
|
||||
});
|
||||
|
||||
group('channel Radio tone', () {
|
||||
final channel = Channel(index: 3, name: '#okimesh', psk: Uint8List(16));
|
||||
|
||||
Widget tile() => Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => ChannelRadioToneTile(
|
||||
channel: channel,
|
||||
onTap: () => showChannelRadioToneDialog(context, channel),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
testWidgets('absent without 0x10', (tester) async {
|
||||
await pump(tester, _Radio(tunes: false), tile());
|
||||
expect(find.text('Radio tone'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Default names what it plays, and is selectable', (
|
||||
tester,
|
||||
) async {
|
||||
final radio = _Radio();
|
||||
await pump(tester, radio, tile());
|
||||
expect(radio.calls, contains('chtune 3'));
|
||||
expect(find.text('Default'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Radio tone'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Kerplop, the radio\'s channel tone'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Silent'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(radio.calls, contains('chtune set 3 1'));
|
||||
// Let the refusal watcher's window close.
|
||||
await tester.pump(const Duration(seconds: 6));
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in new issue