merge main into move-server-main

pull/1476/head
ggurdin 1 year ago
commit 0b8862751d
No known key found for this signature in database
GPG Key ID: A01CB41737CBB478

@ -158,4 +158,10 @@
android:name="flutterEmbedding"
android:value="2" />
</application>
<queries>
<intent>
<action android:name="android.intent.action.TTS_SERVICE" />
</intent>
</queries>
</manifest>

@ -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": {
@ -3904,7 +3899,6 @@
"listen": "Listen",
"addConversationBot": "Enable Conversation Bot",
"addConversationBotDesc": "Add a bot to this chat",
"convoBotSettingsTitle": "Conversation Bot Settings",
"convoBotSettingsDescription": "Edit conversation topic and difficulty",
"enterAConversationTopic": "Enter a conversation topic",
"conversationTopic": "Conversation topic",
@ -4009,7 +4003,7 @@
"accuracy": "Accuracy",
"points": "Points",
"noPaymentInfo": "No payment info necessary!",
"conversationBotModeSelectDescription": "Bot mode",
"conversationBotModeSelectDescription": "Chat activity",
"conversationBotModeSelectOption_discussion": "Discussion",
"conversationBotModeSelectOption_custom": "Custom",
"conversationBotModeSelectOption_conversation": "Conversation",
@ -4017,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",
@ -4030,7 +4024,7 @@
"conversationBotCustomZone_customSystemPromptPlaceholder": "Set custom system prompt",
"conversationBotCustomZone_customSystemPromptEmptyError": "Missing custom system prompt",
"conversationBotCustomZone_customTriggerReactionEnabledLabel": "Responds on ⏩ reaction",
"botConfig": "Conversation Bot Settings",
"botConfig": "Chat settings",
"addConversationBotDialogTitleInvite": "Confirm inviting conversation bot",
"addConversationBotButtonInvite": "Invite",
"addConversationBotDialogInviteConfirmation": "Invite",
@ -4038,7 +4032,7 @@
"addConversationBotButtonRemove": "Remove",
"addConversationBotDialogRemoveConfirmation": "Remove",
"conversationBotConfigConfirmChange": "Confirm",
"conversationBotStatus": "Bot Status",
"conversationBotStatus": "Invite bot",
"conversationBotTextAdventureZone_title": "Text Adventure",
"conversationBotTextAdventureZone_instructionLabel": "Game Master Instructions",
"conversationBotTextAdventureZone_instructionPlaceholder": "Set game master instructions",
@ -4060,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",
@ -4115,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",
@ -4226,19 +4202,23 @@
"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",
"l2SupportBeta": "Beta",
"l2SupportFull": "Full",
"voiceNotAvailable": "It looks like you don't have a voice installed for this language.",
"openVoiceSettings": "Click here to open voice settings",
"playAudio": "Play",
"stop": "Stop",
"grammarCopySCONJ": "Subordinating Conjunction",
"grammarCopyNUM": "Number",
"grammarCopyVERB": "Verb",
@ -4323,6 +4303,7 @@
"grammarCopyAccDat": "Accusative, Dative",
"grammarCopyInf": "Infinitive",
"grammarCopyLong": "Long",
"grammarCopyLoc": "Locative",
"grammarCopyInd": "Indicative",
"grammarCopyCmp": "Comparative",
"grammarCopyRelative_case": "Relative Case",
@ -4352,9 +4333,16 @@
"grammarCopyNumber": "Number",
"grammarCopyConjType": "Conjunction Type",
"grammarCopyPolarity": "Polarity",
"grammarCopyNumberPsor": "Possessor''s Number",
"grammarCopyNumberPsor": "Possessor's Number",
"grammarCopyCase": "Case",
"grammarCopyDefinite": "Definiteness",
"grammarCopyNumForm": "Numeral Form",
"grammarCopyUnknown": "Unknown"
"grammarCopyUnknown": "Unknown",
"enterPrompt": "Please enter a system prompt",
"selectBotLanguage": "Select bot language",
"chooseVoice": "Choose a voice",
"enterLanguageLevel": "Please enter a language level",
"enterDiscussionTopic": "Please enter a discussion topic",
"selectBotChatMode": "Select chat mode",
"messageNotInTargetLang": "Message not in target language"
}

@ -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."
}

@ -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);

@ -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';
@ -233,11 +232,7 @@ abstract class AppRoutes {
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
NewGroup(
// #Pangea
spaceId: state.uri.queryParameters['spaceId'],
// Pangea#
),
const NewGroup(),
),
redirect: loggedOutRedirect,
// #Pangea
@ -410,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(

@ -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));
}

@ -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#

@ -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,

