Merge branch 'main' into 730-fix-accept-replacement

This commit is contained in:
ggurdin 2024-11-01 09:26:30 -04:00 committed by GitHub
commit 5c239641ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
111 changed files with 2493 additions and 2136 deletions

View file

@ -2563,7 +2563,7 @@
"type": "text",
"placeholders": {}
},
"interactiveTranslatorAllowedDesc": "Students can choose whether to use translation assistance in space group chats in Main Menu > My Learning Settings.",
"interactiveTranslatorAllowedDesc": "Students can choose whether to use translation assistance in space group chats in Main Menu > Learning Settings.",
"@interactiveTranslatorAllowedDesc": {
"type": "text",
"placeholders": {}
@ -3030,9 +3030,9 @@
"errorDisableLanguageAssistanceClassDesc": "Translation assistance and grammar assistance are turned off for the space that this chat is in.",
"itIsDisabled": "Interactive Translation is disabled",
"igcIsDisabled": "Interactive Grammar Checking is disabled",
"goToLearningSettings": "Go to My Learning Settings",
"goToLearningSettings": "Go to Learning Settings",
"error405Title": "Languages not set",
"error405Desc": "Please set your languages in Main Menu > My Learning Settings.",
"error405Desc": "Please set your languages in Main Menu > Learning Settings.",
"loginOrSignup": "Sign in with",
"@loginOrSignup": {
"type": "text",
@ -3095,7 +3095,7 @@
"type": "text",
"placeholders": {}
},
"learningSettings": "My Learning Settings",
"learningSettings": "Learning settings",
"classNameRequired": "Please enter a space name",
"@classNameRequired": {
"type": "text",
@ -3656,11 +3656,6 @@
"unknownPrivateChat": "Unknown private chat",
"copyClassCodeDesc": "Users who are already in the app can 'Join space' via the main menu.",
"addToSpaceDesc": "Adding a chat to a space will make the chat appear within the space for students and give them access.",
"@addToSpaceDesc": {
"placeholders": {
"roomtype": {}
}
},
"invitedToSpace": "{user} has invited you to join a space: {space}! Do you wish to accept?",
"@invitedToSpace": {
"placeholders": {
@ -4008,7 +4003,7 @@
"accuracy": "Accuracy",
"points": "Points",
"noPaymentInfo": "No payment info necessary!",
"conversationBotModeSelectDescription": "Chat Activity",
"conversationBotModeSelectDescription": "Chat activity",
"conversationBotModeSelectOption_discussion": "Discussion",
"conversationBotModeSelectOption_custom": "Custom",
"conversationBotModeSelectOption_conversation": "Conversation",
@ -4016,9 +4011,9 @@
"conversationBotModeSelectOption_storyGame": "Story Game",
"conversationBotDiscussionZone_title": "Discussion Settings",
"conversationBotDiscussionZone_discussionTopicLabel": "Discussion Topic",
"conversationBotDiscussionZone_discussionTopicPlaceholder": "Set Discussion Topic",
"conversationBotDiscussionZone_discussionTopicPlaceholder": "Set discussion topic",
"conversationBotDiscussionZone_discussionKeywordsLabel": "Discussion Keywords",
"conversationBotDiscussionZone_discussionKeywordsPlaceholder": "Set Discussion Keywords",
"conversationBotDiscussionZone_discussionKeywordsPlaceholder": "Set discussion keywords",
"conversationBotDiscussionZone_discussionKeywordsHintText": "Comma separated list of keywords to guide the discussion",
"conversationBotDiscussionZone_discussionTriggerScheduleEnabledLabel": "Send discussion prompt on a schedule",
"conversationBotDiscussionZone_discussionTriggerScheduleHourIntervalLabel": "Hours between discussion prompts",
@ -4029,7 +4024,7 @@
"conversationBotCustomZone_customSystemPromptPlaceholder": "Set custom system prompt",
"conversationBotCustomZone_customSystemPromptEmptyError": "Missing custom system prompt",
"conversationBotCustomZone_customTriggerReactionEnabledLabel": "Responds on ⏩ reaction",
"botConfig": "Chat Settings",
"botConfig": "Chat settings",
"addConversationBotDialogTitleInvite": "Confirm inviting conversation bot",
"addConversationBotButtonInvite": "Invite",
"addConversationBotDialogInviteConfirmation": "Invite",
@ -4059,39 +4054,21 @@
"tooltipInstructionsMobileBody": "Press and hold items to view tooltips.",
"tooltipInstructionsBrowserBody": "Hover over items to view tooltips.",
"addSpaceToSpaceDescription": "Select a space to add as a parent",
"roomCapacity": "{roomType} Capacity",
"@roomCapacity": {
"type": "text",
"placeholders": {
"roomType": {}
}
},
"chatCapacity": "Chat capacity",
"spaceCapacity": "Space capacity",
"roomFull": "This room is already at capacity.",
"topicNotSet": "The topic has not been set.",
"capacityNotSet": "This room has no capacity limit.",
"roomCapacityHasBeenChanged": "{roomType} capacity changed",
"@roomCapacityHasBeenChanged": {
"type": "text",
"placeholders": {
"roomType": {}
}
},
"roomExceedsCapacity": "Room exceeds capacity. Consider removing students from the room, or raising the capacity.",
"capacitySetTooLow": "{roomType} capacity cannot be set below the current number of non-admins.",
"@capacitySetTooLow": {
"type": "text",
"placeholders": {
"roomType": {}
}
},
"roomCapacityExplanation": "{roomType} capacity limits the number of non-admins allowed in a room.",
"chatCapacityNotSet": "This chat has no capacity limit.",
"spaceCapacityNotSet": "This space has no capacity limit.",
"chatCapacityHasBeenChanged": "Chat capacity changed",
"spaceCapacityHasBeenChanged": "Space capacity changed",
"chatCapacitySetTooLow": "Chat capacity cannot be set below the current number of non-admins.",
"spaceCapacitySetTooLow": "Space capacity cannot be set below the current number of non-admins.",
"chatCapacityExplanation": "Chat capacity limits the number of non-admins allowed in a chat.",
"spaceCapacityExplanation": "Space capacity limits the number of non-admins allowed in a space.",
"chatExceedsCapacity": "This chat exceeds its capacity.",
"spaceExceedsCapacity": "This space exceeds its capacity.",
"tooManyRequest": "Too many request, please try again later.",
"@roomCapacityExplanation": {
"type": "text",
"placeholders": {
"roomType": {}
}
},
"enterNumber": "Please enter a whole number value.",
"buildTranslation": "Build your translation from the choices above",
"noDatabaseEncryption": "Database encryption is not supported on this platform",
@ -4114,14 +4091,14 @@
"placeholders": {}
},
"addChatToSpaceDesc": "Adding a chat to a space will make the chat appear within the space for students and give them access.",
"addSpaceToSpaceDesc": "Adding a sub space to space will make the sub space appear in the main space''s chat list.",
"addSpaceToSpaceDesc": "Adding a sub space to space will make the sub space appear in the main space's chat list.",
"spaceAnalytics": "Space Analytics",
"changeAnalyticsLanguage": "Change Analytics Language",
"suggestToSpace": "Suggest this space",
"suggestToSpaceDesc": "Suggested sub spaces will appear in their main space''s chat list",
"suggestToSpaceDesc": "Suggested sub spaces will appear in their main space's chat list",
"practice": "Practice",
"noLanguagesSet": "No languages set",
"noActivitiesFound": "That''s enough on this for now! Come back later for more.",
"noActivitiesFound": "That's enough on this for now! Come back later for more.",
"hintTitle": "Hint:",
"speechToTextBody": "See how well you did by looking at your Accuracy and Words Per Minute scores",
"previous": "Previous",
@ -4225,14 +4202,14 @@
"discoverHomeservers": "Discover homeservers",
"whatIsAHomeserver": "What is a homeserver?",
"homeserverDescription": "All your data is stored on the homeserver, just like an email provider. You can choose which homeserver you want to use, while you can still communicate with everyone. Learn more at at https://matrix.org.",
"doesNotSeemToBeAValidHomeserver": "Doesn''t seem to be a compatible homeserver. Wrong URL?",
"doesNotSeemToBeAValidHomeserver": "Doesn't seem to be a compatible homeserver. Wrong URL?",
"grammar": "Grammar",
"contactHasBeenInvitedToTheChat": "Contact has been invited to the chat",
"inviteChat": "📨 Invite chat",
"chatName": "Chat name",
"reportContentIssueTitle": "Report content issue",
"feedback": "Optional feedback",
"reportContentIssueDescription": "Uh oh! AI can faciliate personalized learning experiences but... also hallucinates. Please provide any feedback you have and we''ll try again.",
"reportContentIssueDescription": "Uh oh! AI can faciliate personalized learning experiences but... also hallucinates. Please provide any feedback you have and we'll try again.",
"clickTheWordAgainToDeselect": "Click the selected word to deselect it.",
"l2SupportNa": "Not Available",
"l2SupportAlpha": "Alpha",
@ -4326,6 +4303,7 @@
"grammarCopyAccDat": "Accusative, Dative",
"grammarCopyInf": "Infinitive",
"grammarCopyLong": "Long",
"grammarCopyLoc": "Locative",
"grammarCopyInd": "Indicative",
"grammarCopyCmp": "Comparative",
"grammarCopyRelative_case": "Relative Case",
@ -4355,7 +4333,7 @@
"grammarCopyNumber": "Number",
"grammarCopyConjType": "Conjunction Type",
"grammarCopyPolarity": "Polarity",
"grammarCopyNumberPsor": "Possessor''s Number",
"grammarCopyNumberPsor": "Possessor's Number",
"grammarCopyCase": "Case",
"grammarCopyDefinite": "Definiteness",
"grammarCopyNumForm": "Numeral Form",
@ -4364,5 +4342,7 @@
"selectBotLanguage": "Select bot language",
"chooseVoice": "Choose a voice",
"enterLanguageLevel": "Please enter a language level",
"enterDiscussionTopic": "Please enter a discussion topic"
"enterDiscussionTopic": "Please enter a discussion topic",
"selectBotChatMode": "Select chat mode",
"messageNotInTargetLang": "Message not in target language"
}

View file

@ -4576,14 +4576,8 @@
"roomDataMissing": "Es posible que falten algunos datos de las salas de las que no es miembro.",
"suggestToChat": "Sugerir este chat",
"suggestToChatDesc": "Los chats sugeridos aparecerán en las listas de chats",
"roomCapacity": "Capacidad de la sala",
"roomFull": "Esta sala ya está al límite de su capacidad.",
"topicNotSet": "El tema no se ha fijado.",
"capacityNotSet": "Esta sala no tiene límite de capacidad.",
"roomCapacityHasBeenChanged": "Capacidad de la sala modificada",
"roomExceedsCapacity": "La sala supera su capacidad. Considere la posibilidad de retirar a los alumnos de la sala o de aumentar la capacidad.",
"capacitySetTooLow": "La capacidad de la sala no puede fijarse por debajo del número actual de no administradores.",
"roomCapacityExplanation": "La capacidad de la sala limita el número de personas que pueden entrar en ella.",
"enterNumber": "Introduzca un valor numérico entero.",
"autoIGCToolName": "Ejecutar automáticamente la asistencia lingüística",
"autoIGCToolDescription": "Ejecutar automáticamente la asistencia lingüística después de escribir mensajes",
@ -4731,5 +4725,288 @@
}
},
"commandHint_googly": "Enviar unos ojos saltones",
"reportContentIssue": "Problema de contenido"
"reportContentIssue": "Problema de contenido",
"alwaysUse24HourFormat": "falso",
"countChatsAndCountParticipants": "{chats} chats y {participants} participantes",
"@countChatsAndCountParticipants": {
"type": "text",
"placeholders": {
"chats": {},
"participants": {}
}
},
"noMoreChatsFound": "No se encontraron más chats...",
"noChatsFoundHere": "Aún no se encontraron chats aquí. Inicia un nuevo chat con alguien usando el botón de abajo. ⤵️",
"joinedChats": "Chats unidos",
"unread": "No leído",
"space": "Espacio",
"spaces": "Espacios",
"enterASpacepName": "Ingresa un nombre",
"invitedBy": "📩 Invitado por {user}",
"@invitedBy": {
"placeholders": {
"user": {}
}
},
"clickMessageBody": "Haz clic en un mensaje para herramientas de idioma como traducción, reproducción y más!",
"searchIn": "Buscar en el chat \"{chat}\"...",
"@searchIn": {
"type": "text",
"placeholders": {
"chat": {}
}
},
"subscribedToUnlockTools": "¡Suscríbete para desbloquear la traducción interactiva y la verificación gramatical, la reproducción de audio, las actividades de práctica personalizadas y la analítica de aprendizaje!",
"conversationBotModeSelectOption_storyGame": "Juego de Historia",
"conversationBotCustomZone_title": "Configuraciones Personalizadas",
"conversationBotCustomZone_customSystemPromptLabel": "Mensaje del sistema",
"conversationBotCustomZone_customSystemPromptPlaceholder": "Establecer mensaje del sistema personalizado",
"conversationBotCustomZone_customSystemPromptEmptyError": "Falta mensaje del sistema personalizado",
"conversationBotCustomZone_customTriggerReactionEnabledLabel": "Responde a la reacción ⏩",
"addConversationBotDialogTitleInvite": "Confirmar la invitación del bot de conversación",
"addConversationBotButtonInvite": "Invitar",
"addConversationBotDialogInviteConfirmation": "Invitar",
"addConversationBotButtonTitleRemove": "Confirmar la eliminación del bot de conversación",
"addConversationBotButtonRemove": "Eliminar",
"addConversationBotDialogRemoveConfirmation": "Eliminar",
"conversationBotConfigConfirmChange": "Confirmar",
"conversationBotStatus": "Estado del Bot",
"conversationBotTextAdventureZone_title": "Aventura de Texto",
"conversationBotTextAdventureZone_instructionLabel": "Instrucciones del Maestro del Juego",
"conversationBotTextAdventureZone_instructionPlaceholder": "Establecer instrucciones del maestro del juego",
"conversationBotCustomZone_instructionSystemPromptEmptyError": "Faltan instrucciones del maestro del juego",
"suggestToSpace": "Sugerir este espacio",
"suggestToSpaceDesc": "Los subespacios sugeridos aparecerán en la lista de chats de su espacio principal",
"practice": "Práctica",
"noLanguagesSet": "No hay idiomas configurados",
"hintTitle": "Sugerencia:",
"speechToTextBody": "Ve qué tan bien lo hiciste al mirar tus puntajes de Precisión y Palabras Por Minuto.",
"previous": "Anterior",
"languageButtonLabel": "Idioma: {currentLanguage}",
"@languageButtonLabel": {
"type": "text",
"placeholders": {
"currentLanguage": {}
}
},
"changeAnalyticsView": "Cambiar Vista de Análisis",
"l1TranslationBody": "Los mensajes en tu idioma base no serán traducidos.",
"continueText": "Continuar",
"deleteSubscriptionWarningTitle": "YTienes una suscripción activa",
"deleteSubscriptionWarningBody": "Eliminar tu cuenta no cancelará automáticamente tu suscripción.",
"manageSubscription": "Gestionar Suscripción",
"createSpace": "Crear espacio",
"createChat": "Crear chat",
"error520Title": "Por favor, intenta de nuevo.",
"error520Desc": "Lo sentimos, no pudimos entender tu mensaje...",
"wordsUsed": "Palabras Usadas",
"errorTypes": "Tipos de Error",
"level": "Nivel",
"canceledSend": "Envío cancelado",
"morphsUsed": "Morphs Usados",
"translationChoicesBody": "Haz clic y mantén presionada una opción para una pista.",
"sendCanceled": "Envío cancelado",
"goToSpace": "Ir al espacio: {space}",
"@goToSpace": {
"type": "text",
"space": {}
},
"markAsUnread": "Marcar como no leído",
"userLevel": "{level} - Usuario",
"@userLevel": {
"type": "text",
"placeholders": {
"level": {}
}
},
"moderatorLevel": "{level} - Moderador",
"@moderatorLevel": {
"type": "text",
"placeholders": {
"level": {}
}
},
"adminLevel": "{level} - Administrador",
"@adminLevel": {
"type": "text",
"placeholders": {
"level": {}
}
},
"changeGeneralChatSettings": "Cambiar la configuración general del chat.",
"inviteOtherUsers": "Invitar a otros usuarios a este chat",
"changeTheChatPermissions": "Cambiar los permisos del chat",
"changeTheVisibilityOfChatHistory": "Cambiar la visibilidad del historial de chat",
"changeTheCanonicalRoomAlias": "Cambiar la dirección del chat público principal.",
"sendRoomNotifications": "Enviar una notificación a @room",
"changeTheDescriptionOfTheGroup": "Cambiar la descripción del chat",
"chatPermissionsDescription": "Define qué nivel de poder es necesario para ciertas acciones en este chat. Los niveles de poder 0, 50 y 100 suelen representar a usuarios, moderadores y administradores, pero cualquier graduación es posible.",
"updateInstalled": "🎉 ¡Actualización {version} instalada!",
"@updateInstalled": {
"type": "text",
"placeholders": {
"version": {}
}
},
"loginWithMatrixId": "Iniciar sesión con Matrix-ID.",
"discoverHomeservers": "Descubrir homeservers",
"whatIsAHomeserver": "¿Qué es un homeserver?",
"homeserverDescription": "Todos tus datos se almacenan en el homeserver, al igual que un proveedor de correo electrónico. Puedes elegir qué homeserver deseas utilizar, mientras que aún puedes comunicarte con todos. Aprende más en https://matrix.org.",
"doesNotSeemToBeAValidHomeserver": "No parece ser un homeserver compatible. ¿URL incorrecta?",
"grammar": "Gramática",
"contactHasBeenInvitedToTheChat": "El contacto ha sido invitado al chat",
"inviteChat": "📨 Invitar al chat",
"chatName": "Nombre del chat",
"reportContentIssueTitle": "Informar sobre un problema de contenido",
"feedback": "Comentarios opcionales",
"reportContentIssueDescription": "¡Ups! La IA puede facilitar experiencias de aprendizaje personalizadas, pero... también alucina. Por favor, proporciona cualquier comentario que tengas y lo intentaremos de nuevo.",
"clickTheWordAgainToDeselect": "Click the selected word to deselect it.",
"l2SupportNa": "Haz clic en la palabra seleccionada para deseleccionarla",
"l2SupportAlpha": "Alfa",
"l2SupportBeta": "Beta",
"l2SupportFull": "Lleno",
"chatCapacity": "Capacidad de chat",
"spaceCapacity": "Capacidad espacial",
"chatCapacityHasBeenChanged": "Capacidad de chat modificada",
"spaceCapacityHasBeenChanged": "Capacidad espacial modificada",
"chatCapacitySetTooLow": "La capacidad del chat no se puede establecer por debajo del número actual de no administradores.",
"spaceCapacitySetTooLow": "La capacidad de espacio no puede fijarse por debajo del número actual de no administradores.",
"chatCapacityExplanation": "La capacidad del chat limita el número de usuarios no administradores permitidos en un chat.",
"spaceCapacityExplanation": "La capacidad del espacio limita el número de no administradores permitidos en un espacio.",
"tooManyRequest": "Demasiadas solicitudes, por favor inténtelo más tarde.",
"voiceNotAvailable": "Parece que no tienes una voz instalada para este idioma.",
"openVoiceSettings": "Haz clic aquí para abrir los ajustes de voz",
"playAudio": "Jugar",
"stop": "Stop",
"grammarCopySCONJ": "Conjunción subordinante",
"grammarCopyNUM": "Número",
"grammarCopyVERB": "Verbo",
"grammarCopyAFFIX": "Coloque",
"grammarCopyPARTpos": "Partículas",
"grammarCopyADJ": "Adjetivo",
"grammarCopyCCONJ": "Conjunción de coordinación",
"grammarCopyPUNCT": "Puntuación",
"grammarCopyADV": "Adverbio",
"grammarCopyAUX": "Auxiliar",
"grammarCopySPACE": "Espacio",
"grammarCopySYM": "Símbolo",
"grammarCopyDET": "Determinante",
"grammarCopyPRON": "Pronombre",
"grammarCopyADP": "Adposición",
"grammarCopyPROPN": "Nombre propio",
"grammarCopyNOUN": "Sustantivo",
"grammarCopyINTJ": "Interjección",
"grammarCopyX": "Otros",
"grammarCopyFem": "Femenino",
"grammarCopy2": "Segunda persona",
"grammarCopyImp": "Imperativo",
"grammarCopyQest": "Pregunta",
"grammarCopyPerf": "Perfecto",
"grammarCopyAccNom": "Acusativo, Nominativo",
"grammarCopyObl": "Caso oblicuo",
"grammarCopyAct": "Activo",
"grammarCopyBrck": "Soporte",
"grammarCopyArt": "Artículo",
"grammarCopySing": "Singular",
"grammarCopyMasc": "Hombre",
"grammarCopyMod": "Modal",
"grammarCopyAdverbial": "Adverbial",
"grammarCopyPeri": "Perifrástico",
"grammarCopyDigit": "Dígitos",
"grammarCopyNot_proper": "No procede",
"grammarCopyCard": "Cardenal",
"grammarCopyProp": "Adecuado",
"grammarCopyDash": "Dash",
"grammarCopyYes": "Sí",
"grammarCopySemi": "Punto y coma",
"grammarCopyComm": "Coma",
"grammarCopyCnd": "Condicional",
"grammarCopyIntRel": "Interrogativo, relativo",
"grammarCopyAcc": "Acusativo",
"grammarCopyPartTag": "Partitivo",
"grammarCopyInt": "Preguntas",
"grammarCopyPast": "Anterior",
"grammarCopySup": "Superlativo",
"grammarCopyColo": "Colon",
"grammarCopy3": "Tercera persona",
"grammarCopyPlur": "Plural",
"grammarCopyNpr": "Nombre propio",
"grammarCopyInterrogative": "Preguntas",
"grammarCopyInfm": "Informal",
"grammarCopyTim": "Tiempo",
"grammarCopyNeg": "Negativo",
"grammarCopyTot": "Total",
"grammarCopyAdnomial": "Adnominal",
"grammarCopyProg": "Progresiva",
"grammarCopySub": "Subjuntivo",
"grammarCopyComplementive": "Complementive",
"grammarCopyNom": "Nominativo",
"grammarCopyFut": "Futuro",
"grammarCopyDat": "Dativo",
"grammarCopyPres": "Presente",
"grammarCopyNeut": "Esterilizar",
"grammarCopyRel": "Relativa",
"grammarCopyFinal_ending": "Final",
"grammarCopyDem": "Demostrativo",
"grammarCopyPre": "Preposición",
"grammarCopyFin": "Finito",
"grammarCopyPos": "Positivo",
"grammarCopyQuot": "Presupuesto",
"grammarCopyGer": "Redondo",
"grammarCopyPass": "Pasivo",
"grammarCopyGen": "Genitivo",
"grammarCopyPrs": "Presente",
"grammarCopyDef": "Definitivo",
"grammarCopyOrd": "Ordinal",
"grammarCopyIns": "Instrumental",
"grammarCopyAccDat": "Acusativo, Dativo",
"grammarCopyInf": "Infinitivo",
"grammarCopyLong": "Largo",
"grammarCopyLoc": "Locativa",
"grammarCopyInd": "Indicativo",
"grammarCopyCmp": "Comparativa",
"grammarCopyRelative_case": "Caso relativo",
"grammarCopyExcl": "Exclamativo",
"grammarCopy1": "En primera persona",
"grammarCopyIni": "Inicial",
"grammarCopyPerson": "Persona",
"grammarCopyForeign": "Extranjero",
"grammarCopyVoice": "Voz",
"grammarCopyVerbType": "Tipo de verbo",
"grammarCopyPoss": "Posesivo",
"grammarCopyPrepCase": "Caso preposicional",
"grammarCopyNumType": "Tipo de número",
"grammarCopyNounType": "Tipo de sustantivo",
"grammarCopyReflex": "Reflexivo",
"grammarCopyPronType": "Tipo de pronombre",
"grammarCopyPunctSide": "Puntuación Lado",
"grammarCopyVerbForm": "Forma verbal",
"grammarCopyGender": "Género",
"grammarCopyMood": "Estado de ánimo",
"grammarCopyAspect": "Aspecto",
"grammarCopyPunctType": "Tipo de puntuación",
"grammarCopyTense": "Tense",
"grammarCopyDegree": "Titulación",
"grammarCopyPolite": "Cortesía",
"grammarCopyAdvType": "Tipo de adverbio",
"grammarCopyNumber": "Número",
"grammarCopyConjType": "Tipo de conjunción",
"grammarCopyPolarity": "Polaridad",
"grammarCopyNumberPsor": "Número del poseedor",
"grammarCopyCase": "Caso",
"grammarCopyDefinite": "Definitividad",
"grammarCopyNumForm": "Forma numérica",
"grammarCopyUnknown": "Desconocido",
"enterPrompt": "Introduzca un mensaje del sistema",
"selectBotLanguage": "Selecciona el idioma del bot",
"chooseVoice": "Elige una voz",
"enterLanguageLevel": "Introduzca un nivel de idioma",
"enterDiscussionTopic": "Introduzca un tema de debate",
"selectBotChatMode": "Selecciona el modo de chat",
"messageNotInTargetLang": "El mensaje no está en la lengua de llegada",
"botConfig": "Configuración del chat",
"chatCapacityNotSet": "Este chat no tiene límite de capacidad.",
"spaceCapacityNotSet": "Este espacio no tiene límite de capacidad.",
"chatExceedsCapacity": "Este chat supera su capacidad.",
"spaceExceedsCapacity": "Este espacio supera su capacidad."
}

View file

