diff --git a/docs/initiatives/standalone/975-settings-restructure/tones-screen-parity-mockups.html b/docs/initiatives/standalone/975-settings-restructure/tones-screen-parity-mockups.html index 98773cfb..df8c018c 100644 --- a/docs/initiatives/standalone/975-settings-restructure/tones-screen-parity-mockups.html +++ b/docs/initiatives/standalone/975-settings-restructure/tones-screen-parity-mockups.html @@ -149,7 +149,7 @@ table.par{border-collapse:collapse;font-size:12.5px;max-width:1040px;width:100%; Channel's own tone, with Default0x0B/0x0C CHTUNE, 0x10codec built, no screen"Radio tone" in the channel sheet (frame 7) Settings › Nav text, Message text0x13 GET, 0x14 TEXT_SET, 0x40missingScreen page (frame 8) Settings › Screen off (15 s, 30 s, 1 m, 2 m, 5 m)0x15 SCROFF_SET, 0x40missingScreen page (frame 8) -Settings › Time zone (45 zones, "not set" shows ages)0x16 TZ_SET, 0x40missingzone list (frame 9) +Settings › Time zone (40 zones plus "not set", which shows ages)0x16 TZ_SET, 0x40missingzone list (frame 9) Settings › Battery: 100% point, Set 100% here, Back to auto0x17 BATT_SET, 0x40missingBattery page (frames 10, 11) Settings › GPS0x0D/0x0E, 0x20Location (#977)unchanged Settings › Hibernate, Device pagesnot settings (AmberForge 1350): an action and a navigationn/an/a @@ -301,7 +301,7 @@ table.par{border-collapse:collapse;font-size:12.5px;max-width:1040px;width:100%; -

9Time zone

The badge's 45 zones, its "not set", and a suggestion: the badge suggests from GPS, the app from the phone.

+

9Time zone

The badge's 40 zones, its "not set", and a suggestion: the badge suggests from GPS, the app from the phone.

Time zone
Search zones
diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 911fffab..04b4d6fe 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -66,6 +66,8 @@ import 'meshcore_protocol.dart'; import '../helpers/region_key.dart'; import '../storage/channel_region_scope_store.dart'; import 'offband_device_ui.dart'; +import 'offband_screen_settings.dart'; +import 'offband_tones.dart'; import 'caplog_reassembler.dart'; class DirectRepeater { @@ -413,6 +415,11 @@ class MeshCoreConnector extends ChangeNotifier { DeviceUiSurface? _pendingDeviceUiWrite; GpsModeState? _gpsModeState; final Map _channelTunes = {}; + final Map _eventTones = {}; + final Map _tuneSlots = {}; + ScreenSettingsState? _screenSettings; + bool _tuneSlotTransferActive = false; + final List<_DeviceUiWaiter> _deviceUiWaiters = []; int _pathHashByteWidth = 1; CompanionRadioStats? _latestRadioStats; Stopwatch? _airtimeBumpStopwatch; @@ -903,12 +910,30 @@ class MeshCoreConnector extends ChangeNotifier { /// Last GPS state the radio reported, or null if not read yet. GpsModeState? get gpsModeState => _gpsModeState; - /// Whether this radio answers the `0xC5` channel-tune sub-codes (#971). - bool get supportsChannelTune => firmwareSupportsChannelTune(_offbandCaps2); + /// Whether this radio answers the `0xC5` tune sub-codes: custom slots, + /// channel tune, event tones and preview (caps byte 2 `0x10`). + bool get supportsTunes => firmwareSupportsTunes(_offbandCaps2); + + /// Whether this radio answers the screen-settings sub-codes (#987). + bool get supportsScreenSettings => + firmwareSupportsScreenSettings(_offbandCaps2); /// The tune the radio reported for [channel], or null if not read yet. ChannelTune? channelTuneFor(int channel) => _channelTunes[channel]; + /// The tune the radio reported for [event], or null if not read yet. + EventToneState? eventTone(ToneEvent event) => _eventTones[event]; + + /// `SLOT_GET` replies read so far, by slot index. + Map get tuneSlots => Map.unmodifiable(_tuneSlots); + + /// The radio's screen and battery settings, or null if not read yet. + ScreenSettingsState? get screenSettings => _screenSettings; + + /// True while a slot is being read or saved. One at a time: firmware takes + /// one push per slot, and a second would displace the first. + bool get tuneSlotTransferActive => _tuneSlotTransferActive; + /// Which surface [deviceUiError] belongs to, so a screen shows only its own. /// Null when the error could not be attributed to an outstanding write. DeviceUiSurface? get deviceUiErrorSurface => _deviceUiErrorSurface; @@ -1052,7 +1077,7 @@ class MeshCoreConnector extends ChangeNotifier { /// Ask the radio which tune [channel] plays (#971). Future requestChannelTune(int channel) async { - if (!_deviceUiGateOpen('chtune GET', advertised: supportsChannelTune)) { + if (!_deviceUiGateOpen('chtune GET', advertised: supportsTunes)) { return; } _appDebugLogService?.info( @@ -1065,7 +1090,7 @@ class MeshCoreConnector extends ChangeNotifier { /// Set which tune [channel] plays. Local state follows the device's re-read /// reply, never the request, so a refused choice is never shown as saved. Future setChannelTune(int channel, ChannelTune tune) async { - if (!_deviceUiGateOpen('chtune SET', advertised: supportsChannelTune)) { + if (!_deviceUiGateOpen('chtune SET', advertised: supportsTunes)) { return; } _deviceUiError = null; @@ -1124,6 +1149,283 @@ class MeshCoreConnector extends ChangeNotifier { await sendFrame(buildGpsModeSetFrame(mode, interval)); } + /// Send [frame] and wait for the reply [matches] accepts, or for a refusal + /// attributed to [surface]. Null on timeout. + /// + /// The multi-frame flows (slot read, slot save) and preview need the answer + /// before their next step; the single-frame settings do not, and keep the + /// fire-and-forget shape the rest of this command uses. + Future _sendDeviceUiAndAwait( + Uint8List frame, + DeviceUiSurface surface, + bool Function(OffbandUiReply reply) matches, { + Duration timeout = const Duration(seconds: 4), + }) async { + final waiter = _DeviceUiWaiter(surface, matches); + _deviceUiWaiters.add(waiter); + _pendingDeviceUiWrite = surface; + try { + await sendFrame(frame); + return await waiter.completer.future.timeout( + timeout, + onTimeout: () => null, + ); + } finally { + _deviceUiWaiters.remove(waiter); + // Answered or not, this request is no longer outstanding, so a later + // refusal must not be pinned on it. + if (_pendingDeviceUiWrite == surface) _pendingDeviceUiWrite = null; + } + } + + /// Hand [reply] to the first waiter that wants it. A refusal carries no + /// sub-code, so it goes to the waiter on the surface it was attributed to. + void _offerDeviceUiReply(OffbandUiReply reply, {DeviceUiSurface? errorOn}) { + for (final w in _deviceUiWaiters) { + final wanted = reply.isError + ? errorOn != null && w.surface == errorOn + : w.matches(reply); + if (wanted && !w.completer.isCompleted) { + w.completer.complete(reply); + return; + } + } + } + + void _setDeviceUiError(DeviceUiSurface surface, String message) { + _deviceUiErrorSurface = surface; + _deviceUiError = message; + _appDebugLogService?.warn('${surface.name}: $message', tag: 'DeviceUI'); + notifyListeners(); + } + + /// Read all six event tones (#986). + Future requestEventTones() async { + if (!_deviceUiGateOpen('evtone GET', advertised: supportsTunes)) return; + for (final event in ToneEvent.values) { + await sendFrame(buildEventToneGetFrame(event)); + } + } + + /// Set the tune [event] plays. The id must be one the picker offers; the + /// radio refuses the rest, and the row changes only on its re-read reply. + Future setEventTone(ToneEvent event, int tuneId) async { + if (!_deviceUiGateOpen('evtone SET', advertised: supportsTunes)) return; + _deviceUiError = null; + _deviceUiErrorSurface = null; + _pendingDeviceUiWrite = DeviceUiSurface.eventTone; + _appDebugLogService?.info( + 'evtone SET -> ${event.label} = $tuneId [0xC5][0x10]', + tag: 'DeviceUI', + ); + await sendFrame(buildEventToneSetFrame(event, tuneId)); + } + + /// Audition [tuneId] on the radio. Null when the radio did not answer, or + /// refused the id (the refusal is then shown like any other). + Future playTune(int tuneId) async { + if (!_deviceUiGateOpen('play', advertised: supportsTunes)) return null; + _appDebugLogService?.info('play -> $tuneId [0xC5][0x11]', tag: 'DeviceUI'); + final reply = await _sendDeviceUiAndAwait( + buildTunePlayFrame(tuneId), + DeviceUiSurface.tunePlay, + (r) => r.tunePlay?.tuneId == tuneId, + ); + final result = reply?.tunePlay?.result; + _appDebugLogService?.info( + 'play reply: ${result?.name ?? (reply == null ? 'no answer' : 'refused')}', + tag: 'DeviceUI', + ); + return result; + } + + /// Read every slot's name and length (#970). + Future requestTuneSlots() async { + if (!_deviceUiGateOpen('slot GET', advertised: supportsTunes)) return; + for (var slot = 0; slot < tuneSlotCount; slot++) { + await sendFrame(buildSlotGetFrame(slot)); + } + } + + /// A slot's whole stored tune, read in chunks, or null if the read failed. + /// [onProgress] reports bytes read so far. + Future readTuneSlot( + int slot, { + void Function(int bytesRead)? onProgress, + }) async { + if (!_deviceUiGateOpen('slot READ', advertised: supportsTunes)) { + return null; + } + if (_tuneSlotTransferActive) return null; + _tuneSlotTransferActive = true; + notifyListeners(); + try { + final out = []; + while (true) { + final offset = out.length; + final reply = await _sendDeviceUiAndAwait( + buildSlotReadFrame(slot, offset), + DeviceUiSurface.tuneSlot, + (r) => r.slotChunk?.slot == slot && r.slotChunk?.offset == offset, + ); + final chunk = reply?.slotChunk; + if (chunk == null) { + if (reply == null) { + _setDeviceUiError( + DeviceUiSurface.tuneSlot, + 'The radio did not answer while reading this tune.', + ); + } + return null; + } + out.addAll(chunk.bytes); + onProgress?.call(out.length); + if (!chunk.more) break; + // A chunk that says more but carries nothing would loop forever. + if (chunk.bytes.isEmpty || out.length > maxSlotRtttl) { + _setDeviceUiError( + DeviceUiSurface.tuneSlot, + 'The radio sent this tune in a shape the app cannot read.', + ); + return null; + } + } + _appDebugLogService?.info( + 'slot READ: slot=$slot ${out.length} bytes', + tag: 'DeviceUI', + ); + return latin1.decode(out); + } finally { + _tuneSlotTransferActive = false; + notifyListeners(); + } + } + + /// Save [rtttl] to [slot] in chunks sized to the transport, then re-read the + /// slot. An empty [rtttl] clears it. True only when the radio committed it. + /// + /// The radio replaces a slot only on the final chunk, so a push that fails + /// partway leaves the old tune intact. A displaced push (`ERR_SLOT_OFFSET`) + /// restarts from 0 once; a second refusal stops and is shown. + Future saveTuneSlot( + int slot, + String rtttl, { + void Function(int bytesSent, int total)? onProgress, + }) async { + if (!_deviceUiGateOpen('slot SET', advertised: supportsTunes)) { + return false; + } + if (_tuneSlotTransferActive) return false; + final bytes = latin1.encode(rtttl); + if (bytes.length > maxSlotRtttl) { + _setDeviceUiError( + DeviceUiSurface.tuneSlot, + 'A tune can be at most $maxSlotRtttl characters.', + ); + return false; + } + _deviceUiError = null; + _deviceUiErrorSurface = null; + _tuneSlotTransferActive = true; + notifyListeners(); + try { + final chunkSize = math.max(1, effectiveMaxFrameSize - 5); + for (var attempt = 0; attempt < 2; attempt++) { + final outcome = await _pushTuneSlot(slot, bytes, chunkSize, onProgress); + if (outcome == _SlotPush.committed) { + _deviceUiError = null; + _deviceUiErrorSurface = null; + await _sendDeviceUiAndAwait( + buildSlotGetFrame(slot), + DeviceUiSurface.tuneSlot, + (r) => r.tuneSlot?.slot == slot, + ); + _appDebugLogService?.info( + 'slot SET committed: slot=$slot ${bytes.length} bytes', + tag: 'DeviceUI', + ); + return true; + } + if (outcome != _SlotPush.displaced) return false; + } + return false; + } finally { + _tuneSlotTransferActive = false; + notifyListeners(); + } + } + + Future<_SlotPush> _pushTuneSlot( + int slot, + List bytes, + int chunkSize, + void Function(int bytesSent, int total)? onProgress, + ) async { + var offset = 0; + do { + final end = math.min(offset + chunkSize, bytes.length); + final more = end < bytes.length; + final reply = await _sendDeviceUiAndAwait( + buildSlotSetFrame(slot, offset, more, bytes.sublist(offset, end)), + DeviceUiSurface.tuneSlot, + (r) => r.slotAccepted?.slot == slot, + ); + if (reply == null) { + _setDeviceUiError( + DeviceUiSurface.tuneSlot, + 'The radio did not answer while saving this tune. Nothing was ' + 'changed.', + ); + return _SlotPush.failed; + } + if (reply.isError) { + return reply.errorCode == ButtonConfigError.slotOffset.code + ? _SlotPush.displaced + : _SlotPush.failed; + } + // Mid-push the radio counts what it holds; anything but the end of this + // chunk means another push took over. + if (more && reply.slotAccepted!.accepted != end) { + return _SlotPush.displaced; + } + offset = end; + onProgress?.call(offset, bytes.length); + } while (offset < bytes.length); + return _SlotPush.committed; + } + + /// Read the badge's screen and battery settings (#987). + Future requestScreenSettings() async { + if (!_deviceUiGateOpen('screen GET', advertised: supportsScreenSettings)) { + return; + } + await sendFrame(buildScreenSettingsGetFrame()); + } + + Future _sendScreenSet(String what, Uint8List frame) async { + if (!_deviceUiGateOpen(what, advertised: supportsScreenSettings)) return; + _deviceUiError = null; + _deviceUiErrorSurface = null; + _pendingDeviceUiWrite = DeviceUiSurface.screenSettings; + _appDebugLogService?.info('$what -> $frame', tag: 'DeviceUI'); + await sendFrame(frame); + } + + Future setTextSize(BadgeTextTarget target, BadgeTextSize size) => + _sendScreenSet('text SET', buildTextSizeSetFrame(target, size)); + + /// [seconds] must be one of [screenOffSteps]; the radio refuses the rest. + Future setScreenOff(int seconds) => + _sendScreenSet('scroff SET', buildScreenOffSetFrame(seconds)); + + Future setTimeZone(int index) => + _sendScreenSet('tz SET', buildTimeZoneSetFrame(index)); + + /// Pin the radio's own current reading as 100%, or hand the full point back + /// to auto-learn. The radio refuses a reading that is not a full cell. + Future setBatteryFull({required bool pin}) => + _sendScreenSet('batt SET', buildBatteryFullSetFrame(pin: pin)); + /// Re-read whatever headless-UI state this radio supports. Called after every /// device-info reply so a scope changed by triple-press on the device is /// never displayed stale, and so frame-arrival order does not matter. (#475) @@ -1141,12 +1443,17 @@ class MeshCoreConnector extends ChangeNotifier { _pendingDeviceUiWrite = null; _gpsModeState = null; _channelTunes.clear(); + _eventTones.clear(); + _tuneSlots.clear(); + _screenSettings = null; } void _reconcileDeviceUi() { if (supportsButtonMatrix) requestButtonMatrix(); if (supportsNotifyScope) requestNotifyScope(); if (supportsGpsMode) requestGpsMode(); + // Node Settings names the battery full point in its Battery row. + if (supportsScreenSettings) requestScreenSettings(); } /// Single entry point for `0xC5`: both surfaces and the shared error sub-code @@ -1176,6 +1483,16 @@ class MeshCoreConnector extends ChangeNotifier { _handleChannelTuneReply(tune); return; } + final tunes = parseTuneReply(frame); + if (tunes != null) { + _handleTuneReply(tunes); + return; + } + final screen = parseScreenSettingsReply(frame); + if (screen != null) { + _handleScreenSettingsReply(screen); + return; + } _appDebugLogService?.warn( '0xC5 reply not understood (sub=0x' '${frame.length > 1 ? frame[1].toRadixString(16).padLeft(2, '0') : '??'}, ' @@ -1200,10 +1517,59 @@ class MeshCoreConnector extends ChangeNotifier { DeviceUiSurface.notifyScope => 'Notification scope', DeviceUiSurface.gpsMode => 'GPS mode', DeviceUiSurface.channelTune => 'Channel tune', + DeviceUiSurface.eventTone => 'Event tone', + DeviceUiSurface.tunePlay => 'Tune preview', + DeviceUiSurface.tuneSlot => 'Tune slot', + DeviceUiSurface.screenSettings => 'Screen settings', null => 'Device UI request', }; _appDebugLogService?.warn( - '$what refused: $_deviceUiError', + '$what refused: $_deviceUiError' + '${reply.errorDetail == null ? '' : ' (detail ${reply.errorDetail})'}', + tag: 'DeviceUI', + ); + _offerDeviceUiReply(reply, errorOn: surface); + notifyListeners(); + } + + void _handleTuneReply(OffbandUiReply reply) { + if (reply.isError) { + _setDeviceUiError(DeviceUiSurface.eventTone, reply.errorMessage!); + return; + } + if (_pendingDeviceUiWrite == DeviceUiSurface.eventTone && + reply.sub == offbandUiEventToneSet) { + _pendingDeviceUiWrite = null; + } + final ev = reply.eventTone; + if (ev != null) { + _eventTones[ev.event] = ev.state; + _appDebugLogService?.info( + 'evtone reply: ${ev.event.label} stored=${ev.state.stored} ' + 'effective=${ev.state.effective}', + tag: 'DeviceUI', + ); + } + final slot = reply.tuneSlot; + if (slot != null && slot.slot >= 0 && slot.slot < tuneSlotCount) { + _tuneSlots[slot.slot] = slot.info; + } + _offerDeviceUiReply(reply); + notifyListeners(); + } + + void _handleScreenSettingsReply(OffbandUiReply reply) { + if (_pendingDeviceUiWrite == DeviceUiSurface.screenSettings && + reply.sub != offbandUiScreenGet) { + _pendingDeviceUiWrite = null; + } + final s = reply.screen!; + _screenSettings = s; + _appDebugLogService?.info( + 'screen reply: nav=${s.navText?.name} msg=${s.messageText?.name} ' + 'scroff=${s.screenOffStored}/${s.screenOffSeconds}s tz=${s.timeZone} ' + 'batt=${s.batteryFullStored}/${s.batteryFullMv}mV ' + 'pinned=${s.batteryPinned}', tag: 'DeviceUI', ); notifyListeners(); @@ -10357,3 +10723,14 @@ class _PendingCommandAck { _PendingCommandAck({required this.commandCode, this.channelSendQueueId}); } + +/// A `0xC5` request waiting for its reply. +class _DeviceUiWaiter { + _DeviceUiWaiter(this.surface, this.matches); + final DeviceUiSurface surface; + final bool Function(OffbandUiReply reply) matches; + final Completer completer = Completer(); +} + +/// How one pass of a slot push ended. +enum _SlotPush { committed, displaced, failed } diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index 6dfe7c36..c8526bf2 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -602,9 +602,12 @@ bool firmwareSupportsButtonMatrix(int? offbandCaps2) => bool firmwareSupportsNotifyScope(int? offbandCaps2) => offbandCaps2 != null && (offbandCaps2 & offbandCap2NotifyScope) != 0; -/// Per-channel tune over `0xC5` sub-codes 0x0B / 0x0C (#971). Firmware sets it -/// where `PIN_BUZZER` is defined (meshcore-firmware#1789). -const int offbandCap2ChannelTune = 0x10; +/// The tune system over `0xC5` (#970, #971, #986): custom slots (0x09, 0x0A, +/// 0x12), channel tune (0x0B, 0x0C), event tones (0x0F, 0x10) and preview +/// (0x11). Firmware sets it where the UI plays every sound through its tune +/// engine, which needs a buzzer but is not implied by one, so a radio can +/// advertise the scope bit without this (meshcore-firmware `3e788e2a`). +const int offbandCap2Tunes = 0x10; /// GPS mode and Power-save interval over `0xC5` sub-codes 0x0D / 0x0E (#969). /// Firmware sets it where `ENV_INCLUDE_GPS == 1` (meshcore-firmware#1789). @@ -614,9 +617,17 @@ const int offbandCap2ChannelTune = 0x10; /// for that. Never offer Power save from this bit alone. const int offbandCap2GpsMode = 0x20; -/// True iff this radio answers the `0xC5` channel-tune sub-codes (#971). -bool firmwareSupportsChannelTune(int? offbandCaps2) => - offbandCaps2 != null && (offbandCaps2 & offbandCap2ChannelTune) != 0; +/// The badge's own display and battery settings over `0xC5` 0x13 to 0x17 +/// (#987): text sizes, Screen off, time zone, battery full point. +const int offbandCap2ScreenSettings = 0x40; + +/// True iff this radio answers the `0xC5` tune sub-codes. +bool firmwareSupportsTunes(int? offbandCaps2) => + offbandCaps2 != null && (offbandCaps2 & offbandCap2Tunes) != 0; + +/// True iff this radio answers the `0xC5` screen-settings sub-codes (#987). +bool firmwareSupportsScreenSettings(int? offbandCaps2) => + offbandCaps2 != null && (offbandCaps2 & offbandCap2ScreenSettings) != 0; /// True iff this radio answers the `0xC5` GPS-mode sub-codes (#969). bool firmwareSupportsGpsMode(int? offbandCaps2) => diff --git a/lib/connector/offband_device_ui.dart b/lib/connector/offband_device_ui.dart index ab743780..8a5ba6bb 100644 --- a/lib/connector/offband_device_ui.dart +++ b/lib/connector/offband_device_ui.dart @@ -1,5 +1,8 @@ import 'dart:typed_data'; +import 'offband_screen_settings.dart'; +import 'offband_tones.dart'; + /// Headless-device UI configuration: the button-action matrix (#474) and the /// device notification scope (#475). /// @@ -83,7 +86,22 @@ enum ButtonConfigError { unknownSequence(0x02, 'This radio does not recognise that button sequence'), noBuzzer(0x03, 'This radio has no buzzer'), noGps(0x04, 'This radio has no GPS'), - malformed(0x05, 'The radio could not read the request'); + malformed(0x05, 'The radio could not read the request'), + unsupportedIndicator(0x06, 'This radio has no display or LED to set'), + slotOffset( + 0x07, + 'The radio stopped accepting this tune partway through. Nothing was ' + 'changed. Try saving again.', + ), + batteryTooHigh( + 0x08, + 'The reading is higher than a full cell, so it was not used.', + ), + batteryNotCharged( + 0x09, + 'Not charged yet. Charge the radio fully, then set 100% again. Nothing ' + 'was changed.', + ); const ButtonConfigError(this.code, this.message); final int code; @@ -103,7 +121,16 @@ enum ButtonConfigError { /// Which `0xC5` surface a write targeted. The shared error frame /// `[0xC5][0x7F][reason]` names no sub-code, so the client attributes an error /// to the write it has outstanding, and each screen shows only its own. -enum DeviceUiSurface { buttonMatrix, notifyScope, gpsMode, channelTune } +enum DeviceUiSurface { + buttonMatrix, + notifyScope, + gpsMode, + channelTune, + eventTone, + tunePlay, + tuneSlot, + screenSettings, +} /// The refusal reason for a GPS write. Reason 1 means Power save on a build /// that cannot run it (meshcore-firmware#1789); the rest read as for buttons. @@ -403,12 +430,22 @@ class OffbandUiReply { final DeviceNotifyScope? scope; final GpsModeState? gps; final ChannelTuneReply? channelTune; + final EventToneReply? eventTone; + final TuneSlotReply? tuneSlot; + final TuneSlotAccepted? slotAccepted; + final TuneSlotChunk? slotChunk; + final TunePlayReply? tunePlay; + final ScreenSettingsState? screen; final String? errorMessage; /// The raw reason byte of a `0x7F` error, so a surface can word it for its /// own context. Null on every other reply. final int? errorCode; + /// The byte after the reason, when the error carries one: `ERR_SLOT_OFFSET` + /// sends the offset the radio expects next. + final int? errorDetail; + const OffbandUiReply({ required this.command, required this.sub, @@ -418,8 +455,15 @@ class OffbandUiReply { this.scope, this.gps, this.channelTune, + this.eventTone, + this.tuneSlot, + this.slotAccepted, + this.slotChunk, + this.tunePlay, + this.screen, this.errorMessage, this.errorCode, + this.errorDetail, }); bool get isError => errorMessage != null; @@ -440,6 +484,7 @@ OffbandUiReply? parseButtonMatrixReply(Uint8List frame) { sub: sub, errorMessage: ButtonConfigError.describe(code), errorCode: code, + errorDetail: frame.length >= 4 ? frame[3] : null, ); } diff --git a/lib/connector/offband_screen_settings.dart b/lib/connector/offband_screen_settings.dart new file mode 100644 index 00000000..d0e7de8e --- /dev/null +++ b/lib/connector/offband_screen_settings.dart @@ -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 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, + ), + ); +} diff --git a/lib/connector/offband_tones.dart b/lib/connector/offband_tones.dart new file mode 100644 index 00000000..5be07d89 --- /dev/null +++ b/lib/connector/offband_tones.dart @@ -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 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 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 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 offeredTuneIds(Map 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 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; +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2a9fd297..eca8589f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -326,6 +326,18 @@ "settings_gpsSleepInvalid": "Enter a whole number of seconds, 0 or more.", "settings_buttonActions": "Button actions", "settings_buttonActionsSubtitle": "Assign what the button does", + "settings_badgeScreen": "Screen", + "settings_badgeScreenSubtitle": "Text size, screen off, time zone", + "settings_badgeBattery": "Battery", + "settings_badgeBatterySubtitle": "100% at {mv} mV", + "@settings_badgeBatterySubtitle": { + "placeholders": { + "mv": { + "type": "int" + } + } + }, + "settings_badgeBatteryReading": "Reading from the radio", "settings_buzzerTones": "Buzzer and tones", "settings_buzzerTonesSubtitle": "When this radio beeps", "settings_contactSettings": "Contact Settings", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 119dee78..0f97dd41 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1174,6 +1174,36 @@ abstract class AppLocalizations { /// **'Assign what the button does'** String get settings_buttonActionsSubtitle; + /// No description provided for @settings_badgeScreen. + /// + /// In en, this message translates to: + /// **'Screen'** + String get settings_badgeScreen; + + /// No description provided for @settings_badgeScreenSubtitle. + /// + /// In en, this message translates to: + /// **'Text size, screen off, time zone'** + String get settings_badgeScreenSubtitle; + + /// No description provided for @settings_badgeBattery. + /// + /// In en, this message translates to: + /// **'Battery'** + String get settings_badgeBattery; + + /// No description provided for @settings_badgeBatterySubtitle. + /// + /// In en, this message translates to: + /// **'100% at {mv} mV'** + String settings_badgeBatterySubtitle(int mv); + + /// No description provided for @settings_badgeBatteryReading. + /// + /// In en, this message translates to: + /// **'Reading from the radio'** + String get settings_badgeBatteryReading; + /// No description provided for @settings_buzzerTones. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 8391a1e5..9ad203f8 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -624,6 +624,23 @@ class AppLocalizationsBg extends AppLocalizations { @override String get settings_buttonActionsSubtitle => 'Assign what the button does'; + @override + String get settings_badgeScreen => 'Screen'; + + @override + String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone'; + + @override + String get settings_badgeBattery => 'Battery'; + + @override + String settings_badgeBatterySubtitle(int mv) { + return '100% at $mv mV'; + } + + @override + String get settings_badgeBatteryReading => 'Reading from the radio'; + @override String get settings_buzzerTones => 'Buzzer and tones'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 3f8727bf..34dcc2d1 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -626,6 +626,23 @@ class AppLocalizationsDe extends AppLocalizations { @override String get settings_buttonActionsSubtitle => 'Assign what the button does'; + @override + String get settings_badgeScreen => 'Screen'; + + @override + String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone'; + + @override + String get settings_badgeBattery => 'Battery'; + + @override + String settings_badgeBatterySubtitle(int mv) { + return '100% at $mv mV'; + } + + @override + String get settings_badgeBatteryReading => 'Reading from the radio'; + @override String get settings_buzzerTones => 'Buzzer and tones'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index fbe4226b..91234d98 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -620,6 +620,23 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settings_buttonActionsSubtitle => 'Assign what the button does'; + @override + String get settings_badgeScreen => 'Screen'; + + @override + String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone'; + + @override + String get settings_badgeBattery => 'Battery'; + + @override + String settings_badgeBatterySubtitle(int mv) { + return '100% at $mv mV'; + } + + @override + String get settings_badgeBatteryReading => 'Reading from the radio'; + @override String get settings_buzzerTones => 'Buzzer and tones'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 9c08b90e..df7b515a 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -624,6 +624,23 @@ class AppLocalizationsEs extends AppLocalizations { @override String get settings_buttonActionsSubtitle => 'Assign what the button does'; + @override + String get settings_badgeScreen => 'Screen'; + + @override + String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone'; + + @override + String get settings_badgeBattery => 'Battery'; + + @override + String settings_badgeBatterySubtitle(int mv) { + return '100% at $mv mV'; + } + + @override + String get settings_badgeBatteryReading => 'Reading from the radio'; + @override String get settings_buzzerTones => 'Buzzer and tones'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 466a85f0..06e3f094 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -627,6 +627,23 @@ class AppLocalizationsFr extends AppLocalizations { @override String get settings_buttonActionsSubtitle => 'Assign what the button does'; + @override + String get settings_badgeScreen => 'Screen'; + + @override + String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone'; + + @override + String get settings_badgeBattery => 'Battery'; + + @override + String settings_badgeBatterySubtitle(int mv) { + return '100% at $mv mV'; + } + + @override + String get settings_badgeBatteryReading => 'Reading from the radio'; + @override String get settings_buzzerTones => 'Buzzer and tones'; diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index a8c9d72e..8ec19aec 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -626,6 +626,23 @@ class AppLocalizationsHu extends AppLocalizations { @override String get settings_buttonActionsSubtitle => 'Assign what the button does'; + @override + String get settings_badgeScreen => 'Screen'; + + @override + String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone'; + + @override + String get settings_badgeBattery => 'Battery'; + + @override + String settings_badgeBatterySubtitle(int mv) { + return '100% at $mv mV'; + } + + @override + String get settings_badgeBatteryReading => 'Reading from the radio'; + @override String get settings_buzzerTones => 'Buzzer and tones'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 776a766f..fd297963 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -626,6 +626,23 @@ class AppLocalizationsIt extends AppLocalizations { @override String get settings_buttonActionsSubtitle => 'Assign what the button does'; + @override + String get settings_badgeScreen => 'Screen'; + + @override + String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone'; + + @override + String get settings_badgeBattery => 'Battery'; + + @override + String settings_badgeBatterySubtitle(int mv) { + return '100% at $mv mV'; + } + + @override + String get settings_badgeBatteryReading => 'Reading from the radio'; + @override String get settings_buzzerTones => 'Buzzer and tones'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 36e8383c..ebc0c8ce 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -609,6 +609,23 @@ class AppLocalizationsJa extends AppLocalizations { @override String get settings_buttonActionsSubtitle => 'Assign what the button does'; + @override + String get settings_badgeScreen => 'Screen'; + + @override + String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone'; + + @override + String get settings_badgeBattery => 'Battery'; + + @override + String settings_badgeBatterySubtitle(int mv) { + return '100% at $mv mV'; + } + + @override + String get settings_badgeBatteryReading => 'Reading from the radio'; + @override String get settings_buzzerTones => 'Buzzer and tones'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index adec5408..1a889b5f 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -609,6 +609,23 @@ class AppLocalizationsKo extends AppLocalizations { @override String get settings_buttonActionsSubtitle => 'Assign what the button does'; + @override + String get settings_badgeScreen => 'Screen'; + + @override + String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone'; + + @override + String get settings_badgeBattery => 'Battery'; + + @override + String settings_badgeBatterySubtitle(int mv) { + return '100% at $mv mV'; + } + + @override + String get settings_badgeBatteryReading => 'Reading from the radio'; + @override String get settings_buzzerTones => 'Buzzer and tones'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 3624ba23..492c0739 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -623,6 +623,23 @@ class AppLocalizationsNl extends AppLocalizations { @override String get settings_buttonActionsSubtitle => 'Assign what the button does'; + @override + String get settings_badgeScreen => 'Screen'; + + @override + String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone'; + + @override + String get settings_badgeBattery => 'Battery'; + + @override + String settings_badgeBatterySubtitle(int mv) { + return '100% at $mv mV'; + } + + @override + String get settings_badgeBatteryReading => 'Reading from the radio'; + @override String get settings_buzzerTones => 'Buzzer and tones'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 54678f8c..af099e52 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -629,6 +629,23 @@ class AppLocalizationsPl extends AppLocalizations { @override String get settings_buttonActionsSubtitle => 'Assign what the button does'; + @override + String get settings_badgeScreen => 'Screen'; + + @override + String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone'; + + @override + String get settings_badgeBattery => 'Battery'; + + @override + String settings_badgeBatterySubtitle(int mv) { + return '100% at $mv mV'; + } + + @override + String get settings_badgeBatteryReading => 'Reading from the radio'; + @override String get settings_buzzerTones => 'Buzzer and tones'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index dfa50e00..576a485a 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -626,6 +626,23 @@ class AppLocalizationsPt extends AppLocalizations { @override String get settings_buttonActionsSubtitle => 'Assign what the button does'; + @override + String get settings_badgeScreen => 'Screen'; + + @override + String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone'; + + @override + String get settings_badgeBattery => 'Battery'; + + @override + String settings_badgeBatterySubtitle(int mv) { + return '100% at $mv mV'; + } + + @override + String get settings_badgeBatteryReading => 'Reading from the radio'; + @override String get settings_buzzerTones => 'Buzzer and tones'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 2b5d4c9f..d6111d3a 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -625,6 +625,23 @@ class AppLocalizationsRu extends AppLocalizations { @override String get settings_buttonActionsSubtitle => 'Assign what the button does'; + @override + String get settings_badgeScreen => 'Screen'; + + @override + String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone'; + + @override + String get settings_badgeBattery => 'Battery'; + + @override + String settings_badgeBatterySubtitle(int mv) { + return '100% at $mv mV'; + } + + @override + String get settings_badgeBatteryReading => 'Reading from the radio'; + @override String get settings_buzzerTones => 'Buzzer and tones'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 41bf9d82..c6b8da96 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -624,6 +624,23 @@ class AppLocalizationsSk extends AppLocalizations { @override String get settings_buttonActionsSubtitle => 'Assign what the button does'; + @override + String get settings_badgeScreen => 'Screen'; + + @override + String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone'; + + @override + String get settings_badgeBattery => 'Battery'; + + @override + String settings_badgeBatterySubtitle(int mv) { + return '100% at $mv mV'; + } + + @override + String get settings_badgeBatteryReading => 'Reading from the radio'; + @override String get settings_buzzerTones => 'Buzzer and tones'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index f6288d99..d01c0c18 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -622,6 +622,23 @@ class AppLocalizationsSl extends AppLocalizations { @override String get settings_buttonActionsSubtitle => 'Assign what the button does'; + @override + String get settings_badgeScreen => 'Screen'; + + @override + String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone'; + + @override + String get settings_badgeBattery => 'Battery'; + + @override + String settings_badgeBatterySubtitle(int mv) { + return '100% at $mv mV'; + } + + @override + String get settings_badgeBatteryReading => 'Reading from the radio'; + @override String get settings_buzzerTones => 'Buzzer and tones'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 41e3dbb1..8a3d895f 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -621,6 +621,23 @@ class AppLocalizationsSv extends AppLocalizations { @override String get settings_buttonActionsSubtitle => 'Assign what the button does'; + @override + String get settings_badgeScreen => 'Screen'; + + @override + String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone'; + + @override + String get settings_badgeBattery => 'Battery'; + + @override + String settings_badgeBatterySubtitle(int mv) { + return '100% at $mv mV'; + } + + @override + String get settings_badgeBatteryReading => 'Reading from the radio'; + @override String get settings_buzzerTones => 'Buzzer and tones'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 79efce02..7e2c6b70 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -623,6 +623,23 @@ class AppLocalizationsUk extends AppLocalizations { @override String get settings_buttonActionsSubtitle => 'Assign what the button does'; + @override + String get settings_badgeScreen => 'Screen'; + + @override + String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone'; + + @override + String get settings_badgeBattery => 'Battery'; + + @override + String settings_badgeBatterySubtitle(int mv) { + return '100% at $mv mV'; + } + + @override + String get settings_badgeBatteryReading => 'Reading from the radio'; + @override String get settings_buzzerTones => 'Buzzer and tones'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 6c89bd3b..c5164b8b 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -605,6 +605,23 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settings_buttonActionsSubtitle => 'Assign what the button does'; + @override + String get settings_badgeScreen => 'Screen'; + + @override + String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone'; + + @override + String get settings_badgeBattery => 'Battery'; + + @override + String settings_badgeBatterySubtitle(int mv) { + return '100% at $mv mV'; + } + + @override + String get settings_badgeBatteryReading => 'Reading from the radio'; + @override String get settings_buzzerTones => 'Buzzer and tones'; diff --git a/lib/models/badge_time_zones.dart b/lib/models/badge_time_zones.dart new file mode 100644 index 00000000..320f6d14 --- /dev/null +++ b/lib/models/badge_time_zones.dart @@ -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 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; +} diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 6fea9111..e3fdc41c 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -31,6 +31,7 @@ import '../services/corescope_service.dart'; import '../services/region_discovery_service.dart'; import '../widgets/channel_region_scope_dialog.dart'; import '../widgets/channel_notify_mode.dart'; +import '../widgets/radio_tone.dart'; import '../services/block_service.dart'; import '../services/chat_text_scale_service.dart'; import '../services/translation_service.dart'; @@ -473,6 +474,10 @@ class _ChannelChatScreenState extends State { onSelected: (value) { if (value == 'notifications') { showChannelNotifyModeDialog(context, widget.channel); + } else if (value == 'radioTone') { + final connector = context.read(); + requestChannelRadioTone(connector, _currentChannel.index); + showChannelRadioToneDialog(context, _currentChannel); } else if (value == 'clearChat') { context.read().clearMessagesForChannel( _currentChannel.index, @@ -502,6 +507,17 @@ class _ChannelChatScreenState extends State { ], ), ), + if (context.read().supportsTunes) + const PopupMenuItem( + value: 'radioTone', + child: Row( + children: [ + Icon(Icons.music_note_outlined, size: 20), + SizedBox(width: 12), + Text('Radio tone'), + ], + ), + ), if (context.read().supportsRegionScope) PopupMenuItem( value: 'setRegionScope', diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index a308eb17..1b2ec301 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -27,6 +27,7 @@ import '../widgets/channel_drawer_list.dart'; import '../widgets/sync_progress_overlay.dart'; import '../widgets/unread_badge.dart'; import '../widgets/channel_notify_mode.dart'; +import '../widgets/radio_tone.dart'; import '../widgets/battery_optimization_banner.dart'; import '../helpers/snack_bar_builder.dart'; import 'channel_chat_screen.dart'; @@ -534,6 +535,18 @@ class _ChannelsScreenState extends State } }, ), + // The radio's own tone for this channel (#971), named apart from + // Notifications, which is the phone's. Gated on caps byte 2 0x10. + ChannelRadioToneTile( + channel: channel, + onTap: () async { + Navigator.pop(sheetContext); + await Future.delayed(const Duration(milliseconds: 100)); + if (parentContext.mounted) { + showChannelRadioToneDialog(parentContext, channel); + } + }, + ), ListTile( leading: const Icon(Icons.delete_outline, color: Colors.red), title: Text( diff --git a/lib/screens/settings/badge_battery_view.dart b/lib/screens/settings/badge_battery_view.dart new file mode 100644 index 00000000..4014784c --- /dev/null +++ b/lib/screens/settings/badge_battery_view.dart @@ -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 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 { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) context.read().requestScreenSettings(); + }); + } + + Future _pin(MeshCoreConnector connector) async { + final confirmed = await showDialog( + 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(); + 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'), + ), + ], + ), + ], + ], + ); + } +} diff --git a/lib/screens/settings/badge_screen_view.dart b/lib/screens/settings/badge_screen_view.dart new file mode 100644 index 00000000..baf42794 --- /dev/null +++ b/lib/screens/settings/badge_screen_view.dart @@ -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 createState() => _BadgeScreenViewState(); +} + +class _BadgeScreenViewState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) context.read().requestScreenSettings(); + }); + } + + @override + Widget build(BuildContext context) { + final connector = context.watch(); + 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( + 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 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( + 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 createState() => _TimeZonePickerScreenState(); +} + +class _TimeZonePickerScreenState extends State { + String _query = ''; + + void _choose(int index) { + context.read().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), + ], + ), + ); + } +} diff --git a/lib/screens/settings/device_ui_view.dart b/lib/screens/settings/device_ui_view.dart index e9a6f40c..c0a8d016 100644 --- a/lib/screens/settings/device_ui_view.dart +++ b/lib/screens/settings/device_ui_view.dart @@ -3,6 +3,10 @@ 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'; +import 'tune_slot_editor_screen.dart'; /// Settings for a headless device's physical UI: the button-action matrix /// (#474) and the device notification scope (#475). @@ -43,6 +47,10 @@ class _DeviceUiViewState extends State { c.requestButtonMatrix(); case DeviceUiPart.buzzer: c.requestNotifyScope(); + if (c.supportsTunes) { + c.requestEventTones(); + c.requestTuneSlots(); + } } }); } @@ -53,8 +61,11 @@ class _DeviceUiViewState extends State { null => true, DeviceUiSurface.buttonMatrix => widget.part == DeviceUiPart.buttons, DeviceUiSurface.notifyScope || - DeviceUiSurface.channelTune => widget.part == DeviceUiPart.buzzer, - DeviceUiSurface.gpsMode => false, + DeviceUiSurface.channelTune || + DeviceUiSurface.eventTone || + DeviceUiSurface.tunePlay || + DeviceUiSurface.tuneSlot => widget.part == DeviceUiPart.buzzer, + DeviceUiSurface.gpsMode || DeviceUiSurface.screenSettings => false, }; @override @@ -110,7 +121,7 @@ class _DeviceUiViewState extends State { children: [ if (connector.deviceUiError != null && _ownsError(connector.deviceUiErrorSurface)) - _ErrorBanner( + DeviceUiErrorBanner( message: connector.deviceUiError!, onDismiss: connector.clearDeviceUiError, ), @@ -125,6 +136,25 @@ class _DeviceUiViewState extends State { ), ), ..._scopeTiles(connector), + // The badge's Tones screen, in its order: the scope gates every + // tone below it (#986, #970). Needs the tune engine, 0x10. + if (connector.supportsTunes) ...[ + const _SectionHeader('Tones'), + const Padding( + padding: EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Text('What this radio plays for each event.'), + ), + ..._eventToneTiles(connector), + const _SectionHeader('Custom tunes'), + const Padding( + padding: EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Text( + 'Eight tunes stored on this radio, in RTTTL. A saved tune ' + 'can be picked for any event or channel.', + ), + ), + ..._slotTiles(connector), + ], ], if (showButtons) ...[ const _SectionHeader('Button actions'), @@ -182,6 +212,111 @@ class _DeviceUiViewState extends State { .toList(); } + /// The six events, each naming the tune that plays. "(radio default)" marks + /// one nobody has chosen, so a choice and a default never look alike. + List _eventToneTiles(MeshCoreConnector connector) { + return ToneEvent.values.map((event) { + final state = connector.eventTone(event); + if (state == null) { + return ListTile( + leading: const Icon(Icons.music_note_outlined), + title: Text(event.label), + subtitle: const Text('Reading from the radio'), + ); + } + final name = + tuneLabel(state.effective, connector.tuneSlots) ?? + 'Tune ${state.effective}'; + return ListTile( + leading: Icon(tuneIcon(state.effective)), + title: Text(event.label), + subtitle: Text(state.isRadioDefault ? '$name (radio default)' : name), + trailing: const Icon(Icons.chevron_right), + onTap: () => _pickEventTone(connector, event, state), + ); + }).toList(); + } + + Future _pickEventTone( + MeshCoreConnector connector, + ToneEvent event, + EventToneState state, + ) async { + // No Default row: firmware refuses 0xFF on an event, as the badge's own + // picker offers none (owner decision 2026-10-25). + final choice = await showTunePickerDialog( + context, + title: '${event.label} tone', + description: event == ToneEvent.dm + ? 'Plays for every direct message.' + : null, + current: state.effective, + ); + if (choice is TuneIdChoice) await connector.setEventTone(event, choice.id); + } + + /// All eight slots, as the badge lists them: an empty slot is where you go + /// to fill one, so it is a row too. + List _slotTiles(MeshCoreConnector connector) { + return [ + for (var slot = 0; slot < tuneSlotCount; slot++) + _slotTile(connector, slot, connector.tuneSlots[slot]), + ]; + } + + Widget _slotTile(MeshCoreConnector connector, int slot, TuneSlotInfo? info) { + final number = slot + 1; + if (info == null) { + return ListTile( + leading: const Icon(Icons.music_note_outlined), + title: Text('$number'), + subtitle: const Text('Reading from the radio'), + ); + } + void open() => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => TuneSlotEditorScreen(slot: slot, info: info), + ), + ); + if (info.isEmpty) { + return ListTile( + leading: const Icon(Icons.add_circle_outline), + title: Text('$number (empty)'), + subtitle: const Text('Tap to add a tune'), + onTap: open, + ); + } + final name = info.name.isEmpty ? 'Slot $number' : info.name; + return ListTile( + leading: const Icon(Icons.music_note_outlined), + title: Text('$number $name'), + subtitle: Text('${info.length} characters'), + trailing: IconButton( + icon: const Icon(Icons.play_circle_outline), + color: Theme.of(context).colorScheme.primary, + tooltip: 'Play on the radio', + onPressed: () => _play(connector, slotTuneId(slot)), + ), + onTap: open, + ); + } + + Future _play(MeshCoreConnector connector, int id) async { + final messenger = ScaffoldMessenger.of(context); + final note = describeTunePlay(await connector.playTune(id)); + if (note == null) return; + // Stays until OK: an explanation the user cannot finish reading is not + // one. + messenger.showSnackBar( + SnackBar( + content: Text(note), + duration: const Duration(days: 1), + action: SnackBarAction(label: 'OK', onPressed: () {}), + ), + ); + } + List _buttonTiles(MeshCoreConnector connector) { final matrix = connector.buttonMatrix; if (matrix == null) { @@ -233,41 +368,3 @@ class _SectionHeader extends StatelessWidget { child: Text(title, style: Theme.of(context).textTheme.titleMedium), ); } - -/// Persistent error banner. Stays until dismissed rather than flashing, per the -/// error-visibility rule: an error the user cannot finish reading is not a -/// surfaced error. -class _ErrorBanner extends StatelessWidget { - const _ErrorBanner({required this.message, required this.onDismiss}); - final String message; - final VoidCallback onDismiss; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Container( - margin: const EdgeInsets.all(12), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: scheme.errorContainer, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - Icon(Icons.error_outline, color: scheme.onErrorContainer), - const SizedBox(width: 12), - Expanded( - child: Text( - message, - style: TextStyle(color: scheme.onErrorContainer), - ), - ), - IconButton( - icon: Icon(Icons.close, color: scheme.onErrorContainer), - onPressed: onDismiss, - ), - ], - ), - ); - } -} diff --git a/lib/screens/settings/tune_slot_editor_screen.dart b/lib/screens/settings/tune_slot_editor_screen.dart new file mode 100644 index 00000000..61438d74 --- /dev/null +++ b/lib/screens/settings/tune_slot_editor_screen.dart @@ -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 createState() => _TuneSlotEditorScreenState(); +} + +class _TuneSlotEditorScreenState extends State { + 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 _read() async { + final connector = context.read(); + 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 _save() async { + final connector = context.read(); + 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 _clear() async { + final confirmed = await showDialog( + 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 _playSaved() async { + final connector = context.read(); + 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 _confirmDiscard() async { + final discard = await showDialog( + 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(); + 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, + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 302d2304..9c1d4414 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -25,6 +25,8 @@ import 'settings/app_settings_view.dart'; import 'settings/message_settings_view.dart'; import 'settings/observer_settings_view.dart'; import 'settings/blocked_view.dart'; +import 'settings/badge_battery_view.dart'; +import 'settings/badge_screen_view.dart'; import 'settings/device_ui_view.dart'; import 'settings/location_view.dart'; import '../helpers/device_name_validator.dart'; @@ -410,6 +412,18 @@ class _SettingsScreenState extends State { Widget _radioStatsPane(BuildContext context) => const CompanionRadioStatsBody(); + void _pushBadgePage(BuildContext context, String title, Widget body) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => Scaffold( + appBar: AppBar(title: Text(title), centerTitle: true), + body: body, + ), + ), + ); + } + Widget _identityPane(BuildContext context) { final l10n = context.l10n; final connector = context.watch(); @@ -462,6 +476,41 @@ class _SettingsScreenState extends State { }, ), ], + // The badge's own screen and battery settings (#987), beside + // Button actions (owner decision 2026-10-25). Strictly on the + // 0x40 bit: no bit, no rows, no command. + if (connector.supportsScreenSettings) ...[ + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.monitor), + title: Text(l10n.settings_badgeScreen), + subtitle: Text(l10n.settings_badgeScreenSubtitle), + trailing: const Icon(Icons.chevron_right), + onTap: () => _pushBadgePage( + context, + l10n.settings_badgeScreen, + const BadgeScreenView(), + ), + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.battery_std), + title: Text(l10n.settings_badgeBattery), + subtitle: Text( + connector.screenSettings == null + ? l10n.settings_badgeBatteryReading + : l10n.settings_badgeBatterySubtitle( + connector.screenSettings!.batteryFullMv, + ), + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => _pushBadgePage( + context, + l10n.settings_badgeBattery, + const BadgeBatteryView(), + ), + ), + ], if (connector.selfPublicKey != null) ...[ const Divider(height: 1), Padding( diff --git a/lib/widgets/device_ui_error_banner.dart b/lib/widgets/device_ui_error_banner.dart new file mode 100644 index 00000000..1aa52a4d --- /dev/null +++ b/lib/widgets/device_ui_error_banner.dart @@ -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, + ), + ], + ), + ); + } +} diff --git a/lib/widgets/radio_tone.dart b/lib/widgets/radio_tone.dart new file mode 100644 index 00000000..5edf75e9 --- /dev/null +++ b/lib/widgets/radio_tone.dart @@ -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 showTunePickerDialog( + BuildContext context, { + required String title, + String? description, + required int? current, + String? defaultSubtitle, + bool withDefault = false, +}) { + return showDialog( + 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 _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( + 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 showChannelRadioToneDialog( + BuildContext context, + Channel channel, +) async { + final connector = context.read(); + 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 createState() => _ChannelRadioToneTileState(); +} + +class _ChannelRadioToneTileState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final c = context.read(); + if (c.supportsTunes) requestChannelRadioTone(c, widget.channel.index); + }); + } + + @override + Widget build(BuildContext context) { + final connector = context.watch(); + 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; diff --git a/test/connector/channel_tune_connector_test.dart b/test/connector/channel_tune_connector_test.dart index 08caf6c7..577494c0 100644 --- a/test/connector/channel_tune_connector_test.dart +++ b/test/connector/channel_tune_connector_test.dart @@ -61,7 +61,7 @@ void main() { await connector.requestChannelTune(0); await connector.setChannelTune(0, ChannelTune.tune(0)); - expect(connector.supportsChannelTune, isFalse); + expect(connector.supportsTunes, isFalse); expect(sentTune(), isFalse); }); diff --git a/test/connector/offband_device_ui_test.dart b/test/connector/offband_device_ui_test.dart index d560a21c..81d91d01 100644 --- a/test/connector/offband_device_ui_test.dart +++ b/test/connector/offband_device_ui_test.dart @@ -211,13 +211,21 @@ void main() { test('the new bits collide with no allocated caps-byte-2 bit', () { // Firmware allocates 0x01 scope, 0x02 matrix, 0x04 indicators, 0x08 - // packet hash (OffbandConfigProtocol.h on firmware-base). + // packet hash (OffbandConfigProtocol.h on firmware-base), and + // 3e788e2a adds 0x10 TUNES, 0x20 GPS mode, 0x40 SCREEN_SETTINGS. const allocated = 0x01 | 0x02 | 0x04 | 0x08; - expect(offbandCap2GpsMode & allocated, equals(0)); - expect(offbandCap2ChannelTune & allocated, equals(0)); - expect(offbandCap2GpsMode & offbandCap2ChannelTune, equals(0)); + const added = [ + offbandCap2Tunes, + offbandCap2GpsMode, + offbandCap2ScreenSettings, + ]; + for (final bit in added) { + expect(bit & allocated, equals(0)); + expect(added.where((b) => b & bit != 0).length, equals(1)); + } + expect(offbandCap2Tunes, equals(0x10)); expect(offbandCap2GpsMode, equals(0x20)); - expect(offbandCap2ChannelTune, equals(0x10)); + expect(offbandCap2ScreenSettings, equals(0x40)); }); }); diff --git a/test/connector/offband_screen_settings_test.dart b/test/connector/offband_screen_settings_test.dart new file mode 100644 index 00000000..9074967f --- /dev/null +++ b/test/connector/offband_screen_settings_test.dart @@ -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, + ); + }); + }); +} diff --git a/test/connector/offband_tones_test.dart b/test/connector/offband_tones_test.dart new file mode 100644 index 00000000..a593cdc6 --- /dev/null +++ b/test/connector/offband_tones_test.dart @@ -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 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 = { + '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'), ''); + }); + }); +} diff --git a/test/connector/tunes_connector_test.dart b/test/connector/tunes_connector_test.dart new file mode 100644 index 00000000..354eaf29 --- /dev/null +++ b/test/connector/tunes_connector_test.dart @@ -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 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> sent; + + /// Replies the fake radio sends for each frame the client writes. + List> Function(List 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 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 = []; + 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> Function(List) assembler({bool displaceOnce = false}) { + var held = []; + 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 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); + }); + }); +} diff --git a/test/screens/settings_tones_parity_test.dart b/test/screens/settings_tones_parity_test.dart new file mode 100644 index 00000000..eb69e717 --- /dev/null +++ b/test/screens/settings_tones_parity_test.dart @@ -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 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 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 requestNotifyScope() async => calls.add('scope'); + @override + Future requestEventTones() async => calls.add('evtones'); + @override + Future requestTuneSlots() async => calls.add('slots'); + @override + Future requestScreenSettings() async => calls.add('screen'); + @override + Future requestChannelTune(int channel) async => + calls.add('chtune $channel'); + @override + Future setEventTone(ToneEvent event, int tuneId) async => + calls.add('evtone ${event.name} $tuneId'); + @override + Future setChannelTune(int channel, ChannelTune tune) async => + calls.add('chtune set $channel ${tune.stored}'); + @override + Future playTune(int tuneId) async { + calls.add('play $tuneId'); + return playResult ?? TunePlayResult.played; + } + + @override + Future 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 setTextSize(BadgeTextTarget target, BadgeTextSize size) async => + calls.add('text ${target.name} ${size.name}'); + @override + Future setScreenOff(int seconds) async => calls.add('scroff $seconds'); + @override + Future setTimeZone(int index) async => calls.add('tz $index'); + @override + Future 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 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.value(value: radio), + ChangeNotifierProvider( + create: (_) => AppSettingsService(), + ), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: home, + ), + ), + ); + await tester.pumpAndSettle(); + } + + Future 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 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( + 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)); + }); + }); +} diff --git a/untranslated.json b/untranslated.json index 6a12ec1f..fc982b3c 100644 --- a/untranslated.json +++ b/untranslated.json @@ -61,6 +61,11 @@ "settings_gpsSleepInvalid", "settings_buttonActions", "settings_buttonActionsSubtitle", + "settings_badgeScreen", + "settings_badgeScreenSubtitle", + "settings_badgeBattery", + "settings_badgeBatterySubtitle", + "settings_badgeBatteryReading", "settings_buzzerTones", "settings_buzzerTonesSubtitle", "settings_experimental", @@ -375,6 +380,11 @@ "settings_gpsSleepInvalid", "settings_buttonActions", "settings_buttonActionsSubtitle", + "settings_badgeScreen", + "settings_badgeScreenSubtitle", + "settings_badgeBattery", + "settings_badgeBatterySubtitle", + "settings_badgeBatteryReading", "settings_buzzerTones", "settings_buzzerTonesSubtitle", "settings_experimental", @@ -689,6 +699,11 @@ "settings_gpsSleepInvalid", "settings_buttonActions", "settings_buttonActionsSubtitle", + "settings_badgeScreen", + "settings_badgeScreenSubtitle", + "settings_badgeBattery", + "settings_badgeBatterySubtitle", + "settings_badgeBatteryReading", "settings_buzzerTones", "settings_buzzerTonesSubtitle", "settings_experimental", @@ -1003,6 +1018,11 @@ "settings_gpsSleepInvalid", "settings_buttonActions", "settings_buttonActionsSubtitle", + "settings_badgeScreen", + "settings_badgeScreenSubtitle", + "settings_badgeBattery", + "settings_badgeBatterySubtitle", + "settings_badgeBatteryReading", "settings_buzzerTones", "settings_buzzerTonesSubtitle", "settings_experimental", @@ -1317,6 +1337,11 @@ "settings_gpsSleepInvalid", "settings_buttonActions", "settings_buttonActionsSubtitle", + "settings_badgeScreen", + "settings_badgeScreenSubtitle", + "settings_badgeBattery", + "settings_badgeBatterySubtitle", + "settings_badgeBatteryReading", "settings_buzzerTones", "settings_buzzerTonesSubtitle", "settings_experimental", @@ -1631,6 +1656,11 @@ "settings_gpsSleepInvalid", "settings_buttonActions", "settings_buttonActionsSubtitle", + "settings_badgeScreen", + "settings_badgeScreenSubtitle", + "settings_badgeBattery", + "settings_badgeBatterySubtitle", + "settings_badgeBatteryReading", "settings_buzzerTones", "settings_buzzerTonesSubtitle", "settings_experimental", @@ -1945,6 +1975,11 @@ "settings_gpsSleepInvalid", "settings_buttonActions", "settings_buttonActionsSubtitle", + "settings_badgeScreen", + "settings_badgeScreenSubtitle", + "settings_badgeBattery", + "settings_badgeBatterySubtitle", + "settings_badgeBatteryReading", "settings_buzzerTones", "settings_buzzerTonesSubtitle", "settings_experimental", @@ -2259,6 +2294,11 @@ "settings_gpsSleepInvalid", "settings_buttonActions", "settings_buttonActionsSubtitle", + "settings_badgeScreen", + "settings_badgeScreenSubtitle", + "settings_badgeBattery", + "settings_badgeBatterySubtitle", + "settings_badgeBatteryReading", "settings_buzzerTones", "settings_buzzerTonesSubtitle", "settings_experimental", @@ -2573,6 +2613,11 @@ "settings_gpsSleepInvalid", "settings_buttonActions", "settings_buttonActionsSubtitle", + "settings_badgeScreen", + "settings_badgeScreenSubtitle", + "settings_badgeBattery", + "settings_badgeBatterySubtitle", + "settings_badgeBatteryReading", "settings_buzzerTones", "settings_buzzerTonesSubtitle", "settings_experimental", @@ -2887,6 +2932,11 @@ "settings_gpsSleepInvalid", "settings_buttonActions", "settings_buttonActionsSubtitle", + "settings_badgeScreen", + "settings_badgeScreenSubtitle", + "settings_badgeBattery", + "settings_badgeBatterySubtitle", + "settings_badgeBatteryReading", "settings_buzzerTones", "settings_buzzerTonesSubtitle", "settings_experimental", @@ -3201,6 +3251,11 @@ "settings_gpsSleepInvalid", "settings_buttonActions", "settings_buttonActionsSubtitle", + "settings_badgeScreen", + "settings_badgeScreenSubtitle", + "settings_badgeBattery", + "settings_badgeBatterySubtitle", + "settings_badgeBatteryReading", "settings_buzzerTones", "settings_buzzerTonesSubtitle", "settings_experimental", @@ -3515,6 +3570,11 @@ "settings_gpsSleepInvalid", "settings_buttonActions", "settings_buttonActionsSubtitle", + "settings_badgeScreen", + "settings_badgeScreenSubtitle", + "settings_badgeBattery", + "settings_badgeBatterySubtitle", + "settings_badgeBatteryReading", "settings_buzzerTones", "settings_buzzerTonesSubtitle", "settings_experimental", @@ -3829,6 +3889,11 @@ "settings_gpsSleepInvalid", "settings_buttonActions", "settings_buttonActionsSubtitle", + "settings_badgeScreen", + "settings_badgeScreenSubtitle", + "settings_badgeBattery", + "settings_badgeBatterySubtitle", + "settings_badgeBatteryReading", "settings_buzzerTones", "settings_buzzerTonesSubtitle", "settings_experimental", @@ -4143,6 +4208,11 @@ "settings_gpsSleepInvalid", "settings_buttonActions", "settings_buttonActionsSubtitle", + "settings_badgeScreen", + "settings_badgeScreenSubtitle", + "settings_badgeBattery", + "settings_badgeBatterySubtitle", + "settings_badgeBatteryReading", "settings_buzzerTones", "settings_buzzerTonesSubtitle", "settings_experimental", @@ -4457,6 +4527,11 @@ "settings_gpsSleepInvalid", "settings_buttonActions", "settings_buttonActionsSubtitle", + "settings_badgeScreen", + "settings_badgeScreenSubtitle", + "settings_badgeBattery", + "settings_badgeBatterySubtitle", + "settings_badgeBatteryReading", "settings_buzzerTones", "settings_buzzerTonesSubtitle", "settings_experimental", @@ -4771,6 +4846,11 @@ "settings_gpsSleepInvalid", "settings_buttonActions", "settings_buttonActionsSubtitle", + "settings_badgeScreen", + "settings_badgeScreenSubtitle", + "settings_badgeBattery", + "settings_badgeBatterySubtitle", + "settings_badgeBatteryReading", "settings_buzzerTones", "settings_buzzerTonesSubtitle", "settings_experimental", @@ -5085,6 +5165,11 @@ "settings_gpsSleepInvalid", "settings_buttonActions", "settings_buttonActionsSubtitle", + "settings_badgeScreen", + "settings_badgeScreenSubtitle", + "settings_badgeBattery", + "settings_badgeBatterySubtitle", + "settings_badgeBatteryReading", "settings_buzzerTones", "settings_buzzerTonesSubtitle", "settings_experimental",