From 197b6de4a684c47aa0683579afb7e7cb89b09ceb Mon Sep 17 00:00:00 2001 From: pelgraine <140762863+pelgraine@users.noreply.github.com> Date: Wed, 25 Feb 2026 23:50:52 +1100 Subject: [PATCH] added Favourites filter to mesh Contacts scren; fixed regression with dropped in-call screen; fixed 0 key recognition --- .../companion_radio/ui-new/Contactsscreen.h | 7 +- examples/companion_radio/ui-new/SMSScreen.h | 450 ++++++++++++++++-- variants/lilygo_tdeck_pro/Tca8418keyboard.h | 12 +- 3 files changed, 428 insertions(+), 41 deletions(-) diff --git a/examples/companion_radio/ui-new/Contactsscreen.h b/examples/companion_radio/ui-new/Contactsscreen.h index 9f79da9f..42cc70df 100644 --- a/examples/companion_radio/ui-new/Contactsscreen.h +++ b/examples/companion_radio/ui-new/Contactsscreen.h @@ -18,6 +18,7 @@ public: FILTER_REPEATER, FILTER_ROOM, // Room servers FILTER_SENSOR, + FILTER_FAVOURITE, // Contacts marked as favourite (any type) FILTER_COUNT // keep last }; @@ -48,6 +49,7 @@ private: case FILTER_REPEATER: return "Rptr"; case FILTER_ROOM: return "Room"; case FILTER_SENSOR: return "Sens"; + case FILTER_FAVOURITE: return "Fav"; default: return "?"; } } @@ -61,7 +63,7 @@ private: } } - bool matchesFilter(uint8_t adv_type) const { + bool matchesFilter(uint8_t adv_type, uint8_t flags = 0) const { switch (_filter) { case FILTER_ALL: return true; case FILTER_CHAT: return adv_type == ADV_TYPE_CHAT; @@ -70,6 +72,7 @@ private: case FILTER_SENSOR: return (adv_type != ADV_TYPE_CHAT && adv_type != ADV_TYPE_REPEATER && adv_type != ADV_TYPE_ROOM); + case FILTER_FAVOURITE: return (flags & 0x01) != 0; default: return true; } } @@ -80,7 +83,7 @@ private: ContactInfo contact; for (uint32_t i = 0; i < numContacts && _filteredCount < MAX_VISIBLE; i++) { if (the_mesh.getContactByIdx(i, contact)) { - if (matchesFilter(contact.type)) { + if (matchesFilter(contact.type, contact.flags)) { _filteredIdx[_filteredCount] = (uint16_t)i; _filteredTs[_filteredCount] = contact.last_advert_timestamp; _filteredCount++; diff --git a/examples/companion_radio/ui-new/SMSScreen.h b/examples/companion_radio/ui-new/SMSScreen.h index d76d93ec..a6a8c25b 100644 --- a/examples/companion_radio/ui-new/SMSScreen.h +++ b/examples/companion_radio/ui-new/SMSScreen.h @@ -4,13 +4,16 @@ // SMSScreen - SMS & Phone UI for T-Deck Pro (4G variant) // // Sub-views: -// APP_MENU — landing screen: choose Phone or SMS Inbox -// INBOX — list of conversations (names resolved via SMSContacts) -// CONVERSATION — messages for a selected contact, scrollable -// COMPOSE — text input for new SMS -// CONTACTS — browsable contacts list, pick to compose or call -// EDIT_CONTACT — add or edit a contact name for a phone number -// PHONE_DIALER — enter arbitrary phone number and call +// APP_MENU — landing screen: choose Phone or SMS Inbox +// INBOX — list of conversations (names resolved via SMSContacts) +// CONVERSATION — messages for a selected contact, scrollable +// COMPOSE — text input for new SMS +// CONTACTS — browsable contacts list, pick to compose or call +// EDIT_CONTACT — add or edit a contact name for a phone number +// PHONE_DIALER — enter arbitrary phone number and call +// DIALING_OUT — outgoing call in progress (waiting for answer) +// INCOMING_CALL — incoming call ringing (answer or reject) +// IN_CALL — active voice call with timer, DTMF, volume // // Navigation mirrors ChannelScreen conventions: // W/S: scroll Enter: select/send C: compose new/reply @@ -43,7 +46,8 @@ class UITask; // forward declaration class SMSScreen : public UIScreen { public: - enum SubView { APP_MENU, INBOX, CONVERSATION, COMPOSE, CONTACTS, EDIT_CONTACT, PHONE_DIALER }; + enum SubView { APP_MENU, INBOX, CONVERSATION, COMPOSE, CONTACTS, EDIT_CONTACT, PHONE_DIALER, + DIALING_OUT, INCOMING_CALL, IN_CALL, CALL_ENDED }; private: UITask* _task; @@ -86,6 +90,15 @@ private: bool _editIsNew; // true = adding new, false = editing existing SubView _editReturnView; // where to return after save/cancel + // Voice call UI state + char _callPhone[SMS_PHONE_LEN]; // Phone number for active call + SubView _callReturnView; // View to return to after call ends + unsigned long _callConnectTime; // millis() when call connected (UI timer) + uint8_t _callVolume; // Current speaker volume (0-5) + uint8_t _callDotAnim; // Animation frame for dialing dots + unsigned long _callEndedTime; // millis() when call ended (for brief splash) + unsigned long _callEndedDuration; // Call duration in seconds (for ended screen) + // Refresh debounce bool _needsRefresh; unsigned long _lastRefresh; @@ -115,6 +128,8 @@ public: , _phoneInputPos(0), _enteringPhone(false) , _contactsCursor(0), _contactsScrollTop(0) , _editNamePos(0), _editIsNew(false), _editReturnView(INBOX) + , _callReturnView(APP_MENU), _callConnectTime(0), _callVolume(3), _callDotAnim(0) + , _callEndedTime(0), _callEndedDuration(0) , _needsRefresh(false), _lastRefresh(0) , _sdReady(false) { @@ -124,6 +139,7 @@ public: memset(_activePhone, 0, sizeof(_activePhone)); memset(_editPhone, 0, sizeof(_editPhone)); memset(_editNameBuf, 0, sizeof(_editNameBuf)); + memset(_callPhone, 0, sizeof(_callPhone)); } void setSDReady(bool ready) { _sdReady = ready; } @@ -137,33 +153,92 @@ public: SubView getSubView() const { return _view; } bool isComposing() const { return _view == COMPOSE; } bool isEnteringPhone() const { return _enteringPhone || _view == PHONE_DIALER; } - bool isInCallView() const { return false; } // TODO: return true when DIALING/IN_CALL views are added + bool isInCallView() const { + return _view == DIALING_OUT || _view == INCOMING_CALL || _view == IN_CALL || _view == CALL_ENDED; + } + + // Transition to dialing screen — used by all dial callsites + void startCall(const char* phone) { + strncpy(_callPhone, phone, SMS_PHONE_LEN - 1); + _callPhone[SMS_PHONE_LEN - 1] = '\0'; + _callReturnView = _view; + _callConnectTime = 0; + _callVolume = 3; + _callDotAnim = 0; + _view = DIALING_OUT; + modemManager.dialCall(phone); + } + + // Show brief "Call Ended" splash before returning to previous view + void showCallEnded(unsigned long duration) { + _callEndedDuration = duration; + _callEndedTime = millis(); + _view = CALL_ENDED; + } // Handle call events from modem (incoming, connected, ended, etc.) void onCallEvent(const CallEvent& evt) { - // TODO: implement call UI state transitions (DIALING, IN_CALL, INCOMING_CALL views) - // For now, just log the event switch (evt.type) { case CallEventType::INCOMING: Serial.printf("[SMSScreen] Incoming call from %s\n", evt.phone); + strncpy(_callPhone, evt.phone, SMS_PHONE_LEN - 1); + _callPhone[SMS_PHONE_LEN - 1] = '\0'; + _callConnectTime = 0; + _callVolume = 3; + if (!isInCallView()) { + _callReturnView = _view; + } + _view = INCOMING_CALL; + _needsRefresh = true; break; + case CallEventType::CONNECTED: Serial.printf("[SMSScreen] Call connected: %s\n", evt.phone); + _callConnectTime = millis(); + _view = IN_CALL; + _needsRefresh = true; break; + case CallEventType::ENDED: Serial.printf("[SMSScreen] Call ended (%lus)\n", (unsigned long)evt.duration); + if (_view == IN_CALL || _view == DIALING_OUT) { + // Remote hangup or network drop — show ended splash + showCallEnded(evt.duration); + } else if (_view != CALL_ENDED) { + // Already left call view (e.g. user hung up), just clean up + _callPhone[0] = '\0'; + _callConnectTime = 0; + } + _needsRefresh = true; break; + case CallEventType::MISSED: Serial.printf("[SMSScreen] Missed call from %s\n", evt.phone); + _view = _callReturnView; + _callPhone[0] = '\0'; + _callConnectTime = 0; + _needsRefresh = true; break; + case CallEventType::BUSY: Serial.printf("[SMSScreen] Busy: %s\n", evt.phone); + _view = _callReturnView; + _callPhone[0] = '\0'; + _needsRefresh = true; break; + case CallEventType::NO_ANSWER: Serial.printf("[SMSScreen] No answer: %s\n", evt.phone); + _view = _callReturnView; + _callPhone[0] = '\0'; + _needsRefresh = true; break; + case CallEventType::DIAL_FAILED: Serial.printf("[SMSScreen] Dial failed: %s\n", evt.phone); + _view = _callReturnView; + _callPhone[0] = '\0'; + _needsRefresh = true; break; } } @@ -233,13 +308,17 @@ public: _lastRefresh = millis(); switch (_view) { - case APP_MENU: return renderAppMenu(display); - case INBOX: return renderInbox(display); - case CONVERSATION: return renderConversation(display); - case COMPOSE: return renderCompose(display); - case CONTACTS: return renderContacts(display); - case EDIT_CONTACT: return renderEditContact(display); - case PHONE_DIALER: return renderPhoneDialer(display); + case APP_MENU: return renderAppMenu(display); + case INBOX: return renderInbox(display); + case CONVERSATION: return renderConversation(display); + case COMPOSE: return renderCompose(display); + case CONTACTS: return renderContacts(display); + case EDIT_CONTACT: return renderEditContact(display); + case PHONE_DIALER: return renderPhoneDialer(display); + case DIALING_OUT: return renderDialingOut(display); + case INCOMING_CALL: return renderIncomingCall(display); + case IN_CALL: return renderInCall(display); + case CALL_ENDED: return renderCallEnded(display); } return 1000; } @@ -852,19 +931,250 @@ public: return 2000; } + // ---- Dialing out (waiting for remote answer) ---- + int renderDialingOut(DisplayDriver& display) { + int W = display.width(); + int H = display.height(); + + // Header + display.setTextSize(1); + display.setColor(DisplayDriver::GREEN); + display.setCursor(0, 0); + display.print("Calling"); + + renderSignalIndicator(display, W - 2, 0); + + display.setColor(DisplayDriver::LIGHT); + display.drawRect(0, 11, W, 1); + + // Contact name (left-aligned) + char dispName[SMS_CONTACT_NAME_LEN]; + smsContacts.displayName(_callPhone, dispName, sizeof(dispName)); + + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + display.setCursor(4, 20); + display.print(dispName); + + // Phone number below name (smaller, dimmer) + display.setTextSize(0); + display.setColor(DisplayDriver::LIGHT); + display.setCursor(4, 36); + display.print(_callPhone); + + // Animated dots (centered) + _callDotAnim = (_callDotAnim + 1) % 4; + char dots[4] = {0}; + for (int i = 0; i < (int)_callDotAnim; i++) dots[i] = '.'; + + display.setTextSize(1); + display.setColor(DisplayDriver::YELLOW); + const char* dialLabel = "Dialing"; + uint16_t dialW = display.getTextWidth(dialLabel); + display.setCursor((W - dialW) / 2 - 6, H / 2 + 4); + display.print(dialLabel); + display.print(dots); + + // Footer + display.setTextSize(1); + int footerY = H - 12; + display.drawRect(0, footerY - 2, W, 1); + display.setColor(DisplayDriver::YELLOW); + display.setCursor(0, footerY); + display.print("Ent/Q:Hang up"); + + return 800; // Fast refresh for dot animation + } + + // ---- Incoming call (ringing) ---- + int renderIncomingCall(DisplayDriver& display) { + int W = display.width(); + int H = display.height(); + + // Header + display.setTextSize(1); + display.setColor(DisplayDriver::GREEN); + display.setCursor(0, 0); + display.print("Incoming Call"); + + renderSignalIndicator(display, W - 2, 0); + + display.setColor(DisplayDriver::LIGHT); + display.drawRect(0, 11, W, 1); + + // Caller name (left-aligned) + char dispName[SMS_CONTACT_NAME_LEN]; + smsContacts.displayName(_callPhone, dispName, sizeof(dispName)); + + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + display.setCursor(4, 20); + display.print(dispName); + + // Phone number below name (smaller, dimmer) + display.setTextSize(0); + display.setColor(DisplayDriver::LIGHT); + display.setCursor(4, 36); + display.print(_callPhone); + + // Ringing indicator (centered) + _callDotAnim = (_callDotAnim + 1) % 4; + char dots[4] = {0}; + for (int i = 0; i < (int)_callDotAnim; i++) dots[i] = '.'; + + display.setTextSize(1); + display.setColor(DisplayDriver::YELLOW); + const char* ringLabel = "Ringing"; + uint16_t ringW = display.getTextWidth(ringLabel); + display.setCursor((W - ringW) / 2 - 6, H / 2 + 4); + display.print(ringLabel); + display.print(dots); + + // Footer + display.setTextSize(1); + int footerY = H - 12; + display.drawRect(0, footerY - 2, W, 1); + display.setColor(DisplayDriver::GREEN); + display.setCursor(0, footerY); + display.print("Ent:Answer"); + const char* rt = "Q:Reject"; + display.setColor(DisplayDriver::YELLOW); + display.setCursor(W - display.getTextWidth(rt) - 2, footerY); + display.print(rt); + + return 800; // Fast refresh for ring animation + } + + // ---- In call (active voice call) ---- + int renderInCall(DisplayDriver& display) { + int W = display.width(); + int H = display.height(); + + // Header + display.setTextSize(1); + display.setColor(DisplayDriver::GREEN); + display.setCursor(0, 0); + display.print("In Call"); + + renderSignalIndicator(display, W - 2, 0); + + display.setColor(DisplayDriver::LIGHT); + display.drawRect(0, 11, W, 1); + + // Contact name (left-aligned) + char dispName[SMS_CONTACT_NAME_LEN]; + smsContacts.displayName(_callPhone, dispName, sizeof(dispName)); + + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + display.setCursor(4, 20); + display.print(dispName); + + // Phone number below name (smaller, dimmer) + display.setTextSize(0); + display.setColor(DisplayDriver::LIGHT); + display.setCursor(4, 36); + display.print(_callPhone); + + // Call timer (centered) + unsigned long elapsed = 0; + if (_callConnectTime > 0) { + elapsed = (millis() - _callConnectTime) / 1000; + } + char timeBuf[12]; + snprintf(timeBuf, sizeof(timeBuf), "%02lu:%02lu", elapsed / 60, elapsed % 60); + + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + uint16_t timerW = display.getTextWidth(timeBuf); + display.setCursor((W - timerW) / 2, H / 2 + 4); + display.print(timeBuf); + + // Volume (left-aligned) + display.setTextSize(0); + display.setColor(DisplayDriver::LIGHT); + char volLabel[12]; + snprintf(volLabel, sizeof(volLabel), "Vol: %d/5", _callVolume); + display.setCursor(4, H / 2 + 28); + display.print(volLabel); + display.setTextSize(1); + + // Footer + display.setTextSize(1); + int footerY = H - 12; + display.drawRect(0, footerY - 2, W, 1); + display.setColor(DisplayDriver::YELLOW); + display.setCursor(0, footerY); + display.print("Ent:Hang W/S:Vol 0-9:DTMF"); + + return 1000; // 1s refresh for timer + } + + // ---- Call ended (brief splash) ---- + int renderCallEnded(DisplayDriver& display) { + // Auto-dismiss after 2 seconds + if (_callEndedTime > 0 && (millis() - _callEndedTime) > 2000) { + _view = _callReturnView; + _callPhone[0] = '\0'; + _callConnectTime = 0; + return 0; // Immediate re-render in new view + } + + int W = display.width(); + int H = display.height(); + + // Header + display.setTextSize(1); + display.setColor(DisplayDriver::GREEN); + display.setCursor(0, 0); + display.print("Call Ended"); + + renderSignalIndicator(display, W - 2, 0); + + display.setColor(DisplayDriver::LIGHT); + display.drawRect(0, 11, W, 1); + + // Contact name + char dispName[SMS_CONTACT_NAME_LEN]; + smsContacts.displayName(_callPhone, dispName, sizeof(dispName)); + + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + display.setCursor(4, 20); + display.print(dispName); + + // Duration + if (_callEndedDuration > 0) { + char durBuf[16]; + snprintf(durBuf, sizeof(durBuf), "%02lu:%02lu", + _callEndedDuration / 60, _callEndedDuration % 60); + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + uint16_t durW = display.getTextWidth(durBuf); + display.setCursor((W - durW) / 2, H / 2 + 4); + display.print(durBuf); + } + + return 500; // Check frequently for auto-dismiss + } + // ========================================================================= // INPUT HANDLING // ========================================================================= bool handleInput(char c) override { switch (_view) { - case APP_MENU: return handleAppMenuInput(c); - case INBOX: return handleInboxInput(c); - case CONVERSATION: return handleConversationInput(c); - case COMPOSE: return handleComposeInput(c); - case CONTACTS: return handleContactsInput(c); - case EDIT_CONTACT: return handleEditContactInput(c); - case PHONE_DIALER: return handlePhoneDialerInput(c); + case APP_MENU: return handleAppMenuInput(c); + case INBOX: return handleInboxInput(c); + case CONVERSATION: return handleConversationInput(c); + case COMPOSE: return handleComposeInput(c); + case CONTACTS: return handleContactsInput(c); + case EDIT_CONTACT: return handleEditContactInput(c); + case PHONE_DIALER: return handlePhoneDialerInput(c); + case DIALING_OUT: return handleDialingOutInput(c); + case INCOMING_CALL: return handleIncomingCallInput(c); + case IN_CALL: return handleInCallInput(c); + case CALL_ENDED: return handleCallEndedInput(c); } return false; } @@ -930,8 +1240,7 @@ public: case '\r': // Enter - place call if (_phoneInputPos > 0) { _phoneInputBuf[_phoneInputPos] = '\0'; - modemManager.dialCall(_phoneInputBuf); - // TODO: transition to DIALING/IN_CALL view when implemented + startCall(_phoneInputBuf); } return true; @@ -1013,7 +1322,7 @@ public: // CALL button if (_phoneInputPos > 0) { _phoneInputBuf[_phoneInputPos] = '\0'; - modemManager.dialCall(_phoneInputBuf); + startCall(_phoneInputBuf); changed = true; } } else if (c == '\b') { @@ -1108,8 +1417,7 @@ public: case 'f': case 'F': // Call this number if (_activePhone[0] != '\0') { - modemManager.dialCall(_activePhone); - // TODO: transition to DIALING/IN_CALL view when implemented + startCall(_activePhone); } return true; @@ -1257,8 +1565,7 @@ public: case 'f': case 'F': // Call selected contact if (cnt > 0 && _contactsCursor < cnt) { const SMSContact& ct = smsContacts.get(_contactsCursor); - modemManager.dialCall(ct.phone); - // TODO: transition to DIALING/IN_CALL view when implemented + startCall(ct.phone); } return true; @@ -1313,6 +1620,85 @@ public: return true; } } + + // ---- Dialing out input (Enter or Q to cancel/hang up) ---- + bool handleDialingOutInput(char c) { + switch (c) { + case '\r': // Enter - hang up + case 'q': case 'Q': + modemManager.hangupCall(); + _view = _callReturnView; + _callPhone[0] = '\0'; + return true; + + default: + return true; // Absorb all other keys + } + } + + // ---- Incoming call input (Enter to answer, Q to reject) ---- + bool handleIncomingCallInput(char c) { + switch (c) { + case '\r': // Enter - answer call + modemManager.answerCall(); + return true; + + case 'q': case 'Q': // Reject call + modemManager.hangupCall(); + _view = _callReturnView; + _callPhone[0] = '\0'; + return true; + + default: + return true; // Absorb all other keys + } + } + + // ---- In call input (hangup, volume, DTMF) ---- + bool handleInCallInput(char c) { + switch (c) { + case '\r': // Enter - hang up + case 'q': case 'Q': { + unsigned long dur = 0; + if (_callConnectTime > 0) { + dur = (millis() - _callConnectTime) / 1000; + } + modemManager.hangupCall(); + showCallEnded(dur); + return true; + } + + case 'w': case 'W': // Volume up + if (_callVolume < 5) { + _callVolume++; + modemManager.setCallVolume(_callVolume); + } + return true; + + case 's': case 'S': // Volume down + if (_callVolume > 0) { + _callVolume--; + modemManager.setCallVolume(_callVolume); + } + return true; + + default: + // DTMF tones: 0-9, *, # + if ((c >= '0' && c <= '9') || c == '*' || c == '#') { + modemManager.sendDTMF(c); + } + return true; // Absorb all keys during call + } + } + + // ---- Call ended input (any key dismisses) ---- + bool handleCallEndedInput(char c) { + (void)c; + _view = _callReturnView; + _callPhone[0] = '\0'; + _callConnectTime = 0; + return true; + } }; #endif // SMS_SCREEN_H diff --git a/variants/lilygo_tdeck_pro/Tca8418keyboard.h b/variants/lilygo_tdeck_pro/Tca8418keyboard.h index 83c437aa..8fa25eb9 100644 --- a/variants/lilygo_tdeck_pro/Tca8418keyboard.h +++ b/variants/lilygo_tdeck_pro/Tca8418keyboard.h @@ -256,14 +256,12 @@ public: return KB_KEY_EMOJI; } - // Handle Mic key - produces 0 with Sym, otherwise ignore + // Handle Mic key - always produces '0' (silk-screened on key) + // Sym+Mic also produces '0' (consumes sym so it doesn't leak) if (keyCode == 34) { - if (_symActive) { - _symActive = false; - Serial.println("KB: Sym+Mic -> '0'"); - return '0'; - } - return 0; // Ignore mic without Sym + _symActive = false; + Serial.println("KB: Mic -> '0'"); + return '0'; } // Get the character