Merge branch 'main' into 896-group-constructs-according-to-categories

This commit is contained in:
ggurdin 2024-11-05 09:47:26 -05:00 committed by GitHub
commit 17e295b168
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 235 additions and 129 deletions

View file

@ -4215,8 +4215,9 @@
"l2SupportAlpha": "Alpha",
"l2SupportBeta": "Beta",
"l2SupportFull": "Full",
"voiceNotAvailable": "It looks like you don't have a voice installed for this language.",
"openVoiceSettings": "Click here to open voice settings",
"missingVoiceTitle": "Missing voice",
"voiceNotAvailable": "You don't have a voice installed for this language.",
"openVoiceSettings": "Open voice settings",
"playAudio": "Play",
"stop": "Stop",
"grammarCopyPOSsconj": "Subordinating Conjunction",

View file

@ -56,7 +56,6 @@ class ChatEventList extends StatelessWidget {
context,
InstructionsEnum.clickMessage,
msgEvents[0].eventId,
true,
);
});
// Pangea#

View file

@ -41,7 +41,6 @@ class ITBotButton extends StatelessWidget {
context,
InstructionsEnum.itInstructions,
choreographer.itBotTransformTargetKey,
true,
);
return IconButton(
@ -51,7 +50,7 @@ class ITBotButton extends StatelessWidget {
context,
InstructionsEnum.itInstructions,
choreographer.itBotTransformTargetKey,
false,
showToggle: false,
),
);
}

View file

