Skip to content

Commit

Permalink
Add email entry GUI (#74)
Browse files Browse the repository at this point in the history
* Implement input box for email address confirmation

* Fix typo in changes
Update dialog to match new behavior
Support Text entry when invalid email address is transcribed

* Fix gui event registration

* Make GUI title translatable
Update email address formatting for TTS with unit test

* Update test to handle dialog changes

* Troubleshooting test failure

* Update tests to handle email formatting

* Fix gui input mocking

* More test failure troubleshooting

* Update to use methods added to ovos_utils

* Update skill.json

---------

Co-authored-by: NeonDaniel <[email protected]>
  • Loading branch information
NeonDaniel and NeonDaniel authored Mar 31, 2023
1 parent 718c456 commit 56edba1
Show file tree
Hide file tree
Showing 11 changed files with 176 additions and 27 deletions.
82 changes: 72 additions & 10 deletions __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
from neon_utils.skills.neon_skill import NeonSkill
from neon_utils.user_utils import get_user_prefs
from neon_utils.language_utils import get_supported_languages
from neon_utils.parse_utils import validate_email
from lingua_franca.parse import extract_langcode, get_full_lang_code
from lingua_franca.format import pronounce_lang
from lingua_franca.internal import UnsupportedLanguageError
Expand Down Expand Up @@ -444,7 +445,8 @@ def handle_say_my_email(self, message: Message):
# TODO: Use get_response to ask for the user's email
self.speak_dialog("email_not_known", private=True)
else:
self.speak_dialog("email_is", {"email": email_address},
self.speak_dialog("email_is",
{"email": self._spoken_email(email_address)},
private=True)

@intent_handler(IntentBuilder("SayMyLocation").require("tell_me_my")
Expand Down Expand Up @@ -556,32 +558,85 @@ def handle_set_my_email(self, message: Message):
email_addr = "".join(email_words)
LOG.info(email_addr)

if '@' not in email_addr or '.' not in email_addr.split('@')[1]:
if not validate_email(email_addr):
self.speak_dialog("email_set_error", private=True)
return
email_addr = self.get_gui_input(self.translate("word_email_title"),
"[email protected]")
if not email_addr or not validate_email(email_addr):
LOG.warning(f"Invalid email_addr entered: {email_addr}")
return

current_email = get_user_prefs(message)["user"]["email"]
if current_email and email_addr == current_email:
self.speak_dialog("email_already_set_same",
{"email": current_email}, private=True)
{"email": self._spoken_email(current_email)},
private=True)
return
if current_email:
if self.ask_yesno("email_overwrite", {"old": current_email,
"new": email_addr}) == "yes":
if self.ask_yesno("email_overwrite",
{"old": self._spoken_email(current_email),
"new": self._spoken_email(email_addr)}) == "yes":
self.update_profile({"user": {"email": email_addr}})
self.speak_dialog("email_set", {"email": email_addr},
self.speak_dialog("email_set",
{"email": self._spoken_email(email_addr)},
private=True)
else:
self.speak_dialog("email_not_changed",
{"email": current_email}, private=True)
{"email": self._spoken_email(current_email)},
private=True)
return
if self.ask_yesno("email_confirmation",
{"email": email_addr}) == "yes":
{"email": self._spoken_email(email_addr)}) == "yes":
self.update_profile({"user": {"email": email_addr}})
self.speak_dialog("email_set", {"email": email_addr},
self.speak_dialog("email_set",
{"email": self._spoken_email(email_addr)},
private=True)
else:
self.speak_dialog("email_not_confirmed", private=True)
email_addr = self.get_gui_input(self.translate("word_email_title"),
"[email protected]")
if email_addr:
self.update_profile({"user": {"email": email_addr}})
self.speak_dialog("email_set",
{"email": self._spoken_email(email_addr)},
private=True)
else:
LOG.info("User Cancelled email input")
# TODO: Speak confirmation

def get_gui_input(self, title=None, placeholder=None,
confirm_text=None, exit_text=None) -> Optional[str]:
gui_response = None
response_event = Event()
response_event.clear()
self.gui.show_input_box(title, placeholder, confirm_text, exit_text,
True, True)

def _on_response(message):
nonlocal gui_response
gui_response = message.data.get("text")
response_event.set()

def _on_close(message):
response_event.set()

resp_message = self.gui.build_message_type('input.box.response')
close_message = self.gui.build_message_type('input.box.close')
self.add_event(resp_message, _on_response, once=True)
self.add_event(close_message, _on_close, once=True)
response_event.wait()
self.gui.remove_input_box()
self.remove_event(resp_message)
self.remove_event(close_message)
return gui_response

# TODO: Update to import from ovos-utils
def remove_input_box(self):
LOG.info(f"GUI pages length {len(self.gui.pages)}")
if len(self.gui.pages) > 1:
self.gui.remove_page("SYSTEM_InputBox.qml")
else:
self.gui.release()

@intent_handler(IntentBuilder("SetMyName").optionally("change")
.require("my").require("name").require("rx_setting")
Expand Down Expand Up @@ -962,6 +1017,13 @@ def _get_gender(self, request: str) -> Optional[str]:
LOG.info(f"no gender in request: {request}")
return None

def _spoken_email(self, email_addr: str):
"""
Get a pronouncable email address string
"""
return email_addr.replace('.', f' {self.translate("word_dot")} ')\
.replace('@', f' {self.translate("word_at")} ')

@staticmethod
def _get_name_parts(name: str, user_profile: dict) -> dict:
"""
Expand Down
2 changes: 1 addition & 1 deletion locale/en-us/dialog/email_not_confirmed.dialog
Original file line number Diff line number Diff line change
@@ -1 +1 @@
Okay, not changing anything.
Please enter your email address on screen and press 'Confirm'.
2 changes: 1 addition & 1 deletion locale/en-us/dialog/email_set_error.dialog
Original file line number Diff line number Diff line change
@@ -1 +1 @@
I did not hear a valid email address. Please, try again.
I did not hear a valid email address. Please enter your email address on screen and press 'Confirm'.
1 change: 1 addition & 0 deletions locale/en-us/dialog/word_at.dialog
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
at
1 change: 1 addition & 0 deletions locale/en-us/dialog/word_dot.dialog
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dot
1 change: 1 addition & 0 deletions locale/en-us/dialog/word_email_title.dialog
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Email Address
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
neon-utils[network]~=1.3
ovos_utils~=0.0.28
ovos_utils~=0.0.28,>=0.0.31a1
2 changes: 1 addition & 1 deletion skill.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
"requirements": {
"python": [
"neon-utils[network]~=1.3",
"ovos_utils~=0.0.28"
"ovos_utils~=0.0.28,>=0.0.31a1"
],
"system": {},
"skill": []
Expand Down
3 changes: 3 additions & 0 deletions test/test_resources.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ dialog:
- 'word_text'
- 'word_first_name'
- 'error_change_username'
- 'word_dot'
- 'word_at'
- 'word_email_title'
# regex entities, not necessarily filenames
regex:
- 'rx_language'
Expand Down
34 changes: 21 additions & 13 deletions test/test_skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -629,9 +629,8 @@ def test_handle_say_my_email(self):
test_message.context["user_profiles"][0]["user"]["email"] = \
"[email protected]"
self.skill.handle_say_my_email(test_message)
self.skill.speak_dialog.assert_called_with("email_is",
{"email": "[email protected]"},
private=True)
self.skill.speak_dialog.assert_called_with(
"email_is", {"email": "test at neon dot ai"}, private=True)

@mock.patch('neon_utils.net_utils.check_online')
def test_handle_say_my_location(self, check_online):
Expand Down Expand Up @@ -729,6 +728,8 @@ def test_handle_say_my_birthday(self):
self.skill.speak_dialog.assert_any_call("happy_birthday", private=True)

def test_handle_set_my_email(self):
real_get_gui_input = self.skill.get_gui_input
self.skill.get_gui_input = Mock(return_value=None)
real_ask_yesno = self.skill.ask_yesno
self.skill.ask_yesno = Mock(return_value="no")
test_profile = self.user_config
Expand All @@ -740,7 +741,7 @@ def test_handle_set_my_email(self):
def _check_not_confirmed(msg):
self.skill.handle_set_my_email(msg)
self.skill.ask_yesno.assert_called_once_with(
"email_confirmation", {"email": "test@neon.ai"})
"email_confirmation", {"email": "test at neon dot ai"})
self.skill.speak_dialog.assert_called_once_with(
"email_not_confirmed", private=True)
self.skill.ask_yesno.reset_mock()
Expand Down Expand Up @@ -775,16 +776,16 @@ def _check_not_confirmed(msg):
self.skill.ask_yesno = Mock(return_value="yes")
self.skill.handle_set_my_email(test_message)
self.skill.ask_yesno.assert_called_with("email_confirmation",
{"email": "test@neon.ai"})
{"email": "test at neon dot ai"})
self.skill.speak_dialog.assert_called_with("email_set",
{"email": "test@neon.ai"},
{"email": "test at neon dot ai"},
private=True)
self.assertEqual(test_message.context["user_profiles"][0]
["user"]["email"], "[email protected]")
# Set Email No Change
self.skill.handle_set_my_email(test_message)
self.skill.speak_dialog.assert_called_with("email_already_set_same",
{"email": "test@neon.ai"},
{"email": "test at neon dot ai"},
private=True)
self.assertEqual(test_message.context["user_profiles"][0]
["user"]["email"], "[email protected]")
Expand All @@ -794,24 +795,25 @@ def _check_not_confirmed(msg):
test_message.data["rx_setting"] = "demo at neon dot ai"
self.skill.handle_set_my_email(test_message)
self.skill.ask_yesno.assert_called_with("email_overwrite",
{"old": "test@neon.ai",
"new": "demo@neon.ai"})
{"old": "test at neon dot ai",
"new": "demo at neon dot ai"})
self.skill.speak_dialog.assert_called_with("email_not_changed",
{"email": "test@neon.ai"},
{"email": "test at neon dot ai"},
private=True)
# Change Email Confirmed
self.skill.ask_yesno = Mock(return_value="yes")
self.skill.handle_set_my_email(test_message)
self.skill.ask_yesno.assert_called_with("email_overwrite",
{"old": "test@neon.ai",
"new": "demo@neon.ai"})
{"old": "test at neon dot ai",
"new": "demo at neon dot ai"})
self.skill.speak_dialog.assert_called_with("email_set",
{"email": "demo@neon.ai"},
{"email": "demo at neon dot ai"},
private=True)
self.assertEqual(test_message.context["user_profiles"][0]
["user"]["email"], "[email protected]")

self.skill.ask_yesno = real_ask_yesno
self.skill.get_gui_input = real_get_gui_input

def test_handle_set_my_name(self):
test_profile = self.user_config
Expand Down Expand Up @@ -1429,6 +1431,12 @@ def test_get_lang_name_and_code(self):
with self.assertRaises(UnsupportedLanguageError):
self.skill._get_lang_code_and_name("nothing")

def test_spoken_email(self):
self.assertEqual(self.skill._spoken_email("[email protected]"),
"test at neon dot ai")
self.assertEqual(self.skill._spoken_email("[email protected]"),
"my dot email at domain dot com")


if __name__ == '__main__':
unittest.main()
73 changes: 73 additions & 0 deletions ui/SYSTEM_InputBox.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import QtQuick.Layouts 1.12
import QtQuick 2.12
import QtQuick.Controls 2.12
import org.kde.kirigami 2.11 as Kirigami

import Mycroft 1.0 as Mycroft

Mycroft.Delegate {
id: systemInputBoxFrame
property var title: sessionData.title
property var placeholderText: sessionData.placeholder
property var confirmButtonText: sessionData.confirm_text
property var exitButtonText: sessionData.exit_text
property var skillIDHandler: sessionData.skill_id_handler
property string responseEvent: "input.box.response"
property string exitEvent: "input.box.close"

ColumnLayout {
width: parent.width
spacing: Kirigami.Units.gridUnit

Kirigami.Heading {
level: 2
wrapMode: Text.WordWrap
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
font.bold: true
text: systemInputBoxFrame.title
color: Kirigami.Theme.textColor
}

TextField {
id: txtField
Kirigami.Theme.colorSet: Kirigami.Theme.View
Layout.fillWidth: true
Layout.leftMargin: Mycroft.Units.gridUnit * 5
Layout.rightMargin: Mycroft.Units.gridUnit * 5
Layout.preferredHeight: Mycroft.Units.gridUnit * 4
placeholderText: systemInputBoxFrame.placeholderText

onAccepted: {
triggerGuiEvent(systemInputBoxFrame.responseEvent, {"text": txtField.text})
}
}

RowLayout {
Layout.alignment: Qt.AlignCenter
Button {
Layout.fillWidth: true
Layout.preferredHeight: Mycroft.Units.gridUnit * 5
text: systemInputBoxFrame.confirmButtonText
onClicked: {
console.log(systemInputBoxFrame.responseEvent)
Mycroft.SoundEffects.playClickedSound(Qt.resolvedUrl("../snd/clicked.wav"))
triggerGuiEvent(systemInputBoxFrame.responseEvent, {"text": txtField.text})
}
}

Button {
Layout.fillWidth: true
Layout.preferredHeight: Mycroft.Units.gridUnit * 5
text: systemInputBoxFrame.exitButtonText
onClicked: {
Mycroft.SoundEffects.playClickedSound(Qt.resolvedUrl("../snd/clicked.wav"))
triggerGuiEvent(systemInputBoxFrame.exitEvent, {})
}
}
}
Item {
Layout.fillHeight: true
}
}
}

0 comments on commit 56edba1

Please sign in to comment.