@ -21,11 +21,18 @@ class AudioPlayerWidget extends StatefulWidget {
final Event? event;
final PangeaAudioFile? matrixFile;
final bool autoplay;
final Function(bool)? setIsPlayingAudio;
// Pangea#
static String? currentId;
static const int wavesCount = 40;
// #Pangea
// static const int wavesCount = 40;
static const int wavesCount = kIsWeb ? 100 : 40;
final int? sectionStartMS;
final int? sectionEndMS;
// Pangea#
const AudioPlayerWidget(
this.event, {
@ -33,6 +40,9 @@ class AudioPlayerWidget extends StatefulWidget {
// #Pangea
this.matrixFile,
this.autoplay = false,
this.sectionStartMS,
this.sectionEndMS,
this.setIsPlayingAudio,
// Pangea#
super.key,
});
@ -72,6 +82,24 @@ class AudioPlayerState extends State<AudioPlayerWidget> {
super.dispose();
}
// #Pangea
// @override
// void didUpdateWidget(covariant oldWidget) {
// if ((oldWidget.sectionEndMS != widget.sectionEndMS) ||
// (oldWidget.sectionStartMS != widget.sectionStartMS)) {
// debugPrint('selection changed');
// if (widget.sectionStartMS != null) {
// audioPlayer?.seek(Duration(milliseconds: widget.sectionStartMS!));
// audioPlayer?.play();
// } else {
// audioPlayer?.stop();
// audioPlayer?.seek(null);
// }
// }
// super.didUpdateWidget(oldWidget);
// }
// Pangea#
Future<void> _downloadAction() async {
// #Pangea
// if (status != AudioPlayerStatus.notDownloaded) return;
@ -160,7 +188,16 @@ class AudioPlayerState extends State<AudioPlayerWidget> {
AudioPlayerWidget.wavesCount)
.round();
});
// #Pangea
// if (widget.sectionStartMS != null &&
// widget.sectionEndMS != null &&
// state.inMilliseconds.toDouble() >= widget.sectionEndMS!) {
// audioPlayer.stop();
// audioPlayer.seek(Duration(milliseconds: widget.sectionStartMS!));
// } else
if (state.inMilliseconds.toDouble() == maxPosition) {
// if (state.inMilliseconds.toDouble() == maxPosition) {
// Pangea#
audioPlayer.stop();
audioPlayer.seek(null);
}
@ -169,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);
@ -194,6 +236,11 @@ class AudioPlayerState extends State<AudioPlayerWidget> {
}
// Pangea#
}
// #Pangea
// if (widget.sectionStartMS != null) {
// audioPlayer.seek(Duration(milliseconds: widget.sectionStartMS!));
// }
// Pangea#
audioPlayer.play().onError(
ErrorReporter(context, 'Unable to play audio message')
.onErrorCallback,
@ -311,6 +358,17 @@ class AudioPlayerState extends State<AudioPlayerWidget> {
final statusText = this.statusText ??= _durationString ?? '00:00';
final audioPlayer = this.audioPlayer;
// #Pangea
final msPerWave = (maxPosition / AudioPlayerWidget.wavesCount);
final int? startWave = widget.sectionStartMS != null && msPerWave > 0
? (widget.sectionStartMS! / msPerWave).floor()
: null;
final int? endWave = widget.sectionEndMS != null && msPerWave > 0
? (widget.sectionEndMS! / msPerWave).ceil()
: null;
// Pangea#
return Padding(
// #Pangea
// padding: const EdgeInsets.all(12.0),
@ -352,44 +410,98 @@ class AudioPlayerState extends State<AudioPlayerWidget> {
// #Pangea
// const SizedBox(width: 8),
const SizedBox(width: 5),
// Pangea#
// Row(
// mainAxisSize: MainAxisSize.min,
// children: [
// for (var i = 0; i < AudioPlayerWidget.wavesCount; i++)
// GestureDetector(
// onTapDown: (_) => audioPlayer?.seek(
// Duration(
// milliseconds:
// (maxPosition / AudioPlayerWidget.wavesCount).round() *
// i,
// ),
// ),
// child: Container(
// height: 32,
// color: widget.color.withAlpha(0),
// alignment: Alignment.center,
// child: Opacity(
// opacity: currentPosition > i ? 1 : 0.5,
// child: Container(
// margin: const EdgeInsets.symmetric(horizontal: 1),
// decoration: BoxDecoration(
// color: widget.color,
// borderRadius: BorderRadius.circular(2),
// ),
// // #Pangea
// // width: 2,
// width: 1,
// // Pangea#
// height: 32 * (waveform[i] / 1024),
// ),
// ),
// ),
// ),
// ],
// ),
// const SizedBox(width: 8),
Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < AudioPlayerWidget.wavesCount; i++)
GestureDetector(
onTapDown: (_) => audioPlayer?.seek(
Duration(
milliseconds:
(maxPosition / AudioPlayerWidget.wavesCount).round() *
i,
),
),
child: Container(
height: 32,
color: widget.color.withAlpha(0),
alignment: Alignment.center,
child: Opacity(
opacity: currentPosition > i ? 1 : 0.5,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 1),
decoration: BoxDecoration(
color: widget.color,
borderRadius: BorderRadius.circular(2),
),
// #Pangea
// width: 2,
width: 1,
// Pangea#
height: 32 * (waveform[i] / 1024),
Builder(
builder: (context) {
final double barOpacity = currentPosition > i ? 1 : 0.5;
return 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),
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,
// ),
// ),
// );
},
),
],
),
// #Pangea
// const SizedBox(width: 8),
const SizedBox(width: 5),
// SizedBox(
// width: 36,

@ -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(

@ -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!,

@ -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,

@ -5,7 +5,6 @@ import 'package:fluffychat/pages/chat_details/chat_details_view.dart';
import 'package:fluffychat/pages/settings/settings.dart';
import 'package:fluffychat/pangea/pages/class_settings/p_class_widgets/class_description_button.dart';
import 'package:fluffychat/pangea/utils/set_class_name.dart';
import 'package:fluffychat/pangea/widgets/class/add_space_toggles.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/widgets/app_lock.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -41,9 +40,7 @@ class ChatDetailsController extends State<ChatDetails> {
String? get roomId => widget.roomId;
// #Pangea
final GlobalKey<AddToSpaceState> addToSpaceKey = GlobalKey<AddToSpaceState>();
final GlobalKey<ChatDetailsController>
addConversationBotKey =
final GlobalKey<ChatDetailsController> addConversationBotKey =
GlobalKey<ChatDetailsController>();
bool displayAddStudentOptions = false;

@ -7,10 +7,8 @@ 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/class/add_space_toggles.dart';
import 'package:fluffychat/pangea/widgets/conversation_bot/conversation_bot_settings.dart';
import 'package:fluffychat/utils/fluffy_share.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
@ -214,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,
@ -395,13 +388,6 @@ class ChatDetailsView extends StatelessWidget {
room: room,
),
const Divider(height: 1),
if (!room.isDirectChat && room.isRoomAdmin)
AddToSpaceToggles(
roomId: room.id,
key: controller.addToSpaceKey,
startOpen: false,
),
const Divider(height: 1),
ListTile(
title: Text(
L10n.of(context)!.leave,

@ -23,7 +23,10 @@ class ChatListHeader extends StatelessWidget implements PreferredSizeWidget {
return SliverAppBar(
floating: true,
toolbarHeight: 175,
// #Pangea
// toolbarHeight: 72,
toolbarHeight: controller.isSearchMode ? 72 : 175,
// Pangea#
pinned:
FluffyThemes.isColumnMode(context) || selectMode != SelectMode.normal,
scrolledUnderElevation: selectMode == SelectMode.normal ? 0 : null,
@ -32,111 +35,124 @@ class ChatListHeader extends StatelessWidget implements PreferredSizeWidget {
// selectMode == SelectMode.normal ? Colors.transparent : null,
// Pangea#
automaticallyImplyLeading: false,
leading: selectMode == SelectMode.normal
? null
: IconButton(
tooltip: L10n.of(context)!.cancel,
icon: const Icon(Icons.close_outlined),
onPressed: controller.cancelAction,
color: theme.colorScheme.primary,
),
title: selectMode == SelectMode.share
? Text(
L10n.of(context)!.share,
key: const ValueKey(SelectMode.share),
)
// #Pangea
// leading: selectMode == SelectMode.normal
// ? null
// : IconButton(
// tooltip: L10n.of(context)!.cancel,
// icon: const Icon(Icons.close_outlined),
// onPressed: controller.cancelAction,
// color: theme.colorScheme.primary,
// ),
// Pangea#
title:
// #Pangea
: Column(
children: [
ClientChooserButton(controller),
const LearningProgressIndicators(),
],
// selectMode == SelectMode.share
// ? Text(
// L10n.of(context)!.share,
// key: const ValueKey(SelectMode.share),
// )
// :
// Pangea#
Column(
children: [
TextField(
controller: controller.searchController,
focusNode: controller.searchFocusNode,
textInputAction: TextInputAction.search,
onChanged: (text) => controller.onSearchEnter(
text,
globalSearch: globalSearch,
),
// : TextField(
// controller: controller.searchController,
// focusNode: controller.searchFocusNode,
// textInputAction: TextInputAction.search,
// onChanged: (text) => controller.onSearchEnter(
// text,
// globalSearch: globalSearch,
// ),
// decoration: InputDecoration(
// filled: true,
// fillColor: theme.colorScheme.secondaryContainer,
// border: OutlineInputBorder(
// borderSide: BorderSide.none,
// borderRadius: BorderRadius.circular(99),
// ),
// contentPadding: EdgeInsets.zero,
// hintText: L10n.of(context)!.searchChatsRooms,
// hintStyle: TextStyle(
// color: theme.colorScheme.onPrimaryContainer,
// fontWeight: FontWeight.normal,
// ),
// floatingLabelBehavior: FloatingLabelBehavior.never,
// prefixIcon: controller.isSearchMode
// ? IconButton(
// tooltip: L10n.of(context)!.cancel,
// icon: const Icon(Icons.close_outlined),
// onPressed: controller.cancelSearch,
// color: theme.colorScheme.onPrimaryContainer,
// )
// : IconButton(
// onPressed: controller.startSearch,
// icon: Icon(
// Icons.search_outlined,
// color: theme.colorScheme.onPrimaryContainer,
// ),
// ),
// suffixIcon: controller.isSearchMode && globalSearch
// ? controller.isSearching
// ? const Padding(
// padding: EdgeInsets.symmetric(
// vertical: 10.0,
// horizontal: 12,
// ),
// child: SizedBox.square(
// dimension: 24,
// child: CircularProgressIndicator.adaptive(
// strokeWidth: 2,
// ),
// ),
// )
// : TextButton.icon(
// onPressed: controller.setServer,
// style: TextButton.styleFrom(
// shape: RoundedRectangleBorder(
// borderRadius: BorderRadius.circular(99),
// ),
// textStyle: const TextStyle(fontSize: 12),
// ),
// icon: const Icon(Icons.edit_outlined, size: 16),
// label: Text(
// controller.searchServer ??
// Matrix.of(context).client.homeserver!.host,
// maxLines: 2,
// ),
// )
// : SizedBox(
// width: 0,
// child: ClientChooserButton(controller),
// ),
// ),
// ),
decoration: InputDecoration(
filled: true,
fillColor: theme.colorScheme.secondaryContainer,
border: OutlineInputBorder(
borderSide: BorderSide.none,
borderRadius: BorderRadius.circular(99),
),
contentPadding: EdgeInsets.zero,
hintText: L10n.of(context)!.searchChatsRooms,
hintStyle: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.normal,
),
floatingLabelBehavior: FloatingLabelBehavior.never,
prefixIcon: controller.isSearchMode
? IconButton(
tooltip: L10n.of(context)!.cancel,
icon: const Icon(Icons.close_outlined),
onPressed: controller.cancelSearch,
color: theme.colorScheme.onPrimaryContainer,
)
: IconButton(
onPressed: controller.startSearch,
icon: Icon(
Icons.search_outlined,
color: theme.colorScheme.onPrimaryContainer,
),
),
suffixIcon: controller.isSearchMode && globalSearch
? controller.isSearching
? const Padding(
padding: EdgeInsets.symmetric(
vertical: 10.0,
horizontal: 12,
),
child: SizedBox.square(
dimension: 24,
child: CircularProgressIndicator.adaptive(
strokeWidth: 2,
),
),
)
// #Pangea
: SizedBox(
width: 0,
child: ClientChooserButton(controller),
)
// : TextButton.icon(
// onPressed: controller.setServer,
// style: TextButton.styleFrom(
// shape: RoundedRectangleBorder(
// borderRadius: BorderRadius.circular(99),
// ),
// textStyle: const TextStyle(fontSize: 12),
// ),
// icon: const Icon(Icons.edit_outlined, size: 16),
// label: Text(
// controller.searchServer ??
// Matrix.of(context).client.homeserver!.host,
// maxLines: 2,
// ),
// )
// Pangea#
: SizedBox(
width: 0,
child: ClientChooserButton(controller),
),
),
),
if (!controller.isSearchMode)
const Padding(
padding: EdgeInsets.only(top: 16.0),
child: LearningProgressIndicators(),
),
],
),
// #Pangea
// actions: selectMode == SelectMode.share
// ? [
// Padding(
// padding: const EdgeInsets.symmetric(
// horizontal: 16.0,
// vertical: 8.0,
// ),
// child: ClientChooserButton(controller),
// ),
// ]
// : null,
// Pangea#
actions: selectMode == SelectMode.share
? [
// #Pangea
// Padding(
// padding: const EdgeInsets.symmetric(
// horizontal: 16.0,
// vertical: 8.0,
// ),
// child: ClientChooserButton(controller),
// ),
// Pangea#
]
: null,
);
}

@ -1,15 +1,16 @@
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:fluffychat/pangea/utils/find_conversation_partner_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';
import 'package:fluffychat/widgets/matrix.dart';
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:matrix/matrix.dart';
import '../../utils/fluffy_share.dart';
import 'chat_list.dart';
class ClientChooserButton extends StatelessWidget {
@ -42,34 +43,6 @@ class ClientChooserButton extends StatelessWidget {
),
),
// PopupMenuItem(
// enabled: matrix.client.rooms.any(
// (room) =>
// room.isSpace &&
// room.ownPowerLevel >= ClassDefaultValues.powerLevelOfAdmin,
// ),
// value: SettingsAction.spaceAnalytics,
// child: Row(
// children: [
// const Icon(Icons.analytics_outlined),
// const SizedBox(width: 18),
// Expanded(child: Text(L10n.of(context)!.spaceAnalytics)),
// ],
// ),
// ),
// PopupMenuItem(
// enabled: matrix.client.rooms.any(
// (room) => !room.isSpace && !room.isArchived && !room.isAnalyticsRoom,
// ),
// value: SettingsAction.myAnalytics,
// child: Row(
// children: [
// const Icon(Icons.analytics_outlined),
// const SizedBox(width: 18),
// Expanded(child: Text(L10n.of(context)!.myLearning)),
// ],
// ),
// ),
// PopupMenuItem(
// value: SettingsAction.newGroup,
// child: Row(
// children: [
@ -87,13 +60,23 @@ class ClientChooserButton extends StatelessWidget {
const Icon(Icons.workspaces_outlined),
const SizedBox(width: 18),
// #Pangea
Expanded(child: Text(L10n.of(context)!.createNewSpace)),
Text(L10n.of(context)!.createNewSpace),
// Text(L10n.of(context)!.createNewSpace),
// Pangea#
],
),
),
// #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(
@ -123,36 +106,34 @@ class ClientChooserButton extends StatelessWidget {
children: [
const Icon(Icons.archive_outlined),
const SizedBox(width: 18),
Text(L10n.of(context)!.archive),
Text(L10n.of(context)!!.archive),
],
),
),*/
// #Pangea
PopupMenuItem(
value: SettingsAction.learning,
value: SettingsAction.settings,
child: Row(
children: [
const Icon(Icons.psychology_outlined),
const Icon(Icons.settings_outlined),
const SizedBox(width: 18),
Expanded(child: Text(L10n.of(context)!.learningSettings)),
// #Pangea
Text(L10n.of(context)!.settings),
// Text(L10n.of(context)!.settings),
// Pangea#
],
),
),
// Pangea#
// #Pangea
PopupMenuItem(
value: SettingsAction.settings,
value: SettingsAction.logout,
child: Row(
children: [
const Icon(Icons.settings_outlined),
const Icon(Icons.logout_outlined),
const SizedBox(width: 18),
// #Pangea
// Text(L10n.of(context)!.settings),
Expanded(child: Text(L10n.of(context)!.settings)),
// Pangea#
Expanded(child: Text(L10n.of(context)!.logout)),
],
),
),
// #Pangea
// const PopupMenuDivider(),
// for (final bundle in bundles) ...[
// if (matrix.accountBundles[bundle]!.length != 1 ||
@ -223,16 +204,6 @@ class ClientChooserButton extends StatelessWidget {
// ],
// ),
// ),
PopupMenuItem(
value: SettingsAction.logout,
child: Row(
children: [
const Icon(Icons.logout_outlined),
const SizedBox(width: 18),
Expanded(child: Text(L10n.of(context)!.logout)),
],
),
),
// Pangea#
];
}
@ -243,76 +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: [
// #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(),
// ),
ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Material(
color: Colors.transparent,
child:
// Pangea#
PopupMenuButton<Object>(
onSelected: (o) => _clientSelected(o, context),
itemBuilder: _bundleMenuItems,
// #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
child: ListTile(
mouseCursor: SystemMouseCursors.click,
leading: const Icon(Icons.settings_outlined),
title: Text(L10n.of(context)!.mainMenu),
),
// 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,
// ...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,
),
),
),
],
),
),
],
),
);
);
}
Set<LogicalKeyboardKey>? _buildKeyboardShortcut(int index) {
@ -347,29 +313,34 @@ 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;
case SettingsAction.invite:
FluffyShare.shareInviteLink(context);
break;
// #Pangea
// case SettingsAction.invite:
// FluffyShare.shareInviteLink(context);
// break;
// Pangea#
case SettingsAction.settings:
context.go('/rooms/settings');
break;
case SettingsAction.archive:
context.go('/rooms/archive');
break;
case SettingsAction.setStatus:
controller.setStatus();
// #Pangea
// case SettingsAction.archive:
// context.go('/rooms/archive');
// break;
// case SettingsAction.setStatus:
// controller.setStatus();
// break;
case SettingsAction.learning:
context.go('/rooms/settings/learning');
break;
case SettingsAction.newClass:
context.go('/rooms/newspace');
showDialog(
context: context,
builder: (c) => const SettingsLearning(),
);
break;
case SettingsAction.joinWithClassCode:
SpaceCodeUtil.joinWithSpaceCodeDialog(
@ -377,18 +348,6 @@ class ClientChooserButton extends StatelessWidget {
MatrixState.pangeaController,
);
break;
case SettingsAction.findAConversationPartner:
findConversationPartnerDialog(
context,
MatrixState.pangeaController,
);
break;
// case SettingsAction.spaceAnalytics:
// context.go('/rooms/analytics');
// break;
// case SettingsAction.myAnalytics:
// context.go('/rooms/mylearning');
// break;
case SettingsAction.logout:
pLogoutAction(context);
break;
@ -469,19 +428,19 @@ class ClientChooserButton extends StatelessWidget {
enum SettingsAction {
addAccount,
newGroup,
// #Pangea
// newGroup,
// Pangea#
newSpace,
setStatus,
invite,
// #Pangea
// setStatus,
// invite,
// Pangea#
settings,
archive,
// #Pangea
learning,
// archive,
joinWithClassCode,
// spaceAnalytics,
// myAnalytics,
findAConversationPartner,
learning,
logout,
newClass,
// Pangea#
}

@ -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);

@ -1,33 +1,14 @@
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:fluffychat/pages/new_group/new_group_view.dart';
import 'package:fluffychat/pangea/constants/bot_mode.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/chat_topic_model.dart';
import 'package:fluffychat/pangea/models/lemma.dart';
import 'package:fluffychat/pangea/pages/class_settings/p_class_widgets/room_capacity_button.dart';
import 'package:fluffychat/pangea/utils/bot_name.dart';
import 'package:fluffychat/pangea/utils/class_chat_power_levels.dart';
import 'package:fluffychat/pangea/utils/firebase_analytics.dart';
import 'package:fluffychat/pangea/widgets/class/add_space_toggles.dart';
import 'package:fluffychat/pangea/widgets/conversation_bot/conversation_bot_settings.dart';
import 'package:fluffychat/utils/file_selector.dart';
import 'package:fluffychat/widgets/matrix.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' as sdk;
class NewGroup extends StatefulWidget {
// #Pangea
final String? spaceId;
const NewGroup({
super.key,
this.spaceId,
});
// Pangea#
const NewGroup({super.key});
@override
NewGroupController createState() => NewGroupController();
@ -47,50 +28,25 @@ class NewGroupController extends State<NewGroup> {
bool loading = false;
// #Pangea
PangeaController pangeaController = MatrixState.pangeaController;
final GlobalKey<AddToSpaceState> addToSpaceKey = GlobalKey<AddToSpaceState>();
final GlobalKey<ConversationBotSettingsState> addConversationBotKey =
GlobalKey<ConversationBotSettingsState>();
final GlobalKey<RoomCapacityButtonState> addCapacityKey =
GlobalKey<RoomCapacityButtonState>();
ChatTopic chatTopic = ChatTopic.empty;
void setVocab(List<Lemma> vocab) => setState(() => chatTopic.vocab = vocab);
String? get activeSpaceId =>
GoRouterState.of(context).uri.queryParameters['spaceId'];
// Pangea#
void setPublicGroup(bool b) => setState(() => publicGroup = b);
void setGroupCanBeFound(bool b) => setState(() => groupCanBeFound = b);
void selectPhoto() async {
final photo = await FilePicker.platform.pickFiles(
type: FileType.image,
final photo = await selectFiles(
context,
type: FileSelectorType.images,
allowMultiple: false,
withData: true,
);
final bytes = await photo.singleOrNull?.readAsBytes();
setState(() {
avatarUrl = null;
avatar = photo?.files.singleOrNull?.bytes;
avatar = bytes;
});
}
void submitAction([_]) async {
// #Pangea
if (nameController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.emptyChatNameWarning),
),
);
return;
}
// Pangea#
final client = Matrix.of(context).client;
try {
@ -104,53 +60,12 @@ class NewGroupController extends State<NewGroup> {
if (!mounted) return;
// #Pangea
// validate init bot options
final addBot = addConversationBotKey.currentState?.addBot ?? false;
if (addBot) {
final botOptions = addConversationBotKey.currentState!.botOptions;
if (botOptions.mode == BotMode.custom) {
if (botOptions.customSystemPrompt == null ||
botOptions.customSystemPrompt!.isEmpty) {
setState(() {
error = L10n.of(context)!
.conversationBotCustomZone_customSystemPromptEmptyError;
loading = false;
});
return;
}
} else if (botOptions.mode == BotMode.textAdventure) {
if (botOptions.textAdventureGameMasterInstructions == null ||
botOptions.textAdventureGameMasterInstructions!.isEmpty) {
setState(() {
error = L10n.of(context)!
.conversationBotCustomZone_instructionSystemPromptEmptyError;
loading = false;
});
return;
}
}
}
// Pangea#
final roomId = await client.createGroupChat(
// #Pangea
// visibility:
// publicGroup ? sdk.Visibility.public : sdk.Visibility.private,
// preset: publicGroup
// ? sdk.CreateRoomPreset.publicChat
// : sdk.CreateRoomPreset.privateChat,
preset: sdk.CreateRoomPreset.publicChat,
powerLevelContentOverride:
await ClassChatPowerLevels.powerLevelOverrideForClassChat(
context,
addToSpaceKey.currentState!.parent,
),
invite: [
if (addConversationBotKey.currentState?.addBot ?? false)
BotName.byEnvironment,
],
// Pangea#
visibility:
groupCanBeFound ? sdk.Visibility.public : sdk.Visibility.private,
preset: publicGroup
? sdk.CreateRoomPreset.publicChat
: sdk.CreateRoomPreset.privateChat,
groupName: nameController.text.isNotEmpty ? nameController.text : null,
initialState: [
if (avatar != null)
@ -158,29 +73,12 @@ class NewGroupController extends State<NewGroup> {
type: sdk.EventTypes.RoomAvatar,
content: {'url': avatarUrl.toString()},
),
// #Pangea
if (addConversationBotKey.currentState?.addBot ?? false)
addConversationBotKey.currentState!.botOptions.toStateEvent,
// Pangea#
],
// #Pangea
enableEncryption: false,
// Pangea#
);
if (!mounted) return;
if (publicGroup && groupCanBeFound) {
await client.setRoomVisibilityOnDirectory(
roomId,
visibility: sdk.Visibility.public,
);
}
// #Pangea
GoogleAnalytics.createChat(roomId);
await addToSpaceKey.currentState!.addSpaces(roomId);
final capacity = addCapacityKey.currentState?.capacity;
final room = client.getRoomById(roomId);
if (capacity != null && room != null) {
room.updateRoomCapacity(capacity);
}
// Pangea#
context.go('/rooms/$roomId/invite');
} catch (e, s) {
sdk.Logs().d('Unable to create group', e, s);
@ -191,20 +89,6 @@ class NewGroupController extends State<NewGroup> {
}
}
//#Pangea
@override
void initState() {
Future.delayed(Duration.zero, () {
chatTopic.langCode =
pangeaController.languageController.userL2?.langCode ??
pangeaController.pLanguageStore.targetOptions.first.langCode;
setState(() {});
});
super.initState();
}
//Pangea#
@override
Widget build(BuildContext context) => NewGroupView(this);
}

@ -1,8 +1,5 @@
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pages/new_group/new_group.dart';
import 'package:fluffychat/pangea/pages/class_settings/p_class_widgets/room_capacity_button.dart';
import 'package:fluffychat/pangea/widgets/class/add_space_toggles.dart';
import 'package:fluffychat/pangea/widgets/conversation_bot/conversation_bot_settings.dart';
import 'package:fluffychat/utils/localized_exception_extension.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/layouts/max_width_body.dart';
@ -29,18 +26,9 @@ class NewGroupView extends StatelessWidget {
),
// #Pangea
// title: Text(L10n.of(context)!.createGroup),
title: Text(L10n.of(context)!.createChat),
title: Text(L10n.of(context)!.newChat),
// Pangea#
),
// #Pangea
floatingActionButton: FloatingActionButton.extended(
onPressed: controller.loading ? null : controller.submitAction,
icon: controller.loading ? null : const Icon(Icons.chat_bubble_outline),
label: controller.loading
? const CircularProgressIndicator.adaptive()
: Text(L10n.of(context)!.createChat),
),
// Pangea#
body: MaxWidthBody(
child: Column(
mainAxisSize: MainAxisSize.min,
@ -68,9 +56,6 @@ class NewGroupView extends StatelessWidget {
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: TextField(
// #Pangea
maxLength: 64,
// Pangea#
autofocus: true,
controller: controller.nameController,
autocorrect: false,
@ -85,40 +70,31 @@ class NewGroupView extends StatelessWidget {
),
),
const SizedBox(height: 16),
// #Pangea
RoomCapacityButton(
key: controller.addCapacityKey,
SwitchListTile.adaptive(
contentPadding: const EdgeInsets.symmetric(horizontal: 32),
secondary: const Icon(Icons.public_outlined),
title: Text(L10n.of(context)!.groupIsPublic),
value: controller.publicGroup,
onChanged: controller.loading ? null : controller.setPublicGroup,
),
ConversationBotSettings(
key: controller.addConversationBotKey,
activeSpaceId: controller.activeSpaceId,
),
const Divider(height: 1),
AddToSpaceToggles(
key: controller.addToSpaceKey,
startOpen: true,
activeSpaceId: controller.activeSpaceId,
AnimatedSize(
duration: FluffyThemes.animationDuration,
child: controller.publicGroup
? SwitchListTile.adaptive(
contentPadding:
const EdgeInsets.symmetric(horizontal: 32),
secondary: const Icon(Icons.search_outlined),
title: Text(L10n.of(context)!.groupCanBeFoundViaSearch),
value: controller.groupCanBeFound,
onChanged: controller.loading
? null
: controller.setGroupCanBeFound,
)
: const SizedBox.shrink(),
),
// #Pangea
// SwitchListTile.adaptive(
// secondary: const Icon(Icons.public_outlined),
// title: Text(L10n.of(context)!.groupIsPublic),
// value: controller.publicGroup,
// onChanged: controller.loading ? null : controller.setPublicGroup,
// ),
// AnimatedSize(
// duration: FluffyThemes.animationDuration,
// child: controller.publicGroup
// ? SwitchListTile.adaptive(
// secondary: const Icon(Icons.search_outlined),
// title: Text(L10n.of(context)!.groupCanBeFoundViaSearch),
// value: controller.groupCanBeFound,
// onChanged: controller.loading
// ? null
// : controller.setGroupCanBeFound,
// )
// : const SizedBox.shrink(),
// ),
// SwitchListTile.adaptive(
// contentPadding: const EdgeInsets.symmetric(horizontal: 32),
// secondary: Icon(
// Icons.lock_outlined,
// color: theme.colorScheme.onSurface,
@ -132,29 +108,20 @@ class NewGroupView extends StatelessWidget {
// value: !controller.publicGroup,
// onChanged: null,
// ),
// Padding(
// padding: const EdgeInsets.all(16.0),
// child: SizedBox(
// width: double.infinity,
// child: ElevatedButton(
// onPressed:
// controller.loading ? null : controller.submitAction,
// child: controller.loading
// ? const LinearProgressIndicator()
// : Row(
// children: [
// Expanded(
// child: Text(
// L10n.of(context)!.createGroupAndInviteUsers,
// ),
// ),
// Icon(Icons.adaptive.arrow_forward_outlined),
// ],
// ),
// ),
// ),
// ),
// Pangea#
Padding(
padding: const EdgeInsets.all(16.0),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed:
controller.loading ? null : controller.submitAction,
child: controller.loading
? const LinearProgressIndicator()
: Text(L10n.of(context)!.createGroupAndInviteUsers),
),
),
),
AnimatedSize(
duration: FluffyThemes.animationDuration,
child: error == null

@ -1,20 +1,19 @@
import 'package:file_picker/file_picker.dart';
import 'dart:typed_data';
import 'package:fluffychat/pages/new_space/new_space_view.dart';
import 'package:fluffychat/pangea/constants/class_default_values.dart';
import 'package:fluffychat/pangea/constants/model_keys.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart';
import 'package:fluffychat/pangea/pages/class_settings/p_class_widgets/room_capacity_button.dart';
import 'package:fluffychat/pangea/utils/bot_name.dart';
import 'package:fluffychat/pangea/utils/class_chat_power_levels.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/utils/firebase_analytics.dart';
import 'package:fluffychat/pangea/utils/space_code.dart';
import 'package:fluffychat/pangea/widgets/class/add_space_toggles.dart';
import 'package:fluffychat/utils/file_selector.dart';
import 'package:fluffychat/utils/localized_exception_extension.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart' as sdk;
import 'package:matrix/matrix.dart';
@ -28,48 +27,32 @@ class NewSpace extends StatefulWidget {
class NewSpaceController extends State<NewSpace> {
TextEditingController nameController = TextEditingController();
TextEditingController topicController = TextEditingController();
// #Pangea
bool publicGroup = false;
// bool publicGroup = true;
// final GlobalKey<RoomRulesState> rulesEditorKey = GlobalKey<RoomRulesState>();
final GlobalKey<AddToSpaceState> addToSpaceKey = GlobalKey<AddToSpaceState>();
// commenting out language settings in spaces for now
// final GlobalKey<LanguageSettingsState> languageSettingsKey =
// GlobalKey<LanguageSettingsState>();
final GlobalKey<RoomCapacityButtonState> addCapacityKey =
GlobalKey<RoomCapacityButtonState>();
//Pangea#
bool loading = false;
// #Pangea
// String? nameError;
// String? topicError;
// Pangea#
String? nameError;
String? topicError;
Uint8List? avatar;
Uri? avatarUrl;
void selectPhoto() async {
final photo = await FilePicker.platform.pickFiles(
type: FileType.image,
allowMultiple: false,
withData: true,
final photo = await selectFiles(
context,
type: FileSelectorType.images,
);
final bytes = await photo.firstOrNull?.readAsBytes();
setState(() {
avatarUrl = null;
avatar = photo?.files.singleOrNull?.bytes;
avatar = bytes;
});
}
void setPublicGroup(bool b) => setState(() => publicGroup = b);
// #Pangea
List<StateEvent> get initialState {
final events = <StateEvent>[];
events.add(
List<StateEvent> initialState(String joinCode) {
return [
StateEvent(
type: EventTypes.RoomPowerLevels,
stateKey: '',
@ -84,191 +67,95 @@ class NewSpaceController extends State<NewSpace> {
},
},
),
);
// commenting out pangea room rules in spaces for now
// if (rulesEditorKey.currentState?.rules != null) {
// events.add(rulesEditorKey.currentState!.rules.toStateEvent);
// } else {
// debugger(when: kDebugMode);
// }
// commenting out language settings in spaces for now
// if (languageSettingsKey.currentState != null) {
// events
// .add(languageSettingsKey.currentState!.languageSettings.toStateEvent);
// }
return events;
StateEvent(
type: sdk.EventTypes.RoomJoinRules,
content: {
ModelKey.joinRule:
sdk.JoinRules.knock.toString().replaceAll('JoinRules.', ''),
ModelKey.accessCode: joinCode,
},
),
];
}
//Pangea#
void submitAction([_]) async {
final client = Matrix.of(context).client;
setState(() {
// #Pangea
// nameError = topicError = null;
// Pangea#
nameError = topicError = null;
});
// #Pangea
// commenting out pangea room rules in spaces for now
// if (rulesEditorKey.currentState == null) {
// debugger(when: kDebugMode);
// return;
// }
// commenting out language settings in spaces for now
// if (languageSettingsKey.currentState != null &&
// languageSettingsKey.currentState!.sameLanguages) {
// ScaffoldMessenger.of(context).showSnackBar(
// SnackBar(
// content: Text(L10n.of(context)!.noIdenticalLanguages),
// ),
// );
// return;
// }
// final int? languageLevel =
// languageSettingsKey.currentState!.languageSettings.languageLevel;
// if (languageLevel == null) {
// ScaffoldMessenger.of(context).showSnackBar(
// SnackBar(content: Text(L10n.of(context)!.languageLevelWarning)),
// );
// return;
// }
// Pangea#
if (nameController.text.isEmpty) {
setState(() {
// #Pangea
// nameError = L10n.of(context)!.pleaseChoose;
final String warning = L10n.of(context)!.emptySpaceNameWarning;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(warning)),
);
// Pangea#
nameError = L10n.of(context)!.pleaseChoose;
});
return;
}
setState(() {
loading = true;
});
// #Pangea
// try {
await showFutureLoadingDialog(
context: context,
future: () async {
try {
// Pangea#
final avatar = this.avatar;
avatarUrl ??=
avatar == null ? null : await client.uploadContent(avatar);
final classCode = await SpaceCodeUtil.generateSpaceCode(client);
final spaceId = await client.createRoom(
// #Pangea
preset: publicGroup
? sdk.CreateRoomPreset.publicChat
: sdk.CreateRoomPreset.privateChat,
// #Pangea
creationContent: {'type': RoomCreationTypes.mSpace},
visibility: publicGroup ? sdk.Visibility.public : null,
// #Pangea
// roomAliasName: publicGroup
// ? nameController.text.trim().toLowerCase().replaceAll(' ', '_')
// : null,
// roomAliasName: SpaceCodeUtil.generateSpaceCode(),
// Pangea#
name: nameController.text.trim(),
topic: topicController.text.isEmpty ? null : topicController.text,
// #Pangea
// powerLevelContentOverride: {'events_default': 100},
powerLevelContentOverride: addToSpaceKey.currentState != null
? await ClassChatPowerLevels.powerLevelOverrideForClassChat(
context,
addToSpaceKey.currentState!.parent,
)
: null,
// Pangea#
initialState: [
// #Pangea
...initialState,
if (avatar != null)
sdk.StateEvent(
type: sdk.EventTypes.RoomAvatar,
content: {'url': avatarUrl.toString()},
),
sdk.StateEvent(
type: sdk.EventTypes.RoomJoinRules,
content: {
ModelKey.joinRule: sdk.JoinRules.knock
.toString()
.replaceAll('JoinRules.', ''),
ModelKey.accessCode: classCode,
},
),
// Pangea#
],
// Pangea#
);
// #Pangea
final List<Future<dynamic>> futures = [
Matrix.of(context).client.waitForRoomInSync(spaceId, join: true),
];
if (addToSpaceKey.currentState != null) {
futures.add(addToSpaceKey.currentState!.addSpaces(spaceId));
}
await Future.wait(futures);
final capacity = addCapacityKey.currentState?.capacity;
final space = client.getRoomById(spaceId);
if (capacity != null && space != null) {
space.updateRoomCapacity(capacity);
}
final Room? room = Matrix.of(context).client.getRoomById(spaceId);
if (room == null) {
ErrorHandler.logError(
e: 'Failed to get new space by id $spaceId',
);
MatrixState.pangeaController.classController
.setActiveSpaceIdInChatListController(spaceId);
return;
}
try {
final avatar = this.avatar;
avatarUrl ??= avatar == null ? null : await client.uploadContent(avatar);
// #Pangea
final joinCode = await SpaceCodeUtil.generateSpaceCode(client);
// Pangea#
GoogleAnalytics.createClass(room.name, room.classCode);
try {
await room.invite(BotName.byEnvironment);
} catch (err) {
ErrorHandler.logError(
e: "Failed to invite pangea bot to space ${room.id}",
);
}
// Pangea#
if (!mounted) return;
final spaceId = await client.createRoom(
preset: publicGroup
? sdk.CreateRoomPreset.publicChat
: sdk.CreateRoomPreset.privateChat,
creationContent: {'type': RoomCreationTypes.mSpace},
visibility: publicGroup ? sdk.Visibility.public : null,
roomAliasName: publicGroup
? nameController.text.trim().toLowerCase().replaceAll(' ', '_')
: null,
name: nameController.text.trim(),
topic: topicController.text.isEmpty ? null : topicController.text,
powerLevelContentOverride: {'events_default': 100},
initialState: [
// #Pangea
// context.pop<String>(spaceId);
MatrixState.pangeaController.classController
.setActiveSpaceIdInChatListController(spaceId);
...initialState(joinCode),
// Pangea#
} catch (e, s) {
// #Pangea
ErrorHandler.logError(e: e, s: s);
rethrow;
// setState(() {
// topicError = e.toLocalizedString(context);
// });
// Pangea#
} finally {
setState(() {
loading = false;
});
}
},
);
if (avatar != null)
sdk.StateEvent(
type: sdk.EventTypes.RoomAvatar,
content: {'url': avatarUrl.toString()},
),
],
);
if (!mounted) return;
// #Pangea
Room? room = client.getRoomById(spaceId);
if (room == null) {
await Matrix.of(context).client.waitForRoomInSync(spaceId);
room = client.getRoomById(spaceId);
}
if (room == null) return;
GoogleAnalytics.createClass(room.name, room.classCode);
try {
await room.invite(BotName.byEnvironment);
} catch (err) {
ErrorHandler.logError(
e: "Failed to invite pangea bot to new space",
data: {"spaceId": spaceId, "error": err},
);
}
MatrixState.pangeaController.classController
.setActiveSpaceIdInChatListController(spaceId);
// Pangea#
context.pop<String>(spaceId);
} catch (e) {
setState(() {
topicError = e.toLocalizedString(context);
});
} finally {
setState(() {
loading = false;
});
}
// TODO: Go to spaces
}
@override
// #Pangea
// Widget build(BuildContext context) => NewSpaceView(this);
Widget build(BuildContext context) {
return NewSpaceView(this);
}
// Pangea#
Widget build(BuildContext context) => NewSpaceView(this);
}

@ -1,5 +1,3 @@
import 'package:fluffychat/pangea/pages/class_settings/p_class_widgets/room_capacity_button.dart';
import 'package:fluffychat/pangea/widgets/class/add_space_toggles.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/layouts/max_width_body.dart';
import 'package:flutter/material.dart';
@ -19,15 +17,6 @@ class NewSpaceView extends StatelessWidget {
appBar: AppBar(
title: Text(L10n.of(context)!.createNewSpace),
),
// #Pangea
floatingActionButton: FloatingActionButton.extended(
onPressed: controller.loading ? null : controller.submitAction,
icon: controller.loading ? null : const Icon(Icons.workspaces_outlined),
label: controller.loading
? const CircularProgressIndicator.adaptive()
: Text(L10n.of(context)!.createSpace),
),
// Pangea#
body: MaxWidthBody(
child: Column(
mainAxisSize: MainAxisSize.min,
@ -62,58 +51,38 @@ class NewSpaceView extends StatelessWidget {
decoration: InputDecoration(
prefixIcon: const Icon(Icons.people_outlined),
labelText: L10n.of(context)!.spaceName,
// #Pangea
// errorText: controller.nameError,
// Pangea#
errorText: controller.nameError,
),
),
),
const SizedBox(height: 16),
// #Pangea
RoomCapacityButton(
key: controller.addCapacityKey,
spaceMode: true,
SwitchListTile.adaptive(
contentPadding: const EdgeInsets.symmetric(horizontal: 32),
title: Text(L10n.of(context)!.spaceIsPublic),
value: controller.publicGroup,
onChanged: controller.setPublicGroup,
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 32),
trailing: const Padding(
padding: EdgeInsets.symmetric(horizontal: 16.0),
child: Icon(Icons.info_outlined),
),
subtitle: Text(L10n.of(context)!.newSpaceDescription),
),
AddToSpaceToggles(
key: controller.addToSpaceKey,
startOpen: true,
spaceMode: true,
Padding(
padding: const EdgeInsets.all(16.0),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed:
controller.loading ? null : controller.submitAction,
child: controller.loading
? const LinearProgressIndicator()
: Text(L10n.of(context)!.createNewSpace),
),
),
),
// SwitchListTile.adaptive(
// title: Text(L10n.of(context)!.spaceIsPublic),
// value: controller.publicGroup,
// onChanged: controller.setPublicGroup,
// ),
// ListTile(
// trailing: const Padding(
// padding: EdgeInsets.symmetric(horizontal: 16.0),
// child: Icon(Icons.info_outlined),
// ),
// subtitle: Text(L10n.of(context)!.newSpaceDescription),
// ),
// Padding(
// padding: const EdgeInsets.all(16.0),
// child: SizedBox(
// width: double.infinity,
// child: ElevatedButton(
// onPressed:
// controller.loading ? null : controller.submitAction,
// child: controller.loading
// ? const LinearProgressIndicator()
// : Row(
// children: [
// Expanded(
// child: Text(
// L10n.of(context)!.createNewSpace,
// ),
// ),
// Icon(Icons.adaptive.arrow_forward_outlined),
// ],
// ),
// ),
// ),
// ),
// Pangea#
],
),
),

@ -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),

@ -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;

@ -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;
}
}

@ -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)));
}
}