@ -19,7 +19,7 @@ import 'package:matrix/matrix.dart';
/// Represents an item in the completion cache.
class _RequestCacheItem {
MessageActivityRequest req;
PracticeActivityModel? practiceActivity;
PracticeActivityModelResponse? practiceActivity;
_RequestCacheItem({
required this.req,
@ -99,7 +99,7 @@ class PracticeGenerationController {
//TODO - allow return of activity content before sending the event
// this requires some downstream changes to the way the event is handled
Future<PracticeActivityModel?> getPracticeActivity(
Future<PracticeActivityModelResponse?> getPracticeActivity(
MessageActivityRequest req,
PangeaMessageEvent event,
) async {
@ -119,6 +119,8 @@ class PracticeGenerationController {
return null;
}
final eventCompleter = Completer<PracticeActivityEvent?>();
// if the server points to an existing event, return that event
if (res.existingActivityEventId != null) {
final Event? existingEvent =
@ -127,11 +129,19 @@ class PracticeGenerationController {
debugPrint(
'Existing activity event found: ${existingEvent?.content}',
);
if (existingEvent != null) {
return PracticeActivityEvent(
debugPrint(
"eventID: ${existingEvent?.eventId}, event is redacted: ${existingEvent?.redacted}",
);
if (existingEvent != null && !existingEvent.redacted) {
final activityEvent = PracticeActivityEvent(
event: existingEvent,
timeline: event.timeline,
).practiceActivity;
);
eventCompleter.complete(activityEvent);
return PracticeActivityModelResponse(
activity: activityEvent.practiceActivity,
eventCompleter: eventCompleter,
);
}
}
@ -141,11 +151,30 @@ class PracticeGenerationController {
}
debugPrint('Activity generated: ${res.activity!.toJson()}');
_sendAndPackageEvent(res.activity!, event).then((event) {
eventCompleter.complete(event);
});
_sendAndPackageEvent(res.activity!, event);
_cache[cacheKey] =
_RequestCacheItem(req: req, practiceActivity: res.activity!);
final responseModel = PracticeActivityModelResponse(
activity: res.activity!,
eventCompleter: eventCompleter,
);
return _cache[cacheKey]!.practiceActivity;
_cache[cacheKey] = _RequestCacheItem(
req: req,
practiceActivity: responseModel,
);
return responseModel;
}
}
class PracticeActivityModelResponse {
final PracticeActivityModel? activity;
final Completer<PracticeActivityEvent?> eventCompleter;
PracticeActivityModelResponse({
required this.activity,
required this.eventCompleter,
});
}

View file

@ -15,6 +15,7 @@ enum InstructionsEnum {
l1Translation,
translationChoices,
clickAgainToDeselect,
missingVoice,
}
extension InstructionsEnumExtension on InstructionsEnum {
@ -28,6 +29,8 @@ extension InstructionsEnumExtension on InstructionsEnum {
return l10n.blurMeansTranslateTitle;
case InstructionsEnum.tooltipInstructions:
return l10n.tooltipInstructionsTitle;
case InstructionsEnum.missingVoice:
return l10n.missingVoiceTitle;
case InstructionsEnum.clickAgainToDeselect:
case InstructionsEnum.speechToText:
case InstructionsEnum.l1Translation:
@ -64,6 +67,8 @@ extension InstructionsEnumExtension on InstructionsEnum {
return PlatformInfos.isMobile
? l10n.tooltipInstructionsMobileBody
: l10n.tooltipInstructionsBrowserBody;
case InstructionsEnum.missingVoice:
return l10n.voiceNotAvailable;
}
}
@ -87,6 +92,8 @@ extension InstructionsEnumExtension on InstructionsEnum {
return instructionSettings.showedTranslationChoicesTooltip;
case InstructionsEnum.clickAgainToDeselect:
return instructionSettings.showedClickAgainToDeselect;
case InstructionsEnum.missingVoice:
return instructionSettings.showedMissingVoice;
}
}
}

View file

@ -195,6 +195,8 @@ class MessageActivityRequest {
final String messageId;
final List<ActivityTypeEnum> clientCompatibleActivities;
MessageActivityRequest({
required this.userL1,
required this.userL2,
@ -203,9 +205,28 @@ class MessageActivityRequest {
required this.messageId,
required this.existingActivities,
required this.activityQualityFeedback,
});
clientCompatibleActivities,
}) : clientCompatibleActivities =
clientCompatibleActivities ?? ActivityTypeEnum.values;
factory MessageActivityRequest.fromJson(Map<String, dynamic> json) {
final clientCompatibleActivitiesEntry =
json['client_version_compatible_activity_types'];
List<ActivityTypeEnum>? clientCompatibleActivities;
if (clientCompatibleActivitiesEntry != null &&
clientCompatibleActivitiesEntry is List) {
clientCompatibleActivities = clientCompatibleActivitiesEntry
.map(
(e) => ActivityTypeEnum.values.firstWhereOrNull(
(element) =>
element.string == e as String ||
element.string.split('.').last == e,
),
)
.where((entry) => entry != null)
.cast<ActivityTypeEnum>()
.toList();
}
return MessageActivityRequest(
userL1: json['user_l1'] as String,
userL2: json['user_l2'] as String,
@ -224,6 +245,10 @@ class MessageActivityRequest {
json['activity_quality_feedback'] as Map<String, dynamic>,
)
: null,
clientCompatibleActivities: clientCompatibleActivities != null &&
clientCompatibleActivities.isNotEmpty
? clientCompatibleActivities
: ActivityTypeEnum.values,
);
}
@ -241,7 +266,7 @@ class MessageActivityRequest {
// the server will only return activities of these types
// this for backwards compatibility with old clients
'client_version_compatible_activity_types':
ActivityTypeEnum.values.map((e) => e.string).toList(),
clientCompatibleActivities.map((e) => e.string).toList(),
};
}

View file

@ -185,6 +185,7 @@ class UserInstructions {
bool showedClickMessage;
bool showedBlurMeansTranslate;
bool showedTooltipInstructions;
bool showedMissingVoice;
bool showedSpeechToTextTooltip;
bool showedL1TranslationTooltip;
@ -200,6 +201,7 @@ class UserInstructions {
this.showedL1TranslationTooltip = false,
this.showedTranslationChoicesTooltip = false,
this.showedClickAgainToDeselect = false,
this.showedMissingVoice = false,
});
factory UserInstructions.fromJson(Map<String, dynamic> json) =>
@ -219,6 +221,8 @@ class UserInstructions {
json[InstructionsEnum.speechToText.toString()] ?? false,
showedClickAgainToDeselect:
json[InstructionsEnum.clickAgainToDeselect.toString()] ?? false,
showedMissingVoice:
json[InstructionsEnum.missingVoice.toString()] ?? false,
);
Map<String, dynamic> toJson() {
@ -236,6 +240,7 @@ class UserInstructions {
data[InstructionsEnum.speechToText.toString()] = showedSpeechToTextTooltip;
data[InstructionsEnum.clickAgainToDeselect.toString()] =
showedClickAgainToDeselect;
data[InstructionsEnum.missingVoice.toString()] = showedMissingVoice;
return data;
}

View file

@ -56,6 +56,9 @@ class InstructionsController {
case InstructionsEnum.clickAgainToDeselect:
profile.instructionSettings.showedClickAgainToDeselect = value;
break;
case InstructionsEnum.missingVoice:
profile.instructionSettings.showedMissingVoice = value;
break;
}
return profile;
});
@ -66,9 +69,10 @@ class InstructionsController {
Future<void> showInstructionsPopup(
BuildContext context,
InstructionsEnum key,
String transformTargetKey, [
String transformTargetKey, {
bool showToggle = true,
]) async {
Widget? customContent,
}) async {
final bool userLangsSet =
await _pangeaController.userController.areUserLanguagesSet;
if (!userLangsSet) {
@ -115,6 +119,7 @@ class InstructionsController {
style: botStyle,
),
),
if (customContent != null) customContent,
if (showToggle) InstructionsToggle(instructionsKey: key),
],
),

View file

@ -71,7 +71,11 @@ class MessageAudioCardState extends State<MessageAudioCard> {
final PangeaTokenText selection = widget.selection!;
final tokenText = selection.content;
await widget.tts.speak(tokenText);
await widget.tts.tryToSpeak(
tokenText,
context,
widget.messageEvent.eventId,
);
}
void setSectionStartAndEnd(int? start, int? end) => mounted
@ -196,19 +200,13 @@ class MessageAudioCardState extends State<MessageAudioCard> {
child: _isLoading
? const ToolbarContentLoadingIndicator()
: audioFile != null
? Column(
children: [
AudioPlayerWidget(
null,
matrixFile: audioFile,
sectionStartMS: sectionStartMS,
sectionEndMS: sectionEndMS,
color:
Theme.of(context).colorScheme.onPrimaryContainer,
setIsPlayingAudio: widget.setIsPlayingAudio,
),
widget.tts.missingVoiceButton,
],
? AudioPlayerWidget(
null,
matrixFile: audioFile,
sectionStartMS: sectionStartMS,
sectionEndMS: sectionEndMS,
color: Theme.of(context).colorScheme.onPrimaryContainer,
setIsPlayingAudio: widget.setIsPlayingAudio,
)
: const CardErrorWidget(
error: "Null audio file in message_audio_card",

View file

@ -47,14 +47,6 @@ class MessageToolbar extends StatelessWidget {
final bool messageInUserL2 = pangeaMessageEvent.messageDisplayLangCode ==
MatrixState.pangeaController.languageController.userL2?.langCode;
// If not in the target language show specific messsage
if (!messageInUserL2) {
return MessageDisplayCard(
displayText:
L10n.of(context)!.messageNotInTargetLang, // Pass the display text,
);
}
switch (overLayController.toolbarMode) {
case MessageMode.translation:
return MessageTranslationCard(
@ -104,6 +96,13 @@ class MessageToolbar extends StatelessWidget {
}
}
case MessageMode.practiceActivity:
// If not in the target language show specific messsage
if (!messageInUserL2) {
return MessageDisplayCard(
displayText: L10n.of(context)!
.messageNotInTargetLang, // Pass the display text,
);
}
return PracticeActivityCard(
pangeaMessageEvent: pangeaMessageEvent,
overlayController: overLayController,

View file

@ -6,6 +6,7 @@ import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pangea/enum/message_mode_enum.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
class ToolbarButtons extends StatelessWidget {
@ -25,10 +26,16 @@ class ToolbarButtons extends StatelessWidget {
.where((mode) => mode.shouldShowAsToolbarButton(pangeaMessageEvent.event))
.toList();
bool get messageInUserL2 =>
pangeaMessageEvent.messageDisplayLangCode ==
MatrixState.pangeaController.languageController.userL2?.langCode;
static const double iconWidth = 36.0;
@override
Widget build(BuildContext context) {
final totallyDone =
overlayController.isPracticeComplete || !messageInUserL2;
final double barWidth = width - iconWidth;
if (overlayController.pangeaMessageEvent.isAudioMessage) {
@ -85,14 +92,14 @@ class ToolbarButtons extends StatelessWidget {
index,
overlayController.toolbarMode,
pangeaMessageEvent.numberOfActivitiesCompleted,
overlayController.isPracticeComplete,
totallyDone,
),
),
),
onPressed: mode.isUnlocked(
index,
pangeaMessageEvent.numberOfActivitiesCompleted,
overlayController.isPracticeComplete,
totallyDone,
)
? () => overlayController.updateToolbarMode(mode)
: null,

View file

@ -2,26 +2,23 @@ import 'dart:io';
import 'package:android_intent_plus/android_intent.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
class MissingVoiceButton extends StatelessWidget {
final String targetLangCode;
const MissingVoiceButton({super.key});
const MissingVoiceButton({
required this.targetLangCode,
super.key,
});
void launchTTSSettings(BuildContext context) {
if (Platform.isAndroid) {
Future<void> launchTTSSettings(BuildContext context) async {
if (!kIsWeb && Platform.isAndroid) {
const intent = AndroidIntent(
action: 'com.android.settings.TTS_SETTINGS',
package: 'com.talktolearn.chat',
);
showFutureLoadingDialog(
await showFutureLoadingDialog(
context: context,
future: intent.launch,
);
@ -30,37 +27,18 @@ class MissingVoiceButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color:
Theme.of(context).colorScheme.onPrimaryContainer.withOpacity(0.1),
borderRadius: const BorderRadius.all(
Radius.circular(AppConfig.borderRadius),
return TextButton(
style: ButtonStyle(
backgroundColor: WidgetStateProperty.all<Color>(
AppConfig.primaryColor.withOpacity(0.1),
),
),
padding: const EdgeInsets.all(8),
margin: const EdgeInsets.only(top: 8),
child: SizedBox(
width: AppConfig.toolbarMinWidth,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
L10n.of(context)!.voiceNotAvailable,
textAlign: TextAlign.center,
),
TextButton(
onPressed: () => launchTTSSettings,
// commenting out as suspecting this is causing an issue
// #freeze-activity
style: const ButtonStyle(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(L10n.of(context)!.openVoiceSettings),
),
],
),
onPressed: () async {
MatrixState.pAnyState.closeOverlay();
await launchTTSSettings(context);
},
child: Center(
child: Text(L10n.of(context)!.openVoiceSettings),
),
);
}

View file

@ -1,8 +1,8 @@
import 'dart:developer';
import 'package:fluffychat/pangea/enum/instructions_enum.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/widgets/chat/missing_voice_button.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@ -85,6 +85,37 @@ class TtsController {
}
}
Future<void> showMissingVoicePopup(
BuildContext context,
String eventID,
) async {
await MatrixState.pangeaController.instructions.showInstructionsPopup(
context,
InstructionsEnum.missingVoice,
eventID,
showToggle: false,
customContent: const Padding(
padding: EdgeInsets.only(top: 12),
child: MissingVoiceButton(),
),
);
return;
}
/// A safer version of speak, that handles the case of
/// the language not being supported by the TTS engine
Future<void> tryToSpeak(
String text,
BuildContext context,
String eventID,
) async {
if (isLanguageFullySupported) {
await speak(text);
} else {
await showMissingVoicePopup(context, eventID);
}
}
Future<void> speak(String text) async {
try {
stop();
@ -112,11 +143,4 @@ class TtsController {
bool get isLanguageFullySupported =>
availableLangCodes.contains(targetLanguage);
Widget get missingVoiceButton => targetLanguage != null &&
(kIsWeb || isLanguageFullySupported || !PlatformInfos.isAndroid)
? const SizedBox.shrink()
: MissingVoiceButton(
targetLangCode: targetLanguage!,
);
}

View file

@ -18,12 +18,14 @@ class MultipleChoiceActivity extends StatefulWidget {
final PracticeActivityCardState practiceCardController;
final PracticeActivityModel currentActivity;
final TtsController tts;
final String eventID;
const MultipleChoiceActivity({
super.key,
required this.practiceCardController,
required this.currentActivity,
required this.tts,
required this.eventID,
});
@override
@ -117,6 +119,7 @@ class MultipleChoiceActivityState extends State<MultipleChoiceActivity> {
WordAudioButton(
text: practiceActivity.content.answer,
ttsController: widget.tts,
eventID: widget.eventID,
),
ChoicesArray(
isLoading: false,

View file

@ -3,6 +3,7 @@ import 'dart:developer';
import 'package:fluffychat/pangea/controllers/my_analytics_controller.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/controllers/practice_activity_generation_controller.dart';
import 'package:fluffychat/pangea/enum/activity_type_enum.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart';
@ -45,6 +46,8 @@ class PracticeActivityCard extends StatefulWidget {
class PracticeActivityCardState extends State<PracticeActivityCard> {
PracticeActivityModel? currentActivity;
Completer<PracticeActivityEvent?>? currentActivityCompleter;
PracticeActivityRecordModel? currentCompletionRecord;
bool fetchingActivity = false;
@ -133,9 +136,9 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
return null;
}
final PracticeActivityModel? ourNewActivity = await pangeaController
.practiceGenerationController
.getPracticeActivity(
final PracticeActivityModelResponse? activityResponse =
await pangeaController.practiceGenerationController
.getPracticeActivity(
MessageActivityRequest(
userL1: pangeaController.languageController.userL1!.langCode,
userL2: pangeaController.languageController.userL2!.langCode,
@ -148,13 +151,19 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
.map((activity) => activity.activityRequestMetaData)
.toList(),
activityQualityFeedback: activityFeedback,
clientCompatibleActivities: widget.tts.isLanguageFullySupported
? ActivityTypeEnum.values
: ActivityTypeEnum.values
.where((type) => type != ActivityTypeEnum.wordFocusListening)
.toList(),
),
widget.pangeaMessageEvent,
);
currentActivityCompleter = activityResponse?.eventCompleter;
_updateFetchingActivity(false);
return ourNewActivity;
return activityResponse?.activity;
} catch (e, s) {
debugger(when: kDebugMode);
ErrorHandler.logError(
@ -250,12 +259,27 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
/// clear the current activity, record, and selection
/// fetch a new activity, including the offending activity in the request
void submitFeedback(String feedback) {
if (currentActivity == null) {
Future<void> submitFeedback(String feedback) async {
if (currentActivity == null || currentCompletionRecord == null) {
debugger(when: kDebugMode);
return;
}
if (currentActivityCompleter != null) {
final activityEvent = await currentActivityCompleter!.future;
await activityEvent?.event.redactEvent(reason: feedback);
} else {
debugger(when: kDebugMode);
ErrorHandler.logError(
e: Exception('No completer found for current activity'),
data: {
'activity': currentActivity,
'record': currentCompletionRecord,
'feedback': feedback,
},
);
}
_fetchNewActivity(
ActivityQualityFeedback(
feedbackText: feedback,
@ -297,6 +321,7 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
practiceCardController: this,
currentActivity: currentActivity!,
tts: widget.tts,
eventID: widget.pangeaMessageEvent.eventId,
);
case ActivityTypeEnum.wordFocusListening:
// return WordFocusListeningActivity(
@ -305,6 +330,7 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
practiceCardController: this,
currentActivity: currentActivity!,
tts: widget.tts,
eventID: widget.pangeaMessageEvent.eventId,
);
// default:
// ErrorHandler.logError(

View file

@ -5,11 +5,13 @@ import 'package:flutter_gen/gen_l10n/l10n.dart';
class WordAudioButton extends StatefulWidget {
final String text;
final TtsController ttsController;
final String eventID;
const WordAudioButton({
super.key,
required this.text,
required this.ttsController,
required this.eventID,
});
@override
@ -22,41 +24,40 @@ class WordAudioButtonState extends State<WordAudioButton> {
@override
Widget build(BuildContext context) {
debugPrint('build WordAudioButton');
return Column(
children: [
IconButton(
icon: const Icon(Icons.play_arrow_outlined),
isSelected: _isPlaying,
selectedIcon: const Icon(Icons.pause_outlined),
color: _isPlaying ? Colors.white : null,
style: ButtonStyle(
backgroundColor: WidgetStateProperty.all(
_isPlaying
? Theme.of(context).colorScheme.secondary
: Theme.of(context).colorScheme.primaryContainer,
),
),
tooltip:
_isPlaying ? L10n.of(context)!.stop : L10n.of(context)!.playAudio,
onPressed: () async {
if (_isPlaying) {
await widget.ttsController.tts.stop();
if (mounted) {
setState(() => _isPlaying = false);
}
} else {
if (mounted) {
setState(() => _isPlaying = true);
}
await widget.ttsController.speak(widget.text);
if (mounted) {
setState(() => _isPlaying = false);
}
}
}, // Disable button if language isn't supported
return IconButton(
icon: const Icon(Icons.play_arrow_outlined),
isSelected: _isPlaying,
selectedIcon: const Icon(Icons.pause_outlined),
color: _isPlaying ? Colors.white : null,
style: ButtonStyle(
backgroundColor: WidgetStateProperty.all(
_isPlaying
? Theme.of(context).colorScheme.secondary
: Theme.of(context).colorScheme.primaryContainer,
),
widget.ttsController.missingVoiceButton,
],
),
tooltip:
_isPlaying ? L10n.of(context)!.stop : L10n.of(context)!.playAudio,
onPressed: () async {
if (_isPlaying) {
await widget.ttsController.tts.stop();
if (mounted) {
setState(() => _isPlaying = false);
}
} else {
if (mounted) {
setState(() => _isPlaying = true);
}
await widget.ttsController.tryToSpeak(
widget.text,
context,
widget.eventID,
);
if (mounted) {
setState(() => _isPlaying = false);
}
}
}, // Disable button if language isn't supported
);
}
}

View file

@ -6,7 +6,7 @@ description: Learn a language while texting your friends.
# Pangea#
publish_to: none
# On version bump also increase the build number for F-Droid
version: 1.23.3+3562
version: 1.23.4+3563
environment:
sdk: ">=3.0.0 <4.0.0"