diff --git a/src/app/core/services/misc/text-to-speech.service.ts b/src/app/core/services/misc/text-to-speech.service.ts new file mode 100644 index 000000000..d7da3532f --- /dev/null +++ b/src/app/core/services/misc/text-to-speech.service.ts @@ -0,0 +1,92 @@ +import { Injectable } from '@angular/core' + +import { ConfigKeys } from '../../../shared/enums/config' +import { RemoteConfigService } from '../config/remote-config.service' + +type TtsProvider = 'browser' + +@Injectable({ + providedIn: 'root' +}) +export class TextToSpeechService { + private utterance: SpeechSynthesisUtterance | undefined + private isSpeaking = false + private readonly providerKey = new ConfigKeys('text_to_speech_provider') + private readonly enabledKey = new ConfigKeys('text_to_speech_enabled') + + constructor(private remoteConfig: RemoteConfigService) { } + + isSupported(): boolean { + return typeof window !== 'undefined' && 'speechSynthesis' in window + } + + getSpeakingState(): boolean { + return this.isSpeaking + } + + /** True when the configured provider is available. */ + async isReadAloudAvailable(): Promise { + const isEnabled = await this.isFeatureEnabled() + if (!isEnabled) return false + + const provider = await this.getProvider() + if (provider === 'browser') return this.isSupported() + return false + } + + async speak(text: string, lang?: string): Promise { + if (!text?.trim()) return Promise.resolve() + const isEnabled = await this.isFeatureEnabled() + if (!isEnabled) return Promise.resolve() + + this.stop() + + const provider = await this.getProvider() + if (provider === 'browser') return this.speakBrowser(text, lang) + return Promise.resolve() + } + + stop() { + if (typeof window !== 'undefined' && 'speechSynthesis' in window) { + window.speechSynthesis.cancel() + } + this.utterance = undefined + this.isSpeaking = false + } + + private async getProvider(): Promise { + // Uses a free-form key so provider switching can be remote-configured later. + const conf = await this.remoteConfig.read() + const provider = (await conf.getOrDefault(this.providerKey, 'browser')).trim().toLowerCase() + + return provider === 'browser' ? 'browser' : 'browser' + } + + private async isFeatureEnabled(): Promise { + const conf = await this.remoteConfig.read() + const enabled = (await conf.getOrDefault(this.enabledKey, 'true')).trim().toLowerCase() + return enabled !== 'false' && enabled !== '0' && enabled !== 'no' + } + + private speakBrowser(text: string, lang?: string): Promise { + if (!this.isSupported()) return Promise.resolve() + + this.utterance = new SpeechSynthesisUtterance(text) + if (lang) this.utterance.lang = lang + this.isSpeaking = true + + return new Promise(resolve => { + this.utterance.onend = () => { + this.isSpeaking = false + this.utterance = undefined + resolve() + } + this.utterance.onerror = () => { + this.isSpeaking = false + this.utterance = undefined + resolve() + } + window.speechSynthesis.speak(this.utterance) + }) + } +} diff --git a/src/app/pages/questions/components/question/guided-audio-input/guided-audio-input.component.html b/src/app/pages/questions/components/question/guided-audio-input/guided-audio-input.component.html new file mode 100644 index 000000000..986044d7e --- /dev/null +++ b/src/app/pages/questions/components/question/guided-audio-input/guided-audio-input.component.html @@ -0,0 +1,71 @@ + + + + + + + + + + + + +
+

{{ text }}

+ + + {{ 'GUIDED_AUDIO_REPLAY_PROMPT' | translate }} + +
+
+
+ + + + + + +
+
+ +

{{ 'GUIDED_AUDIO_SPEAKING' | translate }}

+
+
+ + +
+
+
+

{{ 'GUIDED_AUDIO_RECORDING' | translate }}

+

+ {{ recordSecondsRemaining }}s +

+
+ + {{ 'GUIDED_AUDIO_STOP_RECORDING' | translate }} + +
+ + +
+ +

{{ 'GUIDED_AUDIO_RECORDED' | translate }}

+
+ + {{ 'GUIDED_AUDIO_CONFIRM' | translate }} + +
+
+ + +
+ + {{ 'GUIDED_AUDIO_START_RECORDING' | translate }} + +
+ +
+
+ +
\ No newline at end of file diff --git a/src/app/pages/questions/components/question/guided-audio-input/guided-audio-input.component.scss b/src/app/pages/questions/components/question/guided-audio-input/guided-audio-input.component.scss new file mode 100644 index 000000000..6d37fcf2d --- /dev/null +++ b/src/app/pages/questions/components/question/guided-audio-input/guided-audio-input.component.scss @@ -0,0 +1,220 @@ +ion-grid.guided-audio-grid { + height: 100% !important; + display: flex; + flex-direction: column; + padding: 0 8px; + + // Keep guided-audio buttons visually consistent with app defaults. + ion-button.bt { + --background: var(--cl-primary-20); + --border-color: white; + --border-width: 2px; + --border-style: solid; + --border-radius: var(--bt-height); + --box-shadow: none; + } +} + +// ── Image stimulus ──────────────────────────────────────────────────────────── + +.stimulus-row { + flex: 0 1 auto; + max-height: 42%; +} + +.stimulus-image { + max-width: 100%; + max-height: 38vh; + object-fit: contain; + border-radius: 8px; + margin-bottom: 4px; +} + +// ── Prompt ──────────────────────────────────────────────────────────────────── + +.prompt-row { + flex: 0 0 auto; +} + +.prompt-card { + padding: 12px 4px 8px; + border-radius: 8px; + transition: background 0.3s ease; + + &.prompt-speaking { + background-color: var(--cl-primary-10); + } + + h2.prompt-text { + font-size: 18px; + line-height: 1.5; + margin: 0 0 6px; + } + + .replay-btn { + margin-top: 2px; + } +} + +// ── Action area ─────────────────────────────────────────────────────────────── + +.action-row { + flex: 1 1 auto; +} + +.state-block { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + padding: 8px 0; + width: 100%; +} + +// Speaking state +.speaking-indicator { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + + .speaking-icon { + font-size: 44px; + color: var(--ion-color-primary); + animation: pulse 1.4s ease-in-out infinite; + } + + p { + margin: 0; + font-size: 15px; + color: var(--ion-color-medium); + } +} + +// Countdown state +.countdown-ring { + width: 84px; + height: 84px; + border-radius: 50%; + background: var(--ion-color-primary); + display: flex; + align-items: center; + justify-content: center; + animation: pulse 0.9s ease-in-out infinite; + + .countdown-number { + font-size: 44px; + font-weight: bold; + color: white; + line-height: 1; + } +} + +// Recording state +.recording-indicator { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + + .recording-dot { + width: 22px; + height: 22px; + border-radius: 50%; + background-color: var(--cl-danger-40); + animation: blink 1s step-start infinite; + } + + .recording-label { + margin: 0; + font-size: 15px; + color: var(--ion-color-medium); + } + + .timer-label { + margin: 0; + font-size: 28px; + font-weight: bold; + color: var(--ion-color-primary); + } +} + +.bt-stop { + background-color: var(--cl-danger-40); + border-radius: var(--bt-height); + --background: var(--cl-danger-40); + --border-radius: var(--bt-height); +} + +// Recorded state +.done-icon { + font-size: 52px; + color: var(--ion-color-success); +} + +.recorded-label { + margin: 0; + font-size: 15px; + color: var(--ion-color-medium); +} + +.confirm-buttons { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 10px; + width: 100%; +} + +// ── Skip / reason panel ─────────────────────────────────────────────────────── + +.skip-row { + flex: 1 1 auto; + + .skip-question { + font-size: 17px; + font-weight: 500; + margin: 12px 0 10px; + } + + .skip-list { + border-radius: 8px; + overflow: hidden; + margin-bottom: 16px; + + ion-item { + --background: var(--cl-primary-10); + --border-color: var(--ion-color-light); + cursor: pointer; + + &.selected { + --background: rgba(var(--ion-color-primary-rgb), 0.12); + } + } + } + + .cancel-skip-btn { + margin-top: 6px; + } +} + +// ── Shared button helpers ───────────────────────────────────────────────────── + +// ── Animations ──────────────────────────────────────────────────────────────── + +@keyframes pulse { + 0%, 100% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.08); + opacity: 0.82; + } +} + +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} diff --git a/src/app/pages/questions/components/question/guided-audio-input/guided-audio-input.component.ts b/src/app/pages/questions/components/question/guided-audio-input/guided-audio-input.component.ts new file mode 100644 index 000000000..2001c0a39 --- /dev/null +++ b/src/app/pages/questions/components/question/guided-audio-input/guided-audio-input.component.ts @@ -0,0 +1,295 @@ +import { + ChangeDetectorRef, + Component, + EventEmitter, + Input, + OnChanges, + OnDestroy, + OnInit, + Output, + SimpleChanges +} from '@angular/core' +import { NavController, Platform } from '@ionic/angular' +import { Subscription } from 'rxjs' + +import { DefaultMaxAudioAttemptsAllowed } from '../../../../../../assets/data/defaultConfig' +import { LocalizationService } from '../../../../../core/services/misc/localization.service' +import { TextToSpeechService } from '../../../../../core/services/misc/text-to-speech.service' +import { AlertService } from '../../../../../core/services/misc/alert.service' +import { UsageService } from '../../../../../core/services/usage/usage.service' +import { UsageEventType } from '../../../../../shared/enums/events' +import { LocKeys } from '../../../../../shared/enums/localisations' +import { TranslatePipe } from '../../../../../shared/pipes/translate/translate' +import { AudioRecordService } from '../../../services/audio-record.service' +import { GuidedAudioAnnotation } from '../../../../../shared/models/question' + +export type GuidedAudioState = + | 'idle' + | 'speaking' + | 'recording' + | 'recorded' + +@Component({ + selector: 'guided-audio-input', + templateUrl: 'guided-audio-input.component.html', + styleUrls: ['guided-audio-input.component.scss'] +}) +export class GuidedAudioInputComponent implements OnInit, OnChanges, OnDestroy { + private readonly AUTO_TTS_START_DELAY_MS = 1000 + @Input() text: string + @Input() image: string + @Input() config: GuidedAudioAnnotation = {} + @Input() currentlyShown: boolean + @Input() maxAttempts: number = DefaultMaxAudioAttemptsAllowed + + @Output() valueChange = new EventEmitter() + @Output() onRecordStart = new EventEmitter() + + state: GuidedAudioState = 'idle' + recordSecondsRemaining: number = 0 + recordAttempts: number = 0 + hasReplayedPrompt: boolean = false + + get shouldHideFieldLabel(): boolean { + return this.config?.hide_field_label === true + } + + get shouldShowFieldLabel(): boolean { + return !this.shouldHideFieldLabel && !!this.text?.trim() + } + + get shouldShowPromptRow(): boolean { + return this.shouldShowFieldLabel || !!this.config?.allow_replay_prompt + } + + get shouldAllowManualStart(): boolean { + return !this.isAutoRecordEnabled + } + + private get isAutoRecordEnabled(): boolean { + const autoRecordAfterTts = (this.config as any)?.auto_record_after_tts + return autoRecordAfterTts === true || autoRecordAfterTts === 'true' || autoRecordAfterTts === 1 + } + + private recordingTimer: ReturnType + private autoTtsDelayTimer: ReturnType + private promptAudioEl: HTMLAudioElement | undefined + private pauseListener: Subscription + private backButtonListener: Subscription + private isDestroyed = false + + constructor( + private audioRecordService: AudioRecordService, + private textToSpeechService: TextToSpeechService, + private localization: LocalizationService, + private alertService: AlertService, + private platform: Platform, + private navCtrl: NavController, + private translate: TranslatePipe, + private usage: UsageService, + private ref: ChangeDetectorRef + ) { } + + ngOnInit() { + this.pauseListener = this.platform.pause.subscribe(() => { + if (this.state === 'recording') { + this.doStopRecording() + this.showInterruptedAlert() + } + }) + this.backButtonListener = this.platform.backButton.subscribe(() => { + this.cleanup() + navigator['app'].exitApp() + }) + } + + ngOnChanges(changes: SimpleChanges) { + if (changes['currentlyShown']) { + const prev = changes['currentlyShown'].previousValue + const curr = changes['currentlyShown'].currentValue + if (curr && !prev) this.onBecameVisible() + if (!curr && prev) this.onBecameHidden() + } + } + + ngOnDestroy() { + this.isDestroyed = true + this.cleanup() + this.pauseListener?.unsubscribe() + this.backButtonListener?.unsubscribe() + } + + // ─── Lifecycle helpers ────────────────────────────────────────────────────── + + private onBecameVisible() { + this.state = 'idle' + const shouldAutoPlayPrompt = !!this.config?.prompt_audio_src || !!this.config?.auto_tts + if (shouldAutoPlayPrompt) { + const delayMs = this.config?.prompt_start_delay_ms ?? this.AUTO_TTS_START_DELAY_MS + this.autoTtsDelayTimer = setTimeout( + () => !this.isDestroyed && this.playPrompt(), + delayMs + ) + return + } + + // Allow auto-recording even when TTS is disabled. + if (this.isAutoRecordEnabled) { + this.doStartRecording() + } + } + + private onBecameHidden() { + this.cleanup() + } + + // ─── TTS ──────────────────────────────────────────────────────────────────── + + private playPrompt() { + if (this.config?.prompt_audio_src) { + this.playPromptAudio(this.config.prompt_audio_src) + return + } + this.speakPrompt() + } + + private speakPrompt() { + if (!this.text?.trim()) { + this.onTtsDone() + return + } + this.state = 'speaking' + this.ref.markForCheck() + const lang = this.localization.getLanguage()?.value + this.textToSpeechService.speak(this.text, lang).then(() => this.onTtsDone()) + } + + private onTtsDone() { + if (this.isDestroyed || this.state !== 'speaking') return + if (this.isAutoRecordEnabled) { + this.doStartRecording() + } else { + this.state = 'idle' + this.ref.markForCheck() + } + } + + replayPrompt() { + if (this.hasReplayedPrompt) return + this.hasReplayedPrompt = true + this.playPrompt() + } + + // ─── Recording ────────────────────────────────────────────────────────────── + + /** Called from the "Start recording" button in idle state. */ + startRecording() { + if (!this.shouldAllowManualStart) return + this.doStartRecording() + } + + private doStartRecording() { + if (this.recordAttempts >= this.maxAttempts) return + this.recordAttempts++ + this.state = 'recording' + this.onRecordStart.emit(true) + this.ref.markForCheck() + + this.audioRecordService.startAudioRecording().catch(() => { + if (!this.isDestroyed) this.showInterruptedAlert() + }) + + const duration = this.config?.record_seconds ?? 0 + if (duration > 0) { + this.recordSecondsRemaining = duration + this.recordingTimer = setInterval(() => { + this.recordSecondsRemaining-- + this.ref.markForCheck() + if (this.recordSecondsRemaining <= 0) { + clearInterval(this.recordingTimer) + this.doStopRecording() + } + }, 1000) + } + + this.usage.sendGeneralEvent(UsageEventType.RECORDING_STARTED, true) + } + + /** Called from the "Stop recording" button or timer expiry. */ + handleManualStop() { + this.doStopRecording() + } + + private doStopRecording() { + clearInterval(this.recordingTimer) + this.onRecordStart.emit(false) + this.usage.sendGeneralEvent(UsageEventType.RECORDING_STOPPED, true) + this.audioRecordService + .stopAudioRecording() + .then(() => { + if (this.isDestroyed) return + this.state = 'recorded' + this.ref.markForCheck() + }) + .catch(() => { + if (!this.isDestroyed) this.showInterruptedAlert() + }) + } + + confirmRecording() { + this.valueChange.emit(this.audioRecordService.getFormattedAudioData()) + } + + // ─── Utilities ────────────────────────────────────────────────────────────── + + private cleanup() { + this.stopPromptPlayback() + clearTimeout(this.autoTtsDelayTimer) + clearInterval(this.recordingTimer) + if (this.audioRecordService.getIsRecording()) { + this.audioRecordService.stopAudioRecording().catch(() => { }) + } + this.onRecordStart.emit(false) + } + + private playPromptAudio(src: string) { + this.stopPromptPlayback() + this.state = 'speaking' + this.ref.markForCheck() + + this.promptAudioEl = new Audio(src) + this.promptAudioEl.onended = () => this.onTtsDone() + this.promptAudioEl.onerror = () => this.onTtsDone() + void this.promptAudioEl.play().catch(() => this.onTtsDone()) + } + + private stopPromptPlayback() { + this.textToSpeechService.stop() + if (this.promptAudioEl) { + this.promptAudioEl.pause() + this.promptAudioEl.currentTime = 0 + this.promptAudioEl.onended = null + this.promptAudioEl.onerror = null + this.promptAudioEl = undefined + } + } + + private showInterruptedAlert() { + this.usage.sendGeneralEvent(UsageEventType.RECORDING_ERROR) + this.alertService.showAlert({ + header: this.translate.transform(LocKeys.AUDIO_TASK_ALERT.toString()), + message: this.translate.transform( + LocKeys.AUDIO_TASK_ALERT_DESC.toString() + ), + buttons: [ + { + text: this.translate.transform(LocKeys.BTN_OKAY.toString()), + handler: () => { + this.navCtrl.navigateRoot('') + } + } + ], + backdropDismiss: false + }) + } +} diff --git a/src/app/pages/questions/components/question/question.component.html b/src/app/pages/questions/components/question/question.component.html index 1e1db8c01..cb20b305f 100755 --- a/src/app/pages/questions/components/question/question.component.html +++ b/src/app/pages/questions/components/question/question.component.html @@ -1,166 +1,95 @@ -
+
-

{{ question.section_header }}

-
-

- {{ question.field_label }} -

+

+
+
+

+ + + +
-
+
-
- + " [ngSwitch]="question.field_type"> + - + - + - + - + - + - + - + + + + - + - + - + - + - + - +
-
+
\ No newline at end of file diff --git a/src/app/pages/questions/components/question/question.component.scss b/src/app/pages/questions/components/question/question.component.scss index d6f31c21f..4712254ce 100755 --- a/src/app/pages/questions/components/question/question.component.scss +++ b/src/app/pages/questions/components/question/question.component.scss @@ -45,8 +45,32 @@ h2 { margin-bottom: 16px; } -.field-container { - // max-height: 50vh !important; +.question-title-row { + display: flex; + align-items: center; + gap: 4px; +} + +.question-title-row h2 { + flex: 1; + margin-right: 4px; +} + +.read-aloud-icon-button { + margin: 0; + min-width: 22px; + --padding-start: 0; + --padding-end: 0; + --padding-top: 0; + --padding-bottom: 0; + --border-width: 0; + --box-shadow: none; + --background: transparent; + height: 22px; +} + +.read-aloud-icon-button ion-icon { + font-size: 16px; } .field-container-matrix { diff --git a/src/app/pages/questions/components/question/question.component.ts b/src/app/pages/questions/components/question/question.component.ts index eb4cc3db7..2a19fad9b 100755 --- a/src/app/pages/questions/components/question/question.component.ts +++ b/src/app/pages/questions/components/question/question.component.ts @@ -6,8 +6,10 @@ import { OnChanges, OnInit, Output, + SecurityContext, ViewChild } from '@angular/core' +import { DomSanitizer } from '@angular/platform-browser' import { KeyboardEventType, @@ -48,6 +50,12 @@ export class QuestionComponent implements OnInit, OnChanges { answer: EventEmitter = new EventEmitter() @Output() nextAction: EventEmitter = new EventEmitter() + @Output() + readAloud: EventEmitter = new EventEmitter() + @Input() + isReadAloudAvailable = false + @Input() + isReadAloudActive = false value: any currentlyShown = false @@ -61,6 +69,8 @@ export class QuestionComponent implements OnInit, OnChanges { inputHeight = 0 isAutoHeight = false showScrollButton = false + sanitizedSectionHeader = '' + sanitizedFieldLabel = '' defaultYesNoResponse: Response[] = [ { code: '1', label: 'Yes' }, { code: '0', label: 'No' } @@ -69,6 +79,7 @@ export class QuestionComponent implements OnInit, OnChanges { NON_SCROLLABLE_SET: Set = new Set([ QuestionType.timed, QuestionType.audio, + QuestionType.guided_audio, QuestionType.info, QuestionType.text, QuestionType.descriptive, @@ -77,6 +88,7 @@ export class QuestionComponent implements OnInit, OnChanges { HIDE_FIELD_LABEL_SET: Set = new Set([ QuestionType.audio, + QuestionType.guided_audio, QuestionType.descriptive, QuestionType.healthkit ]) @@ -97,11 +109,12 @@ export class QuestionComponent implements OnInit, OnChanges { QuestionType.checkbox ]) - constructor() { + constructor(private sanitizer: DomSanitizer) { this.value = null } ngOnInit() { + this.updateSanitizedHtml() this.isScrollable = !this.NON_SCROLLABLE_SET.has(this.question.field_type) this.isFieldLabelHidden = this.HIDE_FIELD_LABEL_SET.has( this.question.field_type @@ -121,6 +134,7 @@ export class QuestionComponent implements OnInit, OnChanges { } ngOnChanges() { + this.updateSanitizedHtml() this.initRange() if (this.questionIndex === this.currentIndex) { this.currentlyShown = true @@ -228,4 +242,21 @@ export class QuestionComponent implements OnInit, OnChanges { if (start) this.nextAction.emit(NextButtonEventType.DISABLE) else this.nextAction.emit(NextButtonEventType.ENABLE) } + + onReadAloudTap() { + if (!this.isReadAloudAvailable || !this.content?.nativeElement) return + const text = this.content.nativeElement.innerText + .replace(/\s+/g, ' ') + .trim() + this.readAloud.emit(text) + } + + private sanitizeHtml(value: string): string { + return this.sanitizer.sanitize(SecurityContext.HTML, value || '') || '' + } + + private updateSanitizedHtml() { + this.sanitizedSectionHeader = this.sanitizeHtml(this.question?.section_header) + this.sanitizedFieldLabel = this.sanitizeHtml(this.question?.field_label) + } } diff --git a/src/app/pages/questions/components/question/question.module.ts b/src/app/pages/questions/components/question/question.module.ts index 93eb7e0fc..43e00ccb2 100644 --- a/src/app/pages/questions/components/question/question.module.ts +++ b/src/app/pages/questions/components/question/question.module.ts @@ -7,6 +7,7 @@ import { Ionic4DatepickerModule } from '@logisticinfotech/ionic4-datepicker' import { PipesModule } from '../../../../shared/pipes/pipes.module' import { WheelSelectorComponent } from '../wheel-selector/wheel-selector.component' import { AudioInputComponent } from './audio-input/audio-input.component' +import { GuidedAudioInputComponent } from './guided-audio-input/guided-audio-input.component' import { CheckboxInputComponent } from './checkbox-input/checkbox-input.component' import { DescriptiveInputComponent } from './descriptive-input/descriptive-input.component' import { InfoScreenComponent } from './info-screen/info-screen.component' @@ -24,6 +25,7 @@ import { WebInputComponent } from './web-input/web-input.component' const COMPONENTS = [ QuestionComponent, AudioInputComponent, + GuidedAudioInputComponent, CheckboxInputComponent, RadioInputComponent, RangeInputComponent, diff --git a/src/app/pages/questions/components/question/radio-input/radio-input.component.html b/src/app/pages/questions/components/question/radio-input/radio-input.component.html index 7b74b95ac..6eec00ddb 100755 --- a/src/app/pages/questions/components/question/radio-input/radio-input.component.html +++ b/src/app/pages/questions/components/question/radio-input/radio-input.component.html @@ -1,8 +1,13 @@ - - + + - {{ item.response }} + +
+ + {{ item.response }} +
+
-
+ \ No newline at end of file diff --git a/src/app/pages/questions/components/question/radio-input/radio-input.component.scss b/src/app/pages/questions/components/question/radio-input/radio-input.component.scss index 77f17e4f3..787af7565 100755 --- a/src/app/pages/questions/components/question/radio-input/radio-input.component.scss +++ b/src/app/pages/questions/components/question/radio-input/radio-input.component.scss @@ -15,6 +15,14 @@ ion-radio-group { align-content: center; } +ion-radio-group.image-only-grid { + display: flex; + flex-wrap: wrap; + gap: 12px; + justify-content: center; + align-items: stretch; +} + .item-radio-checked { background-color: var(--cl-primary) !important; } @@ -56,3 +64,56 @@ ion-radio { --color-checked: transparent; width: 0; } + +.radio-option-content { + display: flex; + align-items: center; + gap: 12px; +} + +.radio-option-image { + width: 56px; + height: 56px; + object-fit: contain; + border-radius: 6px; + background: rgba(255, 255, 255, 0.08); +} + +ion-item.image-only-option { + width: calc(50% - 6px); + aspect-ratio: 1 / 1; + height: auto; + min-width: 0; + padding: 4px; + margin-bottom: 0; + border-radius: 18px; + justify-content: center; + align-items: center; + --padding-start: 0; + --padding-end: 0; + --inner-padding-start: 0; + --inner-padding-end: 0; +} + +ion-item.image-only-option .radio-option-content { + width: 100%; + height: 100%; + justify-content: center; + align-items: center; +} + +ion-item.image-only-option ion-label { + margin: 0 !important; + width: 100%; + display: flex; + justify-content: center; +} + +ion-item.image-only-option .radio-option-image { + width: 90%; + height: 90%; + object-fit: contain; + object-position: center; + border-radius: 12px; + background: transparent; +} diff --git a/src/app/pages/questions/components/question/radio-input/radio-input.component.ts b/src/app/pages/questions/components/question/radio-input/radio-input.component.ts index 88ba205bb..5492242a7 100755 --- a/src/app/pages/questions/components/question/radio-input/radio-input.component.ts +++ b/src/app/pages/questions/components/question/radio-input/radio-input.component.ts @@ -20,18 +20,40 @@ export class RadioInputComponent implements OnInit { uniqueID: number = uniqueID++ name = `radio-input-${this.uniqueID}` items: Item[] = Array() + hasImageOnlyOptions = false ngOnInit() { this.responses.map((item, i) => { + const parsed = this.parseLabel(item.label) this.items.push({ id: `radio-${this.uniqueID}-${i}`, - response: item.label, + response: parsed.text, + image: parsed.image, value: item.code }) }) + this.hasImageOnlyOptions = + this.items.length > 0 && this.items.every(i => i.image && !i.response) } onInputChange(event) { this.valueChange.emit(event.detail.value) } + + /** + * Optional image format for radio labels: + * - "img:/path/to/image.png" + * - "img:/path/to/image.png|Optional caption" + */ + private parseLabel(label: string): { text: string; image?: string } { + if (!label) return { text: '' } + if (!label.startsWith('img:')) return { text: label } + + const raw = label.replace(/^img:/, '').trim() + const [image, caption] = raw.split('|') + return { + text: (caption || '').trim(), + image: image?.trim() + } + } } diff --git a/src/app/pages/questions/containers/questions-page.component.html b/src/app/pages/questions/containers/questions-page.component.html index 0eda28f07..a747fbeac 100755 --- a/src/app/pages/questions/containers/questions-page.component.html +++ b/src/app/pages/questions/containers/questions-page.component.html @@ -3,67 +3,36 @@
- + + - -
- +
+ + " [question]="question" [questionIndex]="i" [task]="task" [currentIndex]="currentQuestionGroupId" + [isSectionHeaderHidden]="j != currentQuestionIndices[0]" [isMatrix]="item.value.length > 1" + [isReadAloudAvailable]="isReadAloudAvailable" [isReadAloudActive]="isReadAloudActive" + (answer)="onAnswer($event)" (nextAction)="nextAction($event)" (readAloud)="onReadAloud($event)"> +
- - + +
@@ -71,16 +40,9 @@ - - + + \ No newline at end of file diff --git a/src/app/pages/questions/containers/questions-page.component.ts b/src/app/pages/questions/containers/questions-page.component.ts index 9d0f56ffe..63fae372f 100644 --- a/src/app/pages/questions/containers/questions-page.component.ts +++ b/src/app/pages/questions/containers/questions-page.component.ts @@ -6,11 +6,14 @@ import { Observable, Subscription } from 'rxjs' import { AlertService } from '../../../core/services/misc/alert.service' import { LocalizationService } from '../../../core/services/misc/localization.service' +import { TextToSpeechService } from '../../../core/services/misc/text-to-speech.service' +import { RemoteConfigService } from '../../../core/services/config/remote-config.service' import { UsageService } from '../../../core/services/usage/usage.service' import { NextButtonEventType, UsageEventType } from '../../../shared/enums/events' +import { ConfigKeys } from '../../../shared/enums/config' import { LocKeys } from '../../../shared/enums/localisations' import { Assessment, @@ -83,6 +86,12 @@ export class QuestionsPageComponent implements OnInit, OnDestroy { backButtonListener: Subscription showProgressCount: Promise + isReadAloudAvailable = false + isReadAloudActive = false + isAutoReadAloudEnabled = false + private autoReadAloudEnabledKey = new ConfigKeys( + 'text_to_speech_auto_readaloud_enabled' + ) constructor( public navCtrl: NavController, @@ -93,6 +102,8 @@ export class QuestionsPageComponent implements OnInit, OnDestroy { private router: Router, private appLauncher: AppLauncherService, private alertService: AlertService, + private textToSpeechService: TextToSpeechService, + private remoteConfig: RemoteConfigService ) { this.backButtonListener = this.platform.backButton.subscribe(() => { this.sendCompletionLog() @@ -102,12 +113,14 @@ export class QuestionsPageComponent implements OnInit, OnDestroy { ionViewDidLeave() { KeepAwake.allowSleep() + this.stopReadAloud() this.sendCompletionLog() this.questionsService.reset() this.backButtonListener.unsubscribe() } ngOnInit() { + void this.initReadAloudConfig() const nav = this.router.getCurrentNavigation() if (nav) { this.task = nav.extras.state as Task @@ -119,7 +132,8 @@ export class QuestionsPageComponent implements OnInit, OnDestroy { .then(() => this.questionsService.getQuestionnairePayload(this.task)) .then(res => { this.initQuestionnaire(res) - return this.updateToolbarButtons() + this.updateToolbarButtons() + this.autoReadAloudCurrentQuestion() }) } // Initialize swiper with memory management @@ -191,6 +205,7 @@ export class QuestionsPageComponent implements OnInit, OnDestroy { if (start) { this.slides.nativeElement.swiper.update() this.slideQuestion() + this.autoReadAloudCurrentQuestion() } else this.exitQuestionnaire() } @@ -260,6 +275,7 @@ export class QuestionsPageComponent implements OnInit, OnDestroy { } nextQuestion() { + this.stopReadAloud() const questionPosition = this.questionsService.getNextQuestion( this.groupedQuestions, this.currentQuestionGroupId @@ -274,9 +290,11 @@ export class QuestionsPageComponent implements OnInit, OnDestroy { this.currentQuestionGroupId = this.nextQuestionGroupId this.slideQuestion() this.updateToolbarButtons() + this.autoReadAloudCurrentQuestion() } previousQuestion() { + this.stopReadAloud() const currentQuestions = this.getCurrentQuestions() this.questionOrder.pop() this.currentQuestionGroupId = @@ -287,6 +305,7 @@ export class QuestionsPageComponent implements OnInit, OnDestroy { if (!this.isRightButtonDisabled) this.questionsService.deleteLastAnswers(currentQuestions) this.slideQuestion() + this.autoReadAloudCurrentQuestion() } updateToolbarButtons() { @@ -306,6 +325,7 @@ export class QuestionsPageComponent implements OnInit, OnDestroy { } navigateToFinishPage() { + this.stopReadAloud() // Send the finish event and submit timestamps this.progressCount = 1 this.sendEvent(UsageEventType.QUESTIONNAIRE_FINISHED) @@ -404,7 +424,7 @@ export class QuestionsPageComponent implements OnInit, OnDestroy { showDisabledButtonAlert() { const currentQuestionType = this.getCurrentQuestions()[0].field_type // NOTE: Show alert when next is tapped without finishing audio question - if (currentQuestionType == QuestionType.audio) + if (currentQuestionType == QuestionType.audio || currentQuestionType == QuestionType.guided_audio) this.alertService.showAlert({ message: this.localization.translateKey( LocKeys.AUDIO_TASK_BUTTON_ALERT_DESC @@ -445,7 +465,61 @@ export class QuestionsPageComponent implements OnInit, OnDestroy { } } + onReadAloud(textToRead: string) { + if (!this.isReadAloudAvailable) return + if (this.isReadAloudActive) return this.stopReadAloud() + if (!textToRead?.trim()) return + this.startReadAloud(textToRead) + } + + private startReadAloud(textToRead: string) { + const language = this.localization.getLanguage()?.value + this.isReadAloudActive = true + this.textToSpeechService + .speak(textToRead, language) + .finally(() => (this.isReadAloudActive = false)) + } + + private autoReadAloudCurrentQuestion() { + if (!this.isAutoReadAloudEnabled || !this.isReadAloudAvailable) return + const currentQuestions = this.getCurrentQuestions() + if (!currentQuestions?.length) return + + const textToRead = currentQuestions + .map(q => this.stripHtml(q.field_label || '')) + .join(' ') + .replace(/\s+/g, ' ') + .trim() + + if (!textToRead) return + + this.stopReadAloud() + this.startReadAloud(textToRead) + } + + private stripHtml(value: string): string { + return value.replace(/<[^>]*>/g, ' ') + } + + private async initReadAloudConfig() { + this.isReadAloudAvailable = await this.textToSpeechService.isReadAloudAvailable() + const conf = await this.remoteConfig.read() + const autoReadAloud = ( + await conf.getOrDefault(this.autoReadAloudEnabledKey, 'false') + ) + .trim() + .toLowerCase() + this.isAutoReadAloudEnabled = + autoReadAloud === 'true' || autoReadAloud === '1' || autoReadAloud === 'yes' + } + + private stopReadAloud() { + this.textToSpeechService.stop() + this.isReadAloudActive = false + } + ngOnDestroy() { + this.stopReadAloud() // Cleanup swiper if (this.slides && this.slides.nativeElement && this.slides.nativeElement.swiper) { this.slides.nativeElement.swiper.destroy(true, true) diff --git a/src/app/pages/questions/services/questions.service.ts b/src/app/pages/questions/services/questions.service.ts index 5f8595cec..1127f8f32 100644 --- a/src/app/pages/questions/services/questions.service.ts +++ b/src/app/pages/questions/services/questions.service.ts @@ -29,7 +29,8 @@ import { DefaultQuestionnaireProcessorService } from './questionnaire-processor/ export class QuestionsService { PREVIOUS_BUTTON_DISABLED_SET: Set = new Set([ QuestionType.timed, - QuestionType.audio + QuestionType.audio, + QuestionType.guided_audio ]) NEXT_BUTTON_ENABLED_SET: Set = new Set( DefaultSkippableQuestionnaireTypes diff --git a/src/app/shared/enums/localisations.ts b/src/app/shared/enums/localisations.ts index c72013050..603aeb906 100644 --- a/src/app/shared/enums/localisations.ts +++ b/src/app/shared/enums/localisations.ts @@ -184,6 +184,24 @@ export class LocKeys { ) static AUDIO_TASK_ATTEMPT_ALERT = new LocKeys('AUDIO_TASK_ATTEMPT_ALERT') static AUDIO_TASK_HAPPY_ALERT = new LocKeys('AUDIO_TASK_HAPPY_ALERT') + + // Guided audio task + static GUIDED_AUDIO_SPEAKING = new LocKeys('GUIDED_AUDIO_SPEAKING') + static GUIDED_AUDIO_SKIP_TTS = new LocKeys('GUIDED_AUDIO_SKIP_TTS') + static GUIDED_AUDIO_REPLAY_PROMPT = new LocKeys('GUIDED_AUDIO_REPLAY_PROMPT') + static GUIDED_AUDIO_RECORDING = new LocKeys('GUIDED_AUDIO_RECORDING') + static GUIDED_AUDIO_STOP_RECORDING = new LocKeys('GUIDED_AUDIO_STOP_RECORDING') + static GUIDED_AUDIO_START_RECORDING = new LocKeys('GUIDED_AUDIO_START_RECORDING') + static GUIDED_AUDIO_RECORDED = new LocKeys('GUIDED_AUDIO_RECORDED') + static GUIDED_AUDIO_CONFIRM = new LocKeys('GUIDED_AUDIO_CONFIRM') + static GUIDED_AUDIO_RETRY = new LocKeys('GUIDED_AUDIO_RETRY') + static GUIDED_AUDIO_UNABLE_TO_SPEAK = new LocKeys('GUIDED_AUDIO_UNABLE_TO_SPEAK') + static GUIDED_AUDIO_UNABLE_REASON = new LocKeys('GUIDED_AUDIO_UNABLE_REASON') + static GUIDED_AUDIO_REASON_NO_OUTPUT = new LocKeys('GUIDED_AUDIO_REASON_NO_OUTPUT') + static GUIDED_AUDIO_REASON_FATIGUE = new LocKeys('GUIDED_AUDIO_REASON_FATIGUE') + static GUIDED_AUDIO_REASON_TECHNICAL = new LocKeys('GUIDED_AUDIO_REASON_TECHNICAL') + static GUIDED_AUDIO_REASON_OTHER = new LocKeys('GUIDED_AUDIO_REASON_OTHER') + static GUIDED_AUDIO_SKIP_CONFIRM = new LocKeys('GUIDED_AUDIO_SKIP_CONFIRM') static SPLASH_STATUS_UPDATING_CONFIG = new LocKeys( 'SPLASH_STATUS_UPDATING_CONFIG' ) diff --git a/src/app/shared/models/question.ts b/src/app/shared/models/question.ts index 1f1988f32..233202a3c 100755 --- a/src/app/shared/models/question.ts +++ b/src/app/shared/models/question.ts @@ -52,6 +52,7 @@ export class QuestionType { static range = 'range' static slider = 'slider' static audio = 'audio' + static guided_audio = 'guided-audio' static timed = 'timed' static info = 'info' static text = 'text' @@ -61,6 +62,31 @@ export class QuestionType { static healthkit = 'healthkit' } +/** + * Configuration for the guided-audio question type. + * Set via `field_annotation` in the questionnaire JSON. + */ +export interface GuidedAudioAnnotation { + /** Automatically read the prompt aloud via TTS when the question becomes visible. */ + auto_tts?: boolean + /** Optional pre-recorded prompt audio to play instead of TTS. */ + prompt_audio_src?: string + /** Delay in milliseconds before auto prompt starts (audio file or TTS). */ + prompt_start_delay_ms?: number + /** Automatically start recording after TTS finishes (requires auto_tts). */ + auto_record_after_tts?: boolean + /** Fixed recording duration in seconds. 0 = unlimited (manual stop only). */ + record_seconds?: number + /** Show a "Replay instructions" button (allowed once per item). */ + allow_replay_prompt?: boolean + /** Hide the on-screen prompt text (`field_label`) while keeping TTS behavior. */ + hide_field_label?: boolean + /** Show an "Unable to speak" path with reason selection. */ + unable_to_speak_option?: boolean + /** Optional image stimulus URL displayed above the prompt text. */ + image?: string +} + export interface Response { label: string code: number | string @@ -83,6 +109,7 @@ export interface Item { id: string response?: string value: any + image?: string } export interface InfoItem { diff --git a/src/assets/data/defaultConfig.ts b/src/assets/data/defaultConfig.ts index fbac641dc..d74fca09e 100755 --- a/src/assets/data/defaultConfig.ts +++ b/src/assets/data/defaultConfig.ts @@ -272,6 +272,7 @@ export const DefaultAudioRecordOptions = { export const DefaultAutoNextQuestionnaireTypes = [ QuestionType.timed, QuestionType.audio, + QuestionType.guided_audio, QuestionType.healthkit ] diff --git a/src/assets/data/localisations.ts b/src/assets/data/localisations.ts index 7e837bd23..3a8e1e786 100644 --- a/src/assets/data/localisations.ts +++ b/src/assets/data/localisations.ts @@ -1614,6 +1614,166 @@ export const Localisations = { pl: 'Dodaj nagranie', hb: 'לשלוח הקלטה?' }, + GUIDED_AUDIO_SPEAKING: { + da: 'Læser instruktioner højt...', + de: 'Anweisungen werden vorgelesen...', + en: 'Reading instructions aloud...', + es: 'Leyendo instrucciones en voz alta...', + it: 'Lettura delle istruzioni ad alta voce...', + nl: 'Instructies worden voorgelezen...', + pl: 'Czytanie instrukcji na głos...', + hb: 'קורא הוראות בקול...' + }, + GUIDED_AUDIO_SKIP_TTS: { + da: 'Spring over', + de: 'Überspringen', + en: 'Skip', + es: 'Omitir', + it: 'Salta', + nl: 'Overslaan', + pl: 'Pomiń', + hb: 'דלג' + }, + GUIDED_AUDIO_REPLAY_PROMPT: { + da: 'Afspil instruktioner igen', + de: 'Anweisungen wiederholen', + en: 'Replay instructions', + es: 'Repetir instrucciones', + it: 'Ripeti le istruzioni', + nl: 'Instructies opnieuw afspelen', + pl: 'Odtwórz instrukcje ponownie', + hb: 'השמע הוראות שוב' + }, + GUIDED_AUDIO_RECORDING: { + da: 'Optager...', + de: 'Aufnahme läuft...', + en: 'Recording...', + es: 'Grabando...', + it: 'Registrazione in corso...', + nl: 'Opnemen...', + pl: 'Nagrywanie...', + hb: 'מקליט...' + }, + GUIDED_AUDIO_STOP_RECORDING: { + da: 'Stop optagelse', + de: 'Aufnahme stoppen', + en: 'Stop recording', + es: 'Detener grabación', + it: 'Interrompi registrazione', + nl: 'Stop opname', + pl: 'Zatrzymaj nagrywanie', + hb: 'עצור הקלטה' + }, + GUIDED_AUDIO_START_RECORDING: { + da: 'Start optagelse', + de: 'Aufnahme starten', + en: 'Start recording', + es: 'Iniciar grabación', + it: 'Avvia registrazione', + nl: 'Start opname', + pl: 'Rozpocznij nagrywanie', + hb: 'התחל הקלטה' + }, + GUIDED_AUDIO_RECORDED: { + da: 'Optagelse registreret', + de: 'Aufnahme gespeichert', + en: 'Recording captured', + es: 'Grabación capturada', + it: 'Registrazione acquisita', + nl: 'Opname vastgelegd', + pl: 'Nagranie zarejestrowane', + hb: 'הקלטה נלכדה' + }, + GUIDED_AUDIO_CONFIRM: { + da: 'Brug denne optagelse', + de: 'Diese Aufnahme verwenden', + en: 'Use this recording', + es: 'Usar esta grabación', + it: 'Usa questa registrazione', + nl: 'Gebruik deze opname', + pl: 'Użyj tego nagrania', + hb: 'השתמש בהקלטה זו' + }, + GUIDED_AUDIO_RETRY: { + da: 'Prøv igen', + de: 'Erneut versuchen', + en: 'Try again', + es: 'Intentar de nuevo', + it: 'Riprova', + nl: 'Probeer opnieuw', + pl: 'Spróbuj ponownie', + hb: 'נסה שוב' + }, + GUIDED_AUDIO_UNABLE_TO_SPEAK: { + da: 'Jeg kan ikke tale lige nu', + de: 'Ich kann gerade nicht sprechen', + en: 'I cannot speak right now', + es: 'No puedo hablar ahora mismo', + it: 'Non riesco a parlare adesso', + nl: 'Ik kan nu niet spreken', + pl: 'Nie mogę teraz mówić', + hb: 'אני לא יכול לדבר כרגע' + }, + GUIDED_AUDIO_UNABLE_REASON: { + da: 'Hvad forhindrer dig i at tale?', + de: 'Was hindert Sie am Sprechen?', + en: 'What is preventing you from speaking?', + es: '¿Qué le impide hablar?', + it: 'Cosa le impedisce di parlare?', + nl: 'Wat verhindert u te spreken?', + pl: 'Co uniemożliwia Ci mówienie?', + hb: 'מה מונע ממך לדבר?' + }, + GUIDED_AUDIO_REASON_NO_OUTPUT: { + da: 'Ingen verbal output', + de: 'Keine Sprachausgabe', + en: 'No verbal output', + es: 'Sin producción verbal', + it: 'Nessun output verbale', + nl: 'Geen verbale uitvoer', + pl: 'Brak mowy', + hb: 'אין פלט מילולי' + }, + GUIDED_AUDIO_REASON_FATIGUE: { + da: 'For træt', + de: 'Zu erschöpft', + en: 'Too fatigued', + es: 'Demasiado fatigado', + it: 'Troppo affaticato', + nl: 'Te vermoeid', + pl: 'Zbyt zmęczony/a', + hb: 'עייפות רבה מדי' + }, + GUIDED_AUDIO_REASON_TECHNICAL: { + da: 'Teknisk problem', + de: 'Technisches Problem', + en: 'Technical problem', + es: 'Problema técnico', + it: 'Problema tecnico', + nl: 'Technisch probleem', + pl: 'Problem techniczny', + hb: 'בעיה טכנית' + }, + GUIDED_AUDIO_REASON_OTHER: { + da: 'Anden årsag', + de: 'Anderer Grund', + en: 'Other reason', + es: 'Otra razón', + it: 'Altro motivo', + nl: 'Andere reden', + pl: 'Inny powód', + hb: 'סיבה אחרת' + }, + GUIDED_AUDIO_SKIP_CONFIRM: { + da: 'Bekræft og fortsæt', + de: 'Bestätigen und fortfahren', + en: 'Confirm and continue', + es: 'Confirmar y continuar', + it: 'Conferma e continua', + nl: 'Bevestigen en doorgaan', + pl: 'Potwierdź i kontynuuj', + hb: 'אשר והמשך' + }, CONFIG_ERROR_DESC: { da: 'Vi kunne ikke opdatere din konfiguration lige nu. Det kan skyldes et midlertidigt netværksproblem. Tryk på "Prøv igen" for at prøve igen, eller tryk på "Afskedige" for at fortsætte med at bruge appen. Hvis denne besked vises igen næste gang, du åbner appen, bedes du kontakte din studiekoordinator.',