@ -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;

@ -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);
});
}

@ -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,

@ -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),

@ -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 {

@ -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(),
);
},
),
],
),

@ -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,
),

@ -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) {

@ -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";
@ -121,6 +121,9 @@ class ModelKey {
static const String textAdventureGameMasterInstructions =
"text_adventure_game_master_instructions";
static const String targetLanguage = "target_language";
static const String targetVoice = "target_voice";
static const String prevEventId = "prev_event_id";
static const String prevLastUpdated = "prev_last_updated";

@ -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);
}
}

@ -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) {

@ -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 {

@ -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,
);

@ -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});
}

@ -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},
);
}
}

@ -5,13 +5,10 @@ import 'dart:developer';
import 'package:fluffychat/pangea/config/environment.dart';
import 'package:fluffychat/pangea/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/enum/activity_type_enum.dart';
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/message_activity_request.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
import 'package:fluffychat/pangea/network/requests.dart';
import 'package:fluffychat/pangea/network/urls.dart';
@ -22,11 +19,11 @@ import 'package:matrix/matrix.dart';
/// Represents an item in the completion cache.
class _RequestCacheItem {
MessageActivityRequest req;
PracticeActivityModel? practiceActivityEvent;
PracticeActivityModel? practiceActivity;
_RequestCacheItem({
required this.req,
required this.practiceActivityEvent,
required this.practiceActivity,
});
}
@ -109,64 +106,46 @@ class PracticeGenerationController {
final int cacheKey = req.hashCode;
if (_cache.containsKey(cacheKey)) {
return _cache[cacheKey]!.practiceActivityEvent;
} else {
//TODO - send request to server/bot, either via API or via event of type pangeaActivityReq
// for now, just make and send the event from the client
final MessageActivityResponse res = await _fetch(
accessToken: _pangeaController.userController.accessToken,
requestModel: req,
);
return _cache[cacheKey]!.practiceActivity;
}
if (res.finished) {
debugPrint('Activity generation finished');
return null;
}
final MessageActivityResponse res = await _fetch(
accessToken: _pangeaController.userController.accessToken,
requestModel: req,
);
// if the server points to an existing event, return that event
if (res.existingActivityEventId != null) {
final Event? existingEvent =
await event.room.getEventById(res.existingActivityEventId!);
debugPrint(
'Existing activity event found: ${existingEvent?.content}',
);
if (existingEvent != null) {
return PracticeActivityEvent(
event: existingEvent,
timeline: event.timeline,
).practiceActivity;
}
}
if (res.finished) {
debugPrint('Activity generation finished');
return null;
}
// if the server points to an existing event, return that event
if (res.existingActivityEventId != null) {
final Event? existingEvent =
await event.room.getEventById(res.existingActivityEventId!);
if (res.activity == null) {
debugPrint('No activity generated');
return null;
debugPrint(
'Existing activity event found: ${existingEvent?.content}',
);
if (existingEvent != null) {
return PracticeActivityEvent(
event: existingEvent,
timeline: event.timeline,
).practiceActivity;
}
}
if (res.activity == null) {
debugPrint('No activity generated');
return null;
}
debugPrint('Activity generated: ${res.activity!.toJson()}');
debugPrint('Activity generated: ${res.activity!.toJson()}');
_sendAndPackageEvent(res.activity!, event);
_cache[cacheKey] =
_RequestCacheItem(req: req, practiceActivityEvent: res.activity!);
_sendAndPackageEvent(res.activity!, event);
_cache[cacheKey] =
_RequestCacheItem(req: req, practiceActivity: res.activity!);
return _cache[cacheKey]!.practiceActivityEvent;
}
return _cache[cacheKey]!.practiceActivity;
}
PracticeActivityModel _dummyModel(PangeaMessageEvent event) =>
PracticeActivityModel(
tgtConstructs: [
ConstructIdentifier(lemma: "be", type: ConstructTypeEnum.vocab),
],
activityType: ActivityTypeEnum.multipleChoice,
langCode: event.messageDisplayLangCode,
msgId: event.eventId,
multipleChoice: MultipleChoice(
question: "What is a synonym for 'happy'?",
choices: ["sad", "angry", "joyful", "tired"],
answer: "joyful",
spanDisplayDetails: null,
),
);
}