@ -23,6 +23,8 @@ abstract class AppConfig {
static const bool allowOtherHomeservers = true;
static const bool enableRegistration = true;
static const double toolbarMaxHeight = 300.0;
static const double toolbarMinHeight = 70.0;
static const double toolbarMinWidth = 270.0;
// #Pangea
// static const Color primaryColor = Color(0xFF5625BA);
// static const Color primaryColorLight = Color(0xFFCCBDEA);

View file

@ -29,7 +29,6 @@ import 'package:fluffychat/pages/settings_style/settings_style.dart';
import 'package:fluffychat/pangea/guard/p_vguard.dart';
import 'package:fluffychat/pangea/pages/find_partner/find_partner.dart';
import 'package:fluffychat/pangea/pages/p_user_age/p_user_age.dart';
import 'package:fluffychat/pangea/pages/settings_learning/settings_learning.dart';
import 'package:fluffychat/pangea/pages/settings_subscription/settings_subscription.dart';
import 'package:fluffychat/pangea/pages/sign_up/signup.dart';
import 'package:fluffychat/pangea/widgets/class/join_with_link.dart';
@ -406,15 +405,6 @@ abstract class AppRoutes {
],
),
// #Pangea
GoRoute(
path: 'learning',
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
const SettingsLearning(),
),
redirect: loggedOutRedirect,
),
GoRoute(
path: 'subscription',
pageBuilder: (context, state) => defaultPageBuilder(

View file

@ -1,5 +1,6 @@
import 'package:collection/collection.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/config/environment.dart';
import 'package:fluffychat/pangea/controllers/language_list_controller.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/utils/firebase_analytics.dart';
@ -100,6 +101,19 @@ Future<void> startGui(List<Client> clients, SharedPreferences store) async {
await firstClient?.accountDataLoading;
ErrorWidget.builder = (details) => FluffyChatErrorWidget(details);
// #Pangea
// errors seems to happen a lot when users switch better production / staging
// while testing by accident. If the account is a production account but server is
// staging or vice versa, logout.
if (firstClient?.userID?.domain != null) {
final isStagingUser = firstClient!.userID!.domain!.contains("staging");
final isStagingServer = Environment.isStaging;
if (isStagingServer != isStagingUser) {
await firstClient.logout();
}
}
// Pangea#
runApp(FluffyChatApp(clients: clients, pincode: pin, store: store));
}

View file

@ -485,6 +485,15 @@ class ChatController extends State<ChatPageWithRoom>
Future<void>? setReadMarkerFuture;
void setReadMarker({String? eventId}) {
// #Pangea
if (room.client.userID == null ||
eventId != null &&
(eventId.contains("web") ||
eventId.contains("android") ||
eventId.contains("ios"))) {
return;
}
// Pangea#
if (setReadMarkerFuture != null) return;
if (_scrolledUp) return;
if (scrollUpBannerEventId != null) return;
@ -560,6 +569,7 @@ class ChatController extends State<ChatPageWithRoom>
//#Pangea
choreographer.stateListener.close();
choreographer.dispose();
clearSelectedEvents();
MatrixState.pAnyState.closeOverlay();
//Pangea#
super.dispose();
@ -1334,13 +1344,18 @@ class ChatController extends State<ChatPageWithRoom>
}
// Pangea#
void clearSelectedEvents() => setState(() {
// #Pangea
closeSelectionOverlay();
// Pangea#
selectedEvents.clear();
showEmojiPicker = false;
});
void clearSelectedEvents() {
// #Pangea
if (!mounted) return;
// Pangea#
setState(() {
// #Pangea
closeSelectionOverlay();
// Pangea#
selectedEvents.clear();
showEmojiPicker = false;
});
}
void clearSingleSelectedEvent() {
if (selectedEvents.length <= 1) {
@ -1405,7 +1420,7 @@ class ChatController extends State<ChatPageWithRoom>
void onSelectMessage(Event event) {
// #Pangea
if (choreographer.itController.isOpen) {
if (choreographer.itController.willOpen) {
return;
}
// Pangea#

View file

@ -321,6 +321,7 @@ class ChatInputRow extends StatelessWidget {
// #Pangea
// hintText: L10n.of(context)!.writeAMessage,
hintText: hintText(),
disabledBorder: InputBorder.none,
// Pangea#
hintMaxLines: 1,
border: InputBorder.none,

View file

@ -21,6 +21,7 @@ class AudioPlayerWidget extends StatefulWidget {
final Event? event;
final PangeaAudioFile? matrixFile;
final bool autoplay;
final Function(bool)? setIsPlayingAudio;
// Pangea#
static String? currentId;
@ -41,6 +42,7 @@ class AudioPlayerWidget extends StatefulWidget {
this.autoplay = false,
this.sectionStartMS,
this.sectionEndMS,
this.setIsPlayingAudio,
// Pangea#
super.key,
});
@ -204,8 +206,13 @@ class AudioPlayerState extends State<AudioPlayerWidget> {
if (max == null || max == Duration.zero) return;
setState(() => maxPosition = max.inMilliseconds.toDouble());
});
onPlayerStateChanged ??=
audioPlayer.playingStream.listen((_) => setState(() {}));
onPlayerStateChanged ??= audioPlayer.playingStream.listen(
(isPlaying) => setState(() {
// #Pangea
widget.setIsPlayingAudio?.call(isPlaying);
// Pangea#
}),
);
final audioFile = this.audioFile;
if (audioFile != null) {
audioPlayer.setFilePath(audioFile.path);
@ -439,64 +446,61 @@ class AudioPlayerState extends State<AudioPlayerWidget> {
// ],
// ),
// const SizedBox(width: 8),
Expanded(
child: Row(
children: [
for (var i = 0; i < AudioPlayerWidget.wavesCount; i++)
Builder(
builder: (context) {
final double barOpacity = currentPosition > i ? 1 : 0.5;
return Expanded(
child: GestureDetector(
onTapDown: (_) {
audioPlayer?.seek(
Duration(
milliseconds:
(maxPosition / AudioPlayerWidget.wavesCount)
.round() *
i,
),
);
},
child: Stack(
children: [
Container(
margin: const EdgeInsets.symmetric(
horizontal: 0.5,
),
decoration: BoxDecoration(
color: widget.color.withOpacity(barOpacity),
borderRadius: BorderRadius.circular(2),
),
height: 32 * (waveform[i] / 1024),
),
],
Row(
children: [
for (var i = 0; i < AudioPlayerWidget.wavesCount; i++)
Builder(
builder: (context) {
final double barOpacity = currentPosition > i ? 1 : 0.5;
return GestureDetector(
onTapDown: (_) {
audioPlayer?.seek(
Duration(
milliseconds:
(maxPosition / AudioPlayerWidget.wavesCount)
.round() *
i,
),
),
);
// return Container(
// height: 32,
// width: 2,
// alignment: Alignment.center,
// child: Opacity(
// opacity: barOpacity,
// child: Container(
// margin: const EdgeInsets.symmetric(
// horizontal: 1,
// ),
// decoration: BoxDecoration(
// color: widget.color,
// borderRadius: BorderRadius.circular(2),
// ),
// height: 32 * (waveform[i] / 1024),
// width: 2,
// ),
// ),
// );
},
),
],
),
);
},
child: Stack(
children: [
Container(
margin: const EdgeInsets.symmetric(
horizontal: 0.5,
),
decoration: BoxDecoration(
color: widget.color.withOpacity(barOpacity),
borderRadius: BorderRadius.circular(2),
),
height: 32 * (waveform[i] / 1024),
width: 3,
),
],
),
);
// return Container(
// height: 32,
// width: 2,
// alignment: Alignment.center,
// child: Opacity(
// opacity: barOpacity,
// child: Container(
// margin: const EdgeInsets.symmetric(
// horizontal: 1,
// ),
// decoration: BoxDecoration(
// color: widget.color,
// borderRadius: BorderRadius.circular(2),
// ),
// height: 32 * (waveform[i] / 1024),
// width: 2,
// ),
// ),
// );
},
),
],
),
const SizedBox(width: 5),
// SizedBox(

View file

@ -186,7 +186,11 @@ class Message extends StatelessWidget {
if (animateIn && resetAnimateIn != null) {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
animateIn = false;
setState(resetAnimateIn);
// #Pangea
if (context.mounted) {
// Pangea#
setState(resetAnimateIn);
}
});
}
return AnimatedSize(

View file

@ -123,7 +123,6 @@ class MessageContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
// debugger(when: overlayController != null);
final fontSize = AppConfig.messageFontSize * AppConfig.fontSizeFactor;
final buttonTextColor = textColor;
switch (event.type) {
@ -307,7 +306,6 @@ class MessageContent extends StatelessWidget {
height: 1.3,
);
// debugger(when: overlayController != null);
if (overlayController != null && pangeaMessageEvent != null) {
return OverlayMessageText(
pangeaMessageEvent: pangeaMessageEvent!,

View file

@ -478,6 +478,8 @@ class InputBar extends StatelessWidget {
// builder: (context, controller, focusNode) => TextField(
builder: (context, _, focusNode) => TextField(
enableSuggestions: false,
readOnly:
controller != null && controller!.choreographer.isRunningIT,
// Pangea#
controller: controller,
focusNode: focusNode,

View file

@ -7,7 +7,6 @@ import 'package:fluffychat/pangea/pages/class_settings/class_name_header.dart';
import 'package:fluffychat/pangea/pages/class_settings/p_class_widgets/class_description_button.dart';
import 'package:fluffychat/pangea/pages/class_settings/p_class_widgets/class_details_toggle_add_students_tile.dart';
import 'package:fluffychat/pangea/pages/class_settings/p_class_widgets/class_invitation_buttons.dart';
import 'package:fluffychat/pangea/pages/class_settings/p_class_widgets/class_name_button.dart';
import 'package:fluffychat/pangea/pages/class_settings/p_class_widgets/room_capacity_button.dart';
import 'package:fluffychat/pangea/utils/lock_room.dart';
import 'package:fluffychat/pangea/widgets/conversation_bot/conversation_bot_settings.dart';
@ -213,11 +212,6 @@ class ChatDetailsView extends StatelessWidget {
),
Divider(color: theme.dividerColor),
// #Pangea
if (room.isRoomAdmin)
ClassNameButton(
room: room,
controller: controller,
),
if (room.canSendEvent('m.room.topic'))
ClassDescriptionButton(
room: room,

View file

@ -1,4 +1,5 @@
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:fluffychat/pangea/pages/settings_learning/settings_learning.dart';
import 'package:fluffychat/pangea/utils/logout.dart';
import 'package:fluffychat/pangea/utils/space_code.dart';
import 'package:fluffychat/widgets/avatar.dart';
@ -7,7 +8,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:go_router/go_router.dart';
import 'package:keyboard_shortcuts/keyboard_shortcuts.dart';
// import 'package:keyboard_shortcuts/keyboard_shortcuts.dart';
import 'package:matrix/matrix.dart';
import 'chat_list.dart';
@ -41,30 +42,17 @@ class ClientChooserButton extends StatelessWidget {
],
),
),
PopupMenuItem(
value: SettingsAction.learning,
child: Row(
children: [
const Icon(Icons.psychology_outlined),
const SizedBox(width: 18),
Expanded(child: Text(L10n.of(context)!.learningSettings)),
],
),
),
// PopupMenuItem(
// value: SettingsAction.newGroup,
// child: Row(
// children: [
// const Icon(Icons.group_add_outlined),
// const SizedBox(width: 18),
// Text(L10n.of(context)!.createGroup),
// ],
// ),
// ),
// Pangea#
PopupMenuItem(
value: SettingsAction.newGroup,
child: Row(
children: [
const Icon(Icons.group_add_outlined),
const SizedBox(width: 18),
// #Pangea
Expanded(child: Text(L10n.of(context)!.createGroup)),
// Text(L10n.of(context)!.createGroup),
// Pangea#
],
),
),
PopupMenuItem(
value: SettingsAction.newSpace,
child: Row(
@ -79,6 +67,16 @@ class ClientChooserButton extends StatelessWidget {
),
),
// #Pangea
PopupMenuItem(
value: SettingsAction.learning,
child: Row(
children: [
const Icon(Icons.psychology_outlined),
const SizedBox(width: 18),
Expanded(child: Text(L10n.of(context)!.learningSettings)),
],
),
),
// PopupMenuItem(
// value: SettingsAction.setStatus,
// child: Row(
@ -216,60 +214,71 @@ class ClientChooserButton extends StatelessWidget {
var clientCount = 0;
matrix.accountBundles.forEach((key, value) => clientCount += value.length);
return FutureBuilder<Profile>(
future: matrix.client.fetchOwnProfile(),
builder: (context, snapshot) => Stack(
alignment: Alignment.center,
children: [
...List.generate(
clientCount,
(index) => KeyBoardShortcuts(
keysToPress: _buildKeyboardShortcut(index + 1),
helpLabel: L10n.of(context)!.switchToAccount(index + 1),
onKeysPressed: () => _handleKeyboardShortcut(
matrix,
index,
context,
),
child: const SizedBox.shrink(),
// #Pangea
return matrix.client.userID == null
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator.adaptive(),
)
:
// Pangea#
FutureBuilder<Profile>(
future: matrix.client.fetchOwnProfile(),
builder: (context, snapshot) => Stack(
alignment: Alignment.center,
children: [
// #Pangea
// ...List.generate(
// clientCount,
// (index) => KeyBoardShortcuts(
// keysToPress: _buildKeyboardShortcut(index + 1),
// helpLabel: L10n.of(context)!.switchToAccount(index + 1),
// onKeysPressed: () => _handleKeyboardShortcut(
// matrix,
// index,
// context,
// ),
// child: const SizedBox.shrink(),
// ),
// ),
// KeyBoardShortcuts(
// keysToPress: {
// LogicalKeyboardKey.controlLeft,
// LogicalKeyboardKey.tab,
// },
// helpLabel: L10n.of(context)!.nextAccount,
// onKeysPressed: () => _nextAccount(matrix, context),
// child: const SizedBox.shrink(),
// ),
// KeyBoardShortcuts(
// keysToPress: {
// LogicalKeyboardKey.controlLeft,
// LogicalKeyboardKey.shiftLeft,
// LogicalKeyboardKey.tab,
// },
// helpLabel: L10n.of(context)!.previousAccount,
// onKeysPressed: () => _previousAccount(matrix, context),
// child: const SizedBox.shrink(),
// ),
// Pangea#
PopupMenuButton<Object>(
onSelected: (o) => _clientSelected(o, context),
itemBuilder: _bundleMenuItems,
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(99),
child: Avatar(
mxContent: snapshot.data?.avatarUrl,
name: snapshot.data?.displayName ??
matrix.client.userID!.localpart,
size: 32,
),
),
),
],
),
),
KeyBoardShortcuts(
keysToPress: {
LogicalKeyboardKey.controlLeft,
LogicalKeyboardKey.tab,
},
helpLabel: L10n.of(context)!.nextAccount,
onKeysPressed: () => _nextAccount(matrix, context),
child: const SizedBox.shrink(),
),
KeyBoardShortcuts(
keysToPress: {
LogicalKeyboardKey.controlLeft,
LogicalKeyboardKey.shiftLeft,
LogicalKeyboardKey.tab,
},
helpLabel: L10n.of(context)!.previousAccount,
onKeysPressed: () => _previousAccount(matrix, context),
child: const SizedBox.shrink(),
),
PopupMenuButton<Object>(
onSelected: (o) => _clientSelected(o, context),
itemBuilder: _bundleMenuItems,
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(99),
child: Avatar(
mxContent: snapshot.data?.avatarUrl,
name: snapshot.data?.displayName ??
matrix.client.userID!.localpart,
size: 32,
),
),
),
],
),
);
);
}
Set<LogicalKeyboardKey>? _buildKeyboardShortcut(int index) {
@ -304,9 +313,11 @@ class ClientChooserButton extends StatelessWidget {
if (consent != OkCancelResult.ok) return;
context.go('/rooms/settings/addaccount');
break;
case SettingsAction.newGroup:
context.go('/rooms/newgroup');
break;
// #Pangea
// case SettingsAction.newGroup:
// context.go('/rooms/newgroup');
// break;
// Pangea#
case SettingsAction.newSpace:
controller.createNewSpace();
break;
@ -326,7 +337,10 @@ class ClientChooserButton extends StatelessWidget {
// controller.setStatus();
// break;
case SettingsAction.learning:
context.go('/rooms/settings/learning');
showDialog(
context: context,
builder: (c) => const SettingsLearning(),
);
break;
case SettingsAction.joinWithClassCode:
SpaceCodeUtil.joinWithSpaceCodeDialog(
@ -414,7 +428,9 @@ class ClientChooserButton extends StatelessWidget {
enum SettingsAction {
addAccount,
newGroup,
// #Pangea
// newGroup,
// Pangea#
newSpace,
// #Pangea
// setStatus,

View file

@ -317,14 +317,14 @@ class _SpaceViewState extends State<SpaceView> {
key: AddRoomType.subspace,
// #Pangea
// label: L10n.of(context)!.createNewSpace,
label: L10n.of(context)!.newChat,
label: L10n.of(context)!.newSpace,
// Pangea#
),
AlertDialogAction(
key: AddRoomType.chat,
// #Pangea
// label: L10n.of(context)!.createGroup,
label: L10n.of(context)!.createChat,
label: L10n.of(context)!.newChat,
// Pangea#
),
],
@ -404,6 +404,9 @@ class _SpaceViewState extends State<SpaceView> {
),
]
: null,
// #Pangea
enableEncryption: false,
// Pangea#
);
}
await activeSpace.setSpaceChild(roomId);

View file

@ -74,6 +74,9 @@ class NewGroupController extends State<NewGroup> {
content: {'url': avatarUrl.toString()},
),
],
// #Pangea
enableEncryption: false,
// Pangea#
);
if (!mounted) return;
context.go('/rooms/$roomId/invite');

View file

@ -24,7 +24,10 @@ class NewGroupView extends StatelessWidget {
onPressed: controller.loading ? null : Navigator.of(context).pop,
),
),
title: Text(L10n.of(context)!.createGroup),
// #Pangea
// title: Text(L10n.of(context)!.createGroup),
title: Text(L10n.of(context)!.newChat),
// Pangea#
),
body: MaxWidthBody(
child: Column(

View file

@ -136,7 +136,8 @@ class NewSpaceController extends State<NewSpace> {
await room.invite(BotName.byEnvironment);
} catch (err) {
ErrorHandler.logError(
e: "Failed to invite pangea bot to space ${room.id}",
e: "Failed to invite pangea bot to new space",
data: {"spaceId": spaceId, "error": err},
);
}
MatrixState.pangeaController.classController

View file

@ -162,11 +162,6 @@ class SettingsView extends StatelessWidget {
title: Text(L10n.of(context)!.subscriptionManagement),
onTap: () => context.go('/rooms/settings/subscription'),
),
ListTile(
leading: const Icon(Icons.psychology_outlined),
title: Text(L10n.of(context)!.learningSettings),
onTap: () => context.go('/rooms/settings/learning'),
),
// Pangea#
ListTile(
leading: const Icon(Icons.shield_outlined),

View file

@ -54,8 +54,9 @@ class SettingsSecurityController extends State<SettingsSecurity> {
// #Pangea
final subscriptionController =
MatrixState.pangeaController.subscriptionController;
if (subscriptionController.subscription?.isPaidSubscription == true &&
subscriptionController.subscription?.defaultManagementURL != null) {
if (subscriptionController.currentSubscriptionInfo?.isPaidSubscription ==
true &&
subscriptionController.defaultManagementURL != null) {
final resp = await showOkCancelAlertDialog(
useRootNavigator: false,
context: context,
@ -66,7 +67,7 @@ class SettingsSecurityController extends State<SettingsSecurity> {
);
if (resp == OkCancelResult.ok) {
launchUrlString(
subscriptionController.subscription!.defaultManagementURL!,
subscriptionController.defaultManagementURL!,
mode: LaunchMode.externalApplication,
);
return;

View file

@ -68,14 +68,17 @@ class Choreographer {
}
void send(BuildContext context) {
if (isFetching) return;
if (!canSendMessage) return;
if (pangeaController.subscriptionController.canSendStatus ==
CanSendStatus.showPaywall) {
if (pangeaController.subscriptionController.subscriptionStatus ==
SubscriptionStatus.showPaywall) {
OverlayUtil.showPositionedCard(
context: context,
cardToShow: const PaywallCard(),
cardSize: const Size(325, 325),
cardToShow: PaywallCard(
chatController: chatController,
),
maxHeight: 325,
maxWidth: 325,
transformTargetId: inputTransformTargetKey,
);
return;
@ -89,7 +92,7 @@ class Choreographer {
}
Future<void> _sendWithIGC(BuildContext context) async {
if (!igc.canSendMessage) {
if (!canSendMessage) {
igc.showFirstMatch(context);
return;
}
@ -242,23 +245,26 @@ class Choreographer {
}) async {
try {
if (errorService.isError) return;
final CanSendStatus canSendStatus =
pangeaController.subscriptionController.canSendStatus;
final SubscriptionStatus canSendStatus =
pangeaController.subscriptionController.subscriptionStatus;
if (canSendStatus != CanSendStatus.subscribed ||
if (canSendStatus != SubscriptionStatus.subscribed ||
(!igcEnabled && !itEnabled) ||
(!isAutoIGCEnabled && !manual && choreoMode != ChoreoMode.it)) {
return;
}
startLoading();
// if getting language assistance after finishing IT,
// reset the itController
if (choreoMode == ChoreoMode.it &&
itController.isTranslationDone &&
!onlyTokensAndLanguageDetection) {
// debugger(when: kDebugMode);
itController.clear();
}
await (choreoMode == ChoreoMode.it && !itController.isTranslationDone
await (isRunningIT
? itController.getTranslationData(_useCustomInput)
: igc.getIGCTextData(
onlyTokensAndLanguageDetection: onlyTokensAndLanguageDetection,
@ -415,7 +421,7 @@ class Choreographer {
setState();
}
giveInputFocus() {
void giveInputFocus() {
Future.delayed(Duration.zero, () {
chatController.inputFocus.requestFocus();
});
@ -475,6 +481,9 @@ class Choreographer {
bool get _noChange =>
_lastChecked != null && _lastChecked == _textController.text;
bool get isRunningIT =>
choreoMode == ChoreoMode.it && !itController.isTranslationDone;
void startLoading() {
_lastChecked = _textController.text;
isFetching = true;
@ -502,8 +511,6 @@ class Choreographer {
}
}
bool get showIsError => !itController.isOpen && errorService.isError;
LayerLinkAndKey get itBarLinkAndKey =>
MatrixState.pAnyState.layerLinkAndKey(itBarTransformTargetKey);
@ -529,9 +536,9 @@ class Choreographer {
chatController.room,
);
bool get itAutoPlayEnabled {
return pangeaController.userController.profile.userSettings.itAutoPlay;
}
// bool get itAutoPlayEnabled {
// return pangeaController.userController.profile.userSettings.itAutoPlay;
// }
bool get definitionsEnabled =>
pangeaController.permissionsController.isToolEnabled(
@ -567,7 +574,7 @@ class Choreographer {
return AssistanceState.noMessage;
}
if (igc.igcTextData?.matches.isNotEmpty ?? false) {
if ((igc.igcTextData?.matches.isNotEmpty ?? false) || isRunningIT) {
return AssistanceState.fetched;
}
@ -581,4 +588,33 @@ class Choreographer {
return AssistanceState.complete;
}
bool get canSendMessage {
// if there's an error, let them send. we don't want to block them from sending in this case
if (errorService.isError) return true;
// if they're in IT mode, don't let them send
if (itEnabled && isRunningIT) return false;
// if they've turned off IGC then let them send the message when they want
if (!isAutoIGCEnabled) return true;
// if we're in the middle of fetching results, don't let them send
if (isFetching) return false;
// they're supposed to run IGC but haven't yet, don't let them send
if (isAutoIGCEnabled && igc.igcTextData == null) return false;
// if they have relevant matches, don't let them send
final hasITMatches =
igc.igcTextData!.matches.any((match) => match.isITStart);
final hasIGCMatches =
igc.igcTextData!.matches.any((match) => !match.isITStart);
if ((itEnabled && hasITMatches) || (igcEnabled && hasIGCMatches)) {
return false;
}
// otherwise, let them send
return true;
}
}

View file

@ -99,7 +99,7 @@ class IgcController {
final PangeaMatch match = igcTextData!.matches[firstMatchIndex];
if (match.isITStart &&
choreographer.itAutoPlayEnabled &&
// choreographer.itAutoPlayEnabled &&
igcTextData != null) {
choreographer.onITStart(igcTextData!.matches[firstMatchIndex]);
return;
@ -125,7 +125,8 @@ class IgcController {
),
roomId: choreographer.roomId,
),
cardSize: match.isITStart ? const Size(350, 260) : const Size(350, 350),
maxHeight: match.isITStart ? 260 : 350,
maxWidth: 350,
transformTargetId: choreographer.inputTransformTargetKey,
);
}
@ -191,18 +192,4 @@ class IgcController {
// Not sure why this is here
// MatrixState.pAnyState.closeOverlay();
}
bool get canSendMessage {
if (choreographer.isFetching) return false;
if (igcTextData == null ||
choreographer.errorService.isError ||
igcTextData!.matches.isEmpty) {
return true;
}
return !((choreographer.itEnabled &&
igcTextData!.matches.any((match) => match.isOutOfTargetMatch)) ||
(choreographer.igcEnabled &&
igcTextData!.matches.any((match) => !match.isOutOfTargetMatch)));
}
}

View file

@ -4,7 +4,6 @@ import 'dart:developer';
import 'package:fluffychat/pangea/choreographer/controllers/error_service.dart';
import 'package:fluffychat/pangea/constants/choreo_constants.dart';
import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/enum/instructions_enum.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:flutter/foundation.dart';
@ -69,9 +68,10 @@ class ITController {
}
void closeIT() {
//if they close it before completing, just put their text back
//PTODO - explore using last itStep
choreographer.textController.text = sourceText ?? "";
// if the user hasn't gone through any IT steps, reset the text
if (completedITSteps.isEmpty && sourceText != null) {
choreographer.textController.text = sourceText!;
}
clear();
}
@ -180,6 +180,18 @@ class ITController {
}
Future<void> getNextTranslationData() async {
if (sourceText == null) {
ErrorHandler.logError(
e: Exception("sourceText is null in getNextTranslationData"),
data: {
"sourceText": sourceText,
"currentITStep": currentITStep,
"nextITStep": nextITStep,
},
);
return;
}
try {
if (completedITSteps.length < goldRouteTracker.continuances.length) {
final String currentText = choreographer.currentText;

View file

@ -44,17 +44,13 @@ class ChoicesArrayState extends State<ChoicesArray> {
void disableInteraction() {
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
interactionDisabled = true;
});
if (mounted) setState(() => interactionDisabled = true);
});
}
void enableInteractions() {
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
interactionDisabled = false;
});
if (mounted) setState(() => interactionDisabled = false);
});
}

View file

@ -1,15 +1,15 @@
import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart';
import 'package:flutter/material.dart';
import '../../controllers/pangea_controller.dart';
import '../controllers/error_service.dart';
class ChoreographerHasErrorButton extends StatelessWidget {
final ChoreoError error;
final PangeaController pangeaController;
final Choreographer choreographer;
const ChoreographerHasErrorButton(
this.pangeaController,
this.error, {
this.error,
this.choreographer, {
super.key,
});
@ -26,6 +26,7 @@ class ChoreographerHasErrorButton extends StatelessWidget {
),
),
);
choreographer.errorService.resetError();
}
},
mini: true,

View file

@ -341,7 +341,8 @@ class ITChoices extends StatelessWidget {
),
choiceFeedback: choiceFeedback,
),
cardSize: const Size(300, 300),
maxHeight: 300,
maxWidth: 300,
borderColor: borderColor,
transformTargetId: controller.choreographer.itBarTransformTargetKey,
backDropToDismiss: false,
@ -351,7 +352,7 @@ class ITChoices extends StatelessWidget {
void selectContinuance(int index, BuildContext context) {
final Continuance continuance =
controller.currentITStep!.continuances[index];
if (continuance.level == 1 || continuance.wasClicked) {
if (continuance.level == 1) {
Future.delayed(
const Duration(milliseconds: 500),
() => controller.selectTranslation(index),

View file

@ -103,7 +103,7 @@ class ITFeedbackCardController extends State<ITFeedbackCard> {
@override
Widget build(BuildContext context) => error == null
? ITFeedbackCardView(controller: this)
: CardErrorWidget(error: error);
: CardErrorWidget(error: error!);
}
class ITFeedbackCardView extends StatelessWidget {

View file

@ -3,12 +3,12 @@ import 'dart:developer';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart';
import 'package:fluffychat/pangea/models/space_model.dart';
import 'package:fluffychat/pangea/pages/settings_learning/settings_learning.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart';
import '../../../widgets/matrix.dart';
@ -52,7 +52,12 @@ class LanguagePermissionsButtons extends StatelessWidget {
text: copy.description,
style: const TextStyle(color: AppConfig.primaryColor),
recognizer: TapGestureRecognizer()
..onTap = () => context.go('/rooms/settings/learning'),
..onTap = () {
showDialog(
context: context,
builder: (c) => const SettingsLearning(),
);
},
),
],
),

View file

@ -56,7 +56,10 @@ class ChoreographerSendButtonState extends State<ChoreographerSendButton> {
color: widget.controller.choreographer.assistanceState
.stateColor(context),
onPressed: () {
widget.controller.choreographer.send(context);
widget.controller.choreographer.canSendMessage
? widget.controller.choreographer.send(context)
: widget.controller.choreographer.igc
.showFirstMatch(context);
},
tooltip: L10n.of(context)!.send,
),

View file

@ -63,10 +63,13 @@ class StartIGCButtonState extends State<StartIGCButton>
bool get itEnabled => widget.controller.choreographer.itEnabled;
bool get igcEnabled => widget.controller.choreographer.igcEnabled;
CanSendStatus get canSendStatus =>
widget.controller.pangeaController.subscriptionController.canSendStatus;
SubscriptionStatus get subscriptionStatus => widget
.controller.pangeaController.subscriptionController.subscriptionStatus;
bool get grammarCorrectionEnabled =>
(itEnabled || igcEnabled) && canSendStatus == CanSendStatus.subscribed;
(itEnabled || igcEnabled) &&
subscriptionStatus == SubscriptionStatus.subscribed;
@override
Widget build(BuildContext context) {

View file

@ -24,7 +24,7 @@ class ModelKey {
// making this a random string so that it's harder to guess
static const String activatedTrialKey = '7C4EuKIsph';
static const String autoPlayMessages = 'autoPlayMessages';
static const String itAutoPlay = 'itAutoPlay';
static const String itAutoPlay = 'autoPlayIT';
static const String clientClassCity = "city";
static const String clientClassCountry = "country";

View file

@ -1,18 +1,18 @@
import 'dart:async';
class BaseController<T> {
final StreamController<T> stateListener = StreamController<T>();
final StreamController<T> _stateListener = StreamController<T>();
late Stream<T> stateStream;
BaseController() {
stateStream = stateListener.stream.asBroadcastStream();
stateStream = _stateListener.stream.asBroadcastStream();
}
dispose() {
stateListener.close();
_stateListener.close();
}
setState(T data) {
stateListener.add(data);
_stateListener.add(data);
}
}

View file

@ -22,7 +22,7 @@ import 'package:sentry_flutter/sentry_flutter.dart';
class GetAnalyticsController {
late PangeaController _pangeaController;
final List<AnalyticsCacheEntry> _cache = [];
StreamSubscription<AnalyticsUpdateType>? _analyticsUpdateSubscription;
StreamSubscription<AnalyticsUpdate>? _analyticsUpdateSubscription;
CachedStreamController<List<OneConstructUse>> analyticsStream =
CachedStreamController<List<OneConstructUse>>();
@ -87,8 +87,9 @@ class GetAnalyticsController {
prevXP = null;
}
Future<void> onAnalyticsUpdate(AnalyticsUpdateType type) async {
if (type == AnalyticsUpdateType.server) {
Future<void> onAnalyticsUpdate(AnalyticsUpdate analyticsUpdate) async {
if (analyticsUpdate.isLogout) return;
if (analyticsUpdate.type == AnalyticsUpdateType.server) {
await getConstructs(forceUpdate: true);
}
updateAnalyticsStream();
@ -161,7 +162,8 @@ class GetAnalyticsController {
return formattedCache;
} catch (err) {
// if something goes wrong while trying to format the local data, clear it
_pangeaController.myAnalytics.clearMessagesSinceUpdate();
_pangeaController.myAnalytics
.clearMessagesSinceUpdate(clearDrafts: true);
return {};
}
} catch (exception, stackTrace) {

View file

@ -14,26 +14,26 @@ class LanguageDetectionRequest {
/// The full text from which to detect the language.
String fullText;
/// The base language of the user, if known. Including this is much preferred
/// The base language of the user that sent the meessage, if known. Including this is much preferred
/// and should return better results; however, it is not absolutely necessary.
/// This property is nullable to allow for situations where the languages are not set
/// at the time of the request.
String? userL1;
String? senderL1;
/// The target language of the user. This is expected to be set for the request
/// The target language of the user that sent the message. This is expected to be set for the request
/// but is nullable to handle edge cases where it might not be.
String? userL2;
String? senderL2;
LanguageDetectionRequest({
required this.fullText,
this.userL1 = "",
required this.userL2,
required this.senderL1,
required this.senderL2,
});
Map<String, dynamic> toJson() => {
'full_text': fullText,
'user_l1': userL1,
'user_l2': userL2,
'sender_l1': senderL1,
'sender_l2': senderL2,
};
@override
@ -41,12 +41,12 @@ class LanguageDetectionRequest {
if (identical(this, other)) return true;
return other is LanguageDetectionRequest &&
other.fullText == fullText &&
other.userL1 == userL1 &&
other.userL2 == userL2;
other.senderL1 == senderL1 &&
other.senderL2 == senderL2;
}
@override
int get hashCode => fullText.hashCode ^ userL1.hashCode ^ userL2.hashCode;
int get hashCode => fullText.hashCode ^ senderL1.hashCode ^ senderL2.hashCode;
}
class LanguageDetectionResponse {
@ -125,19 +125,6 @@ class LanguageDetectionController {
_cacheClearTimer?.cancel();
}
Future<LanguageDetectionResponse> detectLanguage(
String fullText,
String? userL2,
String? userL1,
) async {
final LanguageDetectionRequest params = LanguageDetectionRequest(
fullText: fullText,
userL1: userL1,
userL2: userL2,
);
return get(params);
}
Future<LanguageDetectionResponse> get(
LanguageDetectionRequest params,
) async {

View file

@ -1,14 +1,19 @@
import 'dart:async';
import 'dart:convert';
import 'package:fluffychat/pangea/config/environment.dart';
import 'package:fluffychat/pangea/controllers/base_controller.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/models/representation_content_model.dart';
import 'package:fluffychat/pangea/models/token_api_models.dart';
import 'package:fluffychat/pangea/models/tokens_event_content_model.dart';
import 'package:fluffychat/pangea/repo/tokens_repo.dart';
import 'package:fluffychat/pangea/network/requests.dart';
import 'package:fluffychat/pangea/network/urls.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart';
import 'package:matrix/matrix.dart';
import '../constants/pangea_event_types.dart';
@ -49,6 +54,38 @@ class MessageDataController extends BaseController {
super.dispose();
}
/// get tokens from the server
static Future<TokensResponseModel> _fetchTokens(
String accessToken,
TokensRequestModel request,
) async {
final Requests req = Requests(
choreoApiKey: Environment.choreoApiKey,
accessToken: accessToken,
);
final Response res = await req.post(
url: PApiUrls.tokenize,
body: request.toJson(),
);
final TokensResponseModel response = TokensResponseModel.fromJson(
jsonDecode(
utf8.decode(res.bodyBytes).toString(),
),
);
if (response.tokens.isEmpty) {
ErrorHandler.logError(
e: Exception(
"empty tokens in tokenize response return",
),
);
}
return response;
}
/// get tokens from the server
/// if repEventId is not null, send the tokens to the room
Future<List<PangeaToken>> _getTokens({
@ -56,7 +93,7 @@ class MessageDataController extends BaseController {
required TokensRequestModel req,
required Room? room,
}) async {
final TokensResponseModel res = await TokensRepo.tokenize(
final TokensResponseModel res = await _fetchTokens(
_pangeaController.userController.accessToken,
req,
);

View file

@ -21,8 +21,8 @@ enum AnalyticsUpdateType { server, local }
/// 2) constructs used by the user, both in sending messages and doing practice activities
class MyAnalyticsController extends BaseController<AnalyticsStream> {
late PangeaController _pangeaController;
CachedStreamController<AnalyticsUpdateType> analyticsUpdateStream =
CachedStreamController<AnalyticsUpdateType>();
CachedStreamController<AnalyticsUpdate> analyticsUpdateStream =
CachedStreamController<AnalyticsUpdate>();
StreamSubscription<AnalyticsStream>? _analyticsStream;
Timer? _updateTimer;
@ -237,11 +237,18 @@ class MyAnalyticsController extends BaseController<AnalyticsStream> {
final int newLevel = _pangeaController.analytics.level;
newLevel > prevLevel
? sendLocalAnalyticsToAnalyticsRoom()
: analyticsUpdateStream.add(AnalyticsUpdateType.local);
: analyticsUpdateStream.add(
AnalyticsUpdate(AnalyticsUpdateType.local),
);
}
/// Clears the local cache of recently sent constructs. Called before updating analytics
void clearMessagesSinceUpdate() {
void clearMessagesSinceUpdate({clearDrafts = false}) {
if (clearDrafts) {
_pangeaController.pStoreService.delete(PLocalKey.messagesSinceUpdate);
return;
}
final localCache = _pangeaController.analytics.messagesSinceUpdate;
final draftKeys = localCache.keys.where((key) => key.startsWith('draft'));
if (draftKeys.isEmpty) {
@ -281,7 +288,9 @@ class MyAnalyticsController extends BaseController<AnalyticsStream> {
/// for the completion of the previous update and returns. Otherwise, it creates a new [_updateCompleter] and
/// proceeds with the update process. If the update is successful, it clears any messages that were received
/// since the last update and notifies the [analyticsUpdateStream].
Future<void> sendLocalAnalyticsToAnalyticsRoom() async {
Future<void> sendLocalAnalyticsToAnalyticsRoom({
onLogout = false,
}) async {
if (_pangeaController.matrixState.client.userID == null) return;
if (!(_updateCompleter?.isCompleted ?? true)) {
await _updateCompleter!.future;
@ -293,7 +302,12 @@ class MyAnalyticsController extends BaseController<AnalyticsStream> {
clearMessagesSinceUpdate();
lastUpdated = DateTime.now();
analyticsUpdateStream.add(AnalyticsUpdateType.server);
analyticsUpdateStream.add(
AnalyticsUpdate(
AnalyticsUpdateType.server,
isLogout: onLogout,
),
);
} catch (err, s) {
ErrorHandler.logError(
e: err,
@ -340,3 +354,10 @@ class AnalyticsStream {
required this.constructs,
});
}
class AnalyticsUpdate {
final AnalyticsUpdateType type;
final bool isLogout;
AnalyticsUpdate(this.type, {this.isLogout = false});
}

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:developer';
import 'dart:math';
import 'package:fluffychat/pangea/constants/bot_mode.dart';
import 'package:fluffychat/pangea/constants/class_default_values.dart';
import 'package:fluffychat/pangea/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/controllers/class_controller.dart';
@ -22,6 +23,7 @@ import 'package:fluffychat/pangea/controllers/user_controller.dart';
import 'package:fluffychat/pangea/controllers/word_net_controller.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart';
import 'package:fluffychat/pangea/guard/p_vguard.dart';
import 'package:fluffychat/pangea/models/bot_options_model.dart';
import 'package:fluffychat/pangea/utils/bot_name.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/utils/instructions.dart';
@ -196,7 +198,7 @@ class PangeaController {
return;
}
const List<Room> botDMs = [];
final List<Room> botDMs = [];
for (final room in matrixState.client.rooms) {
if (await room.isBotDM) {
botDMs.add(room);
@ -205,15 +207,88 @@ class PangeaController {
if (botDMs.isEmpty) {
try {
await matrixState.client.startDirectChat(
BotName.byEnvironment,
enableEncryption: false,
// Copied from client.dart.startDirectChat
final directChatRoomId =
matrixState.client.getDirectChatFromUserId(BotName.byEnvironment);
if (directChatRoomId != null) {
final room = matrixState.client.getRoomById(directChatRoomId);
if (room != null) {
if (room.membership == Membership.join) {
return null;
} else if (room.membership == Membership.invite) {
// we might already have an invite into a DM room. If that is the case, we should try to join. If the room is
// unjoinable, that will automatically leave the room, so in that case we need to continue creating a new
// room. (This implicitly also prevents the room from being returned as a DM room by getDirectChatFromUserId,
// because it only returns joined or invited rooms atm.)
await room.join();
if (room.membership != Membership.leave) {
if (room.membership != Membership.join) {
// Wait for room actually appears in sync with the right membership
await matrixState.client
.waitForRoomInSync(directChatRoomId, join: true);
}
return null;
}
}
}
}
// enableEncryption ??=
// encryptionEnabled && await userOwnsEncryptionKeys(mxid);
// if (enableEncryption) {
// initialState ??= [];
// if (!initialState.any((s) => s.type == EventTypes.Encryption)) {
// initialState.add(
// StateEvent(
// content: {
// 'algorithm': supportedGroupEncryptionAlgorithms.first,
// },
// type: EventTypes.Encryption,
// ),
// );
// }
// }
// Start a new direct chat
final roomId = await matrixState.client.createRoom(
invite: [], // intentionally not invite bot yet
isDirect: true,
preset: CreateRoomPreset.trustedPrivateChat,
initialState: [
BotOptionsModel(mode: BotMode.directChat).toStateEvent,
],
);
final room = matrixState.client.getRoomById(roomId);
if (room == null || room.membership != Membership.join) {
// Wait for room actually appears in sync
await matrixState.client.waitForRoomInSync(roomId, join: true);
}
final botOptions = room!.getState(PangeaEventTypes.botOptions);
if (botOptions == null) {
await matrixState.client.setRoomStateWithKey(
roomId,
PangeaEventTypes.botOptions,
"",
BotOptionsModel(mode: BotMode.directChat).toJson(),
);
await matrixState.client
.getRoomStateWithKey(roomId, PangeaEventTypes.botOptions, "");
}
// invite bot to direct chat
await matrixState.client.setRoomStateWithKey(
roomId, EventTypes.RoomMember, BotName.byEnvironment, {
"membership": Membership.invite.name,
"is_direct": true,
});
await room.addToDirectChat(BotName.byEnvironment);
return null;
} catch (err, stack) {
debugger(when: kDebugMode);
ErrorHandler.logError(e: err, s: stack);
}
return;
}
final Room botDMWithLatestActivity = botDMs.reduce((a, b) {
@ -298,7 +373,8 @@ class PangeaController {
await space.invite(BotName.byEnvironment);
} catch (err) {
ErrorHandler.logError(
e: "Failed to invite pangea bot to space ${space.id}",
e: "Failed to invite pangea bot to existing space",
data: {"spaceId": space.id, "error": err},
);
}
}

View file

@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:collection';
import 'dart:developer';
import 'package:fluffychat/pangea/constants/local.key.dart';
import 'package:fluffychat/pangea/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart';
@ -26,66 +25,60 @@ class PracticeActivityRecordController {
static const int maxStoredEvents = 100;
static final Map<int, _RecordCacheItem> _cache = {};
late final PangeaController _pangeaController;
Timer? _cacheClearTimer;
PracticeActivityRecordController(this._pangeaController) {
_initializeCacheClearing();
PracticeActivityRecordController(this._pangeaController);
int getCompletedActivityCount(String messageID) {
return _completedActivities[messageID] ?? 0;
}
LinkedHashMap<String, int> get completedActivities {
try {
final dynamic locallySaved = _pangeaController.pStoreService.read(
PLocalKey.completedActivities,
);
if (locallySaved == null) return LinkedHashMap<String, int>();
try {
final LinkedHashMap<String, int> cache =
LinkedHashMap<String, int>.from(locallySaved);
return cache;
} catch (err) {
_pangeaController.pStoreService.delete(
PLocalKey.completedActivities,
);
return LinkedHashMap<String, int>();
}
} catch (exception, stackTrace) {
ErrorHandler.logError(
e: PangeaWarningError(
"Failed to get completed activities from cache: $exception",
),
s: stackTrace,
m: 'Failed to get completed activities from cache',
);
return LinkedHashMap<String, int>();
}
}
final LinkedHashMap<String, int> _completedActivities =
LinkedHashMap<String, int>();
// LinkedHashMap<String, int> get _completedActivities {
// try {
// final dynamic locallySaved = _pangeaController.pStoreService.read(
// PLocalKey.completedActivities,
// );
// if (locallySaved == null) return LinkedHashMap<String, int>();
// try {
// final LinkedHashMap<String, int> cache =
// LinkedHashMap<String, int>.from(locallySaved);
// return cache;
// } catch (err) {
// _pangeaController.pStoreService.delete(
// PLocalKey.completedActivities,
// );
// return LinkedHashMap<String, int>();
// }
// } catch (exception, stackTrace) {
// ErrorHandler.logError(
// e: PangeaWarningError(
// "Failed to get completed activities from cache: $exception",
// ),
// s: stackTrace,
// m: 'Failed to get completed activities from cache',
// );
// return LinkedHashMap<String, int>();
// }
// }
Future<void> completeActivity(String messageID) async {
final LinkedHashMap<String, int> currentCache = completedActivities;
final numCompleted = currentCache[messageID] ?? 0;
currentCache[messageID] = numCompleted + 1;
final numCompleted = _completedActivities[messageID] ?? 0;
_completedActivities[messageID] = numCompleted + 1;
// final LinkedHashMap<String, int> currentCache = _completedActivities;
// final numCompleted = currentCache[messageID] ?? 0;
// currentCache[messageID] = numCompleted + 1;
if (currentCache.length > maxStoredEvents) {
currentCache.remove(currentCache.keys.first);
}
// if (currentCache.length > maxStoredEvents) {
// currentCache.remove(currentCache.keys.first);
// }
await _pangeaController.pStoreService.save(
PLocalKey.completedActivities,
currentCache,
);
}
void _initializeCacheClearing() {
const duration = Duration(minutes: 2);
_cacheClearTimer = Timer.periodic(duration, (Timer t) => _clearCache());
}
void _clearCache() {
_cache.clear();
}
void dispose() {
_cacheClearTimer?.cancel();
// await _pangeaController.pStoreService.save(
// PLocalKey.completedActivities,
// currentCache,
// );
debugPrint("completed activities is now: $_completedActivities");
}
/// Sends a practice activity record to the server and returns the corresponding event.

View file

@ -1,11 +1,12 @@
import 'dart:async';
import 'dart:convert';
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/config/environment.dart';
import 'package:fluffychat/pangea/constants/local.key.dart';
import 'package:fluffychat/pangea/controllers/base_controller.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/controllers/user_controller.dart';
import 'package:fluffychat/pangea/models/base_subscription_info.dart';
import 'package:fluffychat/pangea/models/mobile_subscriptions.dart';
import 'package:fluffychat/pangea/models/web_subscriptions.dart';
@ -13,6 +14,7 @@ import 'package:fluffychat/pangea/network/requests.dart';
import 'package:fluffychat/pangea/network/urls.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/utils/firebase_analytics.dart';
import 'package:fluffychat/pangea/utils/subscription_app_id.dart';
import 'package:fluffychat/pangea/widgets/subscription/subscription_paywall.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:flutter/foundation.dart';
@ -23,7 +25,7 @@ import 'package:http/http.dart';
import 'package:purchases_flutter/purchases_flutter.dart';
import 'package:url_launcher/url_launcher_string.dart';
enum CanSendStatus {
enum SubscriptionStatus {
subscribed,
dimissedPaywall,
showPaywall,
@ -31,7 +33,10 @@ enum CanSendStatus {
class SubscriptionController extends BaseController {
late PangeaController _pangeaController;
SubscriptionInfo? subscription;
CurrentSubscriptionInfo? currentSubscriptionInfo;
AvailableSubscriptionsInfo? availableSubscriptionInfo;
final StreamController subscriptionStream = StreamController.broadcast();
final StreamController trialActivationStream = StreamController.broadcast();
@ -39,10 +44,11 @@ class SubscriptionController extends BaseController {
_pangeaController = pangeaController;
}
UserController get userController => _pangeaController.userController;
String? get userID => _pangeaController.matrixState.client.userID;
bool get isSubscribed =>
subscription != null &&
(subscription!.currentSubscriptionId != null ||
subscription!.currentSubscription != null);
currentSubscriptionInfo?.currentSubscriptionId != null;
bool _isInitializing = false;
Completer<void> initialized = Completer<void>();
@ -67,18 +73,28 @@ class SubscriptionController extends BaseController {
Future<void> _initialize() async {
try {
if (_pangeaController.matrixState.client.userID == null) {
if (userID == null) {
debugPrint(
"Attempted to initalize subscription information with null userId",
);
return;
}
subscription = kIsWeb
? WebSubscriptionInfo(pangeaController: _pangeaController)
: MobileSubscriptionInfo(pangeaController: _pangeaController);
availableSubscriptionInfo = AvailableSubscriptionsInfo();
await availableSubscriptionInfo!.setAvailableSubscriptions();
await subscription!.configure();
currentSubscriptionInfo = kIsWeb
? WebSubscriptionInfo(
userID: userID!,
availableSubscriptionInfo: availableSubscriptionInfo!,
)
: MobileSubscriptionInfo(
userID: userID!,
availableSubscriptionInfo: availableSubscriptionInfo!,
);
await currentSubscriptionInfo!.configure();
await currentSubscriptionInfo!.setCurrentSubscription();
if (_activatedNewUserTrial) {
setNewUserTrial();
}
@ -101,7 +117,7 @@ class SubscriptionController extends BaseController {
await _pangeaController.pStoreService.delete(
PLocalKey.beganWebPayment,
);
if (_pangeaController.subscriptionController.isSubscribed) {
if (isSubscribed) {
subscriptionStream.add(true);
}
}
@ -170,7 +186,7 @@ class SubscriptionController extends BaseController {
return;
}
ErrorHandler.logError(
m: "Failed to purchase revenuecat package for user ${_pangeaController.matrixState.client.userID} with error code $errCode",
m: "Failed to purchase revenuecat package for user $userID with error code $errCode",
s: StackTrace.current,
);
return;
@ -178,14 +194,19 @@ class SubscriptionController extends BaseController {
}
}
bool get _activatedNewUserTrial {
final bool activated = _pangeaController
.userController.profile.userSettings.activatedFreeTrial;
return _pangeaController.userController.inTrialWindow && activated;
}
int get currentTrialDays => userController.inTrialWindow(trialDays: 1)
? 1
: userController.inTrialWindow(trialDays: 7)
? 7
: 0;
bool get _activatedNewUserTrial =>
userController.inTrialWindow(trialDays: 1) ||
(userController.inTrialWindow() &&
userController.profile.userSettings.activatedFreeTrial);
void activateNewUserTrial() {
_pangeaController.userController.updateProfile(
userController.updateProfile(
(profile) {
profile.userSettings.activatedFreeTrial = true;
return profile;
@ -196,8 +217,7 @@ class SubscriptionController extends BaseController {
}
void setNewUserTrial() {
final DateTime? createdAt =
_pangeaController.userController.profile.userSettings.createdAt;
final DateTime? createdAt = userController.profile.userSettings.createdAt;
if (createdAt == null) {
ErrorHandler.logError(
m: "Null user profile createAt in subscription settings",
@ -207,31 +227,26 @@ class SubscriptionController extends BaseController {
}
final DateTime expirationDate = createdAt.add(
const Duration(days: 7),
Duration(days: currentTrialDays),
);
subscription?.setTrial(expirationDate);
currentSubscriptionInfo?.setTrial(expirationDate);
}
Future<void> updateCustomerInfo() async {
if (!initialized.isCompleted) {
await initialize();
}
if (subscription == null) {
ErrorHandler.logError(
m: "Null subscription info in subscription settings",
s: StackTrace.current,
);
return;
}
await subscription!.setCustomerInfo();
await currentSubscriptionInfo!.setCurrentSubscription();
setState(null);
}
CanSendStatus get canSendStatus => isSubscribed
? CanSendStatus.subscribed
/// if the user is subscribed, returns subscribed
/// if the user has dismissed the paywall, returns dismissed
SubscriptionStatus get subscriptionStatus => isSubscribed
? SubscriptionStatus.subscribed
: _shouldShowPaywall
? CanSendStatus.showPaywall
: CanSendStatus.dimissedPaywall;
? SubscriptionStatus.showPaywall
: SubscriptionStatus.dimissedPaywall;
DateTime? get _lastDismissedPaywall {
final lastDismissed = _pangeaController.pStoreService.read(
@ -249,6 +264,7 @@ class SubscriptionController extends BaseController {
return backoff;
}
/// whether or not the paywall should be shown
bool get _shouldShowPaywall {
return initialized.isCompleted &&
!isSubscribed &&
@ -281,7 +297,7 @@ class SubscriptionController extends BaseController {
if (!initialized.isCompleted) {
await initialize();
}
if (subscription?.availableSubscriptions.isEmpty ?? true) {
if (availableSubscriptionInfo?.availableSubscriptions.isEmpty ?? true) {
return;
}
if (isSubscribed) return;
@ -307,70 +323,51 @@ class SubscriptionController extends BaseController {
}
}
Future<String> getPaymentLink(String duration, {bool isPromo = false}) async {
Future<String> getPaymentLink(
SubscriptionDuration duration, {
bool isPromo = false,
}) async {
final Requests req = Requests(baseUrl: PApiUrls.baseAPI);
final String reqUrl = Uri.encodeFull(
"${PApiUrls.paymentLink}?pangea_user_id=${_pangeaController.matrixState.client.userID}&duration=$duration&redeem=$isPromo",
"${PApiUrls.paymentLink}?pangea_user_id=$userID&duration=${duration.value}&redeem=$isPromo",
);
final Response res = await req.get(url: reqUrl);
final json = jsonDecode(res.body);
String paymentLink = json["link"]["url"];
final String? email = await _pangeaController.userController.userEmail;
final String? email = await userController.userEmail;
if (email != null) {
paymentLink += "?prefilled_email=${Uri.encodeComponent(email)}";
}
return paymentLink;
}
Future<bool> fetchSubscriptionStatus() async {
final Requests req = Requests(baseUrl: PApiUrls.baseAPI);
final String reqUrl = Uri.encodeFull(
"${PApiUrls.subscriptionExpiration}?pangea_user_id=${_pangeaController.matrixState.client.userID}",
);
String? get defaultManagementURL =>
currentSubscriptionInfo?.currentSubscription
?.defaultManagementURL(availableSubscriptionInfo?.appIds);
}
DateTime? expiration;
try {
final Response res = await req.get(url: reqUrl);
final json = jsonDecode(res.body);
if (json["premium_expires_date"] != null) {
expiration = DateTime.parse(json["premium_expires_date"]);
}
} catch (err) {
ErrorHandler.logError(
e: "Failed to fetch subscripton status for user ${_pangeaController.matrixState.client.userID}",
s: StackTrace.current,
);
}
final bool subscribed =
expiration == null ? false : DateTime.now().isBefore(expiration);
GoogleAnalytics.updateUserSubscriptionStatus(subscribed);
return subscribed;
}
enum SubscriptionPeriodType {
normal,
trial,
}
Future<void> redeemPromoCode(BuildContext context) async {
final List<String>? promoCode = await showTextInputDialog(
useRootNavigator: false,
context: context,
title: L10n.of(context)!.enterPromoCode,
okLabel: L10n.of(context)!.ok,
cancelLabel: L10n.of(context)!.cancel,
textFields: [const DialogTextField()],
);
if (promoCode == null || promoCode.single.isEmpty) return;
launchUrlString(
"${AppConfig.iosPromoCode}${promoCode.single}",
);
}
enum SubscriptionDuration {
month,
year,
}
extension SubscriptionDurationExtension on SubscriptionDuration {
String get value => this == SubscriptionDuration.month ? "month" : "year";
}
class SubscriptionDetails {
double price;
String? duration;
Package? package;
String? appId;
final double price;
final SubscriptionDuration? duration;
final String? appId;
final String id;
String? periodType = "normal";
SubscriptionPeriodType periodType;
Package? package;
SubscriptionDetails({
required this.price,
@ -378,30 +375,35 @@ class SubscriptionDetails {
this.duration,
this.package,
this.appId,
this.periodType,
this.periodType = SubscriptionPeriodType.normal,
});
void makeTrial() => periodType = 'trial';
bool get isTrial => periodType == 'trial';
void makeTrial() => periodType = SubscriptionPeriodType.trial;
bool get isTrial => periodType == SubscriptionPeriodType.trial;
String displayPrice(BuildContext context) {
if (isTrial || price <= 0) {
return L10n.of(context)!.freeTrial;
}
return "\$${price.toStringAsFixed(2)}";
}
String displayPrice(BuildContext context) => isTrial || price <= 0
? L10n.of(context)!.freeTrial
: "\$${price.toStringAsFixed(2)}";
String displayName(BuildContext context) {
if (isTrial) {
return L10n.of(context)!.oneWeekTrial;
}
switch (duration) {
case ('month'):
case (SubscriptionDuration.month):
return L10n.of(context)!.monthlySubscription;
case ('year'):
case (SubscriptionDuration.year):
return L10n.of(context)!.yearlySubscription;
default:
return L10n.of(context)!.defaultSubscription;
}
}
String? defaultManagementURL(SubscriptionAppIds? appIds) {
return appId == appIds?.androidId
? AppConfig.googlePlayMangementUrl
: appId == appIds?.appleId
? AppConfig.appleMangementUrl
: Environment.stripeManagementUrl;
}
}

View file

@ -80,17 +80,23 @@ class TTSToken {
class TextToSpeechRequest {
String text;
String langCode;
String userL1;
String userL2;
List<PangeaTokenText> tokens;
TextToSpeechRequest({
required this.text,
required this.langCode,
required this.userL1,
required this.userL2,
required this.tokens,
});
Map<String, dynamic> toJson() => {
ModelKey.text: text,
ModelKey.langCode: langCode,
ModelKey.userL1: userL1,
ModelKey.userL2: userL2,
ModelKey.tokens: tokens.map((token) => token.toJson()).toList(),
};

View file

@ -121,19 +121,26 @@ class UserController extends BaseController {
/// Initializes the user's profile by waiting for account data to load, reading in account
/// data to profile, and migrating from the pangea profile if the account data is not present.
Future<void> _initialize() async {
// wait for account data to load
// as long as it's not null, then this we've already migrated the profile
await _pangeaController.matrixState.client.waitForAccountData();
if (profile.userSettings.dateOfBirth != null) {
return;
}
// we used to store the user's profile in the pangea server
// we now store it in the matrix account data
final PangeaProfileResponse? resp = await PUserRepo.fetchPangeaUserInfo(
userID: userId!,
matrixAccessToken: _matrixAccessToken!,
);
// if it's null, we don't have a profile in the pangea server
if (resp?.profile == null) {
return;
}
// if we have a profile in the pangea server, we need to migrate it to the matrix account data
final userSetting = UserSettings.fromJson(resp!.profile.toJson());
final newProfile = Profile(userSettings: userSetting);
await newProfile.saveProfileData(waitForDataInSync: true);
@ -189,13 +196,13 @@ class UserController extends BaseController {
}
/// Returns a boolean value indicating whether the user is currently in the trial window.
bool get inTrialWindow {
bool inTrialWindow({int trialDays = 7}) {
final DateTime? createdAt = profile.userSettings.createdAt;
if (createdAt == null) {
return false;
}
return createdAt.isAfter(
DateTime.now().subtract(const Duration(days: 7)),
DateTime.now().subtract(Duration(days: trialDays)),
);
}

View file

@ -105,23 +105,32 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
return 2;
case ConstructUseTypeEnum.corIt:
return 1;
case ConstructUseTypeEnum.ignIt:
case ConstructUseTypeEnum.ignIGC:
case ConstructUseTypeEnum.ignPA:
case ConstructUseTypeEnum.ignWL:
return 1;
case ConstructUseTypeEnum.unk:
case ConstructUseTypeEnum.nan:
return 0;
case ConstructUseTypeEnum.incIt:
case ConstructUseTypeEnum.incIGC:
return -1;
return -2;
case ConstructUseTypeEnum.incPA:
case ConstructUseTypeEnum.incWL:
return -2;
return -3;
}
}
}
class ConstructUseTypeUtil {
static ConstructUseTypeEnum fromString(String value) {
return ConstructUseTypeEnum.values.firstWhere(
(e) => e.string == value,
orElse: () => ConstructUseTypeEnum.nan,
);
}
}

View file

@ -4,7 +4,6 @@ import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/utils/platform_infos.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';
enum InstructionsEnum {
@ -19,24 +18,16 @@ enum InstructionsEnum {
}
extension InstructionsEnumExtension on InstructionsEnum {
String title(BuildContext context) {
if (!context.mounted) {
ErrorHandler.logError(
e: Exception("Context not mounted"),
m: 'InstructionsEnumExtension.title for $this',
);
debugger(when: kDebugMode);
return '';
}
String title(L10n l10n) {
switch (this) {
case InstructionsEnum.itInstructions:
return L10n.of(context)!.itInstructionsTitle;
return l10n.itInstructionsTitle;
case InstructionsEnum.clickMessage:
return L10n.of(context)!.clickMessageTitle;
return l10n.clickMessageTitle;
case InstructionsEnum.blurMeansTranslate:
return L10n.of(context)!.blurMeansTranslateTitle;
return l10n.blurMeansTranslateTitle;
case InstructionsEnum.tooltipInstructions:
return L10n.of(context)!.tooltipInstructionsTitle;
return l10n.tooltipInstructionsTitle;
case InstructionsEnum.clickAgainToDeselect:
case InstructionsEnum.speechToText:
case InstructionsEnum.l1Translation:
@ -53,46 +44,30 @@ extension InstructionsEnumExtension on InstructionsEnum {
}
}
String body(BuildContext context) {
if (!context.mounted) {
ErrorHandler.logError(
e: Exception("Context not mounted"),
m: 'InstructionsEnumExtension.body for $this',
);
debugger(when: kDebugMode);
return "";
}
String body(L10n l10n) {
switch (this) {
case InstructionsEnum.itInstructions:
return L10n.of(context)!.itInstructionsBody;
return l10n.itInstructionsBody;
case InstructionsEnum.clickMessage:
return L10n.of(context)!.clickMessageBody;
return l10n.clickMessageBody;
case InstructionsEnum.blurMeansTranslate:
return L10n.of(context)!.blurMeansTranslateBody;
return l10n.blurMeansTranslateBody;
case InstructionsEnum.speechToText:
return L10n.of(context)!.speechToTextBody;
return l10n.speechToTextBody;
case InstructionsEnum.l1Translation:
return L10n.of(context)!.l1TranslationBody;
return l10n.l1TranslationBody;
case InstructionsEnum.translationChoices:
return L10n.of(context)!.translationChoicesBody;
return l10n.translationChoicesBody;
case InstructionsEnum.clickAgainToDeselect:
return L10n.of(context)!.clickTheWordAgainToDeselect;
return l10n.clickTheWordAgainToDeselect;
case InstructionsEnum.tooltipInstructions:
return PlatformInfos.isMobile
? L10n.of(context)!.tooltipInstructionsMobileBody
: L10n.of(context)!.tooltipInstructionsBrowserBody;
? l10n.tooltipInstructionsMobileBody
: l10n.tooltipInstructionsBrowserBody;
}
}
bool toggledOff(BuildContext context) {
if (!context.mounted) {
ErrorHandler.logError(
e: Exception("Context not mounted"),
m: 'InstructionsEnumExtension.toggledOff for $this',
);
debugger(when: kDebugMode);
return false;
}
bool toggledOff() {
final instructionSettings =
MatrixState.pangeaController.userController.profile.instructionSettings;
switch (this) {

View file

@ -66,10 +66,12 @@ extension MessageModeExtension on MessageMode {
}
}
bool isValidMode(Event event) {
bool shouldShowAsToolbarButton(Event event) {
switch (this) {
case MessageMode.translation:
return event.messageType == MessageTypes.Text;
case MessageMode.textToSpeech:
return event.messageType == MessageTypes.Text;
case MessageMode.definition:
return event.messageType == MessageTypes.Text;
case MessageMode.speechToText:

View file

@ -10,7 +10,7 @@ extension AnalyticsRoomExtension on Room {
return;
}
if (!isRoomAdmin) return;
if (client.userID == null || !isRoomAdmin) return;
final spaceHierarchy = await client.getSpaceHierarchy(
id,
maxDepth: 1,

View file

@ -93,6 +93,8 @@ class PangeaMessageEvent {
text: rep.content.text,
tokens: (await rep.tokensGlobal(context)).map((t) => t.text).toList(),
langCode: langCode,
userL1: l1Code ?? LanguageKeys.unknownLanguage,
userL2: l2Code ?? LanguageKeys.unknownLanguage,
);
final TextToSpeechResponse response =
@ -538,8 +540,7 @@ class PangeaMessageEvent {
int get numberOfActivitiesCompleted {
return MatrixState.pangeaController.activityRecordController
.completedActivities[eventId] ??
0;
.getCompletedActivityCount(eventId);
}
String? get l2Code =>

View file

@ -3,8 +3,8 @@ import 'dart:developer';
import 'package:fluffychat/pangea/extensions/pangea_event_extension.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_choreo_event.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/models/token_api_models.dart';
import 'package:fluffychat/pangea/models/tokens_event_content_model.dart';
import 'package:fluffychat/pangea/repo/tokens_repo.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
@ -135,13 +135,17 @@ class RepresentationEvent {
await MatrixState.pangeaController.messageData.getTokens(
repEventId: _event?.eventId,
room: _event?.room ?? parentMessageEvent.room,
// Jordan - for just tokens, it's not clear which languages to pass
req: TokensRequestModel(
fullText: text,
userL1:
langCode: langCode,
senderL1:
MatrixState.pangeaController.languageController.userL1?.langCode ??
LanguageKeys.unknownLanguage,
userL2: langCode,
// since langCode is known, senderL2 will be used to determine whether these tokens
// need pos/mporph tags whether lemmas are eligible to marked as "save_vocab=true"
senderL2:
MatrixState.pangeaController.languageController.userL2?.langCode ??
LanguageKeys.unknownLanguage,
),
);

View file

@ -36,13 +36,8 @@ class PracticeActivityEvent {
}
PracticeActivityModel get practiceActivity {
try {
_content ??= event.getPangeaContent<PracticeActivityModel>();
return _content!;
} catch (e, s) {
final contentMap = event.content;
rethrow;
}
_content ??= event.getPangeaContent<PracticeActivityModel>();
return _content!;
}
/// All completion records assosiated with this activity

View file

@ -26,6 +26,10 @@ class ConstructListModel {
/// All unique lemmas used in the construct events
List<String> get lemmas => constructList.map((e) => e.lemma).toSet().toList();
/// All unique lemmas used in the construct events with non-zero points
List<String> get lemmasWithPoints =>
constructListWithPoints.map((e) => e.lemma).toSet().toList();
/// A map of lemmas to ConstructUses, each of which contains a lemma
/// key = lemmma + constructType.string, value = ConstructUses
void _buildConstructMap() {
@ -72,6 +76,9 @@ class ConstructListModel {
return _constructList!;
}
List<ConstructUses> get constructListWithPoints =>
constructList.where((constructUse) => constructUse.points > 0).toList();
get maxXPPerLemma {
return type != null
? type!.maxXPPerLemma

View file

@ -1,6 +1,5 @@
import 'dart:developer';
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:flutter/foundation.dart';
@ -106,9 +105,7 @@ class OneConstructUse {
debugger(when: kDebugMode && constructType == null);
return OneConstructUse(
useType: ConstructUseTypeEnum.values
.firstWhereOrNull((e) => e.string == json['useType']) ??
ConstructUseTypeEnum.unk,
useType: ConstructUseTypeUtil.fromString(json['useType']),
lemma: json['lemma'],
form: json['form'],
categories: json['categories'] != null

View file

@ -1,51 +1,33 @@
import 'package:collection/collection.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/config/environment.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/controllers/subscription_controller.dart';
import 'package:fluffychat/pangea/repo/subscription_repo.dart';
import 'package:fluffychat/pangea/utils/subscription_app_id.dart';
class SubscriptionInfo {
PangeaController pangeaController;
List<SubscriptionDetails> availableSubscriptions = [];
String? currentSubscriptionId;
SubscriptionDetails? currentSubscription;
// Gabby - is it necessary to store appIds for each platform?
SubscriptionAppIds? appIds;
List<SubscriptionDetails>? allProducts;
final SubscriptionPlatform platform = SubscriptionPlatform();
List<String> allEntitlements = [];
/// Contains information about the users's current subscription
class CurrentSubscriptionInfo {
final String userID;
final AvailableSubscriptionsInfo availableSubscriptionInfo;
DateTime? expirationDate;
String? currentSubscriptionId;
bool get hasSubscribed => allEntitlements.isNotEmpty;
CurrentSubscriptionInfo({
required this.userID,
required this.availableSubscriptionInfo,
});
SubscriptionInfo({
required this.pangeaController,
}) : super();
SubscriptionDetails? get currentSubscription {
if (currentSubscriptionId == null) return null;
return availableSubscriptionInfo.allProducts?.firstWhereOrNull(
(SubscriptionDetails sub) =>
sub.id.contains(currentSubscriptionId!) ||
currentSubscriptionId!.contains(sub.id),
);
}
Future<void> configure() async {}
//TO-DO - hey Gabby this file feels like it could be reorganized. i'd like to
// 1) move these api calls to a class in a file in repo and
// 2) move the url to the urls file.
// 3) any stateful info to the subscription controller
// let's discuss before you make the changes though
// maybe you had some reason for this organization
/*
Fetch App Ids for each RC app (iOS, Android, and Stripe). Used to determine which app a user
with an active subscription purchased that subscription.
*/
Future<void> setAppIds() async {
if (appIds != null) return;
appIds = await SubscriptionRepo.getAppIds();
}
Future<void> setAllProducts() async {
if (allProducts != null) return;
allProducts = await SubscriptionRepo.getAllProducts();
}
bool get isNewUserTrial =>
currentSubscriptionId == AppConfig.trialSubscriptionId;
@ -64,41 +46,69 @@ class SubscriptionInfo {
String? get purchasePlatformDisplayName {
if (currentSubscription?.appId == null) return null;
return appIds?.appDisplayName(currentSubscription!.appId!);
return availableSubscriptionInfo.appIds
?.appDisplayName(currentSubscription!.appId!);
}
bool get purchasedOnWeb =>
(currentSubscription != null && appIds != null) &&
(currentSubscription?.appId == appIds?.stripeId);
(currentSubscription != null &&
availableSubscriptionInfo.appIds != null) &&
(currentSubscription?.appId ==
availableSubscriptionInfo.appIds?.stripeId);
bool get currentPlatformMatchesPurchasePlatform =>
(currentSubscription != null && appIds != null) &&
(currentSubscription?.appId == appIds?.currentAppId);
(currentSubscription != null &&
availableSubscriptionInfo.appIds != null) &&
(currentSubscription?.appId ==
availableSubscriptionInfo.appIds?.currentAppId);
void resetSubscription() {
currentSubscription = null;
currentSubscriptionId = null;
}
void resetSubscription() => currentSubscriptionId = null;
void setTrial(DateTime expiration) {
if (currentSubscription != null) return;
expirationDate = expiration;
currentSubscriptionId = AppConfig.trialSubscriptionId;
currentSubscription = SubscriptionDetails(
price: 0,
id: AppConfig.trialSubscriptionId,
periodType: 'trial',
);
if (currentSubscription == null) {
availableSubscriptionInfo.availableSubscriptions.add(
SubscriptionDetails(
price: 0,
id: AppConfig.trialSubscriptionId,
periodType: SubscriptionPeriodType.trial,
),
);
}
}
Future<void> setCustomerInfo() async {}
Future<void> setCurrentSubscription() async {}
}
String? get defaultManagementURL {
final String? purchaseAppId = currentSubscription?.appId;
return purchaseAppId == appIds?.androidId
? AppConfig.googlePlayMangementUrl
: purchaseAppId == appIds?.appleId
? AppConfig.appleMangementUrl
: Environment.stripeManagementUrl;
/// Contains information about the suscriptions available on revenuecat
class AvailableSubscriptionsInfo {
List<SubscriptionDetails> availableSubscriptions = [];
SubscriptionAppIds? appIds;
List<SubscriptionDetails>? allProducts;
Future<void> setAvailableSubscriptions() async {
appIds ??= await SubscriptionRepo.getAppIds();
allProducts ??= await SubscriptionRepo.getAllProducts();
availableSubscriptions = (allProducts ?? [])
.where((product) => product.appId == appIds!.currentAppId)
.sorted((a, b) => a.price.compareTo(b.price))
.toList();
// //@Gabby - temporary solution to add trial to list
// if (currentSubscriptionId == null && !hasSubscribed) {
// final id = availableSubscriptions[0].id;
// final package = availableSubscriptions[0].package;
// final duration = availableSubscriptions[0].duration;
// availableSubscriptions.insert(
// 0,
// SubscriptionDetails(
// price: 0,
// id: id,
// duration: duration,
// package: package,
// periodType: SubscriptionPeriodType.trial,
// ),
// );
// }
}
}

View file

@ -1,6 +1,5 @@
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/config/environment.dart';
import 'package:fluffychat/pangea/controllers/subscription_controller.dart';
import 'package:fluffychat/pangea/models/base_subscription_info.dart';
@ -9,8 +8,11 @@ import 'package:flutter/material.dart';
import 'package:purchases_flutter/purchases_flutter.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
class MobileSubscriptionInfo extends SubscriptionInfo {
MobileSubscriptionInfo({required super.pangeaController}) : super();
class MobileSubscriptionInfo extends CurrentSubscriptionInfo {
MobileSubscriptionInfo({
required super.userID,
required super.availableSubscriptionInfo,
});
@override
Future<void> configure() async {
@ -19,112 +21,42 @@ class MobileSubscriptionInfo extends SubscriptionInfo {
: PurchasesConfiguration(Environment.rcIosKey);
try {
await Purchases.configure(
configuration..appUserID = pangeaController.userController.userId,
configuration..appUserID = userID,
);
await super.configure();
await setMobilePackages();
} catch (err) {
ErrorHandler.logError(
m: "Failed to configure revenuecat SDK for user ${pangeaController.userController.userId}",
m: "Failed to configure revenuecat SDK",
s: StackTrace.current,
);
debugPrint(
"Failed to configure revenuecat SDK for user ${pangeaController.userController.userId}",
);
return;
}
await setAppIds();
await setAllProducts();
await setCustomerInfo();
await setMobilePackages();
if (allProducts != null && appIds != null) {
availableSubscriptions = allProducts!
.where((product) => product.appId == appIds!.currentAppId)
.toList();
availableSubscriptions.sort((a, b) => a.price.compareTo(b.price));
if (currentSubscriptionId == null && !hasSubscribed) {
//@Gabby - temporary solution to add trial to list
final id = availableSubscriptions[0].id;
final package = availableSubscriptions[0].package;
final duration = availableSubscriptions[0].duration;
availableSubscriptions.insert(
0,
SubscriptionDetails(
price: 0,
id: id,
duration: duration,
package: package,
periodType: 'trial',
),
);
}
} else {
ErrorHandler.logError(e: Exception("allProducts null || appIds null"));
}
}
Future<void> setMobilePackages() async {
if (allProducts == null) {
ErrorHandler.logError(
m: "Null appProducts in setMobilePrices",
s: StackTrace.current,
);
debugPrint(
"Null appProducts in setMobilePrices",
);
return;
}
Offerings offerings;
try {
offerings = await Purchases.getOfferings();
} catch (err) {
ErrorHandler.logError(
m: "Failed to fetch revenuecat offerings from revenuecat",
s: StackTrace.current,
);
debugPrint(
"Failed to fetch revenuecat offerings from revenuecat",
);
return;
}
if (availableSubscriptionInfo.allProducts == null) return;
final Offerings offerings = await Purchases.getOfferings();
final Offering? offering = offerings.all[Environment.rcOfferingName];
if (offering != null) {
final List<SubscriptionDetails> mobileSubscriptions =
offering.availablePackages
.map(
(package) {
return SubscriptionDetails(
price: package.storeProduct.price,
id: package.storeProduct.identifier,
package: package,
);
},
)
.toList()
.cast<SubscriptionDetails>();
for (final SubscriptionDetails mobileSub in mobileSubscriptions) {
final int productIndex = allProducts!
.indexWhere((product) => product.id.contains(mobileSub.id));
if (productIndex >= 0) {
final SubscriptionDetails updated = allProducts![productIndex];
updated.package = mobileSub.package;
allProducts![productIndex] = updated;
}
}
if (offering == null) return;
final products = availableSubscriptionInfo.allProducts;
for (final package in offering.availablePackages) {
final int productIndex = products!.indexWhere(
(product) => product.id.contains(package.storeProduct.identifier),
);
if (productIndex < 0) continue;
final SubscriptionDetails updated =
availableSubscriptionInfo.allProducts![productIndex];
updated.package = package;
availableSubscriptionInfo.allProducts![productIndex] = updated;
}
}
@override
Future<void> setCustomerInfo() async {
if (allProducts == null) {
ErrorHandler.logError(
m: "Null allProducts in setCustomerInfo",
s: StackTrace.current,
);
debugPrint(
"Null allProducts in setCustomerInfo",
);
return;
}
Future<void> setCurrentSubscription() async {
if (availableSubscriptionInfo.allProducts == null) return;
CustomerInfo info;
try {
@ -132,28 +64,11 @@ class MobileSubscriptionInfo extends SubscriptionInfo {
info = await Purchases.getCustomerInfo();
} catch (err) {
ErrorHandler.logError(
m: "Failed to fetch revenuecat customer info for user ${pangeaController.userController.userId}",
m: "Failed to fetch revenuecat customer info",
s: StackTrace.current,
);
debugPrint(
"Failed to fetch revenuecat customer info for user ${pangeaController.userController.userId}",
);
return;
}
final List<EntitlementInfo> noExpirations =
getEntitlementsWithoutExpiration(info);
if (noExpirations.isNotEmpty) {
Sentry.addBreadcrumb(
Breadcrumb(
message:
"Found revenuecat entitlement(s) without expiration date for user ${pangeaController.userController.userId}: ${noExpirations.map(
(entry) =>
"Entitlement Id: ${entry.identifier}, Purchase Date: ${entry.originalPurchaseDate}",
)}",
),
);
}
final List<EntitlementInfo> activeEntitlements =
info.entitlements.all.entries
@ -166,14 +81,6 @@ class MobileSubscriptionInfo extends SubscriptionInfo {
.map((MapEntry<String, EntitlementInfo> entry) => entry.value)
.toList();
allEntitlements = info.entitlements.all.entries
.map(
(MapEntry<String, EntitlementInfo> entry) =>
entry.value.productIdentifier,
)
.cast<String>()
.toList();
if (activeEntitlements.length > 1) {
debugPrint(
"User has more than one active entitlement.",
@ -185,13 +92,9 @@ class MobileSubscriptionInfo extends SubscriptionInfo {
}
return;
}
final EntitlementInfo activeEntitlement = activeEntitlements[0];
currentSubscriptionId = activeEntitlement.productIdentifier;
currentSubscription = allProducts!.firstWhereOrNull(
(SubscriptionDetails sub) =>
sub.id.contains(currentSubscriptionId!) ||
currentSubscriptionId!.contains(sub.id),
);
expirationDate = activeEntitlement.expirationDate != null
? DateTime.parse(activeEntitlement.expirationDate!)
: null;
@ -205,15 +108,4 @@ class MobileSubscriptionInfo extends SubscriptionInfo {
);
}
}
List<EntitlementInfo> getEntitlementsWithoutExpiration(CustomerInfo info) {
final List<EntitlementInfo> noExpirations = info.entitlements.all.entries
.where(
(MapEntry<String, EntitlementInfo> entry) =>
entry.value.expirationDate == null,
)
.map((MapEntry<String, EntitlementInfo> entry) => entry.value)
.toList();
return noExpirations;
}
}

View file

@ -27,12 +27,7 @@ class ConstructWithXP {
? DateTime.parse(json['last_used'] as String)
: null,
condensedConstructUses: (json['uses'] as List<String>).map((e) {
return ConstructUseTypeEnum.values.firstWhereOrNull(
(element) =>
element.string == e ||
element.toString().split('.').last == e,
) ??
ConstructUseTypeEnum.nan;
return ConstructUseTypeUtil.fromString(e);
}).toList(),
);
}

View file

@ -7,6 +7,7 @@ import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:flutter/foundation.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
class ConstructIdentifier {
final String lemma;
@ -186,8 +187,15 @@ class PracticeActivityModel {
// moving from multiple_choice to content as the key
// this is to make the model more generic
// here for backward compatibility
final Map<String, dynamic> content =
(json['content'] ?? json["multiple_choice"]) as Map<String, dynamic>;
final Map<String, dynamic>? contentMap =
(json['content'] ?? json["multiple_choice"]) as Map<String, dynamic>?;
if (contentMap == null) {
Sentry.addBreadcrumb(
Breadcrumb(data: {"json": json}),
);
throw ("content is null in PracticeActivityModel.fromJson");
}
return PracticeActivityModel(
tgtConstructs: ((json['tgt_constructs'] ?? json['target_constructs'])
@ -203,9 +211,7 @@ class PracticeActivityModel {
e.string == json['activity_type'] as String ||
e.string.split('.').last == json['activity_type'] as String,
),
content: ActivityContent.fromJson(
content,
),
content: ActivityContent.fromJson(contentMap),
);
}

View file

@ -0,0 +1,72 @@
import 'package:fluffychat/pangea/constants/model_keys.dart';
import 'pangea_token_model.dart';
class TokensRequestModel {
/// the text to be tokenized
String fullText;
/// if known, [langCode] is the language of of the text
/// it is used to determine which model to use in tokenizing
String? langCode;
/// [senderL1] and [senderL2] are the languages of the sender
/// if langCode is not known, the [senderL1] and [senderL2] will be used to help determine the language of the text
/// if langCode is known, [senderL1] and [senderL2] will be used to determine whether the tokens need
/// pos/mporph tags and whether lemmas are eligible to marked as "save_vocab=true"
String senderL1;
/// [senderL1] and [senderL2] are the languages of the sender
/// if langCode is not known, the [senderL1] and [senderL2] will be used to help determine the language of the text
/// if langCode is known, [senderL1] and [senderL2] will be used to determine whether the tokens need
/// pos/mporph tags and whether lemmas are eligible to marked as "save_vocab=true"
String senderL2;
TokensRequestModel({
required this.fullText,
required this.langCode,
required this.senderL1,
required this.senderL2,
});
Map<String, dynamic> toJson() => {
ModelKey.fullText: fullText,
ModelKey.userL1: senderL1,
ModelKey.userL2: senderL2,
ModelKey.langCode: langCode,
};
// override equals and hashcode
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is TokensRequestModel &&
other.fullText == fullText &&
other.senderL1 == senderL1 &&
other.senderL2 == senderL2;
}
@override
int get hashCode => fullText.hashCode ^ senderL1.hashCode ^ senderL2.hashCode;
}
class TokensResponseModel {
List<PangeaToken> tokens;
String lang;
TokensResponseModel({required this.tokens, required this.lang});
factory TokensResponseModel.fromJson(
Map<String, dynamic> json,
) =>
TokensResponseModel(
tokens: (json[ModelKey.tokens] as Iterable)
.map<PangeaToken>(
(e) => PangeaToken.fromJson(e as Map<String, dynamic>),
)
.toList()
.cast<PangeaToken>(),
lang: json[ModelKey.lang],
);
}

View file

@ -12,7 +12,7 @@ class UserSettings {
DateTime? dateOfBirth;
DateTime? createdAt;
bool autoPlayMessages;
bool itAutoPlay;
// bool itAutoPlay;
bool activatedFreeTrial;
bool publicProfile;
String? targetLanguage;
@ -23,7 +23,7 @@ class UserSettings {
this.dateOfBirth,
this.createdAt,
this.autoPlayMessages = false,
this.itAutoPlay = false,
// this.itAutoPlay = true,
this.activatedFreeTrial = false,
this.publicProfile = false,
this.targetLanguage,
@ -37,7 +37,7 @@ class UserSettings {
? DateTime.parse(json[ModelKey.userCreatedAt])
: null,
autoPlayMessages: json[ModelKey.autoPlayMessages] ?? false,
itAutoPlay: json[ModelKey.itAutoPlay] ?? false,
// itAutoPlay: json[ModelKey.itAutoPlay] ?? true,
activatedFreeTrial: json[ModelKey.activatedTrialKey] ?? false,
publicProfile: json[ModelKey.publicProfile] ?? false,
targetLanguage: json[ModelKey.l2LanguageKey],
@ -50,7 +50,7 @@ class UserSettings {
data[ModelKey.userDateOfBirth] = dateOfBirth?.toIso8601String();
data[ModelKey.userCreatedAt] = createdAt?.toIso8601String();
data[ModelKey.autoPlayMessages] = autoPlayMessages;
data[ModelKey.itAutoPlay] = itAutoPlay;
// data[ModelKey.itAutoPlay] = itAutoPlay;
data[ModelKey.activatedTrialKey] = activatedFreeTrial;
data[ModelKey.publicProfile] = publicProfile;
data[ModelKey.l2LanguageKey] = targetLanguage;
@ -96,9 +96,9 @@ class UserSettings {
autoPlayMessages: (accountData[ModelKey.autoPlayMessages]
?.content[ModelKey.autoPlayMessages] as bool?) ??
false,
itAutoPlay: (accountData[ModelKey.itAutoPlay]
?.content[ModelKey.itAutoPlay] as bool?) ??
false,
// itAutoPlay: (accountData[ModelKey.itAutoPlay]
// ?.content[ModelKey.itAutoPlay] as bool?) ??
// true,
activatedFreeTrial: (accountData[ModelKey.activatedTrialKey]
?.content[ModelKey.activatedTrialKey] as bool?) ??
false,

View file

@ -1,61 +1,23 @@
import 'package:fluffychat/pangea/controllers/subscription_controller.dart';
import 'package:fluffychat/pangea/models/base_subscription_info.dart';
import 'package:fluffychat/pangea/repo/subscription_repo.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
class WebSubscriptionInfo extends SubscriptionInfo {
WebSubscriptionInfo({required super.pangeaController}) : super();
class WebSubscriptionInfo extends CurrentSubscriptionInfo {
WebSubscriptionInfo({
required super.userID,
required super.availableSubscriptionInfo,
});
@override
Future<void> configure() async {
await setAppIds();
await setAllProducts();
await setCustomerInfo();
if (allProducts == null || appIds == null) {
Sentry.addBreadcrumb(
Breadcrumb(message: "No products found for current app"),
);
return;
}
availableSubscriptions = allProducts!
.where((product) => product.appId == appIds!.currentAppId)
.toList();
availableSubscriptions.sort((a, b) => a.price.compareTo(b.price));
//@Gabby - temporary solution to add trial to list
if (currentSubscriptionId == null && !hasSubscribed) {
final id = availableSubscriptions[0].id;
final package = availableSubscriptions[0].package;
final duration = availableSubscriptions[0].duration;
availableSubscriptions.insert(
0,
SubscriptionDetails(
price: 0,
id: id,
duration: duration,
package: package,
periodType: 'trial',
),
);
}
}
@override
Future<void> setCustomerInfo() async {
if (currentSubscriptionId != null && currentSubscription != null) {
return;
}
final RCSubscriptionResponseModel currentSubscriptionInfo =
await SubscriptionRepo.getCurrentSubscriptionInfo(
pangeaController.matrixState.client.userID,
allProducts,
Future<void> setCurrentSubscription() async {
if (currentSubscriptionId != null) return;
final rcResponse = await SubscriptionRepo.getCurrentSubscriptionInfo(
userID,
availableSubscriptionInfo.allProducts,
);
currentSubscriptionId = currentSubscriptionInfo.currentSubscriptionId;
currentSubscription = currentSubscriptionInfo.currentSubscription;
allEntitlements = currentSubscriptionInfo.allEntitlements ?? [];
expirationDate = currentSubscriptionInfo.expirationDate;
currentSubscriptionId = rcResponse.currentSubscriptionId;
expirationDate = rcResponse.expirationDate;
if (currentSubscriptionId != null && currentSubscription == null) {
Sentry.addBreadcrumb(

View file

@ -42,7 +42,7 @@ class ClassDescriptionButton extends StatelessWidget {
? (room.isRoomAdmin
? (room.isSpace
? L10n.of(context)!.classDescriptionDesc
: L10n.of(context)!.chatTopicDesc)
: L10n.of(context)!.setChatDescription)
: L10n.of(context)!.topicNotSet)
: room.topic,
),
@ -52,7 +52,7 @@ class ClassDescriptionButton extends StatelessWidget {
title: Text(
room.isSpace
? L10n.of(context)!.classDescription
: L10n.of(context)!.chatTopic,
: L10n.of(context)!.chatDescription,
style: TextStyle(
color: Theme.of(context).colorScheme.secondary,
fontWeight: FontWeight.bold,
@ -74,7 +74,7 @@ void setClassTopic(Room room, BuildContext context) {
title: Text(
room.isSpace
? L10n.of(context)!.classDescription
: L10n.of(context)!.chatTopic,
: L10n.of(context)!.chatDescription,
),
content: TextField(
controller: textFieldController,

View file

@ -62,21 +62,17 @@ class RoomCapacityButtonState extends State<RoomCapacityButton> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
L10n.of(context)!.roomExceedsCapacity,
spaceMode
? L10n.of(context)!.chatExceedsCapacity
: L10n.of(context)!.spaceExceedsCapacity,
),
),
);
}
}
String get roomType {
final String chat = L10n.of(context)!.chat;
final String space = L10n.of(context)!.space;
if (widget.room != null) {
return widget.room!.isSpace ? space : chat;
}
return widget.spaceMode ? space : chat;
}
bool get spaceMode =>
(widget.room != null && widget.room!.isSpace) || widget.spaceMode;
@override
Widget build(BuildContext context) {
@ -92,13 +88,17 @@ class RoomCapacityButtonState extends State<RoomCapacityButton> {
),
subtitle: Text(
(capacity == null)
? L10n.of(context)!.capacityNotSet
? spaceMode
? L10n.of(context)!.spaceCapacityNotSet
: L10n.of(context)!.chatCapacityNotSet
: (nonAdmins != null)
? '$nonAdmins/$capacity'
: '$capacity',
),
title: Text(
L10n.of(context)!.roomCapacity(roomType),
spaceMode
? L10n.of(context)!.spaceCapacity
: L10n.of(context)!.chatCapacity,
style: TextStyle(
color: Theme.of(context).colorScheme.secondary,
fontWeight: FontWeight.bold,
@ -116,8 +116,12 @@ class RoomCapacityButtonState extends State<RoomCapacityButton> {
Future<void> setRoomCapacity() async {
final input = await showTextInputDialog(
context: context,
title: L10n.of(context)!.roomCapacity(roomType),
message: L10n.of(context)!.roomCapacityExplanation(roomType),
title: spaceMode
? L10n.of(context)!.spaceCapacity
: L10n.of(context)!.chatCapacity,
message: spaceMode
? L10n.of(context)!.spaceCapacityExplanation
: L10n.of(context)!.chatCapacityExplanation,
okLabel: L10n.of(context)!.ok,
cancelLabel: L10n.of(context)!.cancel,
textFields: [
@ -133,7 +137,9 @@ class RoomCapacityButtonState extends State<RoomCapacityButton> {
return L10n.of(context)!.enterNumber;
}
if (nonAdmins != null && int.parse(value) < int.parse(nonAdmins!)) {
return L10n.of(context)!.capacitySetTooLow(roomType);
return spaceMode
? L10n.of(context)!.spaceCapacitySetTooLow
: L10n.of(context)!.chatCapacitySetTooLow;
}
return null;
},
@ -159,7 +165,9 @@ class RoomCapacityButtonState extends State<RoomCapacityButton> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
L10n.of(context)!.roomCapacityHasBeenChanged(roomType),
spaceMode
? L10n.of(context)!.spaceCapacityHasBeenChanged
: L10n.of(context)!.chatCapacityHasBeenChanged,
),
),
);

View file

@ -2,7 +2,6 @@ import 'dart:developer';
import 'package:fluffychat/pangea/constants/age_limits.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/extensions/client_extension/client_extension.dart';
import 'package:fluffychat/pangea/pages/p_user_age/p_user_age_view.dart';
import 'package:fluffychat/pangea/utils/p_extension.dart';
import 'package:fluffychat/widgets/fluffy_chat_app.dart';
@ -11,7 +10,6 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import '../../utils/bot_name.dart';
import '../../utils/error_handler.dart';
class PUserAge extends StatefulWidget {
@ -34,20 +32,7 @@ class PUserAgeController extends State<PUserAge> {
@override
void initState() {
super.initState();
Future.delayed(Duration.zero, () async {
if (!(await Matrix.of(context).client.hasBotDM)) {
Matrix.of(context)
.client
.startDirectChat(
BotName.byEnvironment,
enableEncryption: false,
)
.onError(
(error, stackTrace) =>
ErrorHandler.logError(e: error, s: stackTrace),
);
}
});
pangeaController.startChatWithBotIfNotPresent();
}
String? dobValidator() {
@ -91,6 +76,7 @@ class PUserAgeController extends State<PUserAge> {
return profile;
});
}
pangeaController.subscriptionController.reinitialize();
FluffyChatApp.router.go('/rooms');
} catch (err, s) {
setState(() {

View file

@ -8,11 +8,7 @@ import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
class SettingsLearning extends StatefulWidget {
final bool isPopup;
const SettingsLearning({
this.isPopup = false,
super.key,
});
const SettingsLearning({super.key});
@override
SettingsLearningController createState() => SettingsLearningController();

View file

@ -4,6 +4,7 @@ import 'package:fluffychat/pangea/widgets/user_settings/country_picker_tile.dart
import 'package:fluffychat/pangea/widgets/user_settings/language_tile.dart';
import 'package:fluffychat/pangea/widgets/user_settings/p_settings_switch_list_tile.dart';
import 'package:fluffychat/widgets/layouts/max_width_body.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
@ -13,18 +14,16 @@ class SettingsLearningView extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
final dialogContent = Scaffold(
appBar: AppBar(
centerTitle: true,
title: Text(
L10n.of(context)!.learningSettings,
),
leading: controller.widget.isPopup
? IconButton(
icon: const Icon(Icons.close),
onPressed: Navigator.of(context).pop,
)
: null,
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: Navigator.of(context).pop,
),
),
body: ListTileTheme(
iconColor: Theme.of(context).textTheme.bodyLarge!.color,
@ -49,19 +48,19 @@ class SettingsLearningView extends StatelessWidget {
value,
),
),
ProfileSettingsSwitchListTile.adaptive(
defaultValue: controller.pangeaController.userController.profile
.userSettings.itAutoPlay,
title:
L10n.of(context)!.interactiveTranslatorAutoPlaySliderHeader,
subtitle: L10n.of(context)!.interactiveTranslatorAutoPlayDesc,
onChange: (bool value) => controller
.pangeaController.userController
.updateProfile((profile) {
profile.userSettings.itAutoPlay = value;
return profile;
}),
),
// ProfileSettingsSwitchListTile.adaptive(
// defaultValue: controller.pangeaController.userController.profile
// .userSettings.itAutoPlay,
// title:
// L10n.of(context)!.interactiveTranslatorAutoPlaySliderHeader,
// subtitle: L10n.of(context)!.interactiveTranslatorAutoPlayDesc,
// onChange: (bool value) => controller
// .pangeaController.userController
// .updateProfile((profile) {
// profile.userSettings.itAutoPlay = value;
// return profile;
// }),
// ),
// ProfileSettingsSwitchListTile.adaptive(
// defaultValue: controller.pangeaController.userController.profile
// .userSettings.autoPlayMessages,
@ -79,5 +78,25 @@ class SettingsLearningView extends StatelessWidget {
),
),
);
return kIsWeb
? Dialog(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 600,
maxHeight: 600,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20.0),
child: dialogContent,
),
),
)
: Dialog.fullscreen(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600),
child: dialogContent,
),
);
}
}

View file

@ -57,30 +57,33 @@ class SubscriptionManagementController extends State<SubscriptionManagement> {
}
bool get subscriptionsAvailable =>
subscriptionController.subscription?.availableSubscriptions.isNotEmpty ??
subscriptionController
.availableSubscriptionInfo?.availableSubscriptions.isNotEmpty ??
false;
bool get currentSubscriptionAvailable =>
subscriptionController.isSubscribed &&
subscriptionController.subscription?.currentSubscription != null;
subscriptionController.currentSubscriptionInfo?.currentSubscription !=
null;
String? get purchasePlatformDisplayName =>
subscriptionController.subscription?.purchasePlatformDisplayName;
String? get purchasePlatformDisplayName => subscriptionController
.currentSubscriptionInfo?.purchasePlatformDisplayName;
bool get currentSubscriptionIsPromotional =>
subscriptionController.subscription?.currentSubscriptionIsPromotional ??
subscriptionController
.currentSubscriptionInfo?.currentSubscriptionIsPromotional ??
false;
bool get isNewUserTrial =>
subscriptionController.subscription?.isNewUserTrial ?? false;
subscriptionController.currentSubscriptionInfo?.isNewUserTrial ?? false;
String get currentSubscriptionTitle =>
subscriptionController.subscription?.currentSubscription
subscriptionController.currentSubscriptionInfo?.currentSubscription
?.displayName(context) ??
"";
String get currentSubscriptionPrice =>
subscriptionController.subscription?.currentSubscription
subscriptionController.currentSubscriptionInfo?.currentSubscription
?.displayPrice(context) ??
"";
@ -88,11 +91,11 @@ class SubscriptionManagementController extends State<SubscriptionManagement> {
if (!currentSubscriptionAvailable || isNewUserTrial) {
return false;
}
if (subscriptionController.subscription!.purchasedOnWeb) {
if (subscriptionController.currentSubscriptionInfo!.purchasedOnWeb) {
return true;
}
return subscriptionController
.subscription!.currentPlatformMatchesPurchasePlatform;
.currentSubscriptionInfo!.currentPlatformMatchesPurchasePlatform;
}
void submitChange({bool isPromo = false}) {
@ -122,12 +125,12 @@ class SubscriptionManagementController extends State<SubscriptionManagement> {
if (email != null) {
managementUrl += "?prefilled_email=${Uri.encodeComponent(email)}";
}
final String? purchaseAppId =
subscriptionController.subscription?.currentSubscription?.appId;
final String? purchaseAppId = subscriptionController
.currentSubscriptionInfo?.currentSubscription?.appId;
if (purchaseAppId == null) return;
final SubscriptionAppIds? appIds =
subscriptionController.subscription!.appIds;
subscriptionController.availableSubscriptionInfo!.appIds;
if (purchaseAppId == appIds?.stripeId) {
launchUrlString(managementUrl);
@ -167,7 +170,7 @@ class SubscriptionManagementController extends State<SubscriptionManagement> {
}
bool isCurrentSubscription(SubscriptionDetails subscription) =>
subscriptionController.subscription?.currentSubscription ==
subscriptionController.currentSubscriptionInfo?.currentSubscription ==
subscription ||
isNewUserTrial && subscription.isTrial;

View file

@ -51,6 +51,8 @@ class SettingsSubscriptionView extends StatelessWidget {
),
];
final isSubscribed = controller.subscriptionController.isSubscribed;
return Scaffold(
appBar: AppBar(
centerTitle: true,
@ -63,13 +65,11 @@ class SettingsSubscriptionView extends StatelessWidget {
child: MaxWidthBody(
child: Column(
children: [
if (controller.subscriptionController.isSubscribed &&
!controller.showManagementOptions)
if (isSubscribed && !controller.showManagementOptions)
ManagementNotAvailableWarning(
controller: controller,
),
if (!(controller.subscriptionController.isSubscribed) ||
controller.isNewUserTrial)
if (!isSubscribed || controller.isNewUserTrial)
ChangeSubscription(controller: controller),
if (controller.showManagementOptions) ...managementButtons,
],
@ -90,13 +90,14 @@ class ManagementNotAvailableWarning extends StatelessWidget {
@override
Widget build(BuildContext context) {
final currentSubscriptionInfo =
controller.subscriptionController.currentSubscriptionInfo;
String getWarningText() {
final DateFormat formatter = DateFormat('yyyy-MM-dd');
if (controller.isNewUserTrial) {
return L10n.of(context)!.trialExpiration(
formatter.format(
controller.subscriptionController.subscription!.expirationDate!,
),
formatter.format(currentSubscriptionInfo!.expirationDate!),
);
}
if (controller.currentSubscriptionAvailable) {
@ -108,15 +109,11 @@ class ManagementNotAvailableWarning extends StatelessWidget {
return warningText;
}
if (controller.currentSubscriptionIsPromotional) {
if (controller
.subscriptionController.subscription?.isLifetimeSubscription ??
false) {
if (currentSubscriptionInfo?.isLifetimeSubscription ?? false) {
return L10n.of(context)!.promotionalSubscriptionDesc;
}
return L10n.of(context)!.promoSubscriptionExpirationDesc(
formatter.format(
controller.subscriptionController.subscription!.expirationDate!,
),
formatter.format(currentSubscriptionInfo!.expirationDate!),
);
}
return L10n.of(context)!.subscriptionManagementUnavailable;

View file

@ -1,14 +1,13 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:http/http.dart' as http;
import 'package:fluffychat/pangea/config/environment.dart';
import 'package:fluffychat/pangea/controllers/subscription_controller.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/utils/subscription_app_id.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import '../network/urls.dart';
class SubscriptionRepo {
@ -120,7 +119,9 @@ class RCProductsResponseModel {
.map(
(productDetails) => SubscriptionDetails(
price: double.parse(metadata['$packageId.price']),
duration: metadata['$packageId.duration'],
duration: SubscriptionDuration.values.firstWhereOrNull(
(duration) => duration.value == metadata['$packageId.duration'],
),
id: productDetails['product']['store_identifier'],
appId: productDetails['product']['app_id'],
),
@ -150,9 +151,6 @@ class RCSubscriptionResponseModel {
final List<String> activeEntitlements =
RCSubscriptionResponseModel.getActiveEntitlements(json);
final List<String> allEntitlements =
RCSubscriptionResponseModel.getAllEntitlements(json);
if (activeEntitlements.length > 1) {
debugPrint(
"User has more than one active entitlement. This shouldn't happen",

View file

@ -1,95 +0,0 @@
import 'dart:convert';
import 'package:http/http.dart';
import 'package:fluffychat/pangea/constants/model_keys.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import '../config/environment.dart';
import '../models/pangea_token_model.dart';
import '../network/requests.dart';
import '../network/urls.dart';
class TokensRepo {
static Future<TokensResponseModel> tokenize(
String accessToken,
TokensRequestModel request,
) async {
final Requests req = Requests(
choreoApiKey: Environment.choreoApiKey,
accessToken: accessToken,
);
final Response res = await req.post(
url: PApiUrls.tokenize,
body: request.toJson(),
);
final TokensResponseModel response = TokensResponseModel.fromJson(
jsonDecode(
utf8.decode(res.bodyBytes).toString(),
),
);
if (response.tokens.isEmpty) {
ErrorHandler.logError(
e: Exception(
"empty tokens in tokenize response return",
),
);
}
return response;
}
}
class TokensRequestModel {
String fullText;
String userL1;
String userL2;
TokensRequestModel({
required this.fullText,
required this.userL1,
required this.userL2,
});
Map<String, dynamic> toJson() => {
ModelKey.fullText: fullText,
ModelKey.userL1: userL1,
ModelKey.userL2: userL2,
};
// override equals and hashcode
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is TokensRequestModel &&
other.fullText == fullText &&
other.userL1 == userL1 &&
other.userL2 == userL2;
}
@override
int get hashCode => fullText.hashCode ^ userL1.hashCode ^ userL2.hashCode;
}
class TokensResponseModel {
List<PangeaToken> tokens;
String lang;
TokensResponseModel({required this.tokens, required this.lang});
factory TokensResponseModel.fromJson(
Map<String, dynamic> json,
) =>
TokensResponseModel(
tokens: (json[ModelKey.tokens] as Iterable)
.map<PangeaToken>(
(e) => PangeaToken.fromJson(e as Map<String, dynamic>),
)
.toList()
.cast<PangeaToken>(),
lang: json[ModelKey.lang],
);
}

View file

@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:go_router/go_router.dart';
@ -27,13 +26,6 @@ void findConversationPartnerDialog(
onPressed: Navigator.of(context).pop,
child: Text(L10n.of(context)!.cancel),
),
TextButton(
onPressed: () {
context.go('/rooms/settings/learning');
Navigator.of(context).pop();
},
child: Text(L10n.of(context)!.accountSettings),
),
],
),
);

View file

@ -2,6 +2,7 @@ import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/enum/instructions_enum.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class InlineTooltip extends StatelessWidget {
final InstructionsEnum instructionsEnum;
@ -15,7 +16,7 @@ class InlineTooltip extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (instructionsEnum.toggledOff(context)) {
if (instructionsEnum.toggledOff()) {
return const SizedBox();
}
@ -30,6 +31,7 @@ class InlineTooltip extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
// Lightbulb icon on the left
Icon(
@ -39,10 +41,10 @@ class InlineTooltip extends StatelessWidget {
),
const SizedBox(width: 8),
// Text in the middle
Expanded(
Flexible(
child: Center(
child: Text(
instructionsEnum.body(context),
instructionsEnum.body(L10n.of(context)!),
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
height: 1.5,

View file

@ -80,7 +80,7 @@ class InstructionsController {
}
_instructionsShown[key.toString()] = true;
if (key.toggledOff(context)) {
if (key.toggledOff()) {
return;
}
if (L10n.of(context) == null) {
@ -94,36 +94,36 @@ class InstructionsController {
final botStyle = BotStyle.text(context);
Future.delayed(
const Duration(seconds: 1),
() => OverlayUtil.showPositionedCard(
context: context,
backDropToDismiss: false,
cardToShow: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
CardHeader(
text: key.title(context),
botExpression: BotExpression.idle,
onClose: () => {_instructionsClosed[key.toString()] = true},
),
const SizedBox(height: 10.0),
Expanded(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(6.0),
child: Text(
key.body(context),
style: botStyle,
),
() {
if (!context.mounted) return;
OverlayUtil.showPositionedCard(
context: context,
backDropToDismiss: false,
cardToShow: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
CardHeader(
text: key.title(L10n.of(context)!),
botExpression: BotExpression.idle,
onClose: () => {_instructionsClosed[key.toString()] = true},
),
const SizedBox(height: 10.0),
Padding(
padding: const EdgeInsets.all(6.0),
child: Text(
key.body(L10n.of(context)!),
style: botStyle,
),
),
),
if (showToggle) InstructionsToggle(instructionsKey: key),
],
),
cardSize: const Size(300.0, 300.0),
transformTargetId: transformTargetKey,
closePrevOverlay: false,
),
if (showToggle) InstructionsToggle(instructionsKey: key),
],
),
maxHeight: 300,
maxWidth: 300,
transformTargetId: transformTargetKey,
closePrevOverlay: false,
);
},
);
}
}
@ -155,7 +155,7 @@ class InstructionsToggleState extends State<InstructionsToggle> {
return SwitchListTile.adaptive(
activeColor: AppConfig.activeToggleColor,
title: Text(L10n.of(context)!.doNotShowAgain),
value: widget.instructionsKey.toggledOff(context),
value: widget.instructionsKey.toggledOff(),
onChanged: ((value) async {
pangeaController.instructions.setToggledOff(
widget.instructionsKey,

View file

@ -21,7 +21,7 @@ void pLogoutAction(BuildContext context, {bool? isDestructiveAction}) async {
// before wiping out locally cached construct data, save it to the server
await MatrixState.pangeaController.myAnalytics
.sendLocalAnalyticsToAnalyticsRoom();
.sendLocalAnalyticsToAnalyticsRoom(onLogout: true);
await showFutureLoadingDialog(
context: context,

View file

@ -20,18 +20,14 @@ class OverlayUtil {
required BuildContext context,
required Widget child,
required String transformTargetId,
double? width,
double? height,
Offset? offset,
backDropToDismiss = true,
blurBackground = false,
Color? borderColor,
Color? backgroundColor,
Alignment? targetAnchor,
Alignment? followerAnchor,
bool closePrevOverlay = true,
Function? onDismiss,
OverlayPositionEnum position = OverlayPositionEnum.transform,
Offset? offset,
}) {
try {
if (closePrevOverlay) {
@ -55,18 +51,16 @@ class OverlayUtil {
right: (position == OverlayPositionEnum.centered) ? 0 : null,
left: (position == OverlayPositionEnum.centered) ? 0 : null,
bottom: (position == OverlayPositionEnum.centered) ? 0 : null,
width: width,
height: height,
child: (position != OverlayPositionEnum.transform)
? child
: CompositedTransformFollower(
targetAnchor: targetAnchor ?? Alignment.topCenter,
followerAnchor:
followerAnchor ?? Alignment.bottomCenter,
targetAnchor: Alignment.topCenter,
followerAnchor: Alignment.bottomCenter,
link: MatrixState.pAnyState
.layerLinkAndKey(transformTargetId)
.link,
showWhenUnlinked: false,
offset: offset ?? Offset.zero,
child: child,
),
),
@ -86,8 +80,9 @@ class OverlayUtil {
static showPositionedCard({
required BuildContext context,
required Widget cardToShow,
required Size cardSize,
required String transformTargetId,
required double maxHeight,
required double maxWidth,
backDropToDismiss = true,
Color? borderColor,
bool closePrevOverlay = true,
@ -100,6 +95,32 @@ class OverlayUtil {
return;
}
Offset offset = Offset.zero;
final RenderBox? targetRenderBox =
layerLinkAndKey.key.currentContext!.findRenderObject() as RenderBox?;
if (targetRenderBox != null && targetRenderBox.hasSize) {
final Offset transformTargetOffset =
(targetRenderBox).localToGlobal(Offset.zero);
final Size transformTargetSize = targetRenderBox.size;
final horizontalMidpoint =
transformTargetOffset.dx + (transformTargetSize.width / 2);
final halfMaxWidth = maxWidth / 2;
final hasLeftOverflow = (horizontalMidpoint - halfMaxWidth) < 0;
final hasRightOverflow = (horizontalMidpoint + halfMaxWidth) >
MediaQuery.of(context).size.width;
double xOffset = 0;
MediaQuery.of(context).size.width - (horizontalMidpoint + halfMaxWidth);
if (hasLeftOverflow) {
xOffset = (transformTargetOffset.dx - halfMaxWidth) * -1;
} else if (hasRightOverflow) {
xOffset = MediaQuery.of(context).size.width -
(horizontalMidpoint + halfMaxWidth);
}
offset = Offset(xOffset, 0);
}
final Widget child = Material(
borderOnForeground: false,
color: Colors.transparent,
@ -107,18 +128,19 @@ class OverlayUtil {
child: OverlayContainer(
cardToShow: cardToShow,
borderColor: borderColor,
maxHeight: maxHeight,
maxWidth: maxWidth,
),
);
showOverlay(
context: context,
child: child,
width: cardSize.width,
height: cardSize.height,
transformTargetId: transformTargetId,
backDropToDismiss: backDropToDismiss,
borderColor: borderColor,
closePrevOverlay: closePrevOverlay,
offset: offset,
);
} catch (err, stack) {
debugger(when: kDebugMode);
@ -138,12 +160,12 @@ class OverlayUtil {
// final OverlayConstraints constraints =
// ChatViewConstraints(transformTargetContext);
// final RenderObject? targetRenderBox =
// transformTargetContext.findRenderObject();
// if (targetRenderBox == null) return Offset.zero;
// final Offset transformTargetOffset =
// (targetRenderBox as RenderBox).localToGlobal(Offset.zero);
// final Size transformTargetSize = targetRenderBox.size;
// final RenderObject? targetRenderBox =
// transformTargetContext.findRenderObject();
// if (targetRenderBox == null) return Offset.zero;
// final Offset transformTargetOffset =
// (targetRenderBox as RenderBox).localToGlobal(Offset.zero);
// final Size transformTargetSize = targetRenderBox.size;
// // ideally horizontally centered on target
// double dx = transformTargetSize.width / 2 - cardSize.width / 2;

View file

@ -49,15 +49,14 @@ enum RCPlatform {
apple,
}
class SubscriptionPlatform {
RCPlatform currentPlatform = kIsWeb
extension RCPlatformExtension on RCPlatform {
RCPlatform get currentPlatform => kIsWeb
? RCPlatform.stripe
: Platform.isAndroid
? RCPlatform.android
: RCPlatform.apple;
@override
String toString() {
String get string {
return currentPlatform == RCPlatform.stripe
? 'stripe'
: currentPlatform == RCPlatform.android

View file

@ -76,8 +76,8 @@ class ChatFloatingActionButtonState extends State<ChatFloatingActionButton> {
}
if (widget.controller.choreographer.errorService.error != null) {
return ChoreographerHasErrorButton(
widget.controller.pangeaController,
widget.controller.choreographer.errorService.error!,
widget.controller.choreographer,
);
}

View file

@ -1,6 +1,7 @@
import 'dart:developer';
import 'dart:math';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pages/chat/events/audio_player.dart';
import 'package:fluffychat/pangea/controllers/text_to_speech_controller.dart';
import 'package:fluffychat/pangea/extensions/pangea_event_extension.dart';
@ -8,7 +9,6 @@ import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dar
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart';
import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart';
import 'package:fluffychat/pangea/widgets/chat/toolbar_content_loading_indicator.dart';
import 'package:fluffychat/pangea/widgets/chat/tts_controller.dart';
import 'package:fluffychat/pangea/widgets/igc/card_error_widget.dart';
@ -21,11 +21,15 @@ class MessageAudioCard extends StatefulWidget {
final PangeaMessageEvent messageEvent;
final MessageOverlayController overlayController;
final PangeaTokenText? selection;
final TtsController tts;
final Function(bool) setIsPlayingAudio;
const MessageAudioCard({
super.key,
required this.messageEvent,
required this.overlayController,
required this.tts,
required this.setIsPlayingAudio,
this.selection,
});
@ -40,8 +44,6 @@ class MessageAudioCardState extends State<MessageAudioCard> {
int? sectionStartMS;
int? sectionEndMS;
TtsController tts = TtsController();
@override
void initState() {
super.initState();
@ -56,7 +58,7 @@ class MessageAudioCardState extends State<MessageAudioCard> {
@override
void didUpdateWidget(covariant oldWidget) {
if (oldWidget.selection != widget.selection) {
if (oldWidget.selection != widget.selection && widget.selection != null) {
debugPrint('selection changed');
setSectionStartAndEndFromSelection();
playSelectionAudio();
@ -65,10 +67,11 @@ class MessageAudioCardState extends State<MessageAudioCard> {
}
Future<void> playSelectionAudio() async {
if (widget.selection == null) return;
final PangeaTokenText selection = widget.selection!;
final tokenText = selection.content;
await tts.speak(tokenText);
await widget.tts.speak(tokenText);
}
void setSectionStartAndEnd(int? start, int? end) => mounted
@ -89,8 +92,7 @@ class MessageAudioCardState extends State<MessageAudioCard> {
// should never happen but just in case
debugger(when: kDebugMode);
ErrorHandler.logError(
e: Exception(),
m: 'audioFile duration is null in MessageAudioCardState',
e: 'audioFile duration is null in MessageAudioCardState',
data: {
'audioFile': audioFile,
},
@ -124,8 +126,7 @@ class MessageAudioCardState extends State<MessageAudioCard> {
// if we didn't find the token, we should pause if debug and log an error
debugger(when: kDebugMode);
ErrorHandler.logError(
e: Exception(),
m: 'could not find token for selection in MessageAudioCardState',
e: 'could not find token for selection in MessageAudioCardState',
data: {
'selection': selection,
'tokens': tokens,
@ -174,7 +175,7 @@ class MessageAudioCardState extends State<MessageAudioCard> {
),
);
ErrorHandler.logError(
e: Exception(),
e: e,
s: s,
m: 'something wrong getting audio in MessageAudioCardState',
data: {
@ -187,26 +188,35 @@ class MessageAudioCardState extends State<MessageAudioCard> {
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(8),
constraints: const BoxConstraints(minHeight: minCardHeight),
alignment: Alignment.center,
child: _isLoading
? const ToolbarContentLoadingIndicator()
: audioFile != null
? Column(
children: [
AudioPlayerWidget(
null,
matrixFile: audioFile,
sectionStartMS: sectionStartMS,
sectionEndMS: sectionEndMS,
color: Theme.of(context).colorScheme.onPrimaryContainer,
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(8),
alignment: Alignment.center,
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,
],
)
: const CardErrorWidget(
error: "Null audio file in message_audio_card",
maxWidth: AppConfig.toolbarMinWidth,
),
tts.missingVoiceButton ?? const SizedBox(),
],
)
: const CardErrorWidget(),
),
],
);
}
}

View file

@ -11,11 +11,13 @@ import 'package:fluffychat/pangea/enum/message_mode_enum.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart';
import 'package:fluffychat/pangea/widgets/chat/message_toolbar_buttons.dart';
import 'package:fluffychat/pangea/widgets/chat/overlay_footer.dart';
import 'package:fluffychat/pangea/widgets/chat/overlay_header.dart';
import 'package:fluffychat/pangea/widgets/chat/overlay_message.dart';
import 'package:fluffychat/pangea/widgets/chat/tts_controller.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
@ -60,11 +62,17 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
/// The number of activities that need to be completed before the toolbar is unlocked
/// If we don't have any good activities for them, we'll decrease this number
static const int neededActivities = 3;
int activitiesLeftToComplete = neededActivities;
bool get messageInUserL2 =>
pangeaMessageEvent.messageDisplayLangCode ==
MatrixState.pangeaController.languageController.userL2?.langCode;
PangeaMessageEvent get pangeaMessageEvent => widget._pangeaMessageEvent;
final TtsController tts = TtsController();
bool isPlayingAudio = false;
@override
void initState() {
super.initState();
@ -97,6 +105,7 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
).listen((_) => setState(() {}));
setInitialToolbarMode();
tts.setupTTS();
}
/// We need to check if the setState call is safe to call immediately
@ -104,16 +113,29 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
/// This is a workaround to prevent that error
@override
void setState(VoidCallback fn) {
if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.idle ||
SchedulerBinding.instance.schedulerPhase ==
SchedulerPhase.postFrameCallbacks) {
final phase = SchedulerBinding.instance.schedulerPhase;
if (mounted &&
(phase == SchedulerPhase.idle ||
phase == SchedulerPhase.postFrameCallbacks)) {
// It's safe to call setState immediately
super.setState(fn);
try {
super.setState(fn);
} catch (e, s) {
ErrorHandler.logError(
e: "Error calling setState in MessageSelectionOverlay: $e",
s: s,
);
}
} else {
// Defer the setState call to after the current frame
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
super.setState(fn);
try {
if (mounted) super.setState(fn);
} catch (e, s) {
ErrorHandler.logError(
e: "Error calling setState in MessageSelectionOverlay after postframeCallback: $e",
s: s,
);
}
});
}
@ -143,6 +165,11 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
toolbarMode = MessageMode.speechToText;
return;
}
// if (!messageInUserL2) {
// activitiesLeftToComplete = 0;
// toolbarMode = MessageMode.nullMode;
// return;
// }
if (activitiesLeftToComplete > 0) {
toolbarMode = MessageMode.practiceActivity;
@ -184,9 +211,10 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
PangeaToken token,
) {
if ([
MessageMode.practiceActivity,
// MessageMode.textToSpeech
].contains(toolbarMode)) {
MessageMode.practiceActivity,
// MessageMode.textToSpeech
].contains(toolbarMode) ||
isPlayingAudio) {
return;
}
@ -257,17 +285,23 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
double get reactionsHeight => hasReactions ? 28 : 0;
double get belowMessageHeight => toolbarButtonsHeight + reactionsHeight;
void setIsPlayingAudio(bool isPlaying) {
if (mounted) {
setState(() => isPlayingAudio = isPlaying);
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (messageSize == null || messageOffset == null) {
if (messageSize == null || messageOffset == null || screenHeight == null) {
return;
}
// position the overlay directly over the underlying message
final headerBottomOffset = screenHeight - headerHeight;
final headerBottomOffset = screenHeight! - headerHeight;
final footerBottomOffset = footerHeight;
final currentBottomOffset = screenHeight -
final currentBottomOffset = screenHeight! -
messageOffset!.dy -
messageSize!.height -
belowMessageHeight;
@ -295,7 +329,7 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
animationEndOffset = midpoint - messageSize!.height - belowMessageHeight;
final totalTopOffset =
animationEndOffset + messageSize!.height + AppConfig.toolbarMaxHeight;
final remainingSpace = screenHeight - totalTopOffset;
final remainingSpace = screenHeight! - totalTopOffset;
if (remainingSpace < headerHeight) {
// the overlay could run over the header, so it needs to be shifted down
animationEndOffset -= (headerHeight - remainingSpace);
@ -310,7 +344,7 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
// update the message height to fit the screen. The message is scrollable, so
// this will make the both the toolbar box and the toolbar buttons visible.
if (animationEndOffset < footerHeight + belowMessageHeight) {
final double remainingSpace = screenHeight -
final double remainingSpace = screenHeight! -
AppConfig.toolbarMaxHeight -
headerHeight -
footerHeight -
@ -345,31 +379,74 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
void dispose() {
_animationController.dispose();
_reactionSubscription?.cancel();
tts.dispose();
super.dispose();
}
RenderBox? get messageRenderBox => MatrixState.pAnyState.getRenderBox(
RenderBox? get messageRenderBox {
try {
return MatrixState.pAnyState.getRenderBox(
widget._event.eventId,
);
} catch (e, s) {
ErrorHandler.logError(e: "Error getting message render box: $e", s: s);
return null;
}
}
Size? get messageSize {
if (messageRenderBox == null || !messageRenderBox!.hasSize) {
return null;
}
try {
return messageRenderBox?.size;
} catch (e, s) {
ErrorHandler.logError(e: "Error getting message size: $e", s: s);
return null;
}
}
Offset? get messageOffset {
if (messageRenderBox == null || !messageRenderBox!.hasSize) {
return null;
}
try {
return messageRenderBox?.localToGlobal(Offset.zero);
} catch (e, s) {
ErrorHandler.logError(e: "Error getting message offset: $e", s: s);
return null;
}
}
Size? get messageSize => messageRenderBox?.size;
Offset? get messageOffset => messageRenderBox?.localToGlobal(Offset.zero);
double? adjustedMessageHeight;
// height of the reply/forward bar + the reaction picker + contextual padding
double get footerHeight =>
48 + 56 + (FluffyThemes.isColumnMode(context) ? 16.0 : 8.0);
MediaQueryData? get mediaQuery {
try {
return MediaQuery.of(context);
} catch (e, s) {
ErrorHandler.logError(e: "Error getting media query: $e", s: s);
return null;
}
}
double get headerHeight =>
(Theme.of(context).appBarTheme.toolbarHeight ?? 56) +
MediaQuery.of(context).padding.top;
(mediaQuery?.padding.top ?? 0);
double get screenHeight => MediaQuery.of(context).size.height;
double? get screenHeight => mediaQuery?.size.height;
double get screenWidth => MediaQuery.of(context).size.width;
double? get screenWidth => mediaQuery?.size.width;
@override
Widget build(BuildContext context) {
if (messageSize == null) return const SizedBox.shrink();
final bool showDetails = (Matrix.of(context)
.store
.getBool(SettingKeys.displayChatDetailsColumn) ??
@ -378,27 +455,24 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
widget.chatController.room.membership == Membership.join;
// the default spacing between the side of the screen and the message bubble
final double messageMargin =
pangeaMessageEvent.ownMessage ? Avatar.defaultSize + 16 : 8;
const double messageMargin = Avatar.defaultSize + 16 + 8;
final horizontalPadding = FluffyThemes.isColumnMode(context) ? 8.0 : 0.0;
// the actual spacing between the side of the screen and
// the message bubble, accounts for wide screen
double extraChatSpace = FluffyThemes.isColumnMode(context)
? ((screenWidth -
(FluffyThemes.columnWidth * 3.5) -
FluffyThemes.navRailWidth) /
2) +
messageMargin
: messageMargin;
if (extraChatSpace < messageMargin) {
extraChatSpace = messageMargin;
const totalMaxWidth = (FluffyThemes.columnWidth * 2.5) - messageMargin;
double? maxWidth;
if (screenWidth != null) {
final chatViewWidth = screenWidth! -
(FluffyThemes.isColumnMode(context)
? (FluffyThemes.columnWidth + FluffyThemes.navRailWidth)
: 0);
maxWidth = chatViewWidth - (2 * horizontalPadding) - messageMargin;
}
if (maxWidth == null || maxWidth > totalMaxWidth) {
maxWidth = totalMaxWidth;
}
final overlayMessage = Container(
constraints: const BoxConstraints(
maxWidth: FluffyThemes.columnWidth * 2.5,
),
constraints: BoxConstraints(maxWidth: maxWidth),
child: Material(
type: MaterialType.transparency,
child: Column(
@ -410,6 +484,7 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
MessageToolbar(
pangeaMessageEvent: widget._pangeaMessageEvent,
overLayController: this,
tts: tts,
),
SizedBox(
height: adjustedMessageHeight,
@ -446,32 +521,37 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
),
);
final horizontalPadding = FluffyThemes.isColumnMode(context) ? 8.0 : 0.0;
final columnOffset = FluffyThemes.isColumnMode(context)
? FluffyThemes.columnWidth + FluffyThemes.navRailWidth
: 0;
final double leftPadding = widget._pangeaMessageEvent.ownMessage
? extraChatSpace
: messageOffset!.dx - horizontalPadding - columnOffset;
final double? leftPadding =
(widget._pangeaMessageEvent.ownMessage || messageOffset == null)
? null
: messageOffset!.dx - horizontalPadding - columnOffset;
final double rightPadding = widget._pangeaMessageEvent.ownMessage
? screenWidth -
final double? rightPadding = (widget._pangeaMessageEvent.ownMessage &&
screenWidth != null &&
messageOffset != null &&
messageSize != null)
? screenWidth! -
messageOffset!.dx -
messageSize!.width -
horizontalPadding
: extraChatSpace;
: null;
final positionedOverlayMessage = _overlayPositionAnimation == null
? Positioned(
left: leftPadding,
right: rightPadding,
bottom: screenHeight -
messageOffset!.dy -
messageSize!.height -
belowMessageHeight,
child: overlayMessage,
)
final positionedOverlayMessage = (_overlayPositionAnimation == null)
? (screenHeight == null || messageSize == null || messageOffset == null)
? const SizedBox.shrink()
: Positioned(
left: leftPadding,
right: rightPadding,
bottom: screenHeight! -
messageOffset!.dy -
messageSize!.height -
belowMessageHeight,
child: overlayMessage,
)
: AnimatedBuilder(
animation: _overlayPositionAnimation!,
builder: (context, child) {

View file

@ -1,11 +1,11 @@
import 'dart:developer';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/enum/instructions_enum.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/models/speech_to_text_models.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/utils/inline_tooltip.dart';
import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart';
import 'package:fluffychat/pangea/widgets/chat/toolbar_content_loading_indicator.dart';
import 'package:fluffychat/pangea/widgets/common/icon_number_widget.dart';
import 'package:fluffychat/pangea/widgets/igc/card_error_widget.dart';
@ -149,21 +149,19 @@ class MessageSpeechToTextCardState extends State<MessageSpeechToTextCard> {
return const ToolbarContentLoadingIndicator();
}
//done fetchig but not results means some kind of error
// done fetchig but not results means some kind of error
if (speechToTextResponse == null) {
return CardErrorWidget(error: error);
return CardErrorWidget(
error: error ?? "Failed to fetch speech to text",
maxWidth: AppConfig.toolbarMinWidth,
);
}
final int words = speechToTextResponse!.transcript.sttTokens.length;
final int accuracy = speechToTextResponse!.transcript.confidence;
final int total = words * accuracy;
//TODO: find better icons
return Container(
return Padding(
padding: const EdgeInsets.all(8),
constraints: const BoxConstraints(minHeight: minCardHeight),
alignment: Alignment.center,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
RichText(
@ -171,19 +169,15 @@ class MessageSpeechToTextCardState extends State<MessageSpeechToTextCard> {
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
mainAxisSize: MainAxisSize.min,
children: [
// IconNumberWidget(
// icon: Icons.abc,
// number: (selectedToken == null ? words : 1).toString(),
// toolTip: L10n.of(context)!.words,
// ),
IconNumberWidget(
icon: Symbols.target,
number:
"${selectedToken?.confidence ?? speechToTextResponse!.transcript.confidence}%",
toolTip: L10n.of(context)!.accuracy,
),
const SizedBox(width: 16),
IconNumberWidget(
icon: Icons.speed,
number: wordsPerMinuteString != null

View file

@ -10,26 +10,30 @@ import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart';
import 'package:fluffychat/pangea/widgets/chat/message_speech_to_text_card.dart';
import 'package:fluffychat/pangea/widgets/chat/message_translation_card.dart';
import 'package:fluffychat/pangea/widgets/chat/message_unsubscribed_card.dart';
import 'package:fluffychat/pangea/widgets/chat/tts_controller.dart';
import 'package:fluffychat/pangea/widgets/igc/word_data_card.dart';
import 'package:fluffychat/pangea/widgets/message_display_card.dart';
import 'package:fluffychat/pangea/widgets/practice_activity/practice_activity_card.dart';
import 'package:fluffychat/pangea/widgets/select_to_define.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';
const double minCardHeight = 70;
class MessageToolbar extends StatelessWidget {
final PangeaMessageEvent pangeaMessageEvent;
final MessageOverlayController overLayController;
final TtsController tts;
const MessageToolbar({
super.key,
required this.pangeaMessageEvent,
required this.overLayController,
required this.tts,
});
Widget get toolbarContent {
Widget toolbarContent(BuildContext context) {
final bool subscribed =
MatrixState.pangeaController.subscriptionController.isSubscribed;
@ -39,6 +43,18 @@ class MessageToolbar extends StatelessWidget {
);
}
// Check if the message is in the user's second language
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(
@ -50,6 +66,8 @@ class MessageToolbar extends StatelessWidget {
messageEvent: pangeaMessageEvent,
overlayController: overLayController,
selection: overLayController.selectedSpan,
tts: tts,
setIsPlayingAudio: overLayController.setIsPlayingAudio,
);
case MessageMode.speechToText:
return MessageSpeechToTextCard(
@ -57,7 +75,9 @@ class MessageToolbar extends StatelessWidget {
);
case MessageMode.definition:
if (!overLayController.isSelection) {
return const SelectToDefine();
return MessageDisplayCard(
displayText: L10n.of(context)!.selectToDefine,
);
} else {
try {
final selectedText = overLayController.targetText;
@ -87,6 +107,7 @@ class MessageToolbar extends StatelessWidget {
return PracticeActivityCard(
pangeaMessageEvent: pangeaMessageEvent,
overlayController: overLayController,
tts: tts,
);
default:
debugger(when: kDebugMode);
@ -101,41 +122,28 @@ class MessageToolbar extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Material(
key: MatrixState.pAnyState
.layerLinkAndKey('${pangeaMessageEvent.eventId}-toolbar')
.key,
type: MaterialType.transparency,
child: Column(
children: [
Container(
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
border: Border.all(
width: 2,
color: Theme.of(context).colorScheme.primary.withOpacity(0.5),
),
borderRadius: const BorderRadius.all(
Radius.circular(AppConfig.borderRadius),
),
),
constraints: const BoxConstraints(
maxHeight: AppConfig.toolbarMaxHeight,
),
child: Row(
children: [
Expanded(
child: SingleChildScrollView(
child: AnimatedSize(
duration: FluffyThemes.animationDuration,
child: toolbarContent,
),
),
),
],
),
),
],
return Container(
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
border: Border.all(
width: 2,
color: Theme.of(context).colorScheme.primary.withOpacity(0.5),
),
borderRadius: const BorderRadius.all(
Radius.circular(AppConfig.borderRadius),
),
),
constraints: const BoxConstraints(
maxHeight: AppConfig.toolbarMaxHeight,
minWidth: AppConfig.toolbarMinWidth,
minHeight: AppConfig.toolbarMinHeight,
// maxWidth is set by MessageSelectionOverlay
),
child: SingleChildScrollView(
child: AnimatedSize(
duration: FluffyThemes.animationDuration,
child: toolbarContent(context),
),
),
);
}

View file

@ -22,7 +22,7 @@ class ToolbarButtons extends StatelessWidget {
overlayController.pangeaMessageEvent;
List<MessageMode> get modes => MessageMode.values
.where((mode) => mode.isValidMode(pangeaMessageEvent.event))
.where((mode) => mode.shouldShowAsToolbarButton(pangeaMessageEvent.event))
.toList();
static const double iconWidth = 36.0;

View file

@ -1,3 +1,4 @@
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/enum/instructions_enum.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
@ -6,7 +7,6 @@ import 'package:fluffychat/pangea/repo/full_text_translation_repo.dart';
import 'package:fluffychat/pangea/utils/bot_style.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/utils/inline_tooltip.dart';
import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart';
import 'package:fluffychat/pangea/widgets/chat/toolbar_content_loading_indicator.dart';
import 'package:fluffychat/pangea/widgets/igc/card_error_widget.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -131,26 +131,38 @@ class MessageTranslationCardState extends State<MessageTranslationCard> {
if (!_fetchingTranslation &&
repEvent == null &&
selectionTranslation == null) {
return const CardErrorWidget();
return const CardErrorWidget(
error: "No translation found",
maxWidth: AppConfig.toolbarMinWidth,
);
}
return Container(
padding: const EdgeInsets.all(8),
constraints: const BoxConstraints(minHeight: minCardHeight),
alignment: Alignment.center,
child: _fetchingTranslation
? const ToolbarContentLoadingIndicator()
: Column(
final loadingTranslation =
(widget.selection != null && selectionTranslation == null) ||
(widget.selection == null && repEvent == null);
if (_fetchingTranslation || loadingTranslation) {
return const ToolbarContentLoadingIndicator();
}
return Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 16),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
widget.selection != null
? Text(
selectionTranslation!,
style: BotStyle.text(context),
)
: Text(
repEvent!.text,
style: BotStyle.text(context),
),
Text(
widget.selection != null
? selectionTranslation!
: repEvent!.text,
style: BotStyle.text(context),
textAlign: TextAlign.center,
),
if (notGoingToTranslate && widget.selection == null)
InlineTooltip(
instructionsEnum: InstructionsEnum.l1Translation,
@ -161,9 +173,11 @@ class MessageTranslationCardState extends State<MessageTranslationCard> {
instructionsEnum: InstructionsEnum.clickAgainToDeselect,
onClose: () => setState(() {}),
),
// if (widget.selection != null)
],
),
),
],
),
);
}
}

View file

@ -16,10 +16,10 @@ class MessageUnsubscribedCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final bool inTrialWindow =
MatrixState.pangeaController.userController.inTrialWindow;
MatrixState.pangeaController.userController.inTrialWindow();
return Container(
padding: const EdgeInsets.all(8),
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Text(

View file

@ -40,21 +40,27 @@ class MissingVoiceButton extends StatelessWidget {
),
padding: const EdgeInsets.all(8),
margin: const EdgeInsets.only(top: 8),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
L10n.of(context)!.voiceNotAvailable,
textAlign: TextAlign.center,
),
TextButton(
onPressed: () => launchTTSSettings,
style: const ButtonStyle(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
child: SizedBox(
width: AppConfig.toolbarMinWidth,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
L10n.of(context)!.voiceNotAvailable,
textAlign: TextAlign.center,
),
child: Text(L10n.of(context)!.openVoiceSettings),
),
],
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),
),
],
),
),
);
}

View file

@ -1,4 +1,4 @@
import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:flutter/material.dart';
class ToolbarContentLoadingIndicator extends StatelessWidget {
@ -8,10 +8,9 @@ class ToolbarContentLoadingIndicator extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(8),
constraints: const BoxConstraints(minHeight: minCardHeight),
alignment: Alignment.center,
return SizedBox(
width: AppConfig.toolbarMinWidth,
height: AppConfig.toolbarMinHeight,
child: Center(
child: SizedBox(
height: 14,

View file

@ -18,8 +18,22 @@ class TtsController {
setupTTS();
}
Future<void> dispose() async {
await tts.stop();
}
onError(dynamic message) => ErrorHandler.logError(
e: message,
m: (message.toString().isNotEmpty) ? message.toString() : 'TTS error',
data: {
'message': message,
},
);
Future<void> setupTTS() async {
try {
tts.setErrorHandler(onError);
targetLanguage ??=
MatrixState.pangeaController.languageController.userL2?.langCode;
@ -32,7 +46,6 @@ class TtsController {
await tts.awaitSpeakCompletion(true);
final voices = await tts.getVoices;
debugPrint("voices: $voices");
availableLangCodes = (voices as List)
.map((v) {
// on iOS / web, the codes are in 'locale', but on Android, they are in 'name'
@ -53,12 +66,48 @@ class TtsController {
}
}
Future<void> speak(String text) async {
targetLanguage ??=
MatrixState.pangeaController.languageController.userL2?.langCode;
Future<void> stop() async {
try {
// return type is dynamic but apparent its supposed to be 1
// https://pub.dev/packages/flutter_tts
final result = await tts.stop();
if (result != 1) {
ErrorHandler.logError(
m: 'Unexpected result from tts.stop',
data: {
'result': result,
},
);
}
} catch (e, s) {
debugger(when: kDebugMode);
ErrorHandler.logError(e: e, s: s);
}
}
await tts.stop();
return tts.speak(text);
Future<void> speak(String text) async {
try {
stop();
targetLanguage ??=
MatrixState.pangeaController.languageController.userL2?.langCode;
final result = await tts.speak(text);
// return type is dynamic but apparent its supposed to be 1
// https://pub.dev/packages/flutter_tts
if (result != 1 && !kIsWeb) {
ErrorHandler.logError(
m: 'Unexpected result from tts.speak',
data: {
'result': result,
'text': text,
},
);
}
} catch (e, s) {
debugger(when: kDebugMode);
ErrorHandler.logError(e: e, s: s);
}
}
bool get isLanguageFullySupported =>

View file

@ -36,31 +36,32 @@ class AnalyticsPopup extends StatelessWidget {
),
body: Padding(
padding: const EdgeInsets.symmetric(vertical: 20),
child: constructsModel.constructList.isEmpty
child: constructsModel.constructListWithPoints.isEmpty
? Center(
child: Text(L10n.of(context)!.noDataFound),
)
: ListView.builder(
itemCount: constructsModel.constructList.length,
itemCount: constructsModel.constructListWithPoints.length,
itemBuilder: (context, index) {
return Tooltip(
message:
"${constructsModel.constructList[index].points} / ${constructsModel.maxXPPerLemma}",
"${constructsModel.constructListWithPoints[index].points} / ${constructsModel.maxXPPerLemma}",
child: ListTile(
onTap: () {},
title: Text(
constructsModel.type == ConstructTypeEnum.morph
? getGrammarCopy(
constructsModel
.constructList[index].lemma,
.constructListWithPoints[index].lemma,
context,
)
: constructsModel.constructList[index].lemma,
: constructsModel
.constructListWithPoints[index].lemma,
),
subtitle: LinearProgressIndicator(
value:
constructsModel.constructList[index].points /
constructsModel.maxXPPerLemma,
value: constructsModel
.constructListWithPoints[index].points /
constructsModel.maxXPPerLemma,
minHeight: 20,
borderRadius: const BorderRadius.all(
Radius.circular(AppConfig.borderRadius),

View file

@ -111,9 +111,9 @@ class LearningProgressIndicatorsState
int? getProgressPoints(ProgressIndicatorEnum indicator) {
switch (indicator) {
case ProgressIndicatorEnum.wordsUsed:
return words?.lemmas.length;
return words?.lemmasWithPoints.length;
case ProgressIndicatorEnum.morphsUsed:
return morphs?.lemmas.length;
return morphs?.lemmasWithPoints.length;
case ProgressIndicatorEnum.level:
return level;
}

View file

@ -30,7 +30,7 @@ class IconNumberWidget extends StatelessWidget {
),
onPressed: onPressed,
),
const SizedBox(width: 8),
const SizedBox(width: 5),
Text(
number.toString(),
style: TextStyle(

View file

@ -2,14 +2,16 @@ import 'package:flutter/material.dart';
class OverlayContainer extends StatelessWidget {
final Widget cardToShow;
final Size cardSize;
final Color? borderColor;
final double maxHeight;
final double maxWidth;
const OverlayContainer({
super.key,
required this.cardToShow,
this.cardSize = const Size(300.0, 300.0),
this.borderColor,
required this.maxHeight,
required this.maxWidth,
});
@override
@ -28,14 +30,19 @@ class OverlayContainer extends StatelessWidget {
),
),
constraints: BoxConstraints(
maxWidth: cardSize.width,
maxHeight: cardSize.height,
minWidth: cardSize.width,
minHeight: cardSize.height,
maxWidth: maxWidth,
maxHeight: maxHeight,
minHeight: 100,
minWidth: 100,
),
//PTODO - position card above input/message
// margin: const EdgeInsets.all(10),
child: cardToShow,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [cardToShow],
),
),
);
}
}

View file

@ -4,62 +4,85 @@ import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class ConversationBotModeDynamicZone extends StatelessWidget {
final BotOptionsModel initialBotOptions;
final GlobalKey<FormState> formKey;
final BotOptionsModel botOptions;
final TextEditingController discussionTopicController;
final TextEditingController discussionKeywordsController;
final TextEditingController customSystemPromptController;
final bool enabled;
const ConversationBotModeDynamicZone({
super.key,
required this.initialBotOptions,
required this.formKey,
required this.botOptions,
required this.discussionTopicController,
required this.discussionKeywordsController,
required this.customSystemPromptController,
this.enabled = true,
});
@override
Widget build(BuildContext context) {
final discussionChildren = [
TextFormField(
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
decoration: InputDecoration(
hintText: L10n.of(context)!
.conversationBotDiscussionZone_discussionTopicPlaceholder,
contentPadding:
const EdgeInsets.symmetric(horizontal: 28.0, vertical: 12.0),
),
controller: discussionTopicController,
validator: (value) => value == null || value.isEmpty
validator: (value) => enabled &&
botOptions.mode == BotMode.discussion &&
(value == null || value.isEmpty)
? L10n.of(context)!.enterDiscussionTopic
: null,
enabled: enabled,
minLines: 1, // Minimum number of lines
maxLines: null, // Allow the field to expand based on content
keyboardType: TextInputType.multiline,
),
const SizedBox(height: 12),
TextFormField(
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
decoration: InputDecoration(
hintText: L10n.of(context)!
.conversationBotDiscussionZone_discussionKeywordsPlaceholder,
contentPadding: const EdgeInsets.symmetric(horizontal: 28.0),
),
controller: discussionKeywordsController,
enabled: enabled,
minLines: 1, // Minimum number of lines
maxLines: null, // Allow the field to expand based on content
keyboardType: TextInputType.multiline,
),
];
final customChildren = [
TextFormField(
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
decoration: InputDecoration(
hintText: L10n.of(context)!
.conversationBotCustomZone_customSystemPromptPlaceholder,
contentPadding: const EdgeInsets.symmetric(horizontal: 28.0),
),
validator: (value) => value == null || value.isEmpty
validator: (value) => enabled &&
botOptions.mode == BotMode.custom &&
(value == null || value.isEmpty)
? L10n.of(context)!.enterPrompt
: null,
controller: customSystemPromptController,
enabled: enabled,
minLines: 1, // Minimum number of lines
maxLines: null, // Allow the field to expand based on content
keyboardType: TextInputType.multiline,
),
];
return Column(
children: [
if (initialBotOptions.mode == BotMode.discussion) ...discussionChildren,
if (initialBotOptions.mode == BotMode.custom) ...customChildren,
if (botOptions.mode == BotMode.discussion) ...discussionChildren,
if (botOptions.mode == BotMode.custom) ...customChildren,
const SizedBox(height: 12),
CheckboxListTile(
title: Text(
@ -67,7 +90,7 @@ class ConversationBotModeDynamicZone extends StatelessWidget {
.conversationBotCustomZone_customTriggerReactionEnabledLabel,
),
enabled: false,
value: initialBotOptions.customTriggerReactionEnabled ?? true,
value: botOptions.customTriggerReactionEnabled ?? true,
onChanged: null,
),
const SizedBox(height: 12),

View file

@ -1,15 +1,18 @@
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:fluffychat/pangea/constants/bot_mode.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class ConversationBotModeSelect extends StatelessWidget {
final String? initialMode;
final void Function(String?)? onChanged;
final void Function(String?) onChanged;
final bool enabled;
const ConversationBotModeSelect({
super.key,
this.initialMode,
this.onChanged,
required this.onChanged,
this.enabled = true,
});
@override
@ -24,23 +27,8 @@ class ConversationBotModeSelect extends StatelessWidget {
// L10n.of(context)!.conversationBotModeSelectOption_storyGame,
};
String? mode = initialMode;
if (!options.containsKey(initialMode)) {
mode = null;
}
return DropdownButtonFormField(
// Initial Value
hint: Text(
options[mode ?? BotMode.discussion]!,
overflow: TextOverflow.clip,
textAlign: TextAlign.center,
),
// ),
isExpanded: true,
// Down Arrow Icon
icon: const Icon(Icons.keyboard_arrow_down),
// Array list of items
return DropdownButtonFormField2(
hint: Text(L10n.of(context)!.selectBotChatMode),
items: [
for (final entry in options.entries)
DropdownMenuItem(
@ -52,7 +40,7 @@ class ConversationBotModeSelect extends StatelessWidget {
),
),
],
onChanged: onChanged,
onChanged: enabled ? onChanged : null,
);
}
}

View file

@ -1,5 +1,7 @@
import 'dart:developer';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pangea/constants/bot_mode.dart';
import 'package:fluffychat/pangea/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart';
import 'package:fluffychat/pangea/models/bot_options_model.dart';
@ -11,19 +13,14 @@ 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';
import 'package:matrix/matrix.dart';
class ConversationBotSettings extends StatefulWidget {
final Room? room;
final bool startOpen;
final String? activeSpaceId;
final Room room;
const ConversationBotSettings({
super.key,
this.room,
this.startOpen = false,
this.activeSpaceId,
required this.room,
});
@override
@ -31,48 +28,10 @@ class ConversationBotSettings extends StatefulWidget {
}
class ConversationBotSettingsState extends State<ConversationBotSettings> {
late BotOptionsModel botOptions;
late bool isOpen;
late bool isCreating;
bool addBot = false;
Room? parentSpace;
ConversationBotSettingsState({Key? key});
final TextEditingController discussionTopicController =
TextEditingController();
final TextEditingController discussionKeywordsController =
TextEditingController();
final TextEditingController customSystemPromptController =
TextEditingController();
@override
void initState() {
super.initState();
isOpen = widget.startOpen;
botOptions = widget.room?.botOptions != null
? BotOptionsModel.fromJson(widget.room?.botOptions?.toJson())
: BotOptionsModel();
widget.room?.botIsInRoom.then((bool isBotRoom) {
setState(() {
addBot = isBotRoom;
});
});
parentSpace = widget.activeSpaceId != null
? Matrix.of(context).client.getRoomById(widget.activeSpaceId!)
: null;
isCreating = widget.room == null;
discussionKeywordsController.text = botOptions.discussionKeywords ?? "";
discussionTopicController.text = botOptions.discussionTopic ?? "";
customSystemPromptController.text = botOptions.customSystemPrompt ?? "";
}
Future<void> setBotOption() async {
if (widget.room == null) return;
Future<void> setBotOptions(BotOptionsModel botOptions) async {
try {
await Matrix.of(context).client.setRoomStateWithKey(
widget.room!.id,
widget.room.id,
PangeaEventTypes.botOptions,
'',
botOptions.toJson(),
@ -83,125 +42,18 @@ class ConversationBotSettingsState extends State<ConversationBotSettings> {
}
}
Future<void> updateBotOption(void Function() makeLocalChange) async {
makeLocalChange();
await showFutureLoadingDialog(
context: context,
future: () async {
try {
await setBotOption();
} catch (err, stack) {
debugger(when: kDebugMode);
ErrorHandler.logError(e: err, s: stack);
}
setState(() {});
},
);
}
void updateAllBotOptions() {
botOptions.discussionTopic = discussionTopicController.text;
botOptions.discussionKeywords = discussionKeywordsController.text;
botOptions.customSystemPrompt = customSystemPromptController.text;
}
Future<void> showBotOptionsDialog() async {
if (isCreating) return;
final bool? confirm = await showDialog<bool>(
final BotOptionsModel? newBotOptions = await showDialog<BotOptionsModel?>(
context: context,
builder: (BuildContext context) {
return StatefulBuilder(
builder: (context, setState) => Dialog(
child: Form(
key: formKey,
child: Container(
padding: const EdgeInsets.all(16),
constraints: const BoxConstraints(
maxWidth: 450,
maxHeight: 725,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20.0),
child: ConversationBotSettingsDialog(
addBot: addBot,
botOptions: botOptions,
formKey: formKey,
updateAddBot: (bool value) =>
setState(() => addBot = value),
discussionKeywordsController: discussionKeywordsController,
discussionTopicController: discussionTopicController,
customSystemPromptController: customSystemPromptController,
),
),
),
),
),
);
},
builder: (BuildContext context) =>
ConversationBotSettingsDialog(room: widget.room),
);
if (confirm == true) {
updateAllBotOptions();
updateBotOption(() => botOptions = botOptions);
final bool isBotRoomMember = await widget.room?.botIsInRoom ?? false;
if (addBot && !isBotRoomMember) {
await widget.room?.invite(BotName.byEnvironment);
} else if (!addBot && isBotRoomMember) {
await widget.room?.kick(BotName.byEnvironment);
}
if (newBotOptions != null) {
setBotOptions(newBotOptions);
}
}
Future<void> showNewRoomBotOptionsDialog() async {
final bool? confirm = await showDialog<bool>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: addBot
? Text(
L10n.of(context)!.addConversationBotButtonTitleRemove,
)
: Text(
L10n.of(context)!.addConversationBotDialogTitleInvite,
),
actions: <Widget>[
TextButton(
onPressed: () {
Navigator.of(context).pop(false);
},
child: Text(L10n.of(context)!.cancel),
),
TextButton(
onPressed: () {
Navigator.of(context).pop(!addBot);
},
child: addBot
? Text(
L10n.of(context)!
.addConversationBotDialogRemoveConfirmation,
)
: Text(
L10n.of(context)!
.addConversationBotDialogInviteConfirmation,
),
),
],
);
},
);
if (confirm == true) {
setState(() => addBot = true);
widget.room?.invite(BotName.byEnvironment);
} else {
setState(() => addBot = false);
widget.room?.kick(BotName.byEnvironment);
}
}
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
@override
Widget build(BuildContext context) {
return AnimatedContainer(
@ -212,17 +64,12 @@ class ConversationBotSettingsState extends State<ConversationBotSettings> {
children: [
ListTile(
title: Text(
isCreating
? L10n.of(context)!.addConversationBot
: L10n.of(context)!.botConfig,
L10n.of(context)!.botConfig,
style: TextStyle(
color: Theme.of(context).colorScheme.secondary,
fontWeight: FontWeight.bold,
),
),
subtitle: isCreating
? Text(L10n.of(context)!.addConversationBotDesc)
: null,
leading: CircleAvatar(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
foregroundColor: Theme.of(context).textTheme.bodyLarge!.color,
@ -231,139 +78,195 @@ class ConversationBotSettingsState extends State<ConversationBotSettings> {
expression: BotExpression.idle,
),
),
trailing: isCreating
? ElevatedButton(
onPressed: showNewRoomBotOptionsDialog,
child: Text(
addBot
? L10n.of(context)!.addConversationBotButtonRemove
: L10n.of(context)!.addConversationBotButtonInvite,
),
)
: const Icon(Icons.settings),
trailing: const Icon(Icons.settings),
onTap: showBotOptionsDialog,
),
if (isCreating && addBot)
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text(
L10n.of(context)!.botConfig,
style: Theme.of(context).textTheme.titleLarge,
),
),
),
Form(
key: formKey,
child: ConversationBotSettingsForm(
botOptions: botOptions,
formKey: formKey,
discussionKeywordsController:
discussionKeywordsController,
discussionTopicController: discussionTopicController,
customSystemPromptController:
customSystemPromptController,
),
),
],
),
),
],
),
);
}
}
class ConversationBotSettingsDialog extends StatelessWidget {
final bool addBot;
final BotOptionsModel botOptions;
final GlobalKey<FormState> formKey;
final void Function(bool) updateAddBot;
final TextEditingController discussionTopicController;
final TextEditingController discussionKeywordsController;
final TextEditingController customSystemPromptController;
class ConversationBotSettingsDialog extends StatefulWidget {
final Room room;
const ConversationBotSettingsDialog({
super.key,
required this.addBot,
required this.botOptions,
required this.formKey,
required this.updateAddBot,
required this.discussionTopicController,
required this.discussionKeywordsController,
required this.customSystemPromptController,
required this.room,
});
@override
ConversationBotSettingsDialogState createState() =>
ConversationBotSettingsDialogState();
}
class ConversationBotSettingsDialogState
extends State<ConversationBotSettingsDialog> {
late BotOptionsModel botOptions;
bool addBot = false;
final TextEditingController discussionTopicController =
TextEditingController();
final TextEditingController discussionKeywordsController =
TextEditingController();
final TextEditingController customSystemPromptController =
TextEditingController();
@override
void initState() {
super.initState();
botOptions = widget.room.botOptions != null
? BotOptionsModel.fromJson(widget.room.botOptions?.toJson())
: BotOptionsModel();
widget.room.botIsInRoom.then((bool isBotRoom) {
setState(() => addBot = isBotRoom);
});
discussionKeywordsController.text = botOptions.discussionKeywords ?? "";
discussionTopicController.text = botOptions.discussionTopic ?? "";
customSystemPromptController.text = botOptions.customSystemPrompt ?? "";
}
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
void updateFromTextControllers() {
botOptions.discussionTopic = discussionTopicController.text;
botOptions.discussionKeywords = discussionKeywordsController.text;
botOptions.customSystemPrompt = customSystemPromptController.text;
}
void onUpdateChatMode(String? mode) {
setState(() => botOptions.mode = mode ?? BotMode.discussion);
}
void onUpdateBotLanguage(String? language) {
setState(() => botOptions.targetLanguage = language);
}
void onUpdateBotVoice(String? voice) {
setState(() => botOptions.targetVoice = voice);
}
void onUpdateBotLanguageLevel(int? level) {
setState(() => botOptions.languageLevel = level);
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 12,
),
child: Text(
L10n.of(context)!.botConfig,
style: Theme.of(context).textTheme.titleLarge,
),
),
),
SwitchListTile(
title: Text(
L10n.of(context)!.conversationBotStatus,
),
value: addBot,
onChanged: updateAddBot,
contentPadding: const EdgeInsets.all(4),
),
if (addBot)
Expanded(
child: SingleChildScrollView(
child: Column(
final dialogContent = Form(
key: formKey,
child: Container(
padding: const EdgeInsets.all(16),
constraints: kIsWeb
? const BoxConstraints(
maxWidth: 450,
maxHeight: 725,
)
: null,
child: ClipRRect(
borderRadius: BorderRadius.circular(20.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const SizedBox(height: 20),
ConversationBotSettingsForm(
botOptions: botOptions,
formKey: formKey,
discussionKeywordsController: discussionKeywordsController,
discussionTopicController: discussionTopicController,
customSystemPromptController: customSystemPromptController,
Padding(
padding: const EdgeInsets.symmetric(
vertical: 12,
),
child: Text(
L10n.of(context)!.botConfig,
style: Theme.of(context).textTheme.titleLarge,
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(null),
),
],
),
),
SwitchListTile(
title: Text(
L10n.of(context)!.conversationBotStatus,
),
value: addBot,
onChanged: (bool value) {
setState(() => addBot = value);
},
contentPadding: const EdgeInsets.all(4),
),
Expanded(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
const SizedBox(height: 20),
AnimatedOpacity(
duration: FluffyThemes.animationDuration,
opacity: addBot ? 1.0 : 0.5,
child: ConversationBotSettingsForm(
botOptions: botOptions,
discussionKeywordsController:
discussionKeywordsController,
discussionTopicController:
discussionTopicController,
customSystemPromptController:
customSystemPromptController,
enabled: addBot,
onUpdateBotMode: onUpdateChatMode,
onUpdateBotLanguage: onUpdateBotLanguage,
onUpdateBotVoice: onUpdateBotVoice,
onUpdateBotLanguageLevel: onUpdateBotLanguageLevel,
),
),
],
),
),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () {
Navigator.of(context).pop(null);
},
child: Text(L10n.of(context)!.cancel),
),
const SizedBox(width: 20),
TextButton(
onPressed: () async {
final isValid = formKey.currentState!.validate();
if (!isValid) return;
updateFromTextControllers();
Navigator.of(context).pop(botOptions);
final bool isBotRoomMember =
await widget.room.botIsInRoom;
if (addBot && !isBotRoomMember) {
await widget.room.invite(BotName.byEnvironment);
} else if (!addBot && isBotRoomMember) {
await widget.room.kick(BotName.byEnvironment);
}
},
child: Text(L10n.of(context)!.confirm),
),
],
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () {
Navigator.of(context).pop(false);
},
child: Text(L10n.of(context)!.cancel),
),
const SizedBox(width: 20),
TextButton(
onPressed: () {
final isValid = formKey.currentState!.validate();
if (!isValid) return;
Navigator.of(context).pop(true);
},
child: Text(L10n.of(context)!.confirm),
),
],
),
],
),
);
return kIsWeb
? Dialog(child: dialogContent)
: Dialog.fullscreen(child: dialogContent);
}
}

View file

@ -1,4 +1,4 @@
import 'package:fluffychat/pangea/constants/bot_mode.dart';
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:fluffychat/pangea/models/bot_options_model.dart';
import 'package:fluffychat/pangea/widgets/conversation_bot/conversation_bot_mode_dynamic_zone.dart';
import 'package:fluffychat/pangea/widgets/conversation_bot/conversation_bot_mode_select.dart';
@ -7,44 +7,40 @@ import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class ConversationBotSettingsForm extends StatefulWidget {
class ConversationBotSettingsForm extends StatelessWidget {
final BotOptionsModel botOptions;
final GlobalKey<FormState> formKey;
final TextEditingController discussionTopicController;
final TextEditingController discussionKeywordsController;
final TextEditingController customSystemPromptController;
final bool enabled;
final void Function(String?) onUpdateBotMode;
final void Function(String?) onUpdateBotLanguage;
final void Function(String?) onUpdateBotVoice;
final void Function(int?) onUpdateBotLanguageLevel;
const ConversationBotSettingsForm({
super.key,
required this.botOptions,
required this.formKey,
required this.discussionTopicController,
required this.discussionKeywordsController,
required this.customSystemPromptController,
required this.onUpdateBotMode,
required this.onUpdateBotLanguage,
required this.onUpdateBotVoice,
required this.onUpdateBotLanguageLevel,
this.enabled = true,
});
@override
ConversationBotSettingsFormState createState() =>
ConversationBotSettingsFormState();
}
class ConversationBotSettingsFormState
extends State<ConversationBotSettingsForm> {
late BotOptionsModel botOptions;
@override
void initState() {
super.initState();
botOptions = widget.botOptions;
}
@override
Widget build(BuildContext context) {
return Column(
children: [
DropdownButtonFormField(
// Initial Value
DropdownButtonFormField2(
dropdownStyleData: const DropdownStyleData(
padding: EdgeInsets.zero,
),
hint: Text(
L10n.of(context)!.selectBotLanguage,
overflow: TextOverflow.clip,
@ -52,7 +48,6 @@ class ConversationBotSettingsFormState
),
value: botOptions.targetLanguage,
isExpanded: true,
icon: const Icon(Icons.keyboard_arrow_down),
items: MatrixState.pangeaController.pLanguageStore.targetOptions
.map((language) {
return DropdownMenuItem(
@ -64,13 +59,10 @@ class ConversationBotSettingsFormState
),
);
}).toList(),
onChanged: (String? newValue) => {
setState(() => botOptions.targetLanguage = newValue!),
},
onChanged: enabled ? onUpdateBotLanguage : null,
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
// Initial Value
DropdownButtonFormField2<String>(
hint: Text(
L10n.of(context)!.chooseVoice,
overflow: TextOverflow.clip,
@ -78,22 +70,17 @@ class ConversationBotSettingsFormState
),
value: botOptions.targetVoice,
isExpanded: true,
icon: const Icon(Icons.keyboard_arrow_down),
items: const [],
onChanged: (String? newValue) => {
setState(() => botOptions.targetVoice = newValue!),
},
onChanged: enabled ? onUpdateBotVoice : null,
),
const SizedBox(height: 12),
LanguageLevelDropdown(
initialLevel: botOptions.languageLevel,
onChanged: (int? newValue) => {
setState(() {
botOptions.languageLevel = newValue!;
}),
},
validator: (value) =>
value == null ? L10n.of(context)!.enterLanguageLevel : null,
onChanged: onUpdateBotLanguageLevel,
validator: (value) => enabled && value == null
? L10n.of(context)!.enterLanguageLevel
: null,
enabled: enabled,
),
const SizedBox(height: 12),
Align(
@ -108,19 +95,16 @@ class ConversationBotSettingsFormState
),
ConversationBotModeSelect(
initialMode: botOptions.mode,
onChanged: (String? mode) => {
setState(() {
botOptions.mode = mode ?? BotMode.discussion;
}),
},
onChanged: onUpdateBotMode,
enabled: enabled,
),
const SizedBox(height: 12),
ConversationBotModeDynamicZone(
initialBotOptions: botOptions,
discussionTopicController: widget.discussionTopicController,
discussionKeywordsController: widget.discussionKeywordsController,
customSystemPromptController: widget.customSystemPromptController,
formKey: widget.formKey,
botOptions: botOptions,
discussionTopicController: discussionTopicController,
discussionKeywordsController: discussionKeywordsController,
customSystemPromptController: customSystemPromptController,
enabled: enabled,
),
],
);

View file

@ -1,50 +1,52 @@
import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart';
import 'package:fluffychat/pangea/utils/bot_style.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart';
import 'package:fluffychat/pangea/widgets/common/bot_face_svg.dart';
import 'package:fluffychat/pangea/widgets/igc/card_header.dart';
import 'package:flutter/material.dart';
class CardErrorWidget extends StatelessWidget {
final Object? error;
final Object error;
final Choreographer? choreographer;
final int? offset;
final double? maxWidth;
const CardErrorWidget({
super.key,
this.error,
required this.error,
this.choreographer,
this.offset,
this.maxWidth,
});
@override
Widget build(BuildContext context) {
final ErrorCopy errorCopy = ErrorCopy(context, error);
return Container(
padding: const EdgeInsets.all(8),
constraints: const BoxConstraints(minHeight: minCardHeight),
alignment: Alignment.center,
child: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
CardHeader(
text: errorCopy.title,
botExpression: BotExpression.addled,
onClose: () => choreographer?.onMatchError(
cursorOffset: offset,
),
return ConstrainedBox(
constraints: maxWidth != null
? BoxConstraints(maxWidth: maxWidth!)
: const BoxConstraints(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
CardHeader(
text: errorCopy.title,
botExpression: BotExpression.addled,
onClose: () => choreographer?.onMatchError(
cursorOffset: offset,
),
const SizedBox(height: 10.0),
Center(
child: Text(
errorCopy.body,
style: BotStyle.text(context),
),
),
const SizedBox(height: 12.0),
Padding(
padding: const EdgeInsets.all(12),
child: Text(
errorCopy.body,
style: BotStyle.text(context),
textAlign: TextAlign.center,
),
],
),
),
],
),
);
}

View file

@ -1,8 +1,8 @@
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/utils/bot_style.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
import '../../../widgets/matrix.dart';
import '../../utils/bot_style.dart';
import '../common/bot_face_svg.dart';
class CardHeader extends StatelessWidget {
@ -23,35 +23,35 @@ class CardHeader extends StatelessWidget {
padding: const EdgeInsets.only(bottom: 5.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.only(top: 3.0),
child: BotFace(
width: 50.0,
expression: botExpression,
Flexible(
child: Row(
children: [
BotFace(
width: 50.0,
expression: botExpression,
),
const SizedBox(width: 12.0),
Flexible(
child: Text(
text,
style: BotStyle.text(context),
softWrap: true,
),
),
],
),
),
const SizedBox(width: 5.0),
Expanded(
child: Text(
text,
style: BotStyle.text(context),
textAlign: TextAlign.left,
),
),
CircleAvatar(
backgroundColor: AppConfig.primaryColor.withOpacity(0.1),
child: IconButton(
icon: const Icon(Icons.close_outlined),
onPressed: () {
if (onClose != null) onClose!();
MatrixState.pAnyState.closeOverlay();
},
color: Theme.of(context).brightness == Brightness.dark
? AppConfig.primaryColorLight
: AppConfig.primaryColor,
),
IconButton(
icon: const Icon(Icons.close_outlined),
onPressed: () {
if (onClose != null) onClose!();
MatrixState.pAnyState.closeOverlay();
},
color: Theme.of(context).brightness == Brightness.dark
? AppConfig.primaryColorLight
: AppConfig.primaryColor,
),
],
),

View file

@ -47,24 +47,36 @@ class PangeaTextController extends TextEditingController {
debugger(when: kDebugMode);
return;
}
final CanSendStatus canSendStatus =
choreographer.pangeaController.subscriptionController.canSendStatus;
if (canSendStatus == CanSendStatus.showPaywall &&
// show the paywall if appropriate
if (choreographer
.pangeaController.subscriptionController.subscriptionStatus ==
SubscriptionStatus.showPaywall &&
!choreographer.isFetching &&
text.isNotEmpty) {
OverlayUtil.showPositionedCard(
context: context,
cardToShow: const PaywallCard(),
cardSize: const Size(325, 325),
cardToShow: PaywallCard(
chatController: choreographer.chatController,
),
maxHeight: 325,
maxWidth: 325,
transformTargetId: choreographer.inputTransformTargetKey,
);
}
// if there is no igc text data, then don't do anything
if (choreographer.igc.igcTextData == null) return;
// debugPrint(
// "onInputTap matches are ${choreographer.igc.igcTextData?.matches.map((e) => e.match.rule.id).toList().toString()}");
// if user is just trying to get their cursor into the text input field to add soemthing,
// then don't interrupt them
if (selection.baseOffset >= text.length) {
return;
}
final int tokenIndex = choreographer.igc.igcTextData!.tokenIndexByOffset(
selection.baseOffset,
);
@ -78,7 +90,7 @@ class PangeaTextController extends TextEditingController {
// if autoplay on and it start then just start it
if (matchIndex != -1 &&
choreographer.itAutoPlayEnabled &&
// choreographer.itAutoPlayEnabled &&
choreographer.igc.igcTextData!.matches[matchIndex].isITStart) {
return choreographer.onITStart(
choreographer.igc.igcTextData!.matches[matchIndex],
@ -112,10 +124,11 @@ class PangeaTextController extends TextEditingController {
if (cardToShow != null) {
OverlayUtil.showPositionedCard(
context: context,
cardSize: matchIndex != -1 &&
maxHeight: matchIndex != -1 &&
choreographer.igc.igcTextData!.matches[matchIndex].isITStart
? const Size(350, 260)
: const Size(350, 400),
? 260
: 400,
maxWidth: 350,
cardToShow: cardToShow,
transformTargetId: choreographer.inputTransformTargetKey,
);
@ -143,9 +156,9 @@ class PangeaTextController extends TextEditingController {
// debugPrint("composing after ${value.composing.textAfter(value.text)}");
// }
final CanSendStatus canSendStatus =
choreographer.pangeaController.subscriptionController.canSendStatus;
if (canSendStatus == CanSendStatus.showPaywall &&
final SubscriptionStatus canSendStatus = choreographer
.pangeaController.subscriptionController.subscriptionStatus;
if (canSendStatus == SubscriptionStatus.showPaywall &&
!choreographer.isFetching &&
text.isNotEmpty) {
return TextSpan(

View file

@ -1,4 +1,5 @@
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pangea/utils/bot_style.dart';
import 'package:fluffychat/pangea/widgets/common/bot_face_svg.dart';
import 'package:fluffychat/pangea/widgets/igc/card_header.dart';
@ -7,14 +8,16 @@ import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class PaywallCard extends StatelessWidget {
final ChatController chatController;
const PaywallCard({
super.key,
required this.chatController,
});
@override
Widget build(BuildContext context) {
final bool inTrialWindow =
MatrixState.pangeaController.userController.inTrialWindow;
MatrixState.pangeaController.userController.inTrialWindow();
return Column(
mainAxisSize: MainAxisSize.max,
@ -69,6 +72,7 @@ class PaywallCard extends StatelessWidget {
width: double.infinity,
child: TextButton(
onPressed: () {
chatController.clearSelectedEvents();
MatrixState.pangeaController.subscriptionController
.showPaywall(context);
},

View file

@ -237,50 +237,48 @@ class WordMatchContent extends StatelessWidget {
? controller.currentExpression
: BotExpression.addled,
),
Expanded(
child: Scrollbar(
Scrollbar(
controller: scrollController,
thumbVisibility: true,
child: SingleChildScrollView(
controller: scrollController,
thumbVisibility: true,
child: SingleChildScrollView(
controller: scrollController,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// const SizedBox(height: 10.0),
// if (matchCopy.description != null)
// Padding(
// padding: const EdgeInsets.only(),
// child: Text(
// matchCopy.description!,
// style: BotStyle.text(context),
// ),
// ),
const SizedBox(height: 8),
if (!controller.widget.scm.pangeaMatch!.isITStart)
ChoicesArray(
originalSpan:
controller.widget.scm.pangeaMatch!.matchContent,
isLoading: controller.fetchingData,
choices:
controller.widget.scm.pangeaMatch!.match.choices
?.map(
(e) => Choice(
text: e.value,
color: e.selected ? e.type.color : null,
isGold: e.type.name == 'bestCorrection',
),
)
.toList(),
onPressed: controller.onChoiceSelect,
uniqueKeyForLayerLink: (int index) =>
"wordMatch$index",
selectedChoiceIndex: controller.selectedChoiceIndex,
),
const SizedBox(height: 12),
PromptAndFeedback(controller: controller),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// const SizedBox(height: 10.0),
// if (matchCopy.description != null)
// Padding(
// padding: const EdgeInsets.only(),
// child: Text(
// matchCopy.description!,
// style: BotStyle.text(context),
// ),
// ),
const SizedBox(height: 8),
if (!controller.widget.scm.pangeaMatch!.isITStart)
ChoicesArray(
originalSpan:
controller.widget.scm.pangeaMatch!.matchContent,
isLoading: controller.fetchingData,
choices:
controller.widget.scm.pangeaMatch!.match.choices
?.map(
(e) => Choice(
text: e.value,
color: e.selected ? e.type.color : null,
isGold: e.type.name == 'bestCorrection',
),
)
.toList(),
onPressed: controller.onChoiceSelect,
uniqueKeyForLayerLink: (int index) =>
"wordMatch$index",
selectedChoiceIndex: controller.selectedChoiceIndex,
),
const SizedBox(height: 12),
PromptAndFeedback(controller: controller),
],
),
),
),
@ -357,16 +355,16 @@ class WordMatchContent extends StatelessWidget {
),
],
),
if (controller.widget.scm.pangeaMatch!.isITStart)
DontShowSwitchListTile(
controller: pangeaController,
onSwitch: (bool value) {
pangeaController.userController.updateProfile((profile) {
profile.userSettings.itAutoPlay = value;
return profile;
});
},
),
// if (controller.widget.scm.pangeaMatch!.isITStart)
// DontShowSwitchListTile(
// controller: pangeaController,
// onSwitch: (bool value) {
// pangeaController.userController.updateProfile((profile) {
// profile.userSettings.itAutoPlay = value;
// return profile;
// });
// },
// ),
],
),
],

View file

@ -1,5 +1,6 @@
import 'dart:developer';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/constants/language_constants.dart';
import 'package:fluffychat/pangea/controllers/contextual_definition_controller.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
@ -7,8 +8,7 @@ import 'package:fluffychat/pangea/models/language_model.dart';
import 'package:fluffychat/pangea/utils/bot_style.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/utils/firebase_analytics.dart';
import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart';
import 'package:fluffychat/pangea/widgets/common/p_circular_loader.dart';
import 'package:fluffychat/pangea/widgets/chat/toolbar_content_loading_indicator.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@ -166,71 +166,68 @@ class WordDataCardView extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (controller.wordNetError != null) {
return CardErrorWidget(error: controller.wordNetError);
return CardErrorWidget(
error: controller.wordNetError!,
maxWidth: AppConfig.toolbarMinWidth,
);
}
if (controller.activeL1 == null || controller.activeL2 == null) {
ErrorHandler.logError(m: "should not be here");
return CardErrorWidget(error: controller.noLanguages);
return CardErrorWidget(
error: controller.noLanguages,
maxWidth: AppConfig.toolbarMinWidth,
);
}
final ScrollController scrollController = ScrollController();
return Container(
padding: const EdgeInsets.all(8),
constraints: const BoxConstraints(minHeight: minCardHeight),
alignment: Alignment.center,
child: Scrollbar(
thumbVisibility: true,
controller: scrollController,
child: SingleChildScrollView(
controller: scrollController,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (controller.widget.choiceFeedback != null)
Text(
controller.widget.choiceFeedback!,
style: BotStyle.text(context),
),
const SizedBox(height: 5.0),
if (controller.wordData != null &&
controller.wordNetError == null &&
controller.activeL1 != null &&
controller.activeL2 != null)
WordNetInfo(
wordData: controller.wordData!,
activeL1: controller.activeL1!,
activeL2: controller.activeL2!,
),
if (controller.isLoadingWordNet) const PCircular(),
const SizedBox(height: 5.0),
// if (controller.widget.hasInfo &&
// !controller.isLoadingContextualDefinition &&
// controller.contextualDefinitionRes == null)
// Material(
// type: MaterialType.transparency,
// child: ListTile(
// leading: const BotFace(
// width: 40, expression: BotExpression.surprised),
// title: Text(L10n.of(context)!.askPangeaBot),
// onTap: controller.handleGetDefinitionButtonPress,
// ),
// ),
if (controller.isLoadingContextualDefinition) const PCircular(),
if (controller.contextualDefinitionRes != null)
Text(
controller.contextualDefinitionRes!.text,
style: BotStyle.text(context),
),
if (controller.definitionError != null)
Text(
L10n.of(context)!.sorryNoResults,
style: BotStyle.text(context),
),
],
),
),
return Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 16),
child: Column(
children: [
if (controller.widget.choiceFeedback != null)
Text(
controller.widget.choiceFeedback!,
style: BotStyle.text(context),
),
const SizedBox(height: 5.0),
if (controller.wordData != null &&
controller.wordNetError == null &&
controller.activeL1 != null &&
controller.activeL2 != null)
WordNetInfo(
wordData: controller.wordData!,
activeL1: controller.activeL1!,
activeL2: controller.activeL2!,
),
if (controller.isLoadingWordNet)
const ToolbarContentLoadingIndicator(),
const SizedBox(height: 5.0),
// if (controller.widget.hasInfo &&
// !controller.isLoadingContextualDefinition &&
// controller.contextualDefinitionRes == null)
// Material(
// type: MaterialType.transparency,
// child: ListTile(
// leading: const BotFace(
// width: 40, expression: BotExpression.surprised),
// title: Text(L10n.of(context)!.askPangeaBot),
// onTap: controller.handleGetDefinitionButtonPress,
// ),
// ),
if (controller.isLoadingContextualDefinition)
const ToolbarContentLoadingIndicator(),
if (controller.contextualDefinitionRes != null)
Text(
controller.contextualDefinitionRes!.text,
style: BotStyle.text(context),
textAlign: TextAlign.center,
),
if (controller.definitionError != null)
Text(
L10n.of(context)!.sorryNoResults,
style: BotStyle.text(context),
textAlign: TextAlign.center,
),
],
),
);
}
@ -251,12 +248,14 @@ class WordNetInfo extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SensesForLanguage(
wordData: wordData,
languageType: LanguageType.target,
language: activeL2,
),
const SizedBox(height: 10),
SensesForLanguage(
wordData: wordData,
languageType: LanguageType.base,
@ -273,52 +272,6 @@ enum LanguageType {
}
class SensesForLanguage extends StatelessWidget {
const SensesForLanguage({
super.key,
required this.wordData,
required this.languageType,
required this.language,
});
final LanguageModel language;
final LanguageType languageType;
final WordData wordData;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 5),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(7, 0, 0, 0),
child: LanguageFlag(
language: language,
),
),
Expanded(
child: PartOfSpeechBlock(
wordData: wordData,
languageType: languageType,
),
),
],
),
);
}
}
class PartOfSpeechBlock extends StatelessWidget {
final WordData wordData;
final LanguageType languageType;
const PartOfSpeechBlock({
super.key,
required this.wordData,
required this.languageType,
});
String get exampleSentence => languageType == LanguageType.target
? wordData.targetExampleSentence
: wordData.baseExampleSentence;
@ -336,70 +289,76 @@ class PartOfSpeechBlock extends StatelessWidget {
return "$word (${wordData.formattedPartOfSpeech(languageType)})";
}
const SensesForLanguage({
super.key,
required this.wordData,
required this.languageType,
required this.language,
});
final LanguageModel language;
final LanguageType languageType;
final WordData wordData;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(10, 0, 10, 0),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerLeft,
child: Text(
formattedTitle(context),
style: BotStyle.text(context, italics: true, bold: false),
),
),
const SizedBox(height: 10),
Padding(
padding: const EdgeInsets.only(left: 14.0, bottom: 10.0),
child: Align(
alignment: Alignment.centerLeft,
child: Column(
children: [
if (definition.isNotEmpty)
RichText(
text: TextSpan(
style: BotStyle.text(
context,
italics: false,
bold: false,
),
children: <TextSpan>[
TextSpan(
text: "${L10n.of(context)!.definition}: ",
style: const TextStyle(fontWeight: FontWeight.bold),
),
TextSpan(text: definition),
],
),
),
const SizedBox(height: 10),
if (exampleSentence.isNotEmpty)
RichText(
text: TextSpan(
style: BotStyle.text(
context,
italics: false,
bold: false,
),
children: <TextSpan>[
TextSpan(
text: "${L10n.of(context)!.exampleSentence}: ",
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
TextSpan(text: exampleSentence),
],
),
),
],
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LanguageFlag(language: language),
const SizedBox(width: 10),
Flexible(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
formattedTitle(context),
style: BotStyle.text(context, italics: true, bold: false),
),
),
const SizedBox(height: 4),
if (definition.isNotEmpty)
RichText(
text: TextSpan(
style: BotStyle.text(
context,
italics: false,
bold: false,
),
children: <TextSpan>[
TextSpan(
text: "${L10n.of(context)!.definition}: ",
style: const TextStyle(fontWeight: FontWeight.bold),
),
TextSpan(text: definition),
],
),
),
const SizedBox(height: 4),
if (exampleSentence.isNotEmpty)
RichText(
text: TextSpan(
style: BotStyle.text(
context,
italics: false,
bold: false,
),
children: <TextSpan>[
TextSpan(
text: "${L10n.of(context)!.exampleSentence}: ",
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
TextSpan(text: exampleSentence),
],
),
),
],
),
],
),
),
],
);
}
}

View file

@ -0,0 +1,23 @@
import 'package:fluffychat/pangea/utils/bot_style.dart';
import 'package:flutter/material.dart';
class MessageDisplayCard extends StatelessWidget {
final String displayText;
const MessageDisplayCard({
super.key,
required this.displayText,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 16),
child: Text(
displayText,
style: BotStyle.text(context),
textAlign: TextAlign.center,
),
);
}
}

View file

@ -6,6 +6,7 @@ import 'package:fluffychat/pangea/controllers/my_analytics_controller.dart';
import 'package:fluffychat/pangea/enum/activity_type_enum.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_record_model.dart';
import 'package:fluffychat/pangea/widgets/chat/tts_controller.dart';
import 'package:fluffychat/pangea/widgets/practice_activity/practice_activity_card.dart';
import 'package:fluffychat/pangea/widgets/practice_activity/word_audio_button.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -14,13 +15,15 @@ import 'package:flutter/material.dart';
/// The multiple choice activity view
class MultipleChoiceActivity extends StatefulWidget {
final MessagePracticeActivityCardState practiceCardController;
final PracticeActivityCardState practiceCardController;
final PracticeActivityModel currentActivity;
final TtsController tts;
const MultipleChoiceActivity({
super.key,
required this.practiceCardController,
required this.currentActivity,
required this.tts,
});
@override
@ -67,6 +70,7 @@ class MultipleChoiceActivityState extends State<MultipleChoiceActivity> {
return;
}
// #freeze-activity
MatrixState.pangeaController.myAnalytics.setState(
AnalyticsStream(
// note - this maybe should be the activity event id
@ -85,16 +89,18 @@ class MultipleChoiceActivityState extends State<MultipleChoiceActivity> {
widget.practiceCardController.onActivityFinish();
}
setState(
() => selectedChoiceIndex = index,
);
if (mounted) {
setState(
() => selectedChoiceIndex = index,
);
}
}
@override
Widget build(BuildContext context) {
final PracticeActivityModel practiceActivity = widget.currentActivity;
return Container(
return Padding(
padding: const EdgeInsets.all(8),
child: Column(
children: [
@ -106,9 +112,13 @@ class MultipleChoiceActivityState extends State<MultipleChoiceActivity> {
),
),
const SizedBox(height: 8),
// #freeze-activity
if (practiceActivity.activityType ==
ActivityTypeEnum.wordFocusListening)
WordAudioButton(text: practiceActivity.content.answer),
WordAudioButton(
text: practiceActivity.content.answer,
ttsController: widget.tts,
),
ChoicesArray(
isLoading: false,
uniqueKeyForLayerLink: (index) => "multiple_choice_$index",

View file

@ -1,3 +1,4 @@
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/utils/bot_style.dart';
import 'package:flutter/material.dart';
@ -71,26 +72,21 @@ class GamifiedTextWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min, // Adjusts the size to fit children
children: [
const SizedBox(height: 10), // Spacing between the star and text
// Star animation above the text
const StarAnimationWidget(),
const SizedBox(height: 10), // Spacing between the star and text
Container(
constraints: const BoxConstraints(
minHeight: 80,
),
padding: const EdgeInsets.all(8),
child: Text(
return SizedBox(
width: AppConfig.toolbarMinWidth,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 16),
child: Column(
children: [
const StarAnimationWidget(),
const SizedBox(height: 10),
Text(
userMessage,
style: BotStyle.text(context),
textAlign: TextAlign.center, // Center-align the text
textAlign: TextAlign.center,
),
),
],
],
),
),
);
}

View file

@ -4,17 +4,16 @@ import 'dart:developer';
import 'package:fluffychat/pangea/controllers/pangea_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/pangea_representation_event.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart';
import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/message_activity_request.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_record_model.dart';
import 'package:fluffychat/pangea/utils/bot_style.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/widgets/animations/gain_points.dart';
import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart';
import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart';
import 'package:fluffychat/pangea/widgets/chat/toolbar_content_loading_indicator.dart';
import 'package:fluffychat/pangea/widgets/chat/tts_controller.dart';
import 'package:fluffychat/pangea/widgets/content_issue_button.dart';
import 'package:fluffychat/pangea/widgets/practice_activity/multiple_choice_activity.dart';
import 'package:fluffychat/pangea/widgets/practice_activity/no_more_practice_card.dart';
@ -30,19 +29,20 @@ import 'package:flutter_gen/gen_l10n/l10n.dart';
class PracticeActivityCard extends StatefulWidget {
final PangeaMessageEvent pangeaMessageEvent;
final MessageOverlayController overlayController;
final TtsController tts;
const PracticeActivityCard({
super.key,
required this.pangeaMessageEvent,
required this.overlayController,
required this.tts,
});
@override
MessagePracticeActivityCardState createState() =>
MessagePracticeActivityCardState();
PracticeActivityCardState createState() => PracticeActivityCardState();
}
class MessagePracticeActivityCardState extends State<PracticeActivityCard> {
class PracticeActivityCardState extends State<PracticeActivityCard> {
PracticeActivityModel? currentActivity;
PracticeActivityRecordModel? currentCompletionRecord;
bool fetchingActivity = false;
@ -120,13 +120,25 @@ class MessagePracticeActivityCardState extends State<PracticeActivityCard> {
return null;
}
if (widget.pangeaMessageEvent.originalSent == null) {
debugger(when: kDebugMode);
_updateFetchingActivity(false);
ErrorHandler.logError(
e: Exception('No original message found in _fetchNewActivity'),
data: {
'event': widget.pangeaMessageEvent.event.toJson(),
},
);
return null;
}
final PracticeActivityModel? ourNewActivity = await pangeaController
.practiceGenerationController
.getPracticeActivity(
MessageActivityRequest(
userL1: pangeaController.languageController.userL1!.langCode,
userL2: pangeaController.languageController.userL2!.langCode,
messageText: representation!.text,
messageText: widget.pangeaMessageEvent.originalSent!.text,
tokensWithXP: await targetTokensController.targetTokens(
context,
widget.pangeaMessageEvent,
@ -165,13 +177,26 @@ class MessagePracticeActivityCardState extends State<PracticeActivityCard> {
);
Future<void> _savorTheJoy() async {
debugger(when: savoringTheJoy && kDebugMode);
try {
debugger(when: savoringTheJoy && kDebugMode);
setState(() => savoringTheJoy = true);
if (mounted) setState(() => savoringTheJoy = true);
await Future.delayed(appropriateTimeForJoy);
await Future.delayed(appropriateTimeForJoy);
if (mounted) setState(() => savoringTheJoy = false);
if (mounted) setState(() => savoringTheJoy = false);
} catch (e, s) {
debugger(when: kDebugMode);
ErrorHandler.logError(
e: e,
s: s,
m: 'Failed to savor the joy',
data: {
'activity': currentActivity,
'record': currentCompletionRecord,
},
);
}
}
/// Called when the user finishes an activity.
@ -201,7 +226,8 @@ class MessagePracticeActivityCardState extends State<PracticeActivityCard> {
widget.pangeaMessageEvent.eventId,
);
//
// wait for the joy to be savored before resolving the activity
// and setting it to replace the previous activity
final Iterable<dynamic> result = await Future.wait([
_savorTheJoy(),
_fetchNewActivity(),
@ -257,27 +283,21 @@ class MessagePracticeActivityCardState extends State<PracticeActivityCard> {
});
}
RepresentationEvent? get representation =>
widget.pangeaMessageEvent.originalSent;
String get messsageText => representation!.text;
PangeaController get pangeaController => MatrixState.pangeaController;
/// The widget that displays the current activity.
/// If there is no current activity, the widget returns a sizedbox with a height of 80.
/// If the activity type is multiple choice, the widget returns a MultipleChoiceActivity.
/// If the activity type is unknown, the widget logs an error and returns a text widget with an error message.
Widget get activityWidget {
if (currentActivity == null) {
// return sizedbox with height of 80
return const SizedBox(height: 80);
}
switch (currentActivity!.activityType) {
Widget? get activityWidget {
switch (currentActivity?.activityType) {
case null:
return null;
case ActivityTypeEnum.multipleChoice:
return MultipleChoiceActivity(
practiceCardController: this,
currentActivity: currentActivity!,
tts: widget.tts,
);
case ActivityTypeEnum.wordFocusListening:
// return WordFocusListeningActivity(
@ -285,74 +305,58 @@ class MessagePracticeActivityCardState extends State<PracticeActivityCard> {
return MultipleChoiceActivity(
practiceCardController: this,
currentActivity: currentActivity!,
tts: widget.tts,
);
default:
ErrorHandler.logError(
e: Exception('Unknown activity type'),
m: 'Unknown activity type',
data: {
'activityType': currentActivity!.activityType,
},
);
return Text(
L10n.of(context)!.oopsSomethingWentWrong,
style: BotStyle.text(context),
);
// default:
// ErrorHandler.logError(
// e: Exception('Unknown activity type'),
// m: 'Unknown activity type',
// data: {
// 'activityType': currentActivity!.activityType,
// },
// );
// return Text(
// L10n.of(context)!.oopsSomethingWentWrong,
// style: BotStyle.text(context),
// );
}
}
String? get userMessage {
if (!fetchingActivity && currentActivity == null) {
return L10n.of(context)!.noActivitiesFound;
}
return null;
}
@override
Widget build(BuildContext context) {
if (userMessage != null) {
return GamifiedTextWidget(userMessage: userMessage!);
if (!fetchingActivity && currentActivity == null) {
return GamifiedTextWidget(
userMessage: L10n.of(context)!.noActivitiesFound,
);
}
return Container(
constraints: const BoxConstraints(
maxWidth: 350,
minWidth: 350,
minHeight: minCardHeight,
),
child: Stack(
alignment: Alignment.center,
children: [
// Main content
const Positioned(
child: PointsGainedAnimation(),
),
Container(
padding: const EdgeInsets.all(8),
return Stack(
alignment: Alignment.center,
children: [
// Main content
const Positioned(
child: PointsGainedAnimation(),
),
if (activityWidget != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 16),
child: activityWidget,
),
// Conditionally show the darkening and progress indicator based on the loading state
if (!savoringTheJoy && fetchingActivity) ...[
// Semi-transparent overlay
Container(
color: Colors.black.withOpacity(0.5), // Darkening effect
),
// Circular progress indicator in the center
const Center(
child: CircularProgressIndicator(),
),
],
// Flag button in the top right corner
Positioned(
top: 0,
right: 0,
child: ContentIssueButton(
isActive: currentActivity != null,
submitFeedback: submitFeedback,
),
),
// Conditionally show the darkening and progress indicator based on the loading state
if (!savoringTheJoy && fetchingActivity) ...[
// Circular progress indicator in the center
const ToolbarContentLoadingIndicator(),
],
),
// Flag button in the top right corner
Positioned(
top: 0,
right: 0,
child: ContentIssueButton(
isActive: currentActivity != null,
submitFeedback: submitFeedback,
),
),
],
);
}
}

Some files were not shown because too many files have changed in this diff Show more