@ -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);
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>();
}
int getCompletedActivityCount(String messageID) {
return _completedActivities[messageID] ?? 0;
}
Future<void> completeActivity(String messageID) async {
final LinkedHashMap<String, int> currentCache = completedActivities;
final numCompleted = currentCache[messageID] ?? 0;
currentCache[messageID] = numCompleted + 1;
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>();
// }
// }
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();
Future<void> completeActivity(String messageID) async {
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);
// }
// 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.

@ -1,12 +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';
@ -14,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';
@ -24,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,
@ -32,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();
@ -40,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>();
@ -68,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);
await subscription!.configure();
availableSubscriptionInfo = AvailableSubscriptionsInfo();
await availableSubscriptionInfo!.setAvailableSubscriptions();
currentSubscriptionInfo = kIsWeb
? WebSubscriptionInfo(
userID: userID!,
availableSubscriptionInfo: availableSubscriptionInfo!,
)
: MobileSubscriptionInfo(
userID: userID!,
availableSubscriptionInfo: availableSubscriptionInfo!,
);
await currentSubscriptionInfo!.configure();
await currentSubscriptionInfo!.setCurrentSubscription();
if (_activatedNewUserTrial) {
setNewUserTrial();
}
@ -102,7 +117,7 @@ class SubscriptionController extends BaseController {
await _pangeaController.pStoreService.delete(
PLocalKey.beganWebPayment,
);
if (_pangeaController.subscriptionController.isSubscribed) {
if (isSubscribed) {
subscriptionStream.add(true);
}
}
@ -171,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;
@ -179,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;
@ -197,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",
@ -208,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(
@ -250,6 +264,7 @@ class SubscriptionController extends BaseController {
return backoff;
}
/// whether or not the paywall should be shown
bool get _shouldShowPaywall {
return initialized.isCompleted &&
!isSubscribed &&
@ -282,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;
@ -308,48 +323,54 @@ 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(
choreoApiKey: Environment.choreoApiKey,
accessToken: _pangeaController.userController.accessToken,
);
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<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}",
);
}
String? get defaultManagementURL =>
currentSubscriptionInfo?.currentSubscription
?.defaultManagementURL(availableSubscriptionInfo?.appIds);
}
enum SubscriptionPeriodType {
normal,
trial,
}
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,
@ -357,30 +378,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;
}
}

@ -5,20 +5,99 @@ import 'dart:typed_data';
import 'package:fluffychat/pangea/config/environment.dart';
import 'package:fluffychat/pangea/constants/model_keys.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/network/urls.dart';
import 'package:http/http.dart';
import '../network/requests.dart';
class PangeaAudioEventData {
final String text;
final String langCode;
final List<TTSToken> tokens;
PangeaAudioEventData({
required this.text,
required this.langCode,
required this.tokens,
});
factory PangeaAudioEventData.fromJson(dynamic json) => PangeaAudioEventData(
text: json[ModelKey.text] as String,
langCode: json[ModelKey.langCode] as String,
tokens: List<TTSToken>.from(
(json[ModelKey.tokens] as Iterable)
.map((x) => TTSToken.fromJson(x))
.toList(),
),
);
Map<String, dynamic> toJson() => {
ModelKey.text: text,
ModelKey.langCode: langCode,
ModelKey.tokens:
List<Map<String, dynamic>>.from(tokens.map((x) => x.toJson())),
};
}
class TTSToken {
final int startMS;
final int endMS;
final PangeaTokenText text;
TTSToken({
required this.startMS,
required this.endMS,
required this.text,
});
factory TTSToken.fromJson(Map<String, dynamic> json) => TTSToken(
startMS: json["start_ms"],
endMS: json["end_ms"],
text: PangeaTokenText.fromJson(json["text"]),
);
Map<String, dynamic> toJson() => {
"start_ms": startMS,
"end_ms": endMS,
"text": text.toJson(),
};
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is TTSToken &&
other.startMS == startMS &&
other.endMS == endMS &&
other.text == text;
}
@override
int get hashCode => startMS.hashCode ^ endMS.hashCode ^ text.hashCode;
}
class TextToSpeechRequest {
String text;
String langCode;
TextToSpeechRequest({required this.text, required this.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(),
};
@override
@ -40,6 +119,7 @@ class TextToSpeechResponse {
int durationMillis;
List<int> waveform;
String fileExtension;
List<TTSToken> ttsTokens;
TextToSpeechResponse({
required this.audioContent,
@ -47,6 +127,7 @@ class TextToSpeechResponse {
required this.durationMillis,
required this.waveform,
required this.fileExtension,
required this.ttsTokens,
});
factory TextToSpeechResponse.fromJson(
@ -58,7 +139,27 @@ class TextToSpeechResponse {
durationMillis: json["duration_millis"],
waveform: List<int>.from(json["wave_form"]),
fileExtension: json["file_extension"],
ttsTokens: List<TTSToken>.from(
json["tts_tokens"].map((x) => TTSToken.fromJson(x)),
),
);
Map<String, dynamic> toJson() => {
"audio_content": audioContent,
"mime_type": mimeType,
"duration_millis": durationMillis,
"wave_form": List<dynamic>.from(waveform.map((x) => x)),
"file_extension": fileExtension,
"tts_tokens": List<dynamic>.from(ttsTokens.map((x) => x.toJson())),
};
PangeaAudioEventData toPangeaAudioEventData(String text, String langCode) {
return PangeaAudioEventData(
text: text,
langCode: langCode,
tokens: ttsTokens,
);
}
}
class _TextToSpeechCacheItem {

@ -120,6 +120,8 @@ 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();
}
@ -173,13 +175,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)),
);
}

@ -1,13 +1,6 @@
enum ActivityDisplayInstructionsEnum { highlight, hide }
enum ActivityDisplayInstructionsEnum { highlight, hide, nothing }
extension ActivityDisplayInstructionsEnumExt
on ActivityDisplayInstructionsEnum {
String get string {
switch (this) {
case ActivityDisplayInstructionsEnum.highlight:
return 'highlight';
case ActivityDisplayInstructionsEnum.hide:
return 'hide';
}
}
String get string => toString().split('.').last;
}

@ -1,16 +1,12 @@
enum ActivityTypeEnum { multipleChoice, freeResponse, listening, speaking }
enum ActivityTypeEnum { multipleChoice, wordFocusListening }
extension ActivityTypeExtension on ActivityTypeEnum {
String get string {
switch (this) {
case ActivityTypeEnum.multipleChoice:
return 'multiple_choice';
case ActivityTypeEnum.freeResponse:
return 'free_response';
case ActivityTypeEnum.listening:
return 'listening';
case ActivityTypeEnum.speaking:
return 'speaking';
case ActivityTypeEnum.wordFocusListening:
return 'word_focus_listening';
}
}
}

@ -38,63 +38,49 @@ enum ConstructUseTypeEnum {
/// was target construct in word meaning in context practice activity and incorrectly selected
incPA,
/// was target lemma in word-focus listening activity and correctly selected
corWL,
/// form of lemma was read-aloud in word-focus listening activity and incorrectly selected
incWL,
/// form of lemma was read-aloud in word-focus listening activity and correctly ignored
ignWL,
/// not defined, likely a new construct introduced by choreo and not yet classified by an old version of the client
nan
}
extension ConstructUseTypeExtension on ConstructUseTypeEnum {
String get string {
switch (this) {
case ConstructUseTypeEnum.ga:
return 'ga';
case ConstructUseTypeEnum.wa:
return 'wa';
case ConstructUseTypeEnum.corIt:
return 'corIt';
case ConstructUseTypeEnum.incIt:
return 'incIt';
case ConstructUseTypeEnum.ignIt:
return 'ignIt';
case ConstructUseTypeEnum.ignIGC:
return 'ignIGC';
case ConstructUseTypeEnum.corIGC:
return 'corIGC';
case ConstructUseTypeEnum.incIGC:
return 'incIGC';
case ConstructUseTypeEnum.unk:
return 'unk';
case ConstructUseTypeEnum.corPA:
return 'corPA';
case ConstructUseTypeEnum.incPA:
return 'incPA';
case ConstructUseTypeEnum.ignPA:
return 'ignPA';
}
}
String get string => toString().split('.').last;
IconData get icon {
switch (this) {
case ConstructUseTypeEnum.ga:
return Icons.check;
case ConstructUseTypeEnum.wa:
return Icons.thumb_up_sharp;
case ConstructUseTypeEnum.corIt:
return Icons.translate;
case ConstructUseTypeEnum.incIt:
return Icons.translate;
case ConstructUseTypeEnum.ignIt:
return Icons.translate;
case ConstructUseTypeEnum.ignIGC:
return Icons.close;
case ConstructUseTypeEnum.corIGC:
return Icons.check;
case ConstructUseTypeEnum.incIGC:
return Icons.close;
case ConstructUseTypeEnum.corPA:
return Icons.check;
case ConstructUseTypeEnum.incPA:
return Icons.close;
case ConstructUseTypeEnum.ignPA:
case ConstructUseTypeEnum.ignWL:
case ConstructUseTypeEnum.incWL:
return Icons.close;
case ConstructUseTypeEnum.ga:
case ConstructUseTypeEnum.corIGC:
case ConstructUseTypeEnum.corPA:
case ConstructUseTypeEnum.corWL:
return Icons.check;
case ConstructUseTypeEnum.unk:
case ConstructUseTypeEnum.nan:
return Icons.help;
}
}
@ -107,30 +93,44 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
/// Practice activities get a moderate amount of points.
int get pointValue {
switch (this) {
case ConstructUseTypeEnum.ga:
return 2;
case ConstructUseTypeEnum.corPA:
return 5;
case ConstructUseTypeEnum.wa:
case ConstructUseTypeEnum.corWL:
return 3;
case ConstructUseTypeEnum.ga:
case ConstructUseTypeEnum.corIGC:
return 2;
case ConstructUseTypeEnum.corIt:
return 1;
case ConstructUseTypeEnum.incIt:
return -1;
case ConstructUseTypeEnum.ignIt:
return 1;
case ConstructUseTypeEnum.ignIGC:
return 1;
case ConstructUseTypeEnum.corIGC:
return 2;
case ConstructUseTypeEnum.incIGC:
return -1;
case ConstructUseTypeEnum.ignPA:
case ConstructUseTypeEnum.ignWL:
case ConstructUseTypeEnum.unk:
case ConstructUseTypeEnum.nan:
return 0;
case ConstructUseTypeEnum.corPA:
return 5;
case ConstructUseTypeEnum.incPA:
case ConstructUseTypeEnum.incIt:
case ConstructUseTypeEnum.incIGC:
return -2;
case ConstructUseTypeEnum.ignPA:
return 1;
case ConstructUseTypeEnum.incPA:
case ConstructUseTypeEnum.incWL:
return -3;
}
}
}
class ConstructUseTypeUtil {
static ConstructUseTypeEnum fromString(String value) {
return ConstructUseTypeEnum.values.firstWhere(
(e) => e.string == value,
orElse: () => ConstructUseTypeEnum.nan,
);
}
}

@ -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) {

@ -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:

@ -1,11 +1,15 @@
import 'dart:developer';
import 'package:fluffychat/pangea/constants/model_keys.dart';
import 'package:fluffychat/pangea/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/controllers/text_to_speech_controller.dart';
import 'package:fluffychat/pangea/models/choreo_record.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/models/representation_content_model.dart';
import 'package:fluffychat/pangea/models/tokens_event_content_model.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/widgets/chat/message_audio_card.dart';
import 'package:flutter/foundation.dart';
import 'package:matrix/matrix.dart';
@ -37,4 +41,48 @@ extension PangeaEvent on Event {
throw Exception("$type events do not have pangea content");
}
}
Future<PangeaAudioFile?> getPangeaAudioFile() async {
if (type != EventTypes.Message || messageType != MessageTypes.Audio) {
ErrorHandler.logError(
e: "Event is not an audio message",
data: {
"event": toJson(),
},
);
return null;
}
final transcription =
content.tryGetMap<String, dynamic>(ModelKey.transcription);
final audioContent =
content.tryGetMap<String, dynamic>('org.matrix.msc1767.audio');
if (transcription == null || audioContent == null) {
ErrorHandler.logError(
e: "Called getPangeaAudioFile on an audio message without transcription or audio content",
);
return null;
}
final matrixFile = await downloadAndDecryptAttachment();
final duration = audioContent.tryGet<int>('duration');
final waveform = audioContent.tryGetList<int>('waveform');
// old audio messages will not have tokens
final tokensContent = transcription.tryGetList(ModelKey.tokens);
if (tokensContent == null) return null;
final tokens = tokensContent
.map((e) => TTSToken.fromJson(e as Map<String, dynamic>))
.toList();
return PangeaAudioFile(
bytes: matrixFile.bytes,
name: matrixFile.name,
tokens: tokens,
mimeType: matrixFile.mimeType,
duration: duration,
waveform: waveform,
);
}
}

@ -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,

@ -81,18 +81,20 @@ class PangeaMessageEvent {
_representations = null;
}
Future<PangeaAudioFile> getMatrixAudioFile(
Future<PangeaAudioFile?> getMatrixAudioFile(
String langCode,
BuildContext context,
) async {
final String text = (await representationByLanguageGlobal(
langCode: langCode,
))
?.text ??
body;
final RepresentationEvent? rep = representationByLanguage(langCode);
if (rep == null) return null;
final TextToSpeechRequest params = TextToSpeechRequest(
text: text,
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 =
@ -111,9 +113,10 @@ class PangeaMessageEvent {
mimeType: response.mimeType,
duration: response.durationMillis,
waveform: response.waveform,
tokens: response.ttsTokens,
);
sendAudioEvent(file, response, text, langCode);
sendAudioEvent(file, response, rep.text, langCode);
return file;
}
@ -137,10 +140,8 @@ class PangeaMessageEvent {
'duration': response.durationMillis,
'waveform': response.waveform,
},
ModelKey.transcription: {
ModelKey.text: text,
ModelKey.langCode: langCode,
},
ModelKey.transcription:
response.toPangeaAudioEventData(text, langCode).toJson(),
},
);
@ -155,97 +156,46 @@ class PangeaMessageEvent {
return audioEvent;
}
//get audio for text and language
//if no audio exists, create it
//if audio exists, return it
Future<Event?> getTextToSpeechGlobal(String langCode) async {
final String text = representationByLanguage(langCode)?.text ?? body;
final local = getTextToSpeechLocal(langCode, text);
if (local != null) return Future.value(local);
final TextToSpeechRequest params = TextToSpeechRequest(
text: text,
langCode: langCode,
);
final TextToSpeechResponse response =
await MatrixState.pangeaController.textToSpeech.get(
params,
);
final audioBytes = base64.decode(response.audioContent);
// if (!TextToSpeechController.isOggFile(audioBytes)) {
// throw Exception("File is not a valid OGG format");
// } else {
// debugPrint("File is a valid OGG format");
// }
// from text, trim whitespace, remove special characters, and limit to 20 characters
// final fileName =
// text.trim().replaceAll(RegExp('[^A-Za-z0-9]'), '').substring(0, 20);
final eventIdParam = _event.eventId;
final fileName =
"audio_for_${eventIdParam}_$langCode.${response.fileExtension}";
final file = MatrixAudioFile(
bytes: audioBytes,
name: fileName,
mimeType: response.mimeType,
);
// try {
final String? eventId = await room.sendFileEvent(
file,
inReplyTo: _event,
extraContent: {
'info': {
...file.info,
'duration': response.durationMillis,
},
'org.matrix.msc3245.voice': {},
'org.matrix.msc1767.audio': {
'duration': response.durationMillis,
'waveform': response.waveform,
},
ModelKey.transcription: {
ModelKey.text: text,
ModelKey.langCode: langCode,
},
},
);
// .timeout(
// Durations.long4,
// onTimeout: () {
// debugPrint("timeout in getTextToSpeechGlobal");
// return null;
// },
// );
debugPrint("eventId in getTextToSpeechGlobal $eventId");
return eventId != null ? room.getEventById(eventId) : null;
}
Event? getTextToSpeechLocal(String langCode, String text) {
return allAudio.firstWhereOrNull(
(element) {
// Safely access the transcription map
final transcription = element.content.tryGetMap(ModelKey.transcription);
// return transcription != null;
if (transcription == null) {
// If transcription is null, this element does not match.
(event) {
try {
// Safely access
final dataMap = event.content.tryGetMap(ModelKey.transcription);
if (dataMap == null) {
return false;
}
// old text to speech content will not have TTSToken data
// we want to disregard them and just generate new ones
// for that, we'll return false if 'tokens' are null
// while in-development, we'll pause here to inspect
// debugger can be removed after we're sure it's working
if (dataMap['tokens'] == null) {
// events before today will definitely not have the tokens
debugger(
when: kDebugMode &&
event.originServerTs.isAfter(DateTime(2024, 10, 16)),
);
return false;
}
final PangeaAudioEventData audioData =
PangeaAudioEventData.fromJson(dataMap as dynamic);
// Check if both language code and text match
return audioData.langCode == langCode && audioData.text == text;
} catch (e, s) {
debugger(when: kDebugMode);
ErrorHandler.logError(
e: e,
s: s,
data: event.content.tryGetMap(ModelKey.transcription),
m: "error parsing data in getTextToSpeechLocal",
);
return false;
}
// Safely get language code and text from the transcription
final elementLangCode = transcription[ModelKey.langCode];
final elementText = transcription[ModelKey.text];
// Check if both language code and text matsch
return elementLangCode == langCode && elementText == text;
},
);
}
@ -590,8 +540,7 @@ class PangeaMessageEvent {
int get numberOfActivitiesCompleted {
return MatrixState.pangeaController.activityRecordController
.completedActivities[eventId] ??
0;
.getCompletedActivityCount(eventId);
}
String? get l2Code =>
@ -639,18 +588,28 @@ class PangeaMessageEvent {
/// Returns a list of all [PracticeActivityEvent] objects
/// associated with this message event.
List<PracticeActivityEvent> get _practiceActivityEvents {
return _latestEdit
final List<Event> events = _latestEdit
.aggregatedEvents(
timeline,
PangeaEventTypes.pangeaActivity,
)
.map(
(e) => PracticeActivityEvent(
.toList();
final List<PracticeActivityEvent> practiceEvents = [];
for (final event in events) {
try {
practiceEvents.add(
PracticeActivityEvent(
timeline: timeline,
event: e,
event: event,
),
)
.toList();
);
final content = practiceEvents.last.practiceActivity;
} catch (e, s) {
ErrorHandler.logError(e: e, s: s, data: event.toJson());
}
}
return practiceEvents;
}
/// Returns a boolean value indicating whether there are any
@ -668,23 +627,10 @@ class PangeaMessageEvent {
List<PracticeActivityEvent> practiceActivitiesByLangCode(
String langCode, {
bool debug = false,
}) {
// @wcjord - disabled try catch for testing
try {
debugger(when: debug);
final List<PracticeActivityEvent> activities = [];
for (final event in _practiceActivityEvents) {
if (event.practiceActivity.langCode == langCode) {
activities.add(event);
}
}
return activities;
} catch (e, s) {
debugger(when: kDebugMode);
ErrorHandler.logError(e: e, s: s, data: event.toJson());
return [];
}
}
}) =>
_practiceActivityEvents
.where((event) => event.practiceActivity.langCode == langCode)
.toList();
/// Returns a list of [PracticeActivityEvent] for the user's active l2.
List<PracticeActivityEvent> get practiceActivities =>

@ -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,
),
);

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

@ -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

@ -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

@ -1,50 +1,32 @@
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 = [];
DateTime? expirationDate;
/// Contains information about the users's current subscription
class CurrentSubscriptionInfo {
final String userID;
final AvailableSubscriptionsInfo availableSubscriptionInfo;
bool get hasSubscribed => allEntitlements.isNotEmpty;
SubscriptionInfo({
required this.pangeaController,
}) : super();
Future<void> configure() async {}
DateTime? expirationDate;
String? currentSubscriptionId;
//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();
CurrentSubscriptionInfo({
required this.userID,
required this.availableSubscriptionInfo,
});
SubscriptionDetails? get currentSubscription {
if (currentSubscriptionId == null) return null;
return availableSubscriptionInfo.allProducts?.firstWhereOrNull(
(SubscriptionDetails sub) =>
sub.id.contains(currentSubscriptionId!) ||
currentSubscriptionId!.contains(sub.id),
);
}
Future<void> setAllProducts() async {
if (allProducts != null) return;
allProducts = await SubscriptionRepo.getAllProducts();
}
Future<void> configure() async {}
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 {}
}
/// Contains information about the suscriptions available on revenuecat
class AvailableSubscriptionsInfo {
List<SubscriptionDetails> availableSubscriptions = [];
SubscriptionAppIds? appIds;
List<SubscriptionDetails>? allProducts;
String? get defaultManagementURL {
final String? purchaseAppId = currentSubscription?.appId;
return purchaseAppId == appIds?.androidId
? AppConfig.googlePlayMangementUrl
: purchaseAppId == appIds?.appleId
? AppConfig.appleMangementUrl
: Environment.stripeManagementUrl;
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,
// ),
// );
// }
}
}

@ -21,6 +21,8 @@ class BotOptionsModel {
bool? customTriggerReactionEnabled;
String? customTriggerReactionKey;
String? textAdventureGameMasterInstructions;
String? targetLanguage;
String? targetVoice;
BotOptionsModel({
////////////////////////////////////////////////////////////////////////////
@ -31,6 +33,8 @@ class BotOptionsModel {
this.keywords = const [],
this.safetyModeration = true,
this.mode = BotMode.discussion,
this.targetLanguage,
this.targetVoice,
////////////////////////////////////////////////////////////////////////////
// Discussion Mode Options
@ -63,6 +67,8 @@ class BotOptionsModel {
: null,
safetyModeration: json[ModelKey.safetyModeration] ?? true,
mode: json[ModelKey.mode] ?? BotMode.discussion,
targetLanguage: json[ModelKey.targetLanguage],
targetVoice: json[ModelKey.targetVoice],
//////////////////////////////////////////////////////////////////////////
// Discussion Mode Options
@ -97,6 +103,8 @@ class BotOptionsModel {
data[ModelKey.languageLevel] = languageLevel;
data[ModelKey.safetyModeration] = safetyModeration;
data[ModelKey.mode] = mode;
data[ModelKey.targetLanguage] = targetLanguage;
data[ModelKey.targetVoice] = targetVoice;
data[ModelKey.discussionTopic] = discussionTopic;
data[ModelKey.discussionKeywords] = discussionKeywords;
data[ModelKey.discussionTriggerReactionEnabled] =
@ -153,6 +161,12 @@ class BotOptionsModel {
case ModelKey.textAdventureGameMasterInstructions:
textAdventureGameMasterInstructions = value;
break;
case ModelKey.targetLanguage:
targetLanguage = value;
break;
case ModelKey.targetVoice:
targetVoice = value;
break;
default:
throw Exception('Invalid key for bot options - $key');
}

@ -1,195 +1,195 @@
import 'dart:convert';
import 'dart:developer';
import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import '../enum/vocab_proficiency_enum.dart';
class VocabHeadwords {
List<VocabList> lists;
VocabHeadwords({
required this.lists,
});
/// in json parameter, keys are the names of the VocabList
/// values are the words in the VocabList
factory VocabHeadwords.fromJson(Map<String, dynamic> json) {
final List<VocabList> lists = [];
for (final entry in json.entries) {
lists.add(
VocabList(
name: entry.key,
lemmas: (entry.value as Iterable).cast<String>().toList(),
),
);
}
return VocabHeadwords(lists: lists);
}
static Future<VocabHeadwords> getHeadwords(String langCode) async {
final String data =
await rootBundle.loadString('${langCode}_headwords.json');
final decoded = jsonDecode(data);
final VocabHeadwords headwords = VocabHeadwords.fromJson(decoded);
return headwords;
}
}
class VocabList {
String name;
/// key is lemma
Map<String, VocabTotals> words = {};
VocabList({
required this.name,
required List<String> lemmas,
}) {
for (final lemma in lemmas) {
words[lemma] = VocabTotals.newTotals;
}
}
void addVocabUse(String lemma, List<OneConstructUse> use) {
words[lemma.toUpperCase()]?.addVocabUseBasedOnUseType(use);
}
ListTotals calculuateTotals() {
final ListTotals listTotals = ListTotals.empty;
for (final word in words.entries) {
debugger(when: kDebugMode && word.key == "baloncesto".toLowerCase());
listTotals.addByType(word.value.proficiencyLevel);
}
return listTotals;
}
}
class ListTotals {
int low;
int medium;
int high;
int unknown;
ListTotals({
required this.low,
required this.medium,
required this.high,
required this.unknown,
});
static get empty => ListTotals(low: 0, medium: 0, high: 0, unknown: 0);
void addByType(VocabProficiencyEnum prof) {
switch (prof) {
case VocabProficiencyEnum.low:
low++;
break;
case VocabProficiencyEnum.medium:
medium++;
break;
case VocabProficiencyEnum.high:
high++;
break;
case VocabProficiencyEnum.unk:
unknown++;
break;
}
}
}
class VocabTotals {
num ga;
num wa;
num corIt;
num incIt;
num ignIt;
VocabTotals({
required this.ga,
required this.wa,
required this.corIt,
required this.incIt,
required this.ignIt,
});
num get calculateEstimatedVocabProficiency {
const num gaWeight = -1;
const num waWeight = 1;
const num corItWeight = 0.5;
const num incItWeight = -0.5;
const num ignItWeight = 0.1;
final num gaScore = ga * gaWeight;
final num waScore = wa * waWeight;
final num corItScore = corIt * corItWeight;
final num incItScore = incIt * incItWeight;
final num ignItScore = ignIt * ignItWeight;
final num totalScore =
gaScore + waScore + corItScore + incItScore + ignItScore;
return totalScore;
}
VocabProficiencyEnum get proficiencyLevel =>
VocabProficiencyUtil.proficiency(calculateEstimatedVocabProficiency);
static VocabTotals get newTotals {
return VocabTotals(
ga: 0,
wa: 0,
corIt: 0,
incIt: 0,
ignIt: 0,
);
}
void addVocabUseBasedOnUseType(List<OneConstructUse> uses) {
for (final use in uses) {
switch (use.useType) {
case ConstructUseTypeEnum.ga:
ga++;
break;
case ConstructUseTypeEnum.wa:
wa++;
break;
case ConstructUseTypeEnum.corIt:
corIt++;
break;
case ConstructUseTypeEnum.incIt:
incIt++;
break;
case ConstructUseTypeEnum.ignIt:
ignIt++;
break;
//TODO - these shouldn't be counted as such
case ConstructUseTypeEnum.ignIGC:
ignIt++;
break;
case ConstructUseTypeEnum.corIGC:
corIt++;
break;
case ConstructUseTypeEnum.incIGC:
incIt++;
break;
//TODO if we bring back Headwords then we need to add these
case ConstructUseTypeEnum.corPA:
break;
case ConstructUseTypeEnum.incPA:
break;
case ConstructUseTypeEnum.unk:
break;
case ConstructUseTypeEnum.ignPA:
break;
}
}
}
}
// import 'dart:convert';
// import 'dart:developer';
// import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart';
// import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
// import 'package:flutter/foundation.dart';
// import 'package:flutter/services.dart';
// import '../enum/vocab_proficiency_enum.dart';
// class VocabHeadwords {
// List<VocabList> lists;
// VocabHeadwords({
// required this.lists,
// });
// /// in json parameter, keys are the names of the VocabList
// /// values are the words in the VocabList
// factory VocabHeadwords.fromJson(Map<String, dynamic> json) {
// final List<VocabList> lists = [];
// for (final entry in json.entries) {
// lists.add(
// VocabList(
// name: entry.key,
// lemmas: (entry.value as Iterable).cast<String>().toList(),
// ),
// );
// }
// return VocabHeadwords(lists: lists);
// }
// static Future<VocabHeadwords> getHeadwords(String langCode) async {
// final String data =
// await rootBundle.loadString('${langCode}_headwords.json');
// final decoded = jsonDecode(data);
// final VocabHeadwords headwords = VocabHeadwords.fromJson(decoded);
// return headwords;
// }
// }
// class VocabList {
// String name;
// /// key is lemma
// Map<String, VocabTotals> words = {};
// VocabList({
// required this.name,
// required List<String> lemmas,
// }) {
// for (final lemma in lemmas) {
// words[lemma] = VocabTotals.newTotals;
// }
// }
// void addVocabUse(String lemma, List<OneConstructUse> use) {
// words[lemma.toUpperCase()]?.addVocabUseBasedOnUseType(use);
// }
// ListTotals calculuateTotals() {
// final ListTotals listTotals = ListTotals.empty;
// for (final word in words.entries) {
// debugger(when: kDebugMode && word.key == "baloncesto".toLowerCase());
// listTotals.addByType(word.value.proficiencyLevel);
// }
// return listTotals;
// }
// }
// class ListTotals {
// int low;
// int medium;
// int high;
// int unknown;
// ListTotals({
// required this.low,
// required this.medium,
// required this.high,
// required this.unknown,
// });
// static get empty => ListTotals(low: 0, medium: 0, high: 0, unknown: 0);
// void addByType(VocabProficiencyEnum prof) {
// switch (prof) {
// case VocabProficiencyEnum.low:
// low++;
// break;
// case VocabProficiencyEnum.medium:
// medium++;
// break;
// case VocabProficiencyEnum.high:
// high++;
// break;
// case VocabProficiencyEnum.unk:
// unknown++;
// break;
// }
// }
// }
// class VocabTotals {
// num ga;
// num wa;
// num corIt;
// num incIt;
// num ignIt;
// VocabTotals({
// required this.ga,
// required this.wa,
// required this.corIt,
// required this.incIt,
// required this.ignIt,
// });
// num get calculateEstimatedVocabProficiency {
// const num gaWeight = -1;
// const num waWeight = 1;
// const num corItWeight = 0.5;
// const num incItWeight = -0.5;
// const num ignItWeight = 0.1;
// final num gaScore = ga * gaWeight;
// final num waScore = wa * waWeight;
// final num corItScore = corIt * corItWeight;
// final num incItScore = incIt * incItWeight;
// final num ignItScore = ignIt * ignItWeight;
// final num totalScore =
// gaScore + waScore + corItScore + incItScore + ignItScore;
// return totalScore;
// }
// VocabProficiencyEnum get proficiencyLevel =>
// VocabProficiencyUtil.proficiency(calculateEstimatedVocabProficiency);
// static VocabTotals get newTotals {
// return VocabTotals(
// ga: 0,
// wa: 0,
// corIt: 0,
// incIt: 0,
// ignIt: 0,
// );
// }
// void addVocabUseBasedOnUseType(List<OneConstructUse> uses) {
// for (final use in uses) {
// switch (use.useType) {
// case ConstructUseTypeEnum.ga:
// ga++;
// break;
// case ConstructUseTypeEnum.wa:
// wa++;
// break;
// case ConstructUseTypeEnum.corIt:
// corIt++;
// break;
// case ConstructUseTypeEnum.incIt:
// incIt++;
// break;
// case ConstructUseTypeEnum.ignIt:
// ignIt++;
// break;
// //TODO - these shouldn't be counted as such
// case ConstructUseTypeEnum.ignIGC:
// ignIt++;
// break;
// case ConstructUseTypeEnum.corIGC:
// corIt++;
// break;
// case ConstructUseTypeEnum.incIGC:
// incIt++;
// break;
// //TODO if we bring back Headwords then we need to add these
// case ConstructUseTypeEnum.corPA:
// break;
// case ConstructUseTypeEnum.incPA:
// break;
// case ConstructUseTypeEnum.unk:
// break;
// case ConstructUseTypeEnum.ignPA:
// break;
// }
// }
// }
// }

@ -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;
}
}

@ -128,8 +128,6 @@ class PangeaToken {
lemma: lemma.text,
type: ConstructTypeEnum.vocab,
),
xp: 0,
lastUsed: null,
),
);
@ -140,8 +138,6 @@ class PangeaToken {
lemma: morph.key,
type: ConstructTypeEnum.morph,
),
xp: 0,
lastUsed: null,
),
);
}

@ -1,5 +1,6 @@
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/enum/activity_type_enum.dart';
import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
@ -7,11 +8,13 @@ class ConstructWithXP {
final ConstructIdentifier id;
int xp;
DateTime? lastUsed;
List<ConstructUseTypeEnum> condensedConstructUses;
ConstructWithXP({
required this.id,
required this.xp,
required this.lastUsed,
this.xp = 0,
this.lastUsed,
this.condensedConstructUses = const [],
});
factory ConstructWithXP.fromJson(Map<String, dynamic> json) {
@ -23,6 +26,9 @@ class ConstructWithXP {
lastUsed: json['last_used'] != null
? DateTime.parse(json['last_used'] as String)
: null,
condensedConstructUses: (json['uses'] as List<String>).map((e) {
return ConstructUseTypeUtil.fromString(e);
}).toList(),
);
}
@ -31,6 +37,7 @@ class ConstructWithXP {
'construct_id': id.toJson(),
'xp': xp,
'last_used': lastUsed?.toIso8601String(),
'uses': condensedConstructUses.map((e) => e.string).toList(),
};
return json;
}
@ -230,6 +237,11 @@ class MessageActivityRequest {
'existing_activities': existingActivities.map((e) => e.toJson()).toList(),
'activity_quality_feedback': activityQualityFeedback?.toJson(),
'iso_8601_time_of_req': DateTime.now().toIso8601String(),
// this is a list of activity types that the client can handle
// the server will only return activities of these types
// this for backwards compatibility with old clients
'client_version_compatible_activity_types':
ActivityTypeEnum.values.map((e) => e.string).toList(),
};
}

@ -5,13 +5,13 @@ import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activ
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class MultipleChoice {
class ActivityContent {
final String question;
final List<String> choices;
final String answer;
final RelevantSpanDisplayDetails? spanDisplayDetails;
MultipleChoice({
ActivityContent({
required this.question,
required this.choices,
required this.answer,
@ -37,12 +37,12 @@ class MultipleChoice {
Color choiceColor(int index) =>
index == correctAnswerIndex ? AppConfig.success : AppConfig.warning;
factory MultipleChoice.fromJson(Map<String, dynamic> json) {
factory ActivityContent.fromJson(Map<String, dynamic> json) {
final spanDisplay = json['span_display_details'] != null &&
json['span_display_details'] is Map
? RelevantSpanDisplayDetails.fromJson(json['span_display_details'])
: null;
return MultipleChoice(
return ActivityContent(
question: json['question'] as String,
choices: (json['choices'] as List).map((e) => e as String).toList(),
answer: json['answer'] ?? json['correct_answer'] as String,

@ -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;
@ -165,110 +166,37 @@ class PracticeActivityRequest {
}
}
class FreeResponse {
final String question;
final String correctAnswer;
final String gradingGuide;
FreeResponse({
required this.question,
required this.correctAnswer,
required this.gradingGuide,
});
factory FreeResponse.fromJson(Map<String, dynamic> json) {
return FreeResponse(
question: json['question'] as String,
correctAnswer: json['correct_answer'] as String,
gradingGuide: json['grading_guide'] as String,
);
}
Map<String, dynamic> toJson() {
return {
'question': question,
'correct_answer': correctAnswer,
'grading_guide': gradingGuide,
};
}
}
class Listening {
final String audioUrl;
final String text;
Listening({required this.audioUrl, required this.text});
factory Listening.fromJson(Map<String, dynamic> json) {
return Listening(
audioUrl: json['audio_url'] as String,
text: json['text'] as String,
);
}
Map<String, dynamic> toJson() {
return {
'audio_url': audioUrl,
'text': text,
};
}
}
class Speaking {
final String text;
Speaking({required this.text});
factory Speaking.fromJson(Map<String, dynamic> json) {
return Speaking(
text: json['text'] as String,
);
}
Map<String, dynamic> toJson() {
return {
'text': text,
};
}
}
class PracticeActivityModel {
final List<ConstructIdentifier> tgtConstructs;
final String langCode;
final String msgId;
final ActivityTypeEnum activityType;
final MultipleChoice? multipleChoice;
final Listening? listening;
final Speaking? speaking;
final FreeResponse? freeResponse;
final ActivityContent content;
PracticeActivityModel({
required this.tgtConstructs,
required this.langCode,
required this.msgId,
required this.activityType,
this.multipleChoice,
this.listening,
this.speaking,
this.freeResponse,
required this.content,
});
String get question {
switch (activityType) {
case ActivityTypeEnum.multipleChoice:
return multipleChoice!.question;
case ActivityTypeEnum.listening:
return listening!.text;
case ActivityTypeEnum.speaking:
return speaking!.text;
case ActivityTypeEnum.freeResponse:
return freeResponse!.question;
default:
return '';
}
}
String get question => content.question;
factory PracticeActivityModel.fromJson(Map<String, dynamic> json) {
// 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>? 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'])
as List)
@ -283,27 +211,12 @@ class PracticeActivityModel {
e.string == json['activity_type'] as String ||
e.string.split('.').last == json['activity_type'] as String,
),
multipleChoice: json['multiple_choice'] != null
? MultipleChoice.fromJson(
json['multiple_choice'] as Map<String, dynamic>,
)
: null,
listening: json['listening'] != null
? Listening.fromJson(json['listening'] as Map<String, dynamic>)
: null,
speaking: json['speaking'] != null
? Speaking.fromJson(json['speaking'] as Map<String, dynamic>)
: null,
freeResponse: json['free_response'] != null
? FreeResponse.fromJson(
json['free_response'] as Map<String, dynamic>,
)
: null,
content: ActivityContent.fromJson(contentMap),
);
}
RelevantSpanDisplayDetails? get relevantSpanDisplayDetails =>
multipleChoice?.spanDisplayDetails;
content.spanDisplayDetails;
Map<String, dynamic> toJson() {
return {
@ -311,10 +224,7 @@ class PracticeActivityModel {
'lang_code': langCode,
'msg_id': msgId,
'activity_type': activityType.string,
'multiple_choice': multipleChoice?.toJson(),
'listening': listening?.toJson(),
'speaking': speaking?.toJson(),
'free_response': freeResponse?.toJson(),
'content': content.toJson(),
};
}
@ -328,10 +238,7 @@ class PracticeActivityModel {
other.langCode == langCode &&
other.msgId == msgId &&
other.activityType == activityType &&
other.multipleChoice == multipleChoice &&
other.listening == listening &&
other.speaking == speaking &&
other.freeResponse == freeResponse;
other.content == content;
}
@override
@ -340,10 +247,7 @@ class PracticeActivityModel {
langCode.hashCode ^
msgId.hashCode ^
activityType.hashCode ^
multipleChoice.hashCode ^
listening.hashCode ^
speaking.hashCode ^
freeResponse.hashCode;
content.hashCode;
}
}
@ -372,7 +276,7 @@ class RelevantSpanDisplayDetails {
return RelevantSpanDisplayDetails(
offset: json['offset'] as int,
length: json['length'] as int,
displayInstructions: display ?? ActivityDisplayInstructionsEnum.hide,
displayInstructions: display ?? ActivityDisplayInstructionsEnum.nothing,
);
}
@ -384,7 +288,6 @@ class RelevantSpanDisplayDetails {
};
}
// override operator == and hashCode
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;

@ -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],
);
}

@ -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,

@ -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(

@ -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,

@ -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,
),
),
);

@ -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(() {

@ -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();

@ -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,
),
);
}
}

@ -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;

@ -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;

@ -5,7 +5,6 @@ import 'package:fluffychat/pangea/config/environment.dart';
import 'package:fluffychat/pangea/models/language_model.dart';
import 'package:fluffychat/pangea/network/urls.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart';
@ -15,7 +14,6 @@ class LanguageRepo {
static Future<List<LanguageModel>> fetchLanguages() async {
final Requests req = Requests(
choreoApiKey: Environment.choreoApiKey,
accessToken: MatrixState.pangeaController.userController.accessToken,
);
final Response res = await req.get(url: PApiUrls.getLanguages);

@ -116,7 +116,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'],
),
@ -146,9 +148,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",

@ -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],
);
}

@ -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),
),
],
),
);

@ -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,

@ -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,

@ -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,

@ -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;

@ -36,15 +36,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

@ -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,
);
}

@ -1,10 +1,18 @@
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';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
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';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix.dart';
@ -12,11 +20,17 @@ import 'package:matrix/matrix.dart';
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,
});
@override
@ -25,9 +39,104 @@ class MessageAudioCard extends StatefulWidget {
class MessageAudioCardState extends State<MessageAudioCard> {
bool _isLoading = false;
Event? localAudioEvent;
PangeaAudioFile? audioFile;
int? sectionStartMS;
int? sectionEndMS;
@override
void initState() {
super.initState();
fetchAudio();
// initializeTTS();
}
// initializeTTS() async {
// tts.setupTTS().then((value) => setState(() {}));
// }
@override
void didUpdateWidget(covariant oldWidget) {
if (oldWidget.selection != widget.selection && widget.selection != null) {
debugPrint('selection changed');
setSectionStartAndEndFromSelection();
playSelectionAudio();
}
super.didUpdateWidget(oldWidget);
}
Future<void> playSelectionAudio() async {
if (widget.selection == null) return;
final PangeaTokenText selection = widget.selection!;
final tokenText = selection.content;
await widget.tts.speak(tokenText);
}
void setSectionStartAndEnd(int? start, int? end) => mounted
? setState(() {
sectionStartMS = start;
sectionEndMS = end;
})
: null;
void setSectionStartAndEndFromSelection() async {
if (audioFile == null) {
// should never happen but just in case
debugger(when: kDebugMode);
return;
}
if (audioFile!.duration == null) {
// should never happen but just in case
debugger(when: kDebugMode);
ErrorHandler.logError(
e: 'audioFile duration is null in MessageAudioCardState',
data: {
'audioFile': audioFile,
},
);
return setSectionStartAndEnd(null, null);
}
// if there is no selection, we don't need to do anything
// but clear the section start and end
if (widget.selection == null) {
return setSectionStartAndEnd(null, null);
}
final PangeaTokenText selection = widget.selection!;
final List<TTSToken> tokens = audioFile!.tokens;
// find the token that corresponds to the selection
// set the start to the start of the token
// set the end to the start of the next token or to the duration of the audio if
// if there is no next token
for (int i = 0; i < tokens.length; i++) {
final TTSToken ttsToken = tokens[i];
if (ttsToken.text.offset == selection.offset) {
return setSectionStartAndEnd(
max(ttsToken.startMS - 150, 0),
min(ttsToken.endMS + 150, audioFile!.duration!),
);
}
}
// if we didn't find the token, we should pause if debug and log an error
debugger(when: kDebugMode);
ErrorHandler.logError(
e: 'could not find token for selection in MessageAudioCardState',
data: {
'selection': selection,
'tokens': tokens,
'sttTokens': audioFile!.tokens,
},
);
setSectionStartAndEnd(null, null);
}
Future<void> fetchAudio() async {
if (!mounted) return;
setState(() => _isLoading = true);
@ -36,20 +145,27 @@ class MessageAudioCardState extends State<MessageAudioCard> {
final String langCode = widget.messageEvent.messageDisplayLangCode;
final String? text =
widget.messageEvent.representationByLanguage(langCode)?.text;
if (text != null) {
final Event? localEvent =
widget.messageEvent.getTextToSpeechLocal(langCode, text);
if (localEvent != null) {
localAudioEvent = localEvent;
if (mounted) setState(() => _isLoading = false);
return;
}
if (text == null) {
//TODO - handle error but get out of flow
}
audioFile =
await widget.messageEvent.getMatrixAudioFile(langCode, context);
final Event? localEvent =
widget.messageEvent.getTextToSpeechLocal(langCode, text!);
if (localEvent != null) {
audioFile = await localEvent.getPangeaAudioFile();
} else {
audioFile = await widget.messageEvent.getMatrixAudioFile(
langCode,
context,
);
}
debugPrint("audio file is now: $audioFile. setting starts and ends...");
setSectionStartAndEndFromSelection();
if (mounted) setState(() => _isLoading = false);
} catch (e, s) {
debugger(when: kDebugMode);
debugPrint(StackTrace.current.toString());
if (!mounted) return;
setState(() => _isLoading = false);
@ -59,7 +175,7 @@ class MessageAudioCardState extends State<MessageAudioCard> {
),
);
ErrorHandler.logError(
e: Exception(),
e: e,
s: s,
m: 'something wrong getting audio in MessageAudioCardState',
data: {
@ -68,47 +184,46 @@ class MessageAudioCardState extends State<MessageAudioCard> {
},
);
}
return;
}
@override
void initState() {
super.initState();
//once we have audio for words, we'll play that
if (widget.overlayController.isSelection) {
widget.overlayController.clearSelection();
}
fetchAudio();
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(8),
constraints: const BoxConstraints(minHeight: minCardHeight),
alignment: Alignment.center,
child: _isLoading
? const ToolbarContentLoadingIndicator()
: localAudioEvent != null || audioFile != null
? Column(
children: [
AudioPlayerWidget(
localAudioEvent,
color: Theme.of(context).colorScheme.onPrimaryContainer,
matrixFile: audioFile,
autoplay: true,
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,
),
],
)
: const CardErrorWidget(),
),
],
);
}
}
class PangeaAudioFile extends MatrixAudioFile {
List<int>? waveform;
List<TTSToken> tokens;
PangeaAudioFile({
required super.bytes,
@ -116,5 +231,6 @@ class PangeaAudioFile extends MatrixAudioFile {
super.mimeType,
super.duration,
this.waveform,
required this.tokens,
});
}

@ -6,15 +6,18 @@ import 'package:fluffychat/config/setting_keys.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pages/chat/events/message_reactions.dart';
import 'package:fluffychat/pangea/enum/activity_display_instructions_enum.dart';
import 'package:fluffychat/pangea/enum/message_mode_enum.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/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';
@ -59,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();
@ -96,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
@ -103,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,
);
}
});
}
@ -142,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;
@ -182,8 +210,11 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
void onClickOverlayMessageToken(
PangeaToken token,
) {
if ([MessageMode.practiceActivity, MessageMode.textToSpeech]
.contains(toolbarMode)) {
if ([
MessageMode.practiceActivity,
// MessageMode.textToSpeech
].contains(toolbarMode) ||
isPlayingAudio) {
return;
}
@ -210,19 +241,23 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
void setSelectedSpan(PracticeActivityModel activity) {
final RelevantSpanDisplayDetails? span =
activity.multipleChoice?.spanDisplayDetails;
activity.content.spanDisplayDetails;
if (span == null) {
debugger(when: kDebugMode);
return;
}
_selectedSpan = PangeaTokenText(
offset: span.offset,
length: span.length,
content: widget._pangeaMessageEvent.messageDisplayText
.substring(span.offset, span.offset + span.length),
);
if (span.displayInstructions != ActivityDisplayInstructionsEnum.nothing) {
_selectedSpan = PangeaTokenText(
offset: span.offset,
length: span.length,
content: widget._pangeaMessageEvent.messageDisplayText
.substring(span.offset, span.offset + span.length),
);
} else {
_selectedSpan = null;
}
setState(() {});
}
@ -250,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;
@ -288,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);
@ -303,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 -
@ -338,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) ??
@ -371,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;
// 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 double messageMargin = Avatar.defaultSize + 16 + 8;
final horizontalPadding = FluffyThemes.isColumnMode(context) ? 8.0 : 0.0;
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(
@ -403,6 +484,7 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
MessageToolbar(
pangeaMessageEvent: widget._pangeaMessageEvent,
overLayController: this,
tts: tts,
),
SizedBox(
height: adjustedMessageHeight,
@ -439,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;
final positionedOverlayMessage = _overlayPositionAnimation == null
? Positioned(
left: leftPadding,
right: rightPadding,
bottom: screenHeight -
messageOffset!.dy -
messageSize!.height -
belowMessageHeight,
child: overlayMessage,
)
: null;
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) {

@ -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

@ -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(
@ -49,6 +65,9 @@ class MessageToolbar extends StatelessWidget {
return MessageAudioCard(
messageEvent: pangeaMessageEvent,
overlayController: overLayController,
selection: overLayController.selectedSpan,
tts: tts,
setIsPlayingAudio: overLayController.setIsPlayingAudio,
);
case MessageMode.speechToText:
return MessageSpeechToTextCard(
@ -56,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;
@ -86,6 +107,7 @@ class MessageToolbar extends StatelessWidget {
return PracticeActivityCard(
pangeaMessageEvent: pangeaMessageEvent,
overlayController: overLayController,
tts: tts,
);
default:
debugger(when: kDebugMode);
@ -100,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),
),
),
);
}

@ -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;
@ -70,34 +70,32 @@ class ToolbarButtons extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: modes
.mapIndexed(
(index, mode) => Tooltip(
message: mode.tooltip(context),
child: IconButton(
iconSize: 20,
icon: Icon(mode.icon),
color: mode == overlayController.toolbarMode
? Colors.white
: null,
isSelected: mode == overlayController.toolbarMode,
style: ButtonStyle(
backgroundColor: WidgetStateProperty.all(
mode.iconButtonColor(
context,
index,
overlayController.toolbarMode,
pangeaMessageEvent.numberOfActivitiesCompleted,
overlayController.isPracticeComplete,
),
(index, mode) => IconButton(
iconSize: 20,
icon: Icon(mode.icon),
tooltip: mode.tooltip(context),
color: mode == overlayController.toolbarMode
? Colors.white
: null,
isSelected: mode == overlayController.toolbarMode,
style: ButtonStyle(
backgroundColor: WidgetStateProperty.all(
mode.iconButtonColor(
context,
index,
overlayController.toolbarMode,
pangeaMessageEvent.numberOfActivitiesCompleted,
overlayController.isPracticeComplete,
),
),
onPressed: mode.isUnlocked(
index,
pangeaMessageEvent.numberOfActivitiesCompleted,
overlayController.isPracticeComplete,
)
? () => overlayController.updateToolbarMode(mode)
: null,
),
onPressed: mode.isUnlocked(
index,
pangeaMessageEvent.numberOfActivitiesCompleted,
overlayController.isPracticeComplete,
)
? () => overlayController.updateToolbarMode(mode)
: null,
),
)
.toList(),

@ -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)
],
),
),
],
),
);
}
}

@ -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(

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

@ -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,

@ -0,0 +1,122 @@
import 'dart:developer';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/widgets/chat/missing_voice_button.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_tts/flutter_tts.dart' as flutter_tts;
class TtsController {
String? targetLanguage;
List<String> availableLangCodes = [];
final flutter_tts.FlutterTts tts = flutter_tts.FlutterTts();
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;
debugger(when: kDebugMode && targetLanguage == null);
tts.setLanguage(
targetLanguage ?? "en",
);
await tts.awaitSpeakCompletion(true);
final voices = await tts.getVoices;
availableLangCodes = (voices as List)
.map((v) {
// on iOS / web, the codes are in 'locale', but on Android, they are in 'name'
final nameCode = v['name']?.split("-").first;
final localeCode = v['locale']?.split("-").first;
return nameCode.length == 2 ? nameCode : localeCode;
})
.toSet()
.cast<String>()
.toList();
debugPrint("availableLangCodes: $availableLangCodes");
debugger(when: kDebugMode && !isLanguageFullySupported);
} catch (e, s) {
debugger(when: kDebugMode);
ErrorHandler.logError(e: e, s: s);
}
}
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);
}
}
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 =>
availableLangCodes.contains(targetLanguage);
Widget get missingVoiceButton => targetLanguage != null &&
(kIsWeb || isLanguageFullySupported || !PlatformInfos.isAndroid)
? const SizedBox.shrink()
: MissingVoiceButton(
targetLangCode: targetLanguage!,
);
}

@ -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),

@ -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;
}

@ -1,268 +0,0 @@
import 'package:collection/collection.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/utils/localized_exception_extension.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';
import '../../../widgets/matrix.dart';
import '../../utils/firebase_analytics.dart';
//PTODO - auto invite students when you add a space and delete the add_class_and_invite.dart file
class AddToSpaceToggles extends StatefulWidget {
final String? roomId;
final bool startOpen;
final String? activeSpaceId;
final bool spaceMode;
const AddToSpaceToggles({
super.key,
this.roomId,
this.startOpen = false,
this.activeSpaceId,
this.spaceMode = false,
});
@override
AddToSpaceState createState() => AddToSpaceState();
}
class AddToSpaceState extends State<AddToSpaceToggles> {
late Room? room;
late Room? parent;
late List<Room> possibleParents;
late bool isOpen;
late bool isSuggested;
AddToSpaceState({Key? key});
@override
void initState() {
initialize();
super.initState();
}
@override
void didUpdateWidget(AddToSpaceToggles oldWidget) {
if (oldWidget.roomId != widget.roomId) {
initialize();
}
super.didUpdateWidget(oldWidget);
}
void initialize() {
//if roomId is null, it means this widget is being used in the creation flow
room = widget.roomId != null
? Matrix.of(context).client.getRoomById(widget.roomId!)
: null;
isSuggested = true;
room?.isSuggested().then((value) => isSuggested = value);
possibleParents = Matrix.of(context)
.client
.rooms
.where(
(Room r) => r.isSpace && widget.roomId != r.id,
)
.toList();
parent = widget.roomId != null
? possibleParents.firstWhereOrNull(
(r) => r.spaceChildren.any((room) => room.roomId == widget.roomId),
)
: null;
//sort possibleParents
//if possibleParent in parents, put first
//use sort but use any instead of contains because contains uses == and we want to compare by id
possibleParents.sort((a, b) {
if (parent?.id == a.id) {
return -1;
} else if (parent?.id == b.id) {
return 1;
} else {
return a.name.compareTo(b.name);
}
});
isOpen = widget.startOpen;
if (widget.activeSpaceId != null) {
final activeSpace =
Matrix.of(context).client.getRoomById(widget.activeSpaceId!);
if (activeSpace == null) {
ErrorHandler.logError(
e: Exception('activeSpaceId ${widget.activeSpaceId} not found'),
);
return;
}
if (activeSpace.canSendEvent(EventTypes.SpaceChild)) {
parent = activeSpace;
}
}
}
Future<void> _addSingleSpace(String roomToAddId, Room newParent) async {
GoogleAnalytics.addParent(roomToAddId, newParent.classCode);
await newParent.pangeaSetSpaceChild(
roomToAddId,
suggested: isSuggested,
);
}
Future<void> addSpaces(String roomToAddId) async {
if (parent == null) return;
await _addSingleSpace(roomToAddId, parent!);
}
Future<void> handleAdd(bool add, Room possibleParent) async {
//in this case, the room has already been made so we handle adding as it happens
if (room != null) {
await showFutureLoadingDialog(
context: context,
future: () => add
? _addSingleSpace(room!.id, possibleParent)
: possibleParent.removeSpaceChild(room!.id),
onError: (e) {
// if error occurs, do not change value of toggle
add = !add;
return (e as Object?)?.toLocalizedString(context) ??
e?.toString() ??
L10n.of(context)!.oopsSomethingWentWrong;
},
);
}
setState(
() => add ? parent = possibleParent : parent = null,
);
}
Widget getAddToSpaceToggleItem(int index) {
final Room possibleParent = possibleParents[index];
final bool canAdd = possibleParent.canAddAsParentOf(
room,
spaceMode: widget.spaceMode,
);
return Opacity(
opacity: canAdd ? 1 : 0.5,
child: Column(
children: [
SwitchListTile.adaptive(
title: possibleParent.nameAndRoomTypeIcon(),
activeColor: AppConfig.activeToggleColor,
value: parent?.id == possibleParent.id,
onChanged: (bool add) => canAdd
? handleAdd(add, possibleParent)
: ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.noPermission),
),
),
),
Divider(
height: 0.5,
color: Theme.of(context).colorScheme.secondary.withAlpha(25),
),
],
),
);
}
Future<void> setSuggested(bool suggested) async {
setState(() => isSuggested = suggested);
if (room != null) {
await showFutureLoadingDialog(
context: context,
future: () async => await room?.setSuggested(suggested),
);
}
}
@override
Widget build(BuildContext context) {
return Column(
children: [
ListTile(
title: Text(
L10n.of(context)!.addToSpace,
style: TextStyle(
color: Theme.of(context).colorScheme.secondary,
fontWeight: FontWeight.bold,
),
),
subtitle: Text(
widget.spaceMode || (room?.isSpace ?? false)
? L10n.of(context)!.addSpaceToSpaceDesc
: L10n.of(context)!.addChatToSpaceDesc,
),
leading: CircleAvatar(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
foregroundColor: Theme.of(context).textTheme.bodyLarge!.color,
child: const Icon(Icons.workspaces_outlined),
),
trailing: Icon(
isOpen
? Icons.keyboard_arrow_down_outlined
: Icons.keyboard_arrow_right_outlined,
),
onTap: () {
setState(() => isOpen = !isOpen);
},
),
if (isOpen) ...[
const Divider(height: 1),
possibleParents.isNotEmpty
? Column(
children: [
SwitchListTile.adaptive(
title: Text(
widget.spaceMode || (room?.isSpace ?? false)
? L10n.of(context)!.suggestToSpace
: L10n.of(context)!.suggestToChat,
),
secondary: Icon(
isSuggested
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
subtitle: Text(
widget.spaceMode || (room?.isSpace ?? false)
? L10n.of(context)!.suggestToSpaceDesc
: L10n.of(context)!.suggestToChatDesc,
),
activeColor: AppConfig.activeToggleColor,
value: isSuggested,
onChanged: (bool add) => setSuggested(add),
),
Divider(
height: 0.5,
color:
Theme.of(context).colorScheme.secondary.withAlpha(25),
),
...possibleParents.mapIndexed(
(index, _) => getAddToSpaceToggleItem(index),
),
],
)
: Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
L10n.of(context)!.inNoSpaces,
style: TextStyle(
color: Theme.of(context).colorScheme.secondary,
),
),
),
),
],
],
);
}
}

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

@ -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],
),
),
);
}
}

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

Loading…
Cancel
Save