diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2eb411a23..68c90c59e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -158,4 +158,10 @@ android:name="flutterEmbedding" android:value="2" /> + + + + + + diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 83de9e423..b5310c8e3 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -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" } \ No newline at end of file diff --git a/assets/l10n/intl_es.arb b/assets/l10n/intl_es.arb index dd8312d8f..fd478e870 100644 --- a/assets/l10n/intl_es.arb +++ b/assets/l10n/intl_es.arb @@ -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." } \ No newline at end of file diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart index 5a0706fe8..ab05ffd99 100644 --- a/lib/config/app_config.dart +++ b/lib/config/app_config.dart @@ -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); diff --git a/lib/config/routes.dart b/lib/config/routes.dart index 0ee135e7d..86a0ca75e 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -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( diff --git a/lib/main.dart b/lib/main.dart index 6be6edc91..9f5e656bd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 startGui(List 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)); } diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index a51a86b37..db5041623 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -485,6 +485,15 @@ class ChatController extends State Future? 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 //#Pangea choreographer.stateListener.close(); choreographer.dispose(); + clearSelectedEvents(); MatrixState.pAnyState.closeOverlay(); //Pangea# super.dispose(); @@ -1334,13 +1344,18 @@ class ChatController extends State } // 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 void onSelectMessage(Event event) { // #Pangea - if (choreographer.itController.isOpen) { + if (choreographer.itController.willOpen) { return; } // Pangea# diff --git a/lib/pages/chat/chat_input_row.dart b/lib/pages/chat/chat_input_row.dart index cc931ef12..57360d25d 100644 --- a/lib/pages/chat/chat_input_row.dart +++ b/lib/pages/chat/chat_input_row.dart @@ -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, diff --git a/lib/pages/chat/events/audio_player.dart b/lib/pages/chat/events/audio_player.dart index 3213d085f..c79fc9cb3 100644 --- a/lib/pages/chat/events/audio_player.dart +++ b/lib/pages/chat/events/audio_player.dart @@ -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 { 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 _downloadAction() async { // #Pangea // if (status != AudioPlayerStatus.notDownloaded) return; @@ -160,7 +188,16 @@ class AudioPlayerState extends State { 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 { 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 { } // 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 { 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 { // #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, diff --git a/lib/pages/chat/events/message.dart b/lib/pages/chat/events/message.dart index a5e60687d..ba8e35ae2 100644 --- a/lib/pages/chat/events/message.dart +++ b/lib/pages/chat/events/message.dart @@ -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( diff --git a/lib/pages/chat/events/message_content.dart b/lib/pages/chat/events/message_content.dart index 2cddb6f67..4e21f00ac 100644 --- a/lib/pages/chat/events/message_content.dart +++ b/lib/pages/chat/events/message_content.dart @@ -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!, diff --git a/lib/pages/chat/input_bar.dart b/lib/pages/chat/input_bar.dart index c0939f278..347d68786 100644 --- a/lib/pages/chat/input_bar.dart +++ b/lib/pages/chat/input_bar.dart @@ -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, diff --git a/lib/pages/chat_details/chat_details.dart b/lib/pages/chat_details/chat_details.dart index 050a1b272..a94430da1 100644 --- a/lib/pages/chat_details/chat_details.dart +++ b/lib/pages/chat_details/chat_details.dart @@ -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 { String? get roomId => widget.roomId; // #Pangea - final GlobalKey addToSpaceKey = GlobalKey(); - final GlobalKey - addConversationBotKey = + final GlobalKey addConversationBotKey = GlobalKey(); bool displayAddStudentOptions = false; diff --git a/lib/pages/chat_details/chat_details_view.dart b/lib/pages/chat_details/chat_details_view.dart index edf277e86..72441e450 100644 --- a/lib/pages/chat_details/chat_details_view.dart +++ b/lib/pages/chat_details/chat_details_view.dart @@ -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, diff --git a/lib/pages/chat_list/chat_list_header.dart b/lib/pages/chat_list/chat_list_header.dart index 4c018355c..1f7a1fbe9 100644 --- a/lib/pages/chat_list/chat_list_header.dart +++ b/lib/pages/chat_list/chat_list_header.dart @@ -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, ); } diff --git a/lib/pages/chat_list/client_chooser_button.dart b/lib/pages/chat_list/client_chooser_button.dart index 0a406fa2c..0ac5ff0a8 100644 --- a/lib/pages/chat_list/client_chooser_button.dart +++ b/lib/pages/chat_list/client_chooser_button.dart @@ -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( - 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( - onSelected: (o) => _clientSelected(o, context), - itemBuilder: _bundleMenuItems, + // #Pangea + return matrix.client.userID == null + ? const SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator.adaptive(), + ) + : + // Pangea# + FutureBuilder( + 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( + 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? _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# } diff --git a/lib/pages/chat_list/space_view.dart b/lib/pages/chat_list/space_view.dart index a45d28f43..41bce1e9a 100644 --- a/lib/pages/chat_list/space_view.dart +++ b/lib/pages/chat_list/space_view.dart @@ -317,14 +317,14 @@ class _SpaceViewState extends State { 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 { ), ] : null, + // #Pangea + enableEncryption: false, + // Pangea# ); } await activeSpace.setSpaceChild(roomId); diff --git a/lib/pages/new_group/new_group.dart b/lib/pages/new_group/new_group.dart index 3d8301ec5..f5156d9d6 100644 --- a/lib/pages/new_group/new_group.dart +++ b/lib/pages/new_group/new_group.dart @@ -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 { bool loading = false; - // #Pangea - PangeaController pangeaController = MatrixState.pangeaController; - final GlobalKey addToSpaceKey = GlobalKey(); - final GlobalKey addConversationBotKey = - GlobalKey(); - final GlobalKey addCapacityKey = - GlobalKey(); - - ChatTopic chatTopic = ChatTopic.empty; - - void setVocab(List 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 { 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 { 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 { } } - //#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); } diff --git a/lib/pages/new_group/new_group_view.dart b/lib/pages/new_group/new_group_view.dart index 6dfcec212..7ea7f9de5 100644 --- a/lib/pages/new_group/new_group_view.dart +++ b/lib/pages/new_group/new_group_view.dart @@ -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 diff --git a/lib/pages/new_space/new_space.dart b/lib/pages/new_space/new_space.dart index 0a7b809d8..9264d0657 100644 --- a/lib/pages/new_space/new_space.dart +++ b/lib/pages/new_space/new_space.dart @@ -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 { TextEditingController nameController = TextEditingController(); TextEditingController topicController = TextEditingController(); - // #Pangea bool publicGroup = false; - // bool publicGroup = true; - // final GlobalKey rulesEditorKey = GlobalKey(); - final GlobalKey addToSpaceKey = GlobalKey(); - // commenting out language settings in spaces for now - // final GlobalKey languageSettingsKey = - // GlobalKey(); - final GlobalKey addCapacityKey = - GlobalKey(); - - //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 get initialState { - final events = []; - - events.add( + List initialState(String joinCode) { + return [ StateEvent( type: EventTypes.RoomPowerLevels, stateKey: '', @@ -84,191 +67,95 @@ class NewSpaceController extends State { }, }, ), - ); - - // 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> 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(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(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); } diff --git a/lib/pages/new_space/new_space_view.dart b/lib/pages/new_space/new_space_view.dart index 087218a48..3842231c8 100644 --- a/lib/pages/new_space/new_space_view.dart +++ b/lib/pages/new_space/new_space_view.dart @@ -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# ], ), ), diff --git a/lib/pages/settings/settings_view.dart b/lib/pages/settings/settings_view.dart index d63776272..cf89cd326 100644 --- a/lib/pages/settings/settings_view.dart +++ b/lib/pages/settings/settings_view.dart @@ -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), diff --git a/lib/pages/settings_security/settings_security.dart b/lib/pages/settings_security/settings_security.dart index 859e33e89..f065e833b 100644 --- a/lib/pages/settings_security/settings_security.dart +++ b/lib/pages/settings_security/settings_security.dart @@ -54,8 +54,9 @@ class SettingsSecurityController extends State { // #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 { ); if (resp == OkCancelResult.ok) { launchUrlString( - subscriptionController.subscription!.defaultManagementURL!, + subscriptionController.defaultManagementURL!, mode: LaunchMode.externalApplication, ); return; diff --git a/lib/pangea/choreographer/controllers/choreographer.dart b/lib/pangea/choreographer/controllers/choreographer.dart index 9053d3353..edb364f80 100644 --- a/lib/pangea/choreographer/controllers/choreographer.dart +++ b/lib/pangea/choreographer/controllers/choreographer.dart @@ -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 _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; + } } diff --git a/lib/pangea/choreographer/controllers/igc_controller.dart b/lib/pangea/choreographer/controllers/igc_controller.dart index 9eeee47b4..6d0e848aa 100644 --- a/lib/pangea/choreographer/controllers/igc_controller.dart +++ b/lib/pangea/choreographer/controllers/igc_controller.dart @@ -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))); - } } diff --git a/lib/pangea/choreographer/controllers/it_controller.dart b/lib/pangea/choreographer/controllers/it_controller.dart index 636415b8e..e30f30b3d 100644 --- a/lib/pangea/choreographer/controllers/it_controller.dart +++ b/lib/pangea/choreographer/controllers/it_controller.dart @@ -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 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; diff --git a/lib/pangea/choreographer/widgets/choice_array.dart b/lib/pangea/choreographer/widgets/choice_array.dart index ff13da78f..32395099e 100644 --- a/lib/pangea/choreographer/widgets/choice_array.dart +++ b/lib/pangea/choreographer/widgets/choice_array.dart @@ -44,17 +44,13 @@ class ChoicesArrayState extends State { 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); }); } diff --git a/lib/pangea/choreographer/widgets/has_error_button.dart b/lib/pangea/choreographer/widgets/has_error_button.dart index 007820139..77a7170d9 100644 --- a/lib/pangea/choreographer/widgets/has_error_button.dart +++ b/lib/pangea/choreographer/widgets/has_error_button.dart @@ -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, diff --git a/lib/pangea/choreographer/widgets/it_bar.dart b/lib/pangea/choreographer/widgets/it_bar.dart index 0c6a07498..529100d94 100644 --- a/lib/pangea/choreographer/widgets/it_bar.dart +++ b/lib/pangea/choreographer/widgets/it_bar.dart @@ -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), diff --git a/lib/pangea/choreographer/widgets/it_feedback_card.dart b/lib/pangea/choreographer/widgets/it_feedback_card.dart index dd72f2457..f006e2552 100644 --- a/lib/pangea/choreographer/widgets/it_feedback_card.dart +++ b/lib/pangea/choreographer/widgets/it_feedback_card.dart @@ -103,7 +103,7 @@ class ITFeedbackCardController extends State { @override Widget build(BuildContext context) => error == null ? ITFeedbackCardView(controller: this) - : CardErrorWidget(error: error); + : CardErrorWidget(error: error!); } class ITFeedbackCardView extends StatelessWidget { diff --git a/lib/pangea/choreographer/widgets/language_permissions_warning_buttons.dart b/lib/pangea/choreographer/widgets/language_permissions_warning_buttons.dart index fcf3b57be..f595e1316 100644 --- a/lib/pangea/choreographer/widgets/language_permissions_warning_buttons.dart +++ b/lib/pangea/choreographer/widgets/language_permissions_warning_buttons.dart @@ -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(), + ); + }, ), ], ), diff --git a/lib/pangea/choreographer/widgets/send_button.dart b/lib/pangea/choreographer/widgets/send_button.dart index f5e358a31..6fba75395 100644 --- a/lib/pangea/choreographer/widgets/send_button.dart +++ b/lib/pangea/choreographer/widgets/send_button.dart @@ -56,7 +56,10 @@ class ChoreographerSendButtonState extends State { 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, ), diff --git a/lib/pangea/choreographer/widgets/start_igc_button.dart b/lib/pangea/choreographer/widgets/start_igc_button.dart index 39d35a5a9..f9782e763 100644 --- a/lib/pangea/choreographer/widgets/start_igc_button.dart +++ b/lib/pangea/choreographer/widgets/start_igc_button.dart @@ -63,10 +63,13 @@ class StartIGCButtonState extends State 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) { diff --git a/lib/pangea/constants/model_keys.dart b/lib/pangea/constants/model_keys.dart index 962532c9f..253d73e55 100644 --- a/lib/pangea/constants/model_keys.dart +++ b/lib/pangea/constants/model_keys.dart @@ -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"; diff --git a/lib/pangea/controllers/base_controller.dart b/lib/pangea/controllers/base_controller.dart index 69939e50a..51a66dd00 100644 --- a/lib/pangea/controllers/base_controller.dart +++ b/lib/pangea/controllers/base_controller.dart @@ -1,18 +1,18 @@ import 'dart:async'; class BaseController { - final StreamController stateListener = StreamController(); + final StreamController _stateListener = StreamController(); late Stream stateStream; BaseController() { - stateStream = stateListener.stream.asBroadcastStream(); + stateStream = _stateListener.stream.asBroadcastStream(); } dispose() { - stateListener.close(); + _stateListener.close(); } setState(T data) { - stateListener.add(data); + _stateListener.add(data); } } diff --git a/lib/pangea/controllers/get_analytics_controller.dart b/lib/pangea/controllers/get_analytics_controller.dart index 03cb2d60b..26aa7234b 100644 --- a/lib/pangea/controllers/get_analytics_controller.dart +++ b/lib/pangea/controllers/get_analytics_controller.dart @@ -22,7 +22,7 @@ import 'package:sentry_flutter/sentry_flutter.dart'; class GetAnalyticsController { late PangeaController _pangeaController; final List _cache = []; - StreamSubscription? _analyticsUpdateSubscription; + StreamSubscription? _analyticsUpdateSubscription; CachedStreamController> analyticsStream = CachedStreamController>(); @@ -87,8 +87,9 @@ class GetAnalyticsController { prevXP = null; } - Future onAnalyticsUpdate(AnalyticsUpdateType type) async { - if (type == AnalyticsUpdateType.server) { + Future 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) { diff --git a/lib/pangea/controllers/language_detection_controller.dart b/lib/pangea/controllers/language_detection_controller.dart index bcc9b4140..51d7c973a 100644 --- a/lib/pangea/controllers/language_detection_controller.dart +++ b/lib/pangea/controllers/language_detection_controller.dart @@ -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 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 detectLanguage( - String fullText, - String? userL2, - String? userL1, - ) async { - final LanguageDetectionRequest params = LanguageDetectionRequest( - fullText: fullText, - userL1: userL1, - userL2: userL2, - ); - return get(params); - } - Future get( LanguageDetectionRequest params, ) async { diff --git a/lib/pangea/controllers/message_data_controller.dart b/lib/pangea/controllers/message_data_controller.dart index a26c558ba..d11c1ea52 100644 --- a/lib/pangea/controllers/message_data_controller.dart +++ b/lib/pangea/controllers/message_data_controller.dart @@ -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 _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> _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, ); diff --git a/lib/pangea/controllers/my_analytics_controller.dart b/lib/pangea/controllers/my_analytics_controller.dart index 77e6caf27..48dc67573 100644 --- a/lib/pangea/controllers/my_analytics_controller.dart +++ b/lib/pangea/controllers/my_analytics_controller.dart @@ -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 { late PangeaController _pangeaController; - CachedStreamController analyticsUpdateStream = - CachedStreamController(); + CachedStreamController analyticsUpdateStream = + CachedStreamController(); StreamSubscription? _analyticsStream; Timer? _updateTimer; @@ -237,11 +237,18 @@ class MyAnalyticsController extends BaseController { 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 { /// 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 sendLocalAnalyticsToAnalyticsRoom() async { + Future 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 { 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}); +} diff --git a/lib/pangea/controllers/pangea_controller.dart b/lib/pangea/controllers/pangea_controller.dart index fbb23845c..c24e8b3d2 100644 --- a/lib/pangea/controllers/pangea_controller.dart +++ b/lib/pangea/controllers/pangea_controller.dart @@ -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 botDMs = []; + final List 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}, ); } } diff --git a/lib/pangea/controllers/practice_activity_generation_controller.dart b/lib/pangea/controllers/practice_activity_generation_controller.dart index 1d3c7f7ae..410f8eeaa 100644 --- a/lib/pangea/controllers/practice_activity_generation_controller.dart +++ b/lib/pangea/controllers/practice_activity_generation_controller.dart @@ -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, - ), - ); } diff --git a/lib/pangea/controllers/practice_activity_record_controller.dart b/lib/pangea/controllers/practice_activity_record_controller.dart index 45b036611..8ee52e696 100644 --- a/lib/pangea/controllers/practice_activity_record_controller.dart +++ b/lib/pangea/controllers/practice_activity_record_controller.dart @@ -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 _cache = {}; late final PangeaController _pangeaController; - Timer? _cacheClearTimer; - PracticeActivityRecordController(this._pangeaController) { - _initializeCacheClearing(); - } + PracticeActivityRecordController(this._pangeaController); - LinkedHashMap get completedActivities { - try { - final dynamic locallySaved = _pangeaController.pStoreService.read( - PLocalKey.completedActivities, - ); - if (locallySaved == null) return LinkedHashMap(); - try { - final LinkedHashMap cache = - LinkedHashMap.from(locallySaved); - return cache; - } catch (err) { - _pangeaController.pStoreService.delete( - PLocalKey.completedActivities, - ); - return LinkedHashMap(); - } - } 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(); - } + int getCompletedActivityCount(String messageID) { + return _completedActivities[messageID] ?? 0; } - Future completeActivity(String messageID) async { - final LinkedHashMap currentCache = completedActivities; - final numCompleted = currentCache[messageID] ?? 0; - currentCache[messageID] = numCompleted + 1; + final LinkedHashMap _completedActivities = + LinkedHashMap(); + + // LinkedHashMap get _completedActivities { + // try { + // final dynamic locallySaved = _pangeaController.pStoreService.read( + // PLocalKey.completedActivities, + // ); + // if (locallySaved == null) return LinkedHashMap(); + // try { + // final LinkedHashMap cache = + // LinkedHashMap.from(locallySaved); + // return cache; + // } catch (err) { + // _pangeaController.pStoreService.delete( + // PLocalKey.completedActivities, + // ); + // return LinkedHashMap(); + // } + // } 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(); + // } + // } - 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 completeActivity(String messageID) async { + final numCompleted = _completedActivities[messageID] ?? 0; + _completedActivities[messageID] = numCompleted + 1; + // final LinkedHashMap 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. diff --git a/lib/pangea/controllers/subscription_controller.dart b/lib/pangea/controllers/subscription_controller.dart index 1f47ecb39..d9b720129 100644 --- a/lib/pangea/controllers/subscription_controller.dart +++ b/lib/pangea/controllers/subscription_controller.dart @@ -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 initialized = Completer(); @@ -68,18 +73,28 @@ class SubscriptionController extends BaseController { Future _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 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 getPaymentLink(String duration, {bool isPromo = false}) async { + Future 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 redeemPromoCode(BuildContext context) async { - final List? 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; + } } diff --git a/lib/pangea/controllers/text_to_speech_controller.dart b/lib/pangea/controllers/text_to_speech_controller.dart index 069722590..9d409c515 100644 --- a/lib/pangea/controllers/text_to_speech_controller.dart +++ b/lib/pangea/controllers/text_to_speech_controller.dart @@ -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 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.from( + (json[ModelKey.tokens] as Iterable) + .map((x) => TTSToken.fromJson(x)) + .toList(), + ), + ); + + Map toJson() => { + ModelKey.text: text, + ModelKey.langCode: langCode, + ModelKey.tokens: + List>.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 json) => TTSToken( + startMS: json["start_ms"], + endMS: json["end_ms"], + text: PangeaTokenText.fromJson(json["text"]), + ); + + Map 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 tokens; + + TextToSpeechRequest({ + required this.text, + required this.langCode, + required this.userL1, + required this.userL2, + required this.tokens, + }); Map 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 waveform; String fileExtension; + List 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.from(json["wave_form"]), fileExtension: json["file_extension"], + ttsTokens: List.from( + json["tts_tokens"].map((x) => TTSToken.fromJson(x)), + ), ); + + Map toJson() => { + "audio_content": audioContent, + "mime_type": mimeType, + "duration_millis": durationMillis, + "wave_form": List.from(waveform.map((x) => x)), + "file_extension": fileExtension, + "tts_tokens": List.from(ttsTokens.map((x) => x.toJson())), + }; + + PangeaAudioEventData toPangeaAudioEventData(String text, String langCode) { + return PangeaAudioEventData( + text: text, + langCode: langCode, + tokens: ttsTokens, + ); + } } class _TextToSpeechCacheItem { diff --git a/lib/pangea/controllers/user_controller.dart b/lib/pangea/controllers/user_controller.dart index b4c0db914..53893cb7b 100644 --- a/lib/pangea/controllers/user_controller.dart +++ b/lib/pangea/controllers/user_controller.dart @@ -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 _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)), ); } diff --git a/lib/pangea/enum/activity_display_instructions_enum.dart b/lib/pangea/enum/activity_display_instructions_enum.dart index 9a96d669c..36dc530b5 100644 --- a/lib/pangea/enum/activity_display_instructions_enum.dart +++ b/lib/pangea/enum/activity_display_instructions_enum.dart @@ -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; } diff --git a/lib/pangea/enum/activity_type_enum.dart b/lib/pangea/enum/activity_type_enum.dart index d429aa038..66bfb3e61 100644 --- a/lib/pangea/enum/activity_type_enum.dart +++ b/lib/pangea/enum/activity_type_enum.dart @@ -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'; } } } diff --git a/lib/pangea/enum/construct_use_type_enum.dart b/lib/pangea/enum/construct_use_type_enum.dart index ab953d24d..196cf89b4 100644 --- a/lib/pangea/enum/construct_use_type_enum.dart +++ b/lib/pangea/enum/construct_use_type_enum.dart @@ -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, + ); + } +} diff --git a/lib/pangea/enum/instructions_enum.dart b/lib/pangea/enum/instructions_enum.dart index e1a403526..a42a01643 100644 --- a/lib/pangea/enum/instructions_enum.dart +++ b/lib/pangea/enum/instructions_enum.dart @@ -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) { diff --git a/lib/pangea/enum/message_mode_enum.dart b/lib/pangea/enum/message_mode_enum.dart index c8659f0fc..cfc42f63b 100644 --- a/lib/pangea/enum/message_mode_enum.dart +++ b/lib/pangea/enum/message_mode_enum.dart @@ -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: diff --git a/lib/pangea/extensions/pangea_event_extension.dart b/lib/pangea/extensions/pangea_event_extension.dart index f18ee23b7..1ca280a98 100644 --- a/lib/pangea/extensions/pangea_event_extension.dart +++ b/lib/pangea/extensions/pangea_event_extension.dart @@ -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 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(ModelKey.transcription); + final audioContent = + content.tryGetMap('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('duration'); + final waveform = audioContent.tryGetList('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)) + .toList(); + + return PangeaAudioFile( + bytes: matrixFile.bytes, + name: matrixFile.name, + tokens: tokens, + mimeType: matrixFile.mimeType, + duration: duration, + waveform: waveform, + ); + } } diff --git a/lib/pangea/extensions/pangea_room_extension/room_analytics_extension.dart b/lib/pangea/extensions/pangea_room_extension/room_analytics_extension.dart index 73371b080..b0224f929 100644 --- a/lib/pangea/extensions/pangea_room_extension/room_analytics_extension.dart +++ b/lib/pangea/extensions/pangea_room_extension/room_analytics_extension.dart @@ -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, diff --git a/lib/pangea/matrix_event_wrappers/pangea_message_event.dart b/lib/pangea/matrix_event_wrappers/pangea_message_event.dart index 5e5a4a059..39d1bd314 100644 --- a/lib/pangea/matrix_event_wrappers/pangea_message_event.dart +++ b/lib/pangea/matrix_event_wrappers/pangea_message_event.dart @@ -81,18 +81,20 @@ class PangeaMessageEvent { _representations = null; } - Future getMatrixAudioFile( + Future 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 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 get _practiceActivityEvents { - return _latestEdit + final List events = _latestEdit .aggregatedEvents( timeline, PangeaEventTypes.pangeaActivity, ) - .map( - (e) => PracticeActivityEvent( + .toList(); + + final List 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 practiceActivitiesByLangCode( String langCode, { bool debug = false, - }) { - // @wcjord - disabled try catch for testing - try { - debugger(when: debug); - final List 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 get practiceActivities => diff --git a/lib/pangea/matrix_event_wrappers/pangea_representation_event.dart b/lib/pangea/matrix_event_wrappers/pangea_representation_event.dart index 172f665f4..61d298807 100644 --- a/lib/pangea/matrix_event_wrappers/pangea_representation_event.dart +++ b/lib/pangea/matrix_event_wrappers/pangea_representation_event.dart @@ -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, ), ); diff --git a/lib/pangea/matrix_event_wrappers/practice_activity_event.dart b/lib/pangea/matrix_event_wrappers/practice_activity_event.dart index f8ac678dd..3de2e2ffc 100644 --- a/lib/pangea/matrix_event_wrappers/practice_activity_event.dart +++ b/lib/pangea/matrix_event_wrappers/practice_activity_event.dart @@ -36,14 +36,8 @@ class PracticeActivityEvent { } PracticeActivityModel get practiceActivity { - try { - _content ??= event.getPangeaContent(); - return _content!; - } catch (e, s) { - final contentMap = event.content; - debugger(when: kDebugMode); - rethrow; - } + _content ??= event.getPangeaContent(); + return _content!; } /// All completion records assosiated with this activity diff --git a/lib/pangea/models/analytics/construct_list_model.dart b/lib/pangea/models/analytics/construct_list_model.dart index d73b5060a..23763824c 100644 --- a/lib/pangea/models/analytics/construct_list_model.dart +++ b/lib/pangea/models/analytics/construct_list_model.dart @@ -26,6 +26,10 @@ class ConstructListModel { /// All unique lemmas used in the construct events List get lemmas => constructList.map((e) => e.lemma).toSet().toList(); + /// All unique lemmas used in the construct events with non-zero points + List 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 get constructListWithPoints => + constructList.where((constructUse) => constructUse.points > 0).toList(); + get maxXPPerLemma { return type != null ? type!.maxXPPerLemma diff --git a/lib/pangea/models/analytics/constructs_model.dart b/lib/pangea/models/analytics/constructs_model.dart index 10a47516a..a9781f9ae 100644 --- a/lib/pangea/models/analytics/constructs_model.dart +++ b/lib/pangea/models/analytics/constructs_model.dart @@ -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 diff --git a/lib/pangea/models/base_subscription_info.dart b/lib/pangea/models/base_subscription_info.dart index 469ac5786..eda6d8a4b 100644 --- a/lib/pangea/models/base_subscription_info.dart +++ b/lib/pangea/models/base_subscription_info.dart @@ -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 availableSubscriptions = []; - String? currentSubscriptionId; - SubscriptionDetails? currentSubscription; - // Gabby - is it necessary to store appIds for each platform? - SubscriptionAppIds? appIds; - List? allProducts; - final SubscriptionPlatform platform = SubscriptionPlatform(); - List 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 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 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 setAllProducts() async { - if (allProducts != null) return; - allProducts = await SubscriptionRepo.getAllProducts(); - } + Future 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 setCustomerInfo() async {} + Future setCurrentSubscription() async {} +} + +/// Contains information about the suscriptions available on revenuecat +class AvailableSubscriptionsInfo { + List availableSubscriptions = []; + SubscriptionAppIds? appIds; + List? allProducts; - String? get defaultManagementURL { - final String? purchaseAppId = currentSubscription?.appId; - return purchaseAppId == appIds?.androidId - ? AppConfig.googlePlayMangementUrl - : purchaseAppId == appIds?.appleId - ? AppConfig.appleMangementUrl - : Environment.stripeManagementUrl; + Future 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, + // ), + // ); + // } } } diff --git a/lib/pangea/models/bot_options_model.dart b/lib/pangea/models/bot_options_model.dart index db2725edc..5bae119c3 100644 --- a/lib/pangea/models/bot_options_model.dart +++ b/lib/pangea/models/bot_options_model.dart @@ -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'); } diff --git a/lib/pangea/models/headwords.dart b/lib/pangea/models/headwords.dart index 2960d7b1d..b9cd02cd8 100644 --- a/lib/pangea/models/headwords.dart +++ b/lib/pangea/models/headwords.dart @@ -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 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 json) { - final List lists = []; - for (final entry in json.entries) { - lists.add( - VocabList( - name: entry.key, - lemmas: (entry.value as Iterable).cast().toList(), - ), - ); - } - return VocabHeadwords(lists: lists); - } - - static Future 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 words = {}; - - VocabList({ - required this.name, - required List lemmas, - }) { - for (final lemma in lemmas) { - words[lemma] = VocabTotals.newTotals; - } - } - - void addVocabUse(String lemma, List 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 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 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 json) { +// final List lists = []; +// for (final entry in json.entries) { +// lists.add( +// VocabList( +// name: entry.key, +// lemmas: (entry.value as Iterable).cast().toList(), +// ), +// ); +// } +// return VocabHeadwords(lists: lists); +// } + +// static Future 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 words = {}; + +// VocabList({ +// required this.name, +// required List lemmas, +// }) { +// for (final lemma in lemmas) { +// words[lemma] = VocabTotals.newTotals; +// } +// } + +// void addVocabUse(String lemma, List 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 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; +// } +// } +// } +// } diff --git a/lib/pangea/models/mobile_subscriptions.dart b/lib/pangea/models/mobile_subscriptions.dart index 00fc6e3fd..cf5685118 100644 --- a/lib/pangea/models/mobile_subscriptions.dart +++ b/lib/pangea/models/mobile_subscriptions.dart @@ -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 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 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 mobileSubscriptions = - offering.availablePackages - .map( - (package) { - return SubscriptionDetails( - price: package.storeProduct.price, - id: package.storeProduct.identifier, - package: package, - ); - }, - ) - .toList() - .cast(); - 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 setCustomerInfo() async { - if (allProducts == null) { - ErrorHandler.logError( - m: "Null allProducts in setCustomerInfo", - s: StackTrace.current, - ); - debugPrint( - "Null allProducts in setCustomerInfo", - ); - return; - } + Future 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 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 activeEntitlements = info.entitlements.all.entries @@ -166,14 +81,6 @@ class MobileSubscriptionInfo extends SubscriptionInfo { .map((MapEntry entry) => entry.value) .toList(); - allEntitlements = info.entitlements.all.entries - .map( - (MapEntry entry) => - entry.value.productIdentifier, - ) - .cast() - .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 getEntitlementsWithoutExpiration(CustomerInfo info) { - final List noExpirations = info.entitlements.all.entries - .where( - (MapEntry entry) => - entry.value.expirationDate == null, - ) - .map((MapEntry entry) => entry.value) - .toList(); - return noExpirations; - } } diff --git a/lib/pangea/models/pangea_token_model.dart b/lib/pangea/models/pangea_token_model.dart index e6b577c20..27361f272 100644 --- a/lib/pangea/models/pangea_token_model.dart +++ b/lib/pangea/models/pangea_token_model.dart @@ -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, ), ); } diff --git a/lib/pangea/models/practice_activities.dart/message_activity_request.dart b/lib/pangea/models/practice_activities.dart/message_activity_request.dart index 458619d20..9101a78ce 100644 --- a/lib/pangea/models/practice_activities.dart/message_activity_request.dart +++ b/lib/pangea/models/practice_activities.dart/message_activity_request.dart @@ -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 condensedConstructUses; ConstructWithXP({ required this.id, - required this.xp, - required this.lastUsed, + this.xp = 0, + this.lastUsed, + this.condensedConstructUses = const [], }); factory ConstructWithXP.fromJson(Map 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).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(), }; } diff --git a/lib/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart b/lib/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart index 28c18d7c0..9c6468db9 100644 --- a/lib/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart +++ b/lib/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart @@ -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 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 json) { + factory ActivityContent.fromJson(Map 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, diff --git a/lib/pangea/models/practice_activities.dart/practice_activity_model.dart b/lib/pangea/models/practice_activities.dart/practice_activity_model.dart index 7c02a7aae..65f02c4f7 100644 --- a/lib/pangea/models/practice_activities.dart/practice_activity_model.dart +++ b/lib/pangea/models/practice_activities.dart/practice_activity_model.dart @@ -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 json) { - return FreeResponse( - question: json['question'] as String, - correctAnswer: json['correct_answer'] as String, - gradingGuide: json['grading_guide'] as String, - ); - } - - Map 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 json) { - return Listening( - audioUrl: json['audio_url'] as String, - text: json['text'] as String, - ); - } - - Map toJson() { - return { - 'audio_url': audioUrl, - 'text': text, - }; - } -} - -class Speaking { - final String text; - - Speaking({required this.text}); - - factory Speaking.fromJson(Map json) { - return Speaking( - text: json['text'] as String, - ); - } - - Map toJson() { - return { - 'text': text, - }; - } -} - class PracticeActivityModel { final List 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 json) { + // moving from multiple_choice to content as the key + // this is to make the model more generic + // here for backward compatibility + final Map? contentMap = + (json['content'] ?? json["multiple_choice"]) as Map?; + + 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, - ) - : null, - listening: json['listening'] != null - ? Listening.fromJson(json['listening'] as Map) - : null, - speaking: json['speaking'] != null - ? Speaking.fromJson(json['speaking'] as Map) - : null, - freeResponse: json['free_response'] != null - ? FreeResponse.fromJson( - json['free_response'] as Map, - ) - : null, + content: ActivityContent.fromJson(contentMap), ); } RelevantSpanDisplayDetails? get relevantSpanDisplayDetails => - multipleChoice?.spanDisplayDetails; + content.spanDisplayDetails; Map 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; diff --git a/lib/pangea/models/token_api_models.dart b/lib/pangea/models/token_api_models.dart new file mode 100644 index 000000000..fff339ddf --- /dev/null +++ b/lib/pangea/models/token_api_models.dart @@ -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 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 tokens; + String lang; + + TokensResponseModel({required this.tokens, required this.lang}); + + factory TokensResponseModel.fromJson( + Map json, + ) => + TokensResponseModel( + tokens: (json[ModelKey.tokens] as Iterable) + .map( + (e) => PangeaToken.fromJson(e as Map), + ) + .toList() + .cast(), + lang: json[ModelKey.lang], + ); +} diff --git a/lib/pangea/models/user_model.dart b/lib/pangea/models/user_model.dart index 7ef85fbd5..1fdebef3a 100644 --- a/lib/pangea/models/user_model.dart +++ b/lib/pangea/models/user_model.dart @@ -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, diff --git a/lib/pangea/models/web_subscriptions.dart b/lib/pangea/models/web_subscriptions.dart index 1a6cc722a..0f8362843 100644 --- a/lib/pangea/models/web_subscriptions.dart +++ b/lib/pangea/models/web_subscriptions.dart @@ -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 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 setCustomerInfo() async { - if (currentSubscriptionId != null && currentSubscription != null) { - return; - } - final RCSubscriptionResponseModel currentSubscriptionInfo = - await SubscriptionRepo.getCurrentSubscriptionInfo( - pangeaController.matrixState.client.userID, - allProducts, + Future 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( diff --git a/lib/pangea/pages/class_settings/p_class_widgets/class_description_button.dart b/lib/pangea/pages/class_settings/p_class_widgets/class_description_button.dart index ff7d0068a..35d787fce 100644 --- a/lib/pangea/pages/class_settings/p_class_widgets/class_description_button.dart +++ b/lib/pangea/pages/class_settings/p_class_widgets/class_description_button.dart @@ -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, diff --git a/lib/pangea/pages/class_settings/p_class_widgets/room_capacity_button.dart b/lib/pangea/pages/class_settings/p_class_widgets/room_capacity_button.dart index 11452830c..5b22c793c 100644 --- a/lib/pangea/pages/class_settings/p_class_widgets/room_capacity_button.dart +++ b/lib/pangea/pages/class_settings/p_class_widgets/room_capacity_button.dart @@ -62,21 +62,17 @@ class RoomCapacityButtonState extends State { 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 { ), 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 { Future 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 { 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 { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - L10n.of(context)!.roomCapacityHasBeenChanged(roomType), + spaceMode + ? L10n.of(context)!.spaceCapacityHasBeenChanged + : L10n.of(context)!.chatCapacityHasBeenChanged, ), ), ); diff --git a/lib/pangea/pages/p_user_age/p_user_age.dart b/lib/pangea/pages/p_user_age/p_user_age.dart index 5fe489485..d7e36f200 100644 --- a/lib/pangea/pages/p_user_age/p_user_age.dart +++ b/lib/pangea/pages/p_user_age/p_user_age.dart @@ -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 { @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 { return profile; }); } + pangeaController.subscriptionController.reinitialize(); FluffyChatApp.router.go('/rooms'); } catch (err, s) { setState(() { diff --git a/lib/pangea/pages/settings_learning/settings_learning.dart b/lib/pangea/pages/settings_learning/settings_learning.dart index 368d0a7c3..3e0a11e4c 100644 --- a/lib/pangea/pages/settings_learning/settings_learning.dart +++ b/lib/pangea/pages/settings_learning/settings_learning.dart @@ -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(); diff --git a/lib/pangea/pages/settings_learning/settings_learning_view.dart b/lib/pangea/pages/settings_learning/settings_learning_view.dart index 835dbb774..13dd8d4d7 100644 --- a/lib/pangea/pages/settings_learning/settings_learning_view.dart +++ b/lib/pangea/pages/settings_learning/settings_learning_view.dart @@ -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, + ), + ); } } diff --git a/lib/pangea/pages/settings_subscription/settings_subscription.dart b/lib/pangea/pages/settings_subscription/settings_subscription.dart index 374abedb3..5e9b6139c 100644 --- a/lib/pangea/pages/settings_subscription/settings_subscription.dart +++ b/lib/pangea/pages/settings_subscription/settings_subscription.dart @@ -57,30 +57,33 @@ class SubscriptionManagementController extends State { } 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 { 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 { 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 { } bool isCurrentSubscription(SubscriptionDetails subscription) => - subscriptionController.subscription?.currentSubscription == + subscriptionController.currentSubscriptionInfo?.currentSubscription == subscription || isNewUserTrial && subscription.isTrial; diff --git a/lib/pangea/pages/settings_subscription/settings_subscription_view.dart b/lib/pangea/pages/settings_subscription/settings_subscription_view.dart index 9d9f21bf7..c7c89e1d2 100644 --- a/lib/pangea/pages/settings_subscription/settings_subscription_view.dart +++ b/lib/pangea/pages/settings_subscription/settings_subscription_view.dart @@ -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; diff --git a/lib/pangea/repo/language_repo.dart b/lib/pangea/repo/language_repo.dart index ab764623c..4c0589750 100644 --- a/lib/pangea/repo/language_repo.dart +++ b/lib/pangea/repo/language_repo.dart @@ -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> fetchLanguages() async { final Requests req = Requests( choreoApiKey: Environment.choreoApiKey, - accessToken: MatrixState.pangeaController.userController.accessToken, ); final Response res = await req.get(url: PApiUrls.getLanguages); diff --git a/lib/pangea/repo/subscription_repo.dart b/lib/pangea/repo/subscription_repo.dart index 7a9dc48c8..4b0174cf3 100644 --- a/lib/pangea/repo/subscription_repo.dart +++ b/lib/pangea/repo/subscription_repo.dart @@ -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 activeEntitlements = RCSubscriptionResponseModel.getActiveEntitlements(json); - final List allEntitlements = - RCSubscriptionResponseModel.getAllEntitlements(json); - if (activeEntitlements.length > 1) { debugPrint( "User has more than one active entitlement. This shouldn't happen", diff --git a/lib/pangea/repo/tokens_repo.dart b/lib/pangea/repo/tokens_repo.dart deleted file mode 100644 index de539b453..000000000 --- a/lib/pangea/repo/tokens_repo.dart +++ /dev/null @@ -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 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 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 tokens; - String lang; - - TokensResponseModel({required this.tokens, required this.lang}); - - factory TokensResponseModel.fromJson( - Map json, - ) => - TokensResponseModel( - tokens: (json[ModelKey.tokens] as Iterable) - .map( - (e) => PangeaToken.fromJson(e as Map), - ) - .toList() - .cast(), - lang: json[ModelKey.lang], - ); -} diff --git a/lib/pangea/utils/find_conversation_partner_dialog.dart b/lib/pangea/utils/find_conversation_partner_dialog.dart index 035f091d8..149e20c74 100644 --- a/lib/pangea/utils/find_conversation_partner_dialog.dart +++ b/lib/pangea/utils/find_conversation_partner_dialog.dart @@ -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), - ), ], ), ); diff --git a/lib/pangea/utils/inline_tooltip.dart b/lib/pangea/utils/inline_tooltip.dart index bef617b98..f96cc05bb 100644 --- a/lib/pangea/utils/inline_tooltip.dart +++ b/lib/pangea/utils/inline_tooltip.dart @@ -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, diff --git a/lib/pangea/utils/instructions.dart b/lib/pangea/utils/instructions.dart index 9011f14ed..681c0de08 100644 --- a/lib/pangea/utils/instructions.dart +++ b/lib/pangea/utils/instructions.dart @@ -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 { 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, diff --git a/lib/pangea/utils/logout.dart b/lib/pangea/utils/logout.dart index e2bdce074..def632828 100644 --- a/lib/pangea/utils/logout.dart +++ b/lib/pangea/utils/logout.dart @@ -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, diff --git a/lib/pangea/utils/overlay.dart b/lib/pangea/utils/overlay.dart index c15d8e945..ef7a16b80 100644 --- a/lib/pangea/utils/overlay.dart +++ b/lib/pangea/utils/overlay.dart @@ -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; diff --git a/lib/pangea/utils/subscription_app_id.dart b/lib/pangea/utils/subscription_app_id.dart index 362e0d16a..de2332ed3 100644 --- a/lib/pangea/utils/subscription_app_id.dart +++ b/lib/pangea/utils/subscription_app_id.dart @@ -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 diff --git a/lib/pangea/widgets/chat/chat_floating_action_button.dart b/lib/pangea/widgets/chat/chat_floating_action_button.dart index 35ea1c3eb..ce4128700 100644 --- a/lib/pangea/widgets/chat/chat_floating_action_button.dart +++ b/lib/pangea/widgets/chat/chat_floating_action_button.dart @@ -76,8 +76,8 @@ class ChatFloatingActionButtonState extends State { } if (widget.controller.choreographer.errorService.error != null) { return ChoreographerHasErrorButton( - widget.controller.pangeaController, widget.controller.choreographer.errorService.error!, + widget.controller.choreographer, ); } diff --git a/lib/pangea/widgets/chat/message_audio_card.dart b/lib/pangea/widgets/chat/message_audio_card.dart index b190da291..47cb41af8 100644 --- a/lib/pangea/widgets/chat/message_audio_card.dart +++ b/lib/pangea/widgets/chat/message_audio_card.dart @@ -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 { 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 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 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 fetchAudio() async { if (!mounted) return; setState(() => _isLoading = true); @@ -36,20 +145,27 @@ class MessageAudioCardState extends State { 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 { ), ); ErrorHandler.logError( - e: Exception(), + e: e, s: s, m: 'something wrong getting audio in MessageAudioCardState', data: { @@ -68,47 +184,46 @@ class MessageAudioCardState extends State { }, ); } - 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? waveform; + List tokens; PangeaAudioFile({ required super.bytes, @@ -116,5 +231,6 @@ class PangeaAudioFile extends MatrixAudioFile { super.mimeType, super.duration, this.waveform, + required this.tokens, }); } diff --git a/lib/pangea/widgets/chat/message_selection_overlay.dart b/lib/pangea/widgets/chat/message_selection_overlay.dart index 428d36e79..4c5cf86da 100644 --- a/lib/pangea/widgets/chat/message_selection_overlay.dart +++ b/lib/pangea/widgets/chat/message_selection_overlay.dart @@ -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 /// 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 ).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 /// 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 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 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 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 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 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 // 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 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 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 MessageToolbar( pangeaMessageEvent: widget._pangeaMessageEvent, overLayController: this, + tts: tts, ), SizedBox( height: adjustedMessageHeight, @@ -439,32 +521,37 @@ class MessageOverlayController extends State ), ); - 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) { diff --git a/lib/pangea/widgets/chat/message_speech_to_text_card.dart b/lib/pangea/widgets/chat/message_speech_to_text_card.dart index 012647b5a..cf61c3d49 100644 --- a/lib/pangea/widgets/chat/message_speech_to_text_card.dart +++ b/lib/pangea/widgets/chat/message_speech_to_text_card.dart @@ -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 { 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 { ), 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 diff --git a/lib/pangea/widgets/chat/message_toolbar.dart b/lib/pangea/widgets/chat/message_toolbar.dart index 006b4e98d..bc6ed54bc 100644 --- a/lib/pangea/widgets/chat/message_toolbar.dart +++ b/lib/pangea/widgets/chat/message_toolbar.dart @@ -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), + ), ), ); } diff --git a/lib/pangea/widgets/chat/message_toolbar_buttons.dart b/lib/pangea/widgets/chat/message_toolbar_buttons.dart index bd5b0802b..190a0fdff 100644 --- a/lib/pangea/widgets/chat/message_toolbar_buttons.dart +++ b/lib/pangea/widgets/chat/message_toolbar_buttons.dart @@ -22,7 +22,7 @@ class ToolbarButtons extends StatelessWidget { overlayController.pangeaMessageEvent; List 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(), diff --git a/lib/pangea/widgets/chat/message_translation_card.dart b/lib/pangea/widgets/chat/message_translation_card.dart index 5e66d9966..3c0d750a3 100644 --- a/lib/pangea/widgets/chat/message_translation_card.dart +++ b/lib/pangea/widgets/chat/message_translation_card.dart @@ -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 { 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 { instructionsEnum: InstructionsEnum.clickAgainToDeselect, onClose: () => setState(() {}), ), - // if (widget.selection != null) ], ), + ), + ], + ), ); } } diff --git a/lib/pangea/widgets/chat/message_unsubscribed_card.dart b/lib/pangea/widgets/chat/message_unsubscribed_card.dart index 5d91099b1..2e62a558e 100644 --- a/lib/pangea/widgets/chat/message_unsubscribed_card.dart +++ b/lib/pangea/widgets/chat/message_unsubscribed_card.dart @@ -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( diff --git a/lib/pangea/widgets/chat/missing_voice_button.dart b/lib/pangea/widgets/chat/missing_voice_button.dart new file mode 100644 index 000000000..1765a9d20 --- /dev/null +++ b/lib/pangea/widgets/chat/missing_voice_button.dart @@ -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), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pangea/widgets/chat/toolbar_content_loading_indicator.dart b/lib/pangea/widgets/chat/toolbar_content_loading_indicator.dart index f61496013..a497e121d 100644 --- a/lib/pangea/widgets/chat/toolbar_content_loading_indicator.dart +++ b/lib/pangea/widgets/chat/toolbar_content_loading_indicator.dart @@ -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, diff --git a/lib/pangea/widgets/chat/tts_controller.dart b/lib/pangea/widgets/chat/tts_controller.dart new file mode 100644 index 000000000..c98cb6220 --- /dev/null +++ b/lib/pangea/widgets/chat/tts_controller.dart @@ -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 availableLangCodes = []; + final flutter_tts.FlutterTts tts = flutter_tts.FlutterTts(); + + TtsController() { + setupTTS(); + } + + Future dispose() async { + await tts.stop(); + } + + onError(dynamic message) => ErrorHandler.logError( + e: message, + m: (message.toString().isNotEmpty) ? message.toString() : 'TTS error', + data: { + 'message': message, + }, + ); + + Future 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() + .toList(); + + debugPrint("availableLangCodes: $availableLangCodes"); + + debugger(when: kDebugMode && !isLanguageFullySupported); + } catch (e, s) { + debugger(when: kDebugMode); + ErrorHandler.logError(e: e, s: s); + } + } + + Future 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 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!, + ); +} diff --git a/lib/pangea/widgets/chat_list/analytics_summary/analytics_popup.dart b/lib/pangea/widgets/chat_list/analytics_summary/analytics_popup.dart index 9dcc7b06b..d7978af73 100644 --- a/lib/pangea/widgets/chat_list/analytics_summary/analytics_popup.dart +++ b/lib/pangea/widgets/chat_list/analytics_summary/analytics_popup.dart @@ -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), diff --git a/lib/pangea/widgets/chat_list/analytics_summary/learning_progress_indicators.dart b/lib/pangea/widgets/chat_list/analytics_summary/learning_progress_indicators.dart index 6695d2673..0a616c6bd 100644 --- a/lib/pangea/widgets/chat_list/analytics_summary/learning_progress_indicators.dart +++ b/lib/pangea/widgets/chat_list/analytics_summary/learning_progress_indicators.dart @@ -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; } diff --git a/lib/pangea/widgets/class/add_space_toggles.dart b/lib/pangea/widgets/class/add_space_toggles.dart deleted file mode 100644 index fd7843955..000000000 --- a/lib/pangea/widgets/class/add_space_toggles.dart +++ /dev/null @@ -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 { - late Room? room; - late Room? parent; - late List 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 _addSingleSpace(String roomToAddId, Room newParent) async { - GoogleAnalytics.addParent(roomToAddId, newParent.classCode); - await newParent.pangeaSetSpaceChild( - roomToAddId, - suggested: isSuggested, - ); - } - - Future addSpaces(String roomToAddId) async { - if (parent == null) return; - await _addSingleSpace(roomToAddId, parent!); - } - - Future 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 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, - ), - ), - ), - ), - ], - ], - ); - } -} diff --git a/lib/pangea/widgets/common/icon_number_widget.dart b/lib/pangea/widgets/common/icon_number_widget.dart index 24307112c..099baf3df 100644 --- a/lib/pangea/widgets/common/icon_number_widget.dart +++ b/lib/pangea/widgets/common/icon_number_widget.dart @@ -30,7 +30,7 @@ class IconNumberWidget extends StatelessWidget { ), onPressed: onPressed, ), - const SizedBox(width: 8), + const SizedBox(width: 5), Text( number.toString(), style: TextStyle( diff --git a/lib/pangea/widgets/common_widgets/overlay_container.dart b/lib/pangea/widgets/common_widgets/overlay_container.dart index 4fc64fb3d..eae6c935f 100644 --- a/lib/pangea/widgets/common_widgets/overlay_container.dart +++ b/lib/pangea/widgets/common_widgets/overlay_container.dart @@ -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], + ), + ), ); } } diff --git a/lib/pangea/widgets/conversation_bot/conversation_bot_custom_system_prompt_input.dart b/lib/pangea/widgets/conversation_bot/conversation_bot_custom_system_prompt_input.dart deleted file mode 100644 index 2e79e0677..000000000 --- a/lib/pangea/widgets/conversation_bot/conversation_bot_custom_system_prompt_input.dart +++ /dev/null @@ -1,95 +0,0 @@ -import 'package:fluffychat/pangea/models/bot_options_model.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; - -class ConversationBotCustomSystemPromptInput extends StatelessWidget { - final BotOptionsModel initialBotOptions; - // call this to update propagate changes to parents - final void Function(BotOptionsModel) onChanged; - - const ConversationBotCustomSystemPromptInput({ - super.key, - required this.initialBotOptions, - required this.onChanged, - }); - - @override - Widget build(BuildContext context) { - String customSystemPrompt = initialBotOptions.customSystemPrompt ?? ""; - - final TextEditingController textFieldController = - TextEditingController(text: customSystemPrompt); - - final GlobalKey customSystemPromptFormKey = - GlobalKey(); - - void setBotCustomSystemPromptAction() async { - showDialog( - context: context, - useRootNavigator: false, - builder: (BuildContext context) => AlertDialog( - title: Text( - L10n.of(context)!.conversationBotCustomZone_customSystemPromptLabel, - ), - content: Form( - key: customSystemPromptFormKey, - child: TextFormField( - minLines: 1, - maxLines: 10, - maxLength: 1000, - controller: textFieldController, - onChanged: (value) { - if (value.isNotEmpty) { - customSystemPrompt = value; - } - }, - validator: (value) { - if (value == null || value.isEmpty) { - return 'This field cannot be empty'; - } - return null; - }, - ), - ), - actions: [ - TextButton( - child: Text(L10n.of(context)!.cancel), - onPressed: () { - Navigator.of(context).pop(); - }, - ), - TextButton( - child: Text(L10n.of(context)!.ok), - onPressed: () { - if (customSystemPromptFormKey.currentState!.validate()) { - if (customSystemPrompt != - initialBotOptions.customSystemPrompt) { - initialBotOptions.customSystemPrompt = customSystemPrompt; - onChanged.call(initialBotOptions); - } - Navigator.of(context).pop(); - } - }, - ), - ], - ), - ); - } - - return ListTile( - onTap: setBotCustomSystemPromptAction, - title: Text( - initialBotOptions.customSystemPrompt ?? - L10n.of(context)! - .conversationBotCustomZone_customSystemPromptPlaceholder, - ), - subtitle: customSystemPrompt.isEmpty - ? Text( - L10n.of(context)! - .conversationBotCustomZone_customSystemPromptEmptyError, - style: const TextStyle(color: Colors.red), - ) - : null, - ); - } -} diff --git a/lib/pangea/widgets/conversation_bot/conversation_bot_custom_zone.dart b/lib/pangea/widgets/conversation_bot/conversation_bot_custom_zone.dart deleted file mode 100644 index 14b05dc90..000000000 --- a/lib/pangea/widgets/conversation_bot/conversation_bot_custom_zone.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'package:fluffychat/pangea/models/bot_options_model.dart'; -import 'package:fluffychat/pangea/widgets/conversation_bot/conversation_bot_custom_system_prompt_input.dart'; -import 'package:fluffychat/pangea/widgets/conversation_bot/conversation_bot_dynamic_zone_label.dart'; -import 'package:fluffychat/pangea/widgets/conversation_bot/conversation_bot_dynamic_zone_title.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; - -class ConversationBotCustomZone extends StatelessWidget { - final BotOptionsModel initialBotOptions; - // call this to update propagate changes to parents - final void Function(BotOptionsModel) onChanged; - - const ConversationBotCustomZone({ - super.key, - required this.initialBotOptions, - required this.onChanged, - }); - - @override - Widget build(BuildContext context) { - return Column( - children: [ - ConversationBotDynamicZoneTitle( - title: L10n.of(context)!.conversationBotCustomZone_title, - ), - ConversationBotDynamicZoneLabel( - label: L10n.of(context)! - .conversationBotCustomZone_customSystemPromptLabel, - ), - Padding( - padding: const EdgeInsets.all(8), - child: ConversationBotCustomSystemPromptInput( - initialBotOptions: initialBotOptions, - onChanged: onChanged, - ), - ), - const SizedBox(height: 12), - CheckboxListTile( - title: Text( - L10n.of(context)! - .conversationBotCustomZone_customTriggerReactionEnabledLabel, - ), - enabled: false, - value: initialBotOptions.customTriggerReactionEnabled ?? true, - onChanged: (value) { - initialBotOptions.customTriggerReactionEnabled = value ?? true; - initialBotOptions.customTriggerReactionKey = - "⏩"; // hard code this for now - onChanged.call(initialBotOptions); - }, - // make this input disabled always - ), - const SizedBox(height: 12), - ], - ); - } -} diff --git a/lib/pangea/widgets/conversation_bot/conversation_bot_discussion_keywords_input.dart b/lib/pangea/widgets/conversation_bot/conversation_bot_discussion_keywords_input.dart deleted file mode 100644 index fa08a860d..000000000 --- a/lib/pangea/widgets/conversation_bot/conversation_bot_discussion_keywords_input.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'package:fluffychat/pangea/models/bot_options_model.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; - -class ConversationBotDiscussionKeywordsInput extends StatelessWidget { - final BotOptionsModel initialBotOptions; - // call this to update propagate changes to parents - final void Function(BotOptionsModel) onChanged; - - const ConversationBotDiscussionKeywordsInput({ - super.key, - required this.initialBotOptions, - required this.onChanged, - }); - - @override - Widget build(BuildContext context) { - String discussionKeywords = initialBotOptions.discussionKeywords ?? ""; - - final TextEditingController textFieldController = - TextEditingController(text: discussionKeywords); - - void setBotDiscussionKeywordsAction() async { - showDialog( - context: context, - useRootNavigator: false, - builder: (BuildContext context) => AlertDialog( - title: Text( - L10n.of(context)! - .conversationBotDiscussionZone_discussionKeywordsLabel, - ), - content: TextField( - minLines: 1, - maxLines: 10, - maxLength: 1000, - controller: textFieldController, - onChanged: (value) { - discussionKeywords = value; - }, - ), - actions: [ - TextButton( - child: Text(L10n.of(context)!.cancel), - onPressed: () { - Navigator.of(context).pop(); - }, - ), - TextButton( - child: Text(L10n.of(context)!.ok), - onPressed: () { - if (discussionKeywords == "") return; - if (discussionKeywords != - initialBotOptions.discussionKeywords) { - initialBotOptions.discussionKeywords = discussionKeywords; - onChanged.call(initialBotOptions); - Navigator.of(context).pop(); - } - }, - ), - ], - ), - ); - } - - return ListTile( - onTap: setBotDiscussionKeywordsAction, - title: Text( - initialBotOptions.discussionKeywords ?? - L10n.of(context)! - .conversationBotDiscussionZone_discussionKeywordsPlaceholder, - ), - ); - } -} diff --git a/lib/pangea/widgets/conversation_bot/conversation_bot_discussion_topic_input.dart b/lib/pangea/widgets/conversation_bot/conversation_bot_discussion_topic_input.dart deleted file mode 100644 index c2d4eefcc..000000000 --- a/lib/pangea/widgets/conversation_bot/conversation_bot_discussion_topic_input.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:fluffychat/pangea/models/bot_options_model.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; - -class ConversationBotDiscussionTopicInput extends StatelessWidget { - final BotOptionsModel initialBotOptions; - // call this to update propagate changes to parents - final void Function(BotOptionsModel) onChanged; - - const ConversationBotDiscussionTopicInput({ - super.key, - required this.initialBotOptions, - required this.onChanged, - }); - - @override - Widget build(BuildContext context) { - String discussionTopic = initialBotOptions.discussionTopic ?? ""; - - final TextEditingController textFieldController = - TextEditingController(text: discussionTopic); - - void setBotDiscussionTopicAction() async { - showDialog( - context: context, - useRootNavigator: false, - builder: (BuildContext context) => AlertDialog( - title: Text( - L10n.of(context)! - .conversationBotDiscussionZone_discussionTopicLabel, - ), - content: TextField( - minLines: 1, - maxLines: 10, - maxLength: 1000, - controller: textFieldController, - onChanged: (value) { - discussionTopic = value; - }, - ), - actions: [ - TextButton( - child: Text(L10n.of(context)!.cancel), - onPressed: () { - Navigator.of(context).pop(); - }, - ), - TextButton( - child: Text(L10n.of(context)!.ok), - onPressed: () { - if (discussionTopic == "") return; - if (discussionTopic != initialBotOptions.discussionTopic) { - initialBotOptions.discussionTopic = discussionTopic; - onChanged.call(initialBotOptions); - Navigator.of(context).pop(); - } - }, - ), - ], - ), - ); - } - - return ListTile( - onTap: setBotDiscussionTopicAction, - title: Text( - initialBotOptions.discussionTopic ?? - L10n.of(context)! - .conversationBotDiscussionZone_discussionTopicPlaceholder, - ), - ); - } -} diff --git a/lib/pangea/widgets/conversation_bot/conversation_bot_discussion_zone.dart b/lib/pangea/widgets/conversation_bot/conversation_bot_discussion_zone.dart deleted file mode 100644 index 6035faf4d..000000000 --- a/lib/pangea/widgets/conversation_bot/conversation_bot_discussion_zone.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'package:fluffychat/pangea/models/bot_options_model.dart'; -import 'package:fluffychat/pangea/widgets/conversation_bot/conversation_bot_discussion_keywords_input.dart'; -import 'package:fluffychat/pangea/widgets/conversation_bot/conversation_bot_discussion_topic_input.dart'; -import 'package:fluffychat/pangea/widgets/conversation_bot/conversation_bot_dynamic_zone_label.dart'; -import 'package:fluffychat/pangea/widgets/conversation_bot/conversation_bot_dynamic_zone_title.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; - -class ConversationBotDiscussionZone extends StatelessWidget { - final BotOptionsModel initialBotOptions; - // call this to update propagate changes to parents - final void Function(BotOptionsModel) onChanged; - - const ConversationBotDiscussionZone({ - super.key, - required this.initialBotOptions, - required this.onChanged, - }); - - @override - Widget build(BuildContext context) { - return Column( - children: [ - ConversationBotDynamicZoneTitle( - title: L10n.of(context)!.conversationBotDiscussionZone_title, - ), - ConversationBotDynamicZoneLabel( - label: L10n.of(context)! - .conversationBotDiscussionZone_discussionTopicLabel, - ), - Padding( - padding: const EdgeInsets.all(8), - child: ConversationBotDiscussionTopicInput( - initialBotOptions: initialBotOptions, - onChanged: onChanged, - ), - ), - const SizedBox(height: 12), - ConversationBotDynamicZoneLabel( - label: L10n.of(context)! - .conversationBotDiscussionZone_discussionKeywordsLabel, - ), - Padding( - padding: const EdgeInsets.all(8), - child: ConversationBotDiscussionKeywordsInput( - initialBotOptions: initialBotOptions, - onChanged: onChanged, - ), - ), - const SizedBox(height: 12), - CheckboxListTile( - title: Text( - L10n.of(context)! - .conversationBotDiscussionZone_discussionTriggerReactionEnabledLabel, - ), - enabled: false, - value: initialBotOptions.discussionTriggerReactionEnabled ?? true, - onChanged: (value) { - initialBotOptions.discussionTriggerReactionEnabled = value ?? true; - initialBotOptions.discussionTriggerReactionKey = - "⏩"; // hard code this for now - onChanged.call(initialBotOptions); - }, - // make this input disabled always - ), - const SizedBox(height: 12), - ], - ); - } -} diff --git a/lib/pangea/widgets/conversation_bot/conversation_bot_dynamic_zone_label.dart b/lib/pangea/widgets/conversation_bot/conversation_bot_dynamic_zone_label.dart deleted file mode 100644 index 6c2043dcd..000000000 --- a/lib/pangea/widgets/conversation_bot/conversation_bot_dynamic_zone_label.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:flutter/material.dart'; - -class ConversationBotDynamicZoneLabel extends StatelessWidget { - final String label; - - const ConversationBotDynamicZoneLabel({ - super.key, - required this.label, - }); - - @override - Widget build(BuildContext context) { - return Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.fromLTRB(12, 0, 0, 0), - child: Text( - label, - style: TextStyle( - color: Theme.of(context).colorScheme.secondary, - fontWeight: FontWeight.bold, - ), - ), - ), - ); - } -} diff --git a/lib/pangea/widgets/conversation_bot/conversation_bot_dynamic_zone_title.dart b/lib/pangea/widgets/conversation_bot/conversation_bot_dynamic_zone_title.dart deleted file mode 100644 index dbfbb00dc..000000000 --- a/lib/pangea/widgets/conversation_bot/conversation_bot_dynamic_zone_title.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:flutter/material.dart'; - -class ConversationBotDynamicZoneTitle extends StatelessWidget { - final String title; - - const ConversationBotDynamicZoneTitle({ - super.key, - required this.title, - }); - - @override - Widget build(BuildContext context) { - return Column( - children: [ - const SizedBox(height: 12), - Text( - title, - style: TextStyle( - color: Theme.of(context).colorScheme.secondary, - fontWeight: FontWeight.bold, - ), - ), - const Divider( - color: Colors.grey, - thickness: 1, - ), - const SizedBox(height: 12), - ], - ); - } -} diff --git a/lib/pangea/widgets/conversation_bot/conversation_bot_mode_dynamic_zone.dart b/lib/pangea/widgets/conversation_bot/conversation_bot_mode_dynamic_zone.dart index 90d7ed789..e691cbcb5 100644 --- a/lib/pangea/widgets/conversation_bot/conversation_bot_mode_dynamic_zone.dart +++ b/lib/pangea/widgets/conversation_bot/conversation_bot_mode_dynamic_zone.dart @@ -1,44 +1,100 @@ import 'package:fluffychat/pangea/constants/bot_mode.dart'; import 'package:fluffychat/pangea/models/bot_options_model.dart'; -import 'package:fluffychat/pangea/widgets/conversation_bot/conversation_bot_custom_zone.dart'; import 'package:flutter/material.dart'; - -import 'conversation_bot_discussion_zone.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; class ConversationBotModeDynamicZone extends StatelessWidget { - final BotOptionsModel initialBotOptions; - final void Function(BotOptionsModel) onChanged; + final BotOptionsModel botOptions; + final TextEditingController discussionTopicController; + final TextEditingController discussionKeywordsController; + final TextEditingController customSystemPromptController; + + final bool enabled; const ConversationBotModeDynamicZone({ super.key, - required this.initialBotOptions, - required this.onChanged, + required this.botOptions, + required this.discussionTopicController, + required this.discussionKeywordsController, + required this.customSystemPromptController, + this.enabled = true, }); @override Widget build(BuildContext context) { - final zoneMap = { - BotMode.discussion: ConversationBotDiscussionZone( - initialBotOptions: initialBotOptions, - onChanged: onChanged, + final discussionChildren = [ + TextFormField( + onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), + decoration: InputDecoration( + hintText: L10n.of(context)! + .conversationBotDiscussionZone_discussionTopicPlaceholder, + contentPadding: + const EdgeInsets.symmetric(horizontal: 28.0, vertical: 12.0), + ), + controller: discussionTopicController, + validator: (value) => enabled && + botOptions.mode == BotMode.discussion && + (value == null || value.isEmpty) + ? L10n.of(context)!.enterDiscussionTopic + : null, + enabled: enabled, + minLines: 1, // Minimum number of lines + maxLines: null, // Allow the field to expand based on content + keyboardType: TextInputType.multiline, ), - BotMode.custom: ConversationBotCustomZone( - initialBotOptions: initialBotOptions, - onChanged: onChanged, + const SizedBox(height: 12), + TextFormField( + onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), + decoration: InputDecoration( + hintText: L10n.of(context)! + .conversationBotDiscussionZone_discussionKeywordsPlaceholder, + contentPadding: const EdgeInsets.symmetric(horizontal: 28.0), + ), + controller: discussionKeywordsController, + enabled: enabled, + minLines: 1, // Minimum number of lines + maxLines: null, // Allow the field to expand based on content + keyboardType: TextInputType.multiline, ), - }; - if (!zoneMap.containsKey(initialBotOptions.mode)) { - return Container(); - } - return Container( - decoration: BoxDecoration( - border: Border.all( - color: Theme.of(context).colorScheme.secondary, - width: 0.5, + ]; + + final customChildren = [ + TextFormField( + onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), + decoration: InputDecoration( + hintText: L10n.of(context)! + .conversationBotCustomZone_customSystemPromptPlaceholder, + contentPadding: const EdgeInsets.symmetric(horizontal: 28.0), ), - borderRadius: const BorderRadius.all(Radius.circular(10)), + validator: (value) => enabled && + botOptions.mode == BotMode.custom && + (value == null || value.isEmpty) + ? L10n.of(context)!.enterPrompt + : null, + controller: customSystemPromptController, + enabled: enabled, + minLines: 1, // Minimum number of lines + maxLines: null, // Allow the field to expand based on content + keyboardType: TextInputType.multiline, ), - child: zoneMap[initialBotOptions.mode], + ]; + + return Column( + children: [ + if (botOptions.mode == BotMode.discussion) ...discussionChildren, + if (botOptions.mode == BotMode.custom) ...customChildren, + const SizedBox(height: 12), + CheckboxListTile( + title: Text( + L10n.of(context)! + .conversationBotCustomZone_customTriggerReactionEnabledLabel, + ), + enabled: false, + value: botOptions.customTriggerReactionEnabled ?? true, + onChanged: null, + ), + const SizedBox(height: 12), + ], ); } } diff --git a/lib/pangea/widgets/conversation_bot/conversation_bot_mode_select.dart b/lib/pangea/widgets/conversation_bot/conversation_bot_mode_select.dart index 753a8a8a8..408e6560e 100644 --- a/lib/pangea/widgets/conversation_bot/conversation_bot_mode_select.dart +++ b/lib/pangea/widgets/conversation_bot/conversation_bot_mode_select.dart @@ -1,15 +1,18 @@ +import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:fluffychat/pangea/constants/bot_mode.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; class ConversationBotModeSelect extends StatelessWidget { final String? initialMode; - final void Function(String?)? onChanged; + final void Function(String?) onChanged; + final bool enabled; const ConversationBotModeSelect({ super.key, this.initialMode, - this.onChanged, + required this.onChanged, + this.enabled = true, }); @override @@ -24,56 +27,20 @@ class ConversationBotModeSelect extends StatelessWidget { // L10n.of(context)!.conversationBotModeSelectOption_storyGame, }; - return Padding( - padding: const EdgeInsets.all(12.0), - child: Container( - decoration: BoxDecoration( - border: Border.all( - color: Theme.of(context).colorScheme.secondary, - width: 0.5, - ), - borderRadius: const BorderRadius.all(Radius.circular(10)), - ), - child: DropdownButton( - // Initial Value - hint: Padding( - padding: const EdgeInsets.only(left: 15), + return DropdownButtonFormField2( + hint: Text(L10n.of(context)!.selectBotChatMode), + items: [ + for (final entry in options.entries) + DropdownMenuItem( + value: entry.key, child: Text( - options[initialMode ?? BotMode.discussion]!, - style: const TextStyle().copyWith( - color: Theme.of(context).textTheme.bodyLarge!.color, - fontSize: 14, - ), + entry.value, overflow: TextOverflow.clip, textAlign: TextAlign.center, ), ), - isExpanded: true, - underline: Container(), - // Down Arrow Icon - icon: const Icon(Icons.keyboard_arrow_down), - // Array list of items - items: [ - for (final entry in options.entries) - DropdownMenuItem( - value: entry.key, - child: Padding( - padding: const EdgeInsets.only(left: 15), - child: Text( - entry.value, - style: const TextStyle().copyWith( - color: Theme.of(context).textTheme.bodyLarge!.color, - fontSize: 14, - ), - overflow: TextOverflow.clip, - textAlign: TextAlign.center, - ), - ), - ), - ], - onChanged: onChanged, - ), - ), + ], + onChanged: enabled ? onChanged : null, ); } } diff --git a/lib/pangea/widgets/conversation_bot/conversation_bot_settings.dart b/lib/pangea/widgets/conversation_bot/conversation_bot_settings.dart index fe85b48c7..e30429214 100644 --- a/lib/pangea/widgets/conversation_bot/conversation_bot_settings.dart +++ b/lib/pangea/widgets/conversation_bot/conversation_bot_settings.dart @@ -1,5 +1,7 @@ import 'dart:developer'; +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/pangea/constants/bot_mode.dart'; import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart'; import 'package:fluffychat/pangea/models/bot_options_model.dart'; @@ -11,19 +13,14 @@ import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:future_loading_dialog/future_loading_dialog.dart'; import 'package:matrix/matrix.dart'; class ConversationBotSettings extends StatefulWidget { - final Room? room; - final bool startOpen; - final String? activeSpaceId; + final Room room; const ConversationBotSettings({ super.key, - this.room, - this.startOpen = false, - this.activeSpaceId, + required this.room, }); @override @@ -31,37 +28,10 @@ class ConversationBotSettings extends StatefulWidget { } class ConversationBotSettingsState extends State { - late BotOptionsModel botOptions; - late bool isOpen; - late bool isCreating; - bool addBot = false; - Room? parentSpace; - - ConversationBotSettingsState({Key? key}); - - @override - void initState() { - super.initState(); - isOpen = widget.startOpen; - botOptions = widget.room?.botOptions != null - ? BotOptionsModel.fromJson(widget.room?.botOptions?.toJson()) - : BotOptionsModel(); - widget.room?.botIsInRoom.then((bool isBotRoom) { - setState(() { - addBot = isBotRoom; - }); - }); - parentSpace = widget.activeSpaceId != null - ? Matrix.of(context).client.getRoomById(widget.activeSpaceId!) - : null; - isCreating = widget.room == null; - } - - Future setBotOption() async { - if (widget.room == null) return; + Future setBotOptions(BotOptionsModel botOptions) async { try { await Matrix.of(context).client.setRoomStateWithKey( - widget.room!.id, + widget.room.id, PangeaEventTypes.botOptions, '', botOptions.toJson(), @@ -72,20 +42,16 @@ class ConversationBotSettingsState extends State { } } - Future updateBotOption(void Function() makeLocalChange) async { - makeLocalChange(); - await showFutureLoadingDialog( + Future showBotOptionsDialog() async { + final BotOptionsModel? newBotOptions = await showDialog( context: context, - future: () async { - try { - await setBotOption(); - } catch (err, stack) { - debugger(when: kDebugMode); - ErrorHandler.logError(e: err, s: stack); - } - setState(() {}); - }, + builder: (BuildContext context) => + ConversationBotSettingsDialog(room: widget.room), ); + + if (newBotOptions != null) { + setBotOptions(newBotOptions); + } } @override @@ -98,17 +64,12 @@ class ConversationBotSettingsState extends State { children: [ ListTile( title: Text( - isCreating - ? L10n.of(context)!.addConversationBot - : L10n.of(context)!.botConfig, + L10n.of(context)!.botConfig, style: TextStyle( color: Theme.of(context).colorScheme.secondary, fontWeight: FontWeight.bold, ), ), - subtitle: isCreating - ? Text(L10n.of(context)!.addConversationBotDesc) - : null, leading: CircleAvatar( backgroundColor: Theme.of(context).scaffoldBackgroundColor, foregroundColor: Theme.of(context).textTheme.bodyLarge!.color, @@ -117,164 +78,195 @@ class ConversationBotSettingsState extends State { expression: BotExpression.idle, ), ), - trailing: isCreating - ? ElevatedButton( - onPressed: () async { - final bool? confirm = await showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: addBot - ? Text( - L10n.of(context)! - .addConversationBotButtonTitleRemove, - ) - : Text( - L10n.of(context)! - .addConversationBotDialogTitleInvite, - ), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(false); - }, - child: Text(L10n.of(context)!.cancel), - ), - TextButton( - onPressed: () { - Navigator.of(context).pop(!addBot); - }, - child: addBot - ? Text( - L10n.of(context)! - .addConversationBotDialogRemoveConfirmation, - ) - : Text( - L10n.of(context)! - .addConversationBotDialogInviteConfirmation, - ), - ), - ], - ); - }, - ); + trailing: const Icon(Icons.settings), + onTap: showBotOptionsDialog, + ), + ], + ), + ); + } +} - if (confirm == true) { - setState(() => addBot = true); - widget.room?.invite(BotName.byEnvironment); - } else { - setState(() => addBot = false); - widget.room?.kick(BotName.byEnvironment); - } - }, - child: addBot - ? Text( - L10n.of(context)!.addConversationBotButtonRemove, - ) - : Text( - L10n.of(context)!.addConversationBotButtonInvite, - ), - ) - : const Icon(Icons.settings), - onTap: isCreating - ? null - : () async { - final bool? confirm = await showDialog( - context: context, - builder: (BuildContext context) { - return StatefulBuilder( - builder: (context, setState) => AlertDialog( - title: Text( - L10n.of(context)!.botConfig, - ), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: - const EdgeInsets.fromLTRB(0, 0, 0, 12), - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text( - L10n.of(context)!.conversationBotStatus, - ), - Switch( - value: addBot, - onChanged: (value) { - setState( - () => addBot = value, - ); - }, - ), - ], - ), - ), - if (addBot) - Flexible( - child: SingleChildScrollView( - child: Container( - decoration: BoxDecoration( - border: Border.all( - color: Theme.of(context) - .colorScheme - .secondary, - width: 0.5, - ), - borderRadius: const BorderRadius.all( - Radius.circular(10), - ), - ), - child: ConversationBotSettingsForm( - botOptions: botOptions, - ), - ), - ), - ), - ], - ), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(false); - }, - child: Text(L10n.of(context)!.cancel), - ), - TextButton( - onPressed: () { - Navigator.of(context).pop(true); - }, - child: Text( - L10n.of(context)! - .conversationBotConfigConfirmChange, - ), - ), - ], +class ConversationBotSettingsDialog extends StatefulWidget { + final Room room; + + const ConversationBotSettingsDialog({ + super.key, + required this.room, + }); + + @override + ConversationBotSettingsDialogState createState() => + ConversationBotSettingsDialogState(); +} + +class ConversationBotSettingsDialogState + extends State { + late BotOptionsModel botOptions; + bool addBot = false; + + final TextEditingController discussionTopicController = + TextEditingController(); + final TextEditingController discussionKeywordsController = + TextEditingController(); + final TextEditingController customSystemPromptController = + TextEditingController(); + + @override + void initState() { + super.initState(); + botOptions = widget.room.botOptions != null + ? BotOptionsModel.fromJson(widget.room.botOptions?.toJson()) + : BotOptionsModel(); + + widget.room.botIsInRoom.then((bool isBotRoom) { + setState(() => addBot = isBotRoom); + }); + + discussionKeywordsController.text = botOptions.discussionKeywords ?? ""; + discussionTopicController.text = botOptions.discussionTopic ?? ""; + customSystemPromptController.text = botOptions.customSystemPrompt ?? ""; + } + + final GlobalKey formKey = GlobalKey(); + + void updateFromTextControllers() { + botOptions.discussionTopic = discussionTopicController.text; + botOptions.discussionKeywords = discussionKeywordsController.text; + botOptions.customSystemPrompt = customSystemPromptController.text; + } + + void onUpdateChatMode(String? mode) { + setState(() => botOptions.mode = mode ?? BotMode.discussion); + } + + void onUpdateBotLanguage(String? language) { + setState(() => botOptions.targetLanguage = language); + } + + void onUpdateBotVoice(String? voice) { + setState(() => botOptions.targetVoice = voice); + } + + void onUpdateBotLanguageLevel(int? level) { + setState(() => botOptions.languageLevel = level); + } + + @override + Widget build(BuildContext context) { + final dialogContent = Form( + key: formKey, + child: Container( + padding: const EdgeInsets.all(16), + constraints: kIsWeb + ? const BoxConstraints( + maxWidth: 450, + maxHeight: 725, + ) + : null, + child: ClipRRect( + borderRadius: BorderRadius.circular(20.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + vertical: 12, + ), + child: Text( + L10n.of(context)!.botConfig, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(null), + ), + ], + ), + SwitchListTile( + title: Text( + L10n.of(context)!.conversationBotStatus, + ), + value: addBot, + onChanged: (bool value) { + setState(() => addBot = value); + }, + contentPadding: const EdgeInsets.all(4), + ), + Expanded( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + const SizedBox(height: 20), + AnimatedOpacity( + duration: FluffyThemes.animationDuration, + opacity: addBot ? 1.0 : 0.5, + child: ConversationBotSettingsForm( + botOptions: botOptions, + discussionKeywordsController: + discussionKeywordsController, + discussionTopicController: + discussionTopicController, + customSystemPromptController: + customSystemPromptController, + enabled: addBot, + onUpdateBotMode: onUpdateChatMode, + onUpdateBotLanguage: onUpdateBotLanguage, + onUpdateBotVoice: onUpdateBotVoice, + onUpdateBotLanguageLevel: onUpdateBotLanguageLevel, ), - ); - }, - ); - if (confirm == true) { - updateBotOption(() { - botOptions = botOptions; - }); + ), + ], + ), + ), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(null); + }, + child: Text(L10n.of(context)!.cancel), + ), + const SizedBox(width: 20), + TextButton( + onPressed: () async { + final isValid = formKey.currentState!.validate(); + if (!isValid) return; + + updateFromTextControllers(); + + Navigator.of(context).pop(botOptions); + final bool isBotRoomMember = - await widget.room?.botIsInRoom ?? false; + await widget.room.botIsInRoom; if (addBot && !isBotRoomMember) { - await widget.room?.invite(BotName.byEnvironment); + await widget.room.invite(BotName.byEnvironment); } else if (!addBot && isBotRoomMember) { - await widget.room?.kick(BotName.byEnvironment); + await widget.room.kick(BotName.byEnvironment); } - } - }, + }, + child: Text(L10n.of(context)!.confirm), + ), + ], + ), + ], ), - if (isCreating && addBot) - ConversationBotSettingsForm( - botOptions: botOptions, - ), - ], + ), ), ); + + return kIsWeb + ? Dialog(child: dialogContent) + : Dialog.fullscreen(child: dialogContent); } } diff --git a/lib/pangea/widgets/conversation_bot/conversation_bot_settings_form.dart b/lib/pangea/widgets/conversation_bot/conversation_bot_settings_form.dart index b630f608b..6b5535bc4 100644 --- a/lib/pangea/widgets/conversation_bot/conversation_bot_settings_form.dart +++ b/lib/pangea/widgets/conversation_bot/conversation_bot_settings_form.dart @@ -1,87 +1,110 @@ -import 'package:fluffychat/pangea/constants/bot_mode.dart'; +import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:fluffychat/pangea/models/bot_options_model.dart'; import 'package:fluffychat/pangea/widgets/conversation_bot/conversation_bot_mode_dynamic_zone.dart'; import 'package:fluffychat/pangea/widgets/conversation_bot/conversation_bot_mode_select.dart'; import 'package:fluffychat/pangea/widgets/space/language_level_dropdown.dart'; +import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; -class ConversationBotSettingsForm extends StatefulWidget { +class ConversationBotSettingsForm extends StatelessWidget { final BotOptionsModel botOptions; + final TextEditingController discussionTopicController; + final TextEditingController discussionKeywordsController; + final TextEditingController customSystemPromptController; + + final bool enabled; + final void Function(String?) onUpdateBotMode; + final void Function(String?) onUpdateBotLanguage; + final void Function(String?) onUpdateBotVoice; + final void Function(int?) onUpdateBotLanguageLevel; + const ConversationBotSettingsForm({ super.key, required this.botOptions, + required this.discussionTopicController, + required this.discussionKeywordsController, + required this.customSystemPromptController, + required this.onUpdateBotMode, + required this.onUpdateBotLanguage, + required this.onUpdateBotVoice, + required this.onUpdateBotLanguageLevel, + this.enabled = true, }); - @override - ConversationBotSettingsFormState createState() => - ConversationBotSettingsFormState(); -} - -class ConversationBotSettingsFormState - extends State { - final formKey = GlobalKey(); - - late BotOptionsModel botOptions; - - @override - void initState() { - super.initState(); - botOptions = widget.botOptions; - } - @override Widget build(BuildContext context) { return Column( children: [ - Padding( - padding: const EdgeInsets.all(12.0), - child: Text( - L10n.of(context)!.conversationLanguageLevel, - style: TextStyle( - color: Theme.of(context).colorScheme.secondary, - fontWeight: FontWeight.bold, - fontSize: 16, - ), + DropdownButtonFormField2( + dropdownStyleData: const DropdownStyleData( + padding: EdgeInsets.zero, + ), + hint: Text( + L10n.of(context)!.selectBotLanguage, + overflow: TextOverflow.clip, + textAlign: TextAlign.center, ), + value: botOptions.targetLanguage, + isExpanded: true, + items: MatrixState.pangeaController.pLanguageStore.targetOptions + .map((language) { + return DropdownMenuItem( + value: language.langCode, + child: Text( + language.getDisplayName(context) ?? language.langCode, + overflow: TextOverflow.clip, + textAlign: TextAlign.center, + ), + ); + }).toList(), + onChanged: enabled ? onUpdateBotLanguage : null, ), + const SizedBox(height: 12), + DropdownButtonFormField2( + hint: Text( + L10n.of(context)!.chooseVoice, + overflow: TextOverflow.clip, + textAlign: TextAlign.center, + ), + value: botOptions.targetVoice, + isExpanded: true, + items: const [], + onChanged: enabled ? onUpdateBotVoice : null, + ), + const SizedBox(height: 12), LanguageLevelDropdown( initialLevel: botOptions.languageLevel, - onChanged: (int? newValue) => { - setState(() { - botOptions.languageLevel = newValue!; - }), - }, + onChanged: onUpdateBotLanguageLevel, + validator: (value) => enabled && value == null + ? L10n.of(context)!.enterLanguageLevel + : null, + enabled: enabled, ), - Text( - L10n.of(context)!.conversationBotModeSelectDescription, - style: TextStyle( - color: Theme.of(context).colorScheme.secondary, - fontWeight: FontWeight.bold, - fontSize: 16, + const SizedBox(height: 12), + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Text( + L10n.of(context)!.conversationBotModeSelectDescription, + style: Theme.of(context).textTheme.titleLarge, + ), ), ), ConversationBotModeSelect( initialMode: botOptions.mode, - onChanged: (String? mode) => { - setState(() { - botOptions.mode = mode ?? BotMode.discussion; - }), - }, + onChanged: onUpdateBotMode, + enabled: enabled, ), - Padding( - padding: const EdgeInsets.all(12), - child: ConversationBotModeDynamicZone( - initialBotOptions: botOptions, - onChanged: (BotOptionsModel? newOptions) { - if (newOptions != null) { - setState(() { - botOptions = newOptions; - }); - } - }, - ), + const SizedBox(height: 12), + ConversationBotModeDynamicZone( + botOptions: botOptions, + discussionTopicController: discussionTopicController, + discussionKeywordsController: discussionKeywordsController, + customSystemPromptController: customSystemPromptController, + enabled: enabled, ), ], ); diff --git a/lib/pangea/widgets/conversation_bot/conversation_bot_text_adventure_zone.dart b/lib/pangea/widgets/conversation_bot/conversation_bot_text_adventure_zone.dart index effbf70ee..ed2c41486 100644 --- a/lib/pangea/widgets/conversation_bot/conversation_bot_text_adventure_zone.dart +++ b/lib/pangea/widgets/conversation_bot/conversation_bot_text_adventure_zone.dart @@ -1,10 +1,8 @@ import 'package:fluffychat/pangea/models/bot_options_model.dart'; -import 'package:fluffychat/pangea/widgets/conversation_bot/conversation_bot_dynamic_zone_label.dart'; -import 'package:fluffychat/pangea/widgets/conversation_bot/conversation_bot_dynamic_zone_title.dart'; import 'package:fluffychat/pangea/widgets/conversation_bot/conversation_bot_text_adventure_game_master_instruction_input.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; +// TODO check how this looks class ConversationBotTextAdventureZone extends StatelessWidget { final BotOptionsModel initialBotOptions; // call this to update propagate changes to parents @@ -20,13 +18,6 @@ class ConversationBotTextAdventureZone extends StatelessWidget { Widget build(BuildContext context) { return Column( children: [ - ConversationBotDynamicZoneTitle( - title: L10n.of(context)!.conversationBotTextAdventureZone_title, - ), - ConversationBotDynamicZoneLabel( - label: L10n.of(context)! - .conversationBotTextAdventureZone_instructionLabel, - ), Padding( padding: const EdgeInsets.all(8), child: ConversationBotGameMasterInstructionsInput( diff --git a/lib/pangea/widgets/igc/card_error_widget.dart b/lib/pangea/widgets/igc/card_error_widget.dart index 3f08f6277..bbe1bc63b 100644 --- a/lib/pangea/widgets/igc/card_error_widget.dart +++ b/lib/pangea/widgets/igc/card_error_widget.dart @@ -1,50 +1,52 @@ import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart'; import 'package:fluffychat/pangea/utils/bot_style.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; -import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart'; import 'package:fluffychat/pangea/widgets/common/bot_face_svg.dart'; import 'package:fluffychat/pangea/widgets/igc/card_header.dart'; import 'package:flutter/material.dart'; class CardErrorWidget extends StatelessWidget { - final Object? error; + final Object error; final Choreographer? choreographer; final int? offset; + final double? maxWidth; + const CardErrorWidget({ super.key, - this.error, + required this.error, this.choreographer, this.offset, + this.maxWidth, }); @override Widget build(BuildContext context) { final ErrorCopy errorCopy = ErrorCopy(context, error); - return Container( - padding: const EdgeInsets.all(8), - constraints: const BoxConstraints(minHeight: minCardHeight), - alignment: Alignment.center, - child: SingleChildScrollView( - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - CardHeader( - text: errorCopy.title, - botExpression: BotExpression.addled, - onClose: () => choreographer?.onMatchError( - cursorOffset: offset, - ), + return ConstrainedBox( + constraints: maxWidth != null + ? BoxConstraints(maxWidth: maxWidth!) + : const BoxConstraints(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + CardHeader( + text: errorCopy.title, + botExpression: BotExpression.addled, + onClose: () => choreographer?.onMatchError( + cursorOffset: offset, ), - const SizedBox(height: 10.0), - Center( - child: Text( - errorCopy.body, - style: BotStyle.text(context), - ), + ), + const SizedBox(height: 12.0), + Padding( + padding: const EdgeInsets.all(12), + child: Text( + errorCopy.body, + style: BotStyle.text(context), + textAlign: TextAlign.center, ), - ], - ), + ), + ], ), ); } diff --git a/lib/pangea/widgets/igc/card_header.dart b/lib/pangea/widgets/igc/card_header.dart index 5ee6b98f2..0063c9638 100644 --- a/lib/pangea/widgets/igc/card_header.dart +++ b/lib/pangea/widgets/igc/card_header.dart @@ -1,8 +1,8 @@ +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/pangea/utils/bot_style.dart'; +import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/material.dart'; -import 'package:fluffychat/config/app_config.dart'; -import '../../../widgets/matrix.dart'; -import '../../utils/bot_style.dart'; import '../common/bot_face_svg.dart'; class CardHeader extends StatelessWidget { @@ -23,35 +23,35 @@ class CardHeader extends StatelessWidget { padding: const EdgeInsets.only(bottom: 5.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, children: [ - Padding( - padding: const EdgeInsets.only(top: 3.0), - child: BotFace( - width: 50.0, - expression: botExpression, + Flexible( + child: Row( + children: [ + BotFace( + width: 50.0, + expression: botExpression, + ), + const SizedBox(width: 12.0), + Flexible( + child: Text( + text, + style: BotStyle.text(context), + softWrap: true, + ), + ), + ], ), ), const SizedBox(width: 5.0), - Expanded( - child: Text( - text, - style: BotStyle.text(context), - textAlign: TextAlign.left, - ), - ), - CircleAvatar( - backgroundColor: AppConfig.primaryColor.withOpacity(0.1), - child: IconButton( - icon: const Icon(Icons.close_outlined), - onPressed: () { - if (onClose != null) onClose!(); - MatrixState.pAnyState.closeOverlay(); - }, - color: Theme.of(context).brightness == Brightness.dark - ? AppConfig.primaryColorLight - : AppConfig.primaryColor, - ), + IconButton( + icon: const Icon(Icons.close_outlined), + onPressed: () { + if (onClose != null) onClose!(); + MatrixState.pAnyState.closeOverlay(); + }, + color: Theme.of(context).brightness == Brightness.dark + ? AppConfig.primaryColorLight + : AppConfig.primaryColor, ), ], ), diff --git a/lib/pangea/widgets/igc/pangea_text_controller.dart b/lib/pangea/widgets/igc/pangea_text_controller.dart index 63c5b3f94..a8ad07d71 100644 --- a/lib/pangea/widgets/igc/pangea_text_controller.dart +++ b/lib/pangea/widgets/igc/pangea_text_controller.dart @@ -47,24 +47,36 @@ class PangeaTextController extends TextEditingController { debugger(when: kDebugMode); return; } - final CanSendStatus canSendStatus = - choreographer.pangeaController.subscriptionController.canSendStatus; - if (canSendStatus == CanSendStatus.showPaywall && + + // show the paywall if appropriate + if (choreographer + .pangeaController.subscriptionController.subscriptionStatus == + SubscriptionStatus.showPaywall && !choreographer.isFetching && text.isNotEmpty) { OverlayUtil.showPositionedCard( context: context, - cardToShow: const PaywallCard(), - cardSize: const Size(325, 325), + cardToShow: PaywallCard( + chatController: choreographer.chatController, + ), + maxHeight: 325, + maxWidth: 325, transformTargetId: choreographer.inputTransformTargetKey, ); } + // if there is no igc text data, then don't do anything if (choreographer.igc.igcTextData == null) return; // debugPrint( // "onInputTap matches are ${choreographer.igc.igcTextData?.matches.map((e) => e.match.rule.id).toList().toString()}"); + // if user is just trying to get their cursor into the text input field to add soemthing, + // then don't interrupt them + if (selection.baseOffset >= text.length) { + return; + } + final int tokenIndex = choreographer.igc.igcTextData!.tokenIndexByOffset( selection.baseOffset, ); @@ -78,7 +90,7 @@ class PangeaTextController extends TextEditingController { // if autoplay on and it start then just start it if (matchIndex != -1 && - choreographer.itAutoPlayEnabled && + // choreographer.itAutoPlayEnabled && choreographer.igc.igcTextData!.matches[matchIndex].isITStart) { return choreographer.onITStart( choreographer.igc.igcTextData!.matches[matchIndex], @@ -112,10 +124,11 @@ class PangeaTextController extends TextEditingController { if (cardToShow != null) { OverlayUtil.showPositionedCard( context: context, - cardSize: matchIndex != -1 && + maxHeight: matchIndex != -1 && choreographer.igc.igcTextData!.matches[matchIndex].isITStart - ? const Size(350, 260) - : const Size(350, 400), + ? 260 + : 400, + maxWidth: 350, cardToShow: cardToShow, transformTargetId: choreographer.inputTransformTargetKey, ); @@ -143,9 +156,9 @@ class PangeaTextController extends TextEditingController { // debugPrint("composing after ${value.composing.textAfter(value.text)}"); // } - final CanSendStatus canSendStatus = - choreographer.pangeaController.subscriptionController.canSendStatus; - if (canSendStatus == CanSendStatus.showPaywall && + final SubscriptionStatus canSendStatus = choreographer + .pangeaController.subscriptionController.subscriptionStatus; + if (canSendStatus == SubscriptionStatus.showPaywall && !choreographer.isFetching && text.isNotEmpty) { return TextSpan( diff --git a/lib/pangea/widgets/igc/paywall_card.dart b/lib/pangea/widgets/igc/paywall_card.dart index a2c35ff86..9308016fb 100644 --- a/lib/pangea/widgets/igc/paywall_card.dart +++ b/lib/pangea/widgets/igc/paywall_card.dart @@ -1,4 +1,5 @@ import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pangea/utils/bot_style.dart'; import 'package:fluffychat/pangea/widgets/common/bot_face_svg.dart'; import 'package:fluffychat/pangea/widgets/igc/card_header.dart'; @@ -7,14 +8,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; class PaywallCard extends StatelessWidget { + final ChatController chatController; const PaywallCard({ super.key, + required this.chatController, }); @override Widget build(BuildContext context) { final bool inTrialWindow = - MatrixState.pangeaController.userController.inTrialWindow; + MatrixState.pangeaController.userController.inTrialWindow(); return Column( mainAxisSize: MainAxisSize.max, @@ -69,6 +72,7 @@ class PaywallCard extends StatelessWidget { width: double.infinity, child: TextButton( onPressed: () { + chatController.clearSelectedEvents(); MatrixState.pangeaController.subscriptionController .showPaywall(context); }, diff --git a/lib/pangea/widgets/igc/span_card.dart b/lib/pangea/widgets/igc/span_card.dart index d1353232d..63ecad332 100644 --- a/lib/pangea/widgets/igc/span_card.dart +++ b/lib/pangea/widgets/igc/span_card.dart @@ -237,50 +237,48 @@ class WordMatchContent extends StatelessWidget { ? controller.currentExpression : BotExpression.addled, ), - Expanded( - child: Scrollbar( + Scrollbar( + controller: scrollController, + thumbVisibility: true, + child: SingleChildScrollView( controller: scrollController, - thumbVisibility: true, - child: SingleChildScrollView( - controller: scrollController, - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // const SizedBox(height: 10.0), - // if (matchCopy.description != null) - // Padding( - // padding: const EdgeInsets.only(), - // child: Text( - // matchCopy.description!, - // style: BotStyle.text(context), - // ), - // ), - const SizedBox(height: 8), - if (!controller.widget.scm.pangeaMatch!.isITStart) - ChoicesArray( - originalSpan: - controller.widget.scm.pangeaMatch!.matchContent, - isLoading: controller.fetchingData, - choices: - controller.widget.scm.pangeaMatch!.match.choices - ?.map( - (e) => Choice( - text: e.value, - color: e.selected ? e.type.color : null, - isGold: e.type.name == 'bestCorrection', - ), - ) - .toList(), - onPressed: controller.onChoiceSelect, - uniqueKeyForLayerLink: (int index) => - "wordMatch$index", - selectedChoiceIndex: controller.selectedChoiceIndex, - ), - const SizedBox(height: 12), - PromptAndFeedback(controller: controller), - ], - ), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // const SizedBox(height: 10.0), + // if (matchCopy.description != null) + // Padding( + // padding: const EdgeInsets.only(), + // child: Text( + // matchCopy.description!, + // style: BotStyle.text(context), + // ), + // ), + const SizedBox(height: 8), + if (!controller.widget.scm.pangeaMatch!.isITStart) + ChoicesArray( + originalSpan: + controller.widget.scm.pangeaMatch!.matchContent, + isLoading: controller.fetchingData, + choices: + controller.widget.scm.pangeaMatch!.match.choices + ?.map( + (e) => Choice( + text: e.value, + color: e.selected ? e.type.color : null, + isGold: e.type.name == 'bestCorrection', + ), + ) + .toList(), + onPressed: controller.onChoiceSelect, + uniqueKeyForLayerLink: (int index) => + "wordMatch$index", + selectedChoiceIndex: controller.selectedChoiceIndex, + ), + const SizedBox(height: 12), + PromptAndFeedback(controller: controller), + ], ), ), ), @@ -357,16 +355,16 @@ class WordMatchContent extends StatelessWidget { ), ], ), - if (controller.widget.scm.pangeaMatch!.isITStart) - DontShowSwitchListTile( - controller: pangeaController, - onSwitch: (bool value) { - pangeaController.userController.updateProfile((profile) { - profile.userSettings.itAutoPlay = value; - return profile; - }); - }, - ), + // if (controller.widget.scm.pangeaMatch!.isITStart) + // DontShowSwitchListTile( + // controller: pangeaController, + // onSwitch: (bool value) { + // pangeaController.userController.updateProfile((profile) { + // profile.userSettings.itAutoPlay = value; + // return profile; + // }); + // }, + // ), ], ), ], diff --git a/lib/pangea/widgets/igc/word_data_card.dart b/lib/pangea/widgets/igc/word_data_card.dart index 6f1492a75..06e7a1ed7 100644 --- a/lib/pangea/widgets/igc/word_data_card.dart +++ b/lib/pangea/widgets/igc/word_data_card.dart @@ -1,5 +1,6 @@ import 'dart:developer'; +import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/constants/language_constants.dart'; import 'package:fluffychat/pangea/controllers/contextual_definition_controller.dart'; import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; @@ -7,8 +8,7 @@ import 'package:fluffychat/pangea/models/language_model.dart'; import 'package:fluffychat/pangea/utils/bot_style.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:fluffychat/pangea/utils/firebase_analytics.dart'; -import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart'; -import 'package:fluffychat/pangea/widgets/common/p_circular_loader.dart'; +import 'package:fluffychat/pangea/widgets/chat/toolbar_content_loading_indicator.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -166,71 +166,68 @@ class WordDataCardView extends StatelessWidget { @override Widget build(BuildContext context) { if (controller.wordNetError != null) { - return CardErrorWidget(error: controller.wordNetError); + return CardErrorWidget( + error: controller.wordNetError!, + maxWidth: AppConfig.toolbarMinWidth, + ); } if (controller.activeL1 == null || controller.activeL2 == null) { ErrorHandler.logError(m: "should not be here"); - return CardErrorWidget(error: controller.noLanguages); + return CardErrorWidget( + error: controller.noLanguages, + maxWidth: AppConfig.toolbarMinWidth, + ); } - final ScrollController scrollController = ScrollController(); - - return Container( - padding: const EdgeInsets.all(8), - constraints: const BoxConstraints(minHeight: minCardHeight), - alignment: Alignment.center, - child: Scrollbar( - thumbVisibility: true, - controller: scrollController, - child: SingleChildScrollView( - controller: scrollController, - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (controller.widget.choiceFeedback != null) - Text( - controller.widget.choiceFeedback!, - style: BotStyle.text(context), - ), - const SizedBox(height: 5.0), - if (controller.wordData != null && - controller.wordNetError == null && - controller.activeL1 != null && - controller.activeL2 != null) - WordNetInfo( - wordData: controller.wordData!, - activeL1: controller.activeL1!, - activeL2: controller.activeL2!, - ), - if (controller.isLoadingWordNet) const PCircular(), - const SizedBox(height: 5.0), - // if (controller.widget.hasInfo && - // !controller.isLoadingContextualDefinition && - // controller.contextualDefinitionRes == null) - // Material( - // type: MaterialType.transparency, - // child: ListTile( - // leading: const BotFace( - // width: 40, expression: BotExpression.surprised), - // title: Text(L10n.of(context)!.askPangeaBot), - // onTap: controller.handleGetDefinitionButtonPress, - // ), - // ), - if (controller.isLoadingContextualDefinition) const PCircular(), - if (controller.contextualDefinitionRes != null) - Text( - controller.contextualDefinitionRes!.text, - style: BotStyle.text(context), - ), - if (controller.definitionError != null) - Text( - L10n.of(context)!.sorryNoResults, - style: BotStyle.text(context), - ), - ], - ), - ), + return Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 16), + child: Column( + children: [ + if (controller.widget.choiceFeedback != null) + Text( + controller.widget.choiceFeedback!, + style: BotStyle.text(context), + ), + const SizedBox(height: 5.0), + if (controller.wordData != null && + controller.wordNetError == null && + controller.activeL1 != null && + controller.activeL2 != null) + WordNetInfo( + wordData: controller.wordData!, + activeL1: controller.activeL1!, + activeL2: controller.activeL2!, + ), + if (controller.isLoadingWordNet) + const ToolbarContentLoadingIndicator(), + const SizedBox(height: 5.0), + // if (controller.widget.hasInfo && + // !controller.isLoadingContextualDefinition && + // controller.contextualDefinitionRes == null) + // Material( + // type: MaterialType.transparency, + // child: ListTile( + // leading: const BotFace( + // width: 40, expression: BotExpression.surprised), + // title: Text(L10n.of(context)!.askPangeaBot), + // onTap: controller.handleGetDefinitionButtonPress, + // ), + // ), + if (controller.isLoadingContextualDefinition) + const ToolbarContentLoadingIndicator(), + if (controller.contextualDefinitionRes != null) + Text( + controller.contextualDefinitionRes!.text, + style: BotStyle.text(context), + textAlign: TextAlign.center, + ), + if (controller.definitionError != null) + Text( + L10n.of(context)!.sorryNoResults, + style: BotStyle.text(context), + textAlign: TextAlign.center, + ), + ], ), ); } @@ -251,12 +248,14 @@ class WordNetInfo extends StatelessWidget { @override Widget build(BuildContext context) { return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ SensesForLanguage( wordData: wordData, languageType: LanguageType.target, language: activeL2, ), + const SizedBox(height: 10), SensesForLanguage( wordData: wordData, languageType: LanguageType.base, @@ -273,52 +272,6 @@ enum LanguageType { } class SensesForLanguage extends StatelessWidget { - const SensesForLanguage({ - super.key, - required this.wordData, - required this.languageType, - required this.language, - }); - - final LanguageModel language; - final LanguageType languageType; - final WordData wordData; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 5), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(7, 0, 0, 0), - child: LanguageFlag( - language: language, - ), - ), - Expanded( - child: PartOfSpeechBlock( - wordData: wordData, - languageType: languageType, - ), - ), - ], - ), - ); - } -} - -class PartOfSpeechBlock extends StatelessWidget { - final WordData wordData; - final LanguageType languageType; - - const PartOfSpeechBlock({ - super.key, - required this.wordData, - required this.languageType, - }); - String get exampleSentence => languageType == LanguageType.target ? wordData.targetExampleSentence : wordData.baseExampleSentence; @@ -336,70 +289,76 @@ class PartOfSpeechBlock extends StatelessWidget { return "$word (${wordData.formattedPartOfSpeech(languageType)})"; } + const SensesForLanguage({ + super.key, + required this.wordData, + required this.languageType, + required this.language, + }); + + final LanguageModel language; + final LanguageType languageType; + final WordData wordData; + @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB(10, 0, 10, 0), - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Align( - alignment: Alignment.centerLeft, - child: Text( - formattedTitle(context), - style: BotStyle.text(context, italics: true, bold: false), - ), - ), - const SizedBox(height: 10), - Padding( - padding: const EdgeInsets.only(left: 14.0, bottom: 10.0), - child: Align( - alignment: Alignment.centerLeft, - child: Column( - children: [ - if (definition.isNotEmpty) - RichText( - text: TextSpan( - style: BotStyle.text( - context, - italics: false, - bold: false, - ), - children: [ - TextSpan( - text: "${L10n.of(context)!.definition}: ", - style: const TextStyle(fontWeight: FontWeight.bold), - ), - TextSpan(text: definition), - ], + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LanguageFlag(language: language), + const SizedBox(width: 10), + Flexible( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + formattedTitle(context), + style: BotStyle.text(context, italics: true, bold: false), + ), + const SizedBox(height: 4), + if (definition.isNotEmpty) + RichText( + text: TextSpan( + style: BotStyle.text( + context, + italics: false, + bold: false, + ), + children: [ + TextSpan( + text: "${L10n.of(context)!.definition}: ", + style: const TextStyle(fontWeight: FontWeight.bold), ), + TextSpan(text: definition), + ], + ), + ), + const SizedBox(height: 4), + if (exampleSentence.isNotEmpty) + RichText( + text: TextSpan( + style: BotStyle.text( + context, + italics: false, + bold: false, ), - const SizedBox(height: 10), - if (exampleSentence.isNotEmpty) - RichText( - text: TextSpan( - style: BotStyle.text( - context, - italics: false, - bold: false, + children: [ + TextSpan( + text: "${L10n.of(context)!.exampleSentence}: ", + style: const TextStyle( + fontWeight: FontWeight.bold, ), - children: [ - TextSpan( - text: "${L10n.of(context)!.exampleSentence}: ", - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - ), - TextSpan(text: exampleSentence), - ], ), - ), - ], - ), - ), + TextSpan(text: exampleSentence), + ], + ), + ), + ], ), - ], - ), + ), + ], ); } } diff --git a/lib/pangea/widgets/message_display_card.dart b/lib/pangea/widgets/message_display_card.dart new file mode 100644 index 000000000..8536f2c99 --- /dev/null +++ b/lib/pangea/widgets/message_display_card.dart @@ -0,0 +1,23 @@ +import 'package:fluffychat/pangea/utils/bot_style.dart'; +import 'package:flutter/material.dart'; + +class MessageDisplayCard extends StatelessWidget { + final String displayText; + + const MessageDisplayCard({ + super.key, + required this.displayText, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 16), + child: Text( + displayText, + style: BotStyle.text(context), + textAlign: TextAlign.center, + ), + ); + } +} diff --git a/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart b/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart index 7675e39d3..e76021000 100644 --- a/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart +++ b/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart @@ -3,22 +3,27 @@ import 'dart:developer'; import 'package:collection/collection.dart'; import 'package:fluffychat/pangea/choreographer/widgets/choice_array.dart'; import 'package:fluffychat/pangea/controllers/my_analytics_controller.dart'; +import 'package:fluffychat/pangea/enum/activity_type_enum.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_record_model.dart'; +import 'package:fluffychat/pangea/widgets/chat/tts_controller.dart'; import 'package:fluffychat/pangea/widgets/practice_activity/practice_activity_card.dart'; +import 'package:fluffychat/pangea/widgets/practice_activity/word_audio_button.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; /// The multiple choice activity view class MultipleChoiceActivity extends StatefulWidget { - final MessagePracticeActivityCardState practiceCardController; - final PracticeActivityModel? currentActivity; + final PracticeActivityCardState practiceCardController; + final PracticeActivityModel currentActivity; + final TtsController tts; const MultipleChoiceActivity({ super.key, required this.practiceCardController, required this.currentActivity, + required this.tts, }); @override @@ -52,7 +57,7 @@ class MultipleChoiceActivityState extends State { } final bool isCorrect = - widget.currentActivity!.multipleChoice!.isCorrect(value, index); + widget.currentActivity.content.isCorrect(value, index); currentRecordModel?.addResponse( text: value, @@ -65,6 +70,7 @@ class MultipleChoiceActivityState extends State { return; } + // #freeze-activity MatrixState.pangeaController.myAnalytics.setState( AnalyticsStream( // note - this maybe should be the activity event id @@ -79,50 +85,54 @@ class MultipleChoiceActivityState extends State { ); // If the selected choice is correct, send the record and get the next activity - if (widget.currentActivity!.multipleChoice!.isCorrect(value, index)) { + if (widget.currentActivity.content.isCorrect(value, index)) { widget.practiceCardController.onActivityFinish(); } - setState( - () => selectedChoiceIndex = index, - ); + if (mounted) { + setState( + () => selectedChoiceIndex = index, + ); + } } @override Widget build(BuildContext context) { - final PracticeActivityModel? practiceActivity = widget.currentActivity; + final PracticeActivityModel practiceActivity = widget.currentActivity; - if (practiceActivity == null) { - return const SizedBox(); - } - - return Container( + return Padding( padding: const EdgeInsets.all(8), child: Column( children: [ Text( - practiceActivity.multipleChoice!.question, + practiceActivity.content.question, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, ), ), const SizedBox(height: 8), + // #freeze-activity + if (practiceActivity.activityType == + ActivityTypeEnum.wordFocusListening) + WordAudioButton( + text: practiceActivity.content.answer, + ttsController: widget.tts, + ), ChoicesArray( isLoading: false, uniqueKeyForLayerLink: (index) => "multiple_choice_$index", originalSpan: "placeholder", onPressed: updateChoice, selectedChoiceIndex: selectedChoiceIndex, - choices: practiceActivity.multipleChoice!.choices + choices: practiceActivity.content.choices .mapIndexed( (index, value) => Choice( text: value, color: currentRecordModel?.hasTextResponse(value) ?? false - ? practiceActivity.multipleChoice!.choiceColor(index) + ? practiceActivity.content.choiceColor(index) : null, - isGold: practiceActivity.multipleChoice! - .isCorrect(value, index), + isGold: practiceActivity.content.isCorrect(value, index), ), ) .toList(), diff --git a/lib/pangea/widgets/practice_activity/no_more_practice_card.dart b/lib/pangea/widgets/practice_activity/no_more_practice_card.dart index 1cef6c174..12a087d97 100644 --- a/lib/pangea/widgets/practice_activity/no_more_practice_card.dart +++ b/lib/pangea/widgets/practice_activity/no_more_practice_card.dart @@ -1,3 +1,4 @@ +import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/utils/bot_style.dart'; import 'package:flutter/material.dart'; @@ -71,26 +72,21 @@ class GamifiedTextWidget extends StatelessWidget { @override Widget build(BuildContext context) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, // Adjusts the size to fit children - children: [ - const SizedBox(height: 10), // Spacing between the star and text - // Star animation above the text - const StarAnimationWidget(), - const SizedBox(height: 10), // Spacing between the star and text - Container( - constraints: const BoxConstraints( - minHeight: 80, - ), - padding: const EdgeInsets.all(8), - child: Text( + return SizedBox( + width: AppConfig.toolbarMinWidth, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 16), + child: Column( + children: [ + const StarAnimationWidget(), + const SizedBox(height: 10), + Text( userMessage, style: BotStyle.text(context), - textAlign: TextAlign.center, // Center-align the text + textAlign: TextAlign.center, ), - ), - ], + ], + ), ), ); } diff --git a/lib/pangea/widgets/practice_activity/practice_activity_card.dart b/lib/pangea/widgets/practice_activity/practice_activity_card.dart index 7e80e2aaa..dbb98de73 100644 --- a/lib/pangea/widgets/practice_activity/practice_activity_card.dart +++ b/lib/pangea/widgets/practice_activity/practice_activity_card.dart @@ -4,17 +4,16 @@ import 'dart:developer'; import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; import 'package:fluffychat/pangea/enum/activity_type_enum.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; -import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_representation_event.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart'; import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/message_activity_request.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_record_model.dart'; -import 'package:fluffychat/pangea/utils/bot_style.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:fluffychat/pangea/widgets/animations/gain_points.dart'; import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart'; -import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart'; +import 'package:fluffychat/pangea/widgets/chat/toolbar_content_loading_indicator.dart'; +import 'package:fluffychat/pangea/widgets/chat/tts_controller.dart'; import 'package:fluffychat/pangea/widgets/content_issue_button.dart'; import 'package:fluffychat/pangea/widgets/practice_activity/multiple_choice_activity.dart'; import 'package:fluffychat/pangea/widgets/practice_activity/no_more_practice_card.dart'; @@ -30,19 +29,20 @@ import 'package:flutter_gen/gen_l10n/l10n.dart'; class PracticeActivityCard extends StatefulWidget { final PangeaMessageEvent pangeaMessageEvent; final MessageOverlayController overlayController; + final TtsController tts; const PracticeActivityCard({ super.key, required this.pangeaMessageEvent, required this.overlayController, + required this.tts, }); @override - MessagePracticeActivityCardState createState() => - MessagePracticeActivityCardState(); + PracticeActivityCardState createState() => PracticeActivityCardState(); } -class MessagePracticeActivityCardState extends State { +class PracticeActivityCardState extends State { PracticeActivityModel? currentActivity; PracticeActivityRecordModel? currentCompletionRecord; bool fetchingActivity = false; @@ -120,13 +120,25 @@ class MessagePracticeActivityCardState extends State { return null; } + if (widget.pangeaMessageEvent.originalSent == null) { + debugger(when: kDebugMode); + _updateFetchingActivity(false); + ErrorHandler.logError( + e: Exception('No original message found in _fetchNewActivity'), + data: { + 'event': widget.pangeaMessageEvent.event.toJson(), + }, + ); + return null; + } + final PracticeActivityModel? ourNewActivity = await pangeaController .practiceGenerationController .getPracticeActivity( MessageActivityRequest( userL1: pangeaController.languageController.userL1!.langCode, userL2: pangeaController.languageController.userL2!.langCode, - messageText: representation!.text, + messageText: widget.pangeaMessageEvent.originalSent!.text, tokensWithXP: await targetTokensController.targetTokens( context, widget.pangeaMessageEvent, @@ -165,13 +177,26 @@ class MessagePracticeActivityCardState extends State { ); Future _savorTheJoy() async { - debugger(when: savoringTheJoy && kDebugMode); + try { + debugger(when: savoringTheJoy && kDebugMode); - setState(() => savoringTheJoy = true); + if (mounted) setState(() => savoringTheJoy = true); - await Future.delayed(appropriateTimeForJoy); + await Future.delayed(appropriateTimeForJoy); - if (mounted) setState(() => savoringTheJoy = false); + if (mounted) setState(() => savoringTheJoy = false); + } catch (e, s) { + debugger(when: kDebugMode); + ErrorHandler.logError( + e: e, + s: s, + m: 'Failed to savor the joy', + data: { + 'activity': currentActivity, + 'record': currentCompletionRecord, + }, + ); + } } /// Called when the user finishes an activity. @@ -201,7 +226,8 @@ class MessagePracticeActivityCardState extends State { widget.pangeaMessageEvent.eventId, ); - // + // wait for the joy to be savored before resolving the activity + // and setting it to replace the previous activity final Iterable result = await Future.wait([ _savorTheJoy(), _fetchNewActivity(), @@ -257,95 +283,80 @@ class MessagePracticeActivityCardState extends State { }); } - RepresentationEvent? get representation => - widget.pangeaMessageEvent.originalSent; - - String get messsageText => representation!.text; - PangeaController get pangeaController => MatrixState.pangeaController; /// The widget that displays the current activity. /// If there is no current activity, the widget returns a sizedbox with a height of 80. /// If the activity type is multiple choice, the widget returns a MultipleChoiceActivity. /// If the activity type is unknown, the widget logs an error and returns a text widget with an error message. - Widget get activityWidget { - if (currentActivity == null) { - // return sizedbox with height of 80 - return const SizedBox(height: 80); - } - switch (currentActivity!.activityType) { + Widget? get activityWidget { + switch (currentActivity?.activityType) { + case null: + return null; case ActivityTypeEnum.multipleChoice: return MultipleChoiceActivity( practiceCardController: this, - currentActivity: currentActivity, + currentActivity: currentActivity!, + tts: widget.tts, ); - default: - ErrorHandler.logError( - e: Exception('Unknown activity type'), - m: 'Unknown activity type', - data: { - 'activityType': currentActivity!.activityType, - }, - ); - return Text( - L10n.of(context)!.oopsSomethingWentWrong, - style: BotStyle.text(context), + case ActivityTypeEnum.wordFocusListening: + // return WordFocusListeningActivity( + // activity: currentActivity!, practiceCardController: this); + return MultipleChoiceActivity( + practiceCardController: this, + currentActivity: currentActivity!, + tts: widget.tts, ); + // default: + // ErrorHandler.logError( + // e: Exception('Unknown activity type'), + // m: 'Unknown activity type', + // data: { + // 'activityType': currentActivity!.activityType, + // }, + // ); + // return Text( + // L10n.of(context)!.oopsSomethingWentWrong, + // style: BotStyle.text(context), + // ); } } - String? get userMessage { - if (!fetchingActivity && currentActivity == null) { - return L10n.of(context)!.noActivitiesFound; - } - return null; - } - @override Widget build(BuildContext context) { - if (userMessage != null) { - return GamifiedTextWidget(userMessage: userMessage!); + if (!fetchingActivity && currentActivity == null) { + return GamifiedTextWidget( + userMessage: L10n.of(context)!.noActivitiesFound, + ); } - return Container( - constraints: const BoxConstraints( - maxWidth: 350, - minWidth: 350, - minHeight: minCardHeight, - ), - child: Stack( - alignment: Alignment.center, - children: [ - // Main content - const Positioned( - child: PointsGainedAnimation(), - ), - Container( - padding: const EdgeInsets.all(8), + return Stack( + alignment: Alignment.center, + children: [ + // Main content + const Positioned( + child: PointsGainedAnimation(), + ), + if (activityWidget != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 16), child: activityWidget, ), - // Conditionally show the darkening and progress indicator based on the loading state - if (!savoringTheJoy && fetchingActivity) ...[ - // Semi-transparent overlay - Container( - color: Colors.black.withOpacity(0.5), // Darkening effect - ), - // Circular progress indicator in the center - const Center( - child: CircularProgressIndicator(), - ), - ], - // Flag button in the top right corner - Positioned( - top: 0, - right: 0, - child: ContentIssueButton( - isActive: currentActivity != null, - submitFeedback: submitFeedback, - ), - ), + // Conditionally show the darkening and progress indicator based on the loading state + if (!savoringTheJoy && fetchingActivity) ...[ + // Circular progress indicator in the center + const ToolbarContentLoadingIndicator(), ], - ), + // Flag button in the top right corner + Positioned( + top: 0, + right: 0, + child: ContentIssueButton( + isActive: currentActivity != null, + submitFeedback: submitFeedback, + ), + ), + ], ); } } diff --git a/lib/pangea/widgets/practice_activity/target_tokens_controller.dart b/lib/pangea/widgets/practice_activity/target_tokens_controller.dart index f22e097e4..e358614f3 100644 --- a/lib/pangea/widgets/practice_activity/target_tokens_controller.dart +++ b/lib/pangea/widgets/practice_activity/target_tokens_controller.dart @@ -58,17 +58,9 @@ class TargetTokensController { return _targetTokens = []; } - _targetTokens = []; - for (int i = 0; i < tokens.length; i++) { - //don't bother with tokens that we don't save to vocab - if (!tokens[i].lemma.saveVocab) { - continue; - } - - _targetTokens!.add(tokens[i].emptyTokenWithXP); - } - - return _targetTokens!; + return _targetTokens = tokens + .map((token) => token.emptyTokenWithXP) + .toList(); } Future updateTokensWithConstructs( @@ -84,6 +76,12 @@ class TargetTokensController { _targetTokens ??= await _initialize(context, pangeaMessageEvent); for (final token in _targetTokens!) { + + // we don't need to do this for tokens that don't have saveVocab set to true + if (!token.token.lemma.saveVocab){ + continue; + } + for (final construct in token.constructs) { final constructUseModel = constructList.getConstructUses( construct.id.lemma, diff --git a/lib/pangea/widgets/practice_activity/word_audio_button.dart b/lib/pangea/widgets/practice_activity/word_audio_button.dart new file mode 100644 index 000000000..2f56299c8 --- /dev/null +++ b/lib/pangea/widgets/practice_activity/word_audio_button.dart @@ -0,0 +1,63 @@ +import 'package:fluffychat/pangea/widgets/chat/tts_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; + +class WordAudioButton extends StatefulWidget { + final String text; + final TtsController ttsController; + + const WordAudioButton({ + super.key, + required this.text, + required this.ttsController, + }); + + @override + WordAudioButtonState createState() => WordAudioButtonState(); +} + +class WordAudioButtonState extends State { + bool _isPlaying = false; + + @override + Widget build(BuildContext context) { + debugPrint('build WordAudioButton'); + return Column( + children: [ + IconButton( + icon: const Icon(Icons.play_arrow_outlined), + isSelected: _isPlaying, + selectedIcon: const Icon(Icons.pause_outlined), + color: _isPlaying ? Colors.white : null, + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all( + _isPlaying + ? Theme.of(context).colorScheme.secondary + : Theme.of(context).colorScheme.primaryContainer, + ), + ), + tooltip: + _isPlaying ? L10n.of(context)!.stop : L10n.of(context)!.playAudio, + onPressed: () async { + if (_isPlaying) { + await widget.ttsController.tts.stop(); + if (mounted) { + setState(() => _isPlaying = false); + } + } else { + if (mounted) { + setState(() => _isPlaying = true); + } + await widget.ttsController.speak(widget.text); + if (mounted) { + setState(() => _isPlaying = false); + } + } + }, // Disable button if language isn't supported + ), + // #freeze-activity + widget.ttsController.missingVoiceButton, + ], + ); + } +} diff --git a/lib/pangea/widgets/practice_activity/word_focus_listening_activity.dart b/lib/pangea/widgets/practice_activity/word_focus_listening_activity.dart new file mode 100644 index 000000000..8e22aced8 --- /dev/null +++ b/lib/pangea/widgets/practice_activity/word_focus_listening_activity.dart @@ -0,0 +1,173 @@ +import 'dart:developer'; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/pangea/controllers/my_analytics_controller.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/models/practice_activities.dart/practice_activity_record_model.dart'; +import 'package:fluffychat/pangea/widgets/chat/tts_controller.dart'; +import 'package:fluffychat/pangea/widgets/practice_activity/practice_activity_card.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class WordFocusListeningActivity extends StatefulWidget { + final PracticeActivityModel activity; + final PracticeActivityCardState practiceCardController; + + const WordFocusListeningActivity({ + super.key, + required this.activity, + required this.practiceCardController, + }); + + @override + WordFocusListeningActivityState createState() => + WordFocusListeningActivityState(); + + ActivityContent get activityContent => activity.content; +} + +class WordFocusListeningActivityState + extends State { + int? selectedChoiceIndex; + + TtsController tts = TtsController(); + + final double buttonSize = 40; + + PracticeActivityRecordModel? get currentRecordModel => + widget.practiceCardController.currentCompletionRecord; + + initializeTTS() async { + tts.setupTTS().then((value) => setState(() {})); + } + + @override + void initState() { + super.initState(); + initializeTTS(); + } + + void checkAnswer(int index) { + final String value = widget.activityContent.choices[index]; + + if (currentRecordModel?.hasTextResponse(value) ?? false) { + return; + } + + final bool isCorrect = widget.activity.content.isCorrect(value, index); + + currentRecordModel?.addResponse( + text: value, + score: isCorrect ? 1 : 0, + ); + + if (currentRecordModel == null || + currentRecordModel!.latestResponse == null) { + debugger(when: kDebugMode); + return; + } + + MatrixState.pangeaController.myAnalytics.setState( + AnalyticsStream( + // note - this maybe should be the activity event id + eventId: + widget.practiceCardController.widget.pangeaMessageEvent.eventId, + roomId: widget.practiceCardController.widget.pangeaMessageEvent.room.id, + constructs: currentRecordModel!.latestResponse!.toUses( + widget.practiceCardController.currentActivity!, + widget.practiceCardController.metadata, + ), + ), + ); + setState(() { + selectedChoiceIndex = index; + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + children: [ + // Text question at the top + Text( + widget.activityContent.question, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + + // Blank slot for the answer + DragTarget( + builder: (context, candidateData, rejectedData) { + return CircleAvatar( + radius: buttonSize, + backgroundColor: Colors.transparent, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: AppConfig.primaryColor.withOpacity(0.4), + width: 2, + style: BorderStyle.solid, + ), + ), + ), + ); + }, + onAcceptWithDetails: (details) => checkAnswer(details.data), + ), + const SizedBox(height: 10), + // Audio options as draggable buttons + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: List.generate( + widget.activityContent.choices.length, + (index) => Draggable( + data: index, + feedback: _buildAudioButton(context, theme, index), + childWhenDragging: _buildAudioButton(context, theme, index, true), + child: _buildAudioButton(context, theme, index), + ), + ), + ), + ], + ); + } + + // Helper method to build the audio buttons + Widget _buildAudioButton( + BuildContext context, + ThemeData theme, + int index, [ + bool dragging = false, + ]) { + final isAnswerCorrect = widget.activityContent.isCorrect( + widget.activityContent.choices[index], + index, + ); + Color buttonColor; + if (selectedChoiceIndex == index) { + buttonColor = isAnswerCorrect + ? theme.colorScheme.secondary.withOpacity(0.7) // Correct: Green + : theme.colorScheme.error.withOpacity(0.7); // Incorrect: Red + } else { + buttonColor = + AppConfig.primaryColor.withOpacity(0.4); // Default: Primary color + } + + return GestureDetector( + onTap: () => tts.speak(widget.activityContent.choices[index]), + child: CircleAvatar( + radius: buttonSize, + backgroundColor: dragging ? Colors.grey.withOpacity(0.5) : buttonColor, + child: const Icon(Icons.play_arrow), + ), + ); + } +} diff --git a/lib/pangea/widgets/select_to_define.dart b/lib/pangea/widgets/select_to_define.dart deleted file mode 100644 index 7020e5e77..000000000 --- a/lib/pangea/widgets/select_to_define.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:fluffychat/pangea/utils/bot_style.dart'; -import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; - -class SelectToDefine extends StatelessWidget { - const SelectToDefine({ - super.key, - }); - - @override - Widget build(BuildContext context) { - return Center( - child: Container( - constraints: const BoxConstraints(minHeight: minCardHeight), - padding: const EdgeInsets.all(8), - child: Center( - child: Text( - L10n.of(context)!.selectToDefine, - style: BotStyle.text(context), - ), - ), - ), - ); - } -} diff --git a/lib/pangea/widgets/space/language_level_dropdown.dart b/lib/pangea/widgets/space/language_level_dropdown.dart index aeb1cfd36..a8c618bc1 100644 --- a/lib/pangea/widgets/space/language_level_dropdown.dart +++ b/lib/pangea/widgets/space/language_level_dropdown.dart @@ -1,3 +1,4 @@ +import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:fluffychat/pangea/constants/language_constants.dart'; import 'package:fluffychat/pangea/utils/language_level_copy.dart'; import 'package:flutter/material.dart'; @@ -6,71 +7,41 @@ import 'package:flutter_gen/gen_l10n/l10n.dart'; class LanguageLevelDropdown extends StatelessWidget { final int? initialLevel; final void Function(int?)? onChanged; + final String? Function(int?)? validator; + final bool enabled; const LanguageLevelDropdown({ super.key, this.initialLevel, this.onChanged, + this.validator, + this.enabled = true, }); @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(12.0), - child: Container( - decoration: BoxDecoration( - border: Border.all( - color: Theme.of(context).colorScheme.secondary, - width: 0.5, - ), - borderRadius: const BorderRadius.all(Radius.circular(10)), - ), - child: DropdownButton( - // Initial Value - hint: Padding( - padding: const EdgeInsets.only(left: 15), - child: Text( - initialLevel == null - ? L10n.of(context)!.selectLanguageLevel - : LanguageLevelTextPicker.languageLevelText( - context, - initialLevel!, - ), - style: const TextStyle().copyWith( - color: Theme.of(context).textTheme.bodyLarge!.color, - fontSize: 14, - ), - overflow: TextOverflow.clip, - textAlign: TextAlign.center, + return DropdownButtonFormField2( + hint: Text( + L10n.of(context)!.selectLanguageLevel, + overflow: TextOverflow.clip, + textAlign: TextAlign.center, + ), + value: initialLevel, + items: LanguageLevelType.allInts.map((int levelOption) { + return DropdownMenuItem( + value: levelOption, + child: Text( + LanguageLevelTextPicker.languageLevelText( + context, + levelOption, ), + overflow: TextOverflow.clip, + textAlign: TextAlign.center, ), - isExpanded: true, - underline: Container(), - // Down Arrow Icon - icon: const Icon(Icons.keyboard_arrow_down), - // Array list of items - items: LanguageLevelType.allInts.map((int levelOption) { - return DropdownMenuItem( - value: levelOption, - child: Text( - LanguageLevelTextPicker.languageLevelText( - context, - levelOption, - ), - style: const TextStyle().copyWith( - color: Theme.of(context).textTheme.bodyLarge!.color, - fontSize: 14, - ), - overflow: TextOverflow.clip, - textAlign: TextAlign.center, - ), - ); - }).toList(), - // After selecting the desired option,it will - // change button value to selected value - onChanged: onChanged, - ), - ), + ); + }).toList(), + onChanged: enabled ? onChanged : null, + validator: validator, ); } } diff --git a/lib/pangea/widgets/subscription/subscription_buttons.dart b/lib/pangea/widgets/subscription/subscription_buttons.dart index 2af95056a..dd922e216 100644 --- a/lib/pangea/widgets/subscription/subscription_buttons.dart +++ b/lib/pangea/widgets/subscription/subscription_buttons.dart @@ -15,14 +15,16 @@ class SubscriptionButtons extends StatelessWidget { @override Widget build(BuildContext context) { - final bool inTrialWindow = pangeaController.userController.inTrialWindow; + final bool inTrialWindow = pangeaController.userController.inTrialWindow(); return ListView.builder( shrinkWrap: true, - itemCount: controller - .subscriptionController.subscription!.availableSubscriptions.length, + itemCount: controller.subscriptionController.availableSubscriptionInfo! + .availableSubscriptions.length, itemBuilder: (BuildContext context, int i) { final SubscriptionDetails subscription = pangeaController - .subscriptionController.subscription!.availableSubscriptions[i]; + .subscriptionController + .availableSubscriptionInfo! + .availableSubscriptions[i]; return Column( children: [ ListTile( diff --git a/lib/pangea/widgets/subscription/subscription_options.dart b/lib/pangea/widgets/subscription/subscription_options.dart index d40f68023..5584d37fd 100644 --- a/lib/pangea/widgets/subscription/subscription_options.dart +++ b/lib/pangea/widgets/subscription/subscription_options.dart @@ -19,7 +19,7 @@ class SubscriptionOptions extends StatelessWidget { alignment: WrapAlignment.center, direction: Axis.horizontal, spacing: 10, - children: pangeaController.userController.inTrialWindow + children: pangeaController.userController.inTrialWindow() ? [ SubscriptionCard( onTap: () => pangeaController.subscriptionController @@ -27,7 +27,7 @@ class SubscriptionOptions extends StatelessWidget { SubscriptionDetails( price: 0, id: "", - periodType: 'trial', + periodType: SubscriptionPeriodType.trial, ), context, ), @@ -36,8 +36,8 @@ class SubscriptionOptions extends StatelessWidget { buttonText: L10n.of(context)!.activateTrial, ), ] - : pangeaController - .subscriptionController.subscription!.availableSubscriptions + : pangeaController.subscriptionController.availableSubscriptionInfo! + .availableSubscriptions .map( (subscription) => SubscriptionCard( subscription: subscription, diff --git a/lib/utils/client_manager.dart b/lib/utils/client_manager.dart index 5ea52f5e5..808dfd9fc 100644 --- a/lib/utils/client_manager.dart +++ b/lib/utils/client_manager.dart @@ -139,6 +139,7 @@ abstract class ClientManager { timeline: StateFilter( notTypes: [ PangeaEventTypes.construct, + PangeaEventTypes.summaryAnalytics, ], ), ), diff --git a/lib/utils/error_reporter.dart b/lib/utils/error_reporter.dart index 650277bf1..2e7d41122 100644 --- a/lib/utils/error_reporter.dart +++ b/lib/utils/error_reporter.dart @@ -1,3 +1,4 @@ +import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:matrix/matrix.dart'; @@ -11,18 +12,17 @@ class ErrorReporter { void onErrorCallback(Object error, [StackTrace? stackTrace]) async { Logs().e(message ?? 'Error caught', error, stackTrace); // #Pangea - // Attempt to retrieve the L10n instance using the current context - final L10n? l10n = L10n.of(context); - - // Check if the L10n instance is null - if (l10n == null) { - // Log an error message saying that the localization object is null - Logs().e('Localization object is null, cannot show error message.'); - // Exits early to prevent further execution - return; - } - try { + // Attempt to retrieve the L10n instance using the current context + final L10n? l10n = L10n.of(context); + + // Check if the L10n instance is null + if (l10n == null) { + // Log an error message saying that the localization object is null + Logs().e('Localization object is null, cannot show error message.'); + // Exits early to prevent further execution + return; + } ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( @@ -32,6 +32,12 @@ class ErrorReporter { ); } catch (err) { debugPrint("Failed to show error snackbar."); + } finally { + ErrorHandler.logError( + e: error, + s: stackTrace, + m: message ?? 'Error caught', + ); } } // final text = '$error\n${stackTrace ?? ''}'; diff --git a/lib/utils/file_selector.dart b/lib/utils/file_selector.dart new file mode 100644 index 000000000..e9d36652c --- /dev/null +++ b/lib/utils/file_selector.dart @@ -0,0 +1,78 @@ +import 'package:file_picker/file_picker.dart'; +import 'package:file_selector/file_selector.dart'; +import 'package:fluffychat/utils/platform_infos.dart'; +import 'package:fluffychat/widgets/app_lock.dart'; +import 'package:flutter/widgets.dart'; + +Future> selectFiles( + BuildContext context, { + String? title, + FileSelectorType type = FileSelectorType.any, + bool allowMultiple = false, +}) async { + if (!PlatformInfos.isLinux) { + final result = await AppLock.of(context).pauseWhile( + FilePicker.platform.pickFiles( + compressionQuality: 0, + allowMultiple: allowMultiple, + type: type.filePickerType, + allowedExtensions: type.extensions, + ), + ); + return result?.xFiles ?? []; + } + + if (allowMultiple) { + return await AppLock.of(context).pauseWhile( + openFiles( + confirmButtonText: title, + acceptedTypeGroups: type.groups, + ), + ); + } + final file = await AppLock.of(context).pauseWhile( + openFile( + confirmButtonText: title, + acceptedTypeGroups: type.groups, + ), + ); + if (file == null) return []; + return [file]; +} + +enum FileSelectorType { + any([], FileType.any, null), + images( + [ + XTypeGroup( + label: 'JPG', + extensions: ['jpg', 'JPG', 'jpeg', 'JPEG'], + ), + XTypeGroup( + label: 'PNGs', + extensions: ['png', 'PNG'], + ), + XTypeGroup( + label: 'WEBP', + extensions: ['WebP', 'WEBP'], + ), + ], + FileType.image, + null, + ), + zip( + [ + XTypeGroup( + label: 'ZIP', + extensions: ['zip', 'ZIP'], + ), + ], + FileType.custom, + ['zip', 'ZIP'], + ); + + const FileSelectorType(this.groups, this.filePickerType, this.extensions); + final List groups; + final FileType filePickerType; + final List? extensions; +} diff --git a/lib/widgets/chat_settings_popup_menu.dart b/lib/widgets/chat_settings_popup_menu.dart index b4271213f..6e216a38a 100644 --- a/lib/widgets/chat_settings_popup_menu.dart +++ b/lib/widgets/chat_settings_popup_menu.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:adaptive_dialog/adaptive_dialog.dart'; import 'package:fluffychat/pangea/pages/settings_learning/settings_learning.dart'; import 'package:fluffychat/pangea/utils/download_chat.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'; @@ -154,27 +153,7 @@ class ChatSettingsPopupMenuState extends State { case ChatPopupMenuActions.learningSettings: showDialog( context: context, - builder: (c) { - return kIsWeb - ? Dialog( - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 600, - maxHeight: 600, - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(20.0), - child: const SettingsLearning(isPopup: true), - ), - ), - ) - : Dialog.fullscreen( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 600), - child: const SettingsLearning(isPopup: true), - ), - ); - }, + builder: (c) => const SettingsLearning(), ); break; // Pangea# diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 47b695fb9..c97a460b3 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -17,6 +17,7 @@ import firebase_messaging import flutter_app_badger import flutter_local_notifications import flutter_secure_storage_macos +import flutter_tts import flutter_web_auth_2 import flutter_webrtc import geolocator_apple @@ -54,6 +55,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterAppBadgerPlugin.register(with: registry.registrar(forPlugin: "FlutterAppBadgerPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) + FlutterTtsPlugin.register(with: registry.registrar(forPlugin: "FlutterTtsPlugin")) FlutterWebAuth2Plugin.register(with: registry.registrar(forPlugin: "FlutterWebAuth2Plugin")) FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) diff --git a/pubspec.lock b/pubspec.lock index bb56964d1..62b8d21a7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -33,6 +33,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.4.1" + android_intent_plus: + dependency: "direct main" + description: + name: android_intent_plus + sha256: "38921ec22ebb3b9a7eb678792cf6fab0b6f458b61b9d327688573449c9b47db3" + url: "https://pub.dev" + source: hosted + version: "5.2.0" animations: dependency: "direct main" description: @@ -337,6 +345,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.0" + dropdown_button2: + dependency: "direct main" + description: + name: dropdown_button2 + sha256: b0fe8d49a030315e9eef6c7ac84ca964250155a6224d491c1365061bc974a9e1 + url: "https://pub.dev" + source: hosted + version: "2.3.9" dynamic_color: dependency: "direct main" description: @@ -416,6 +432,30 @@ packages: url: "https://pub.dev" source: hosted version: "8.0.6" + file_selector: + dependency: "direct main" + description: + name: file_selector + sha256: "5019692b593455127794d5718304ff1ae15447dea286cdda9f0db2a796a1b828" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + file_selector_android: + dependency: transitive + description: + name: file_selector_android + sha256: "00aafa9ae05a8663d0b4f17abd2a02316911ca0f46f9b9dacb9578b324d99590" + url: "https://pub.dev" + source: hosted + version: "0.5.1+9" + file_selector_ios: + dependency: transitive + description: + name: file_selector_ios + sha256: "94b98ad950b8d40d96fee8fa88640c2e4bd8afcdd4817993bd04e20310f45420" + url: "https://pub.dev" + source: hosted + version: "0.5.3+1" file_selector_linux: dependency: transitive description: @@ -440,6 +480,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.2" + file_selector_web: + dependency: transitive + description: + name: file_selector_web + sha256: c4c0ea4224d97a60a7067eca0c8fd419e708ff830e0c83b11a48faf566cec3e7 + url: "https://pub.dev" + source: hosted + version: "0.9.4+2" file_selector_windows: dependency: transitive description: @@ -829,6 +877,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_tts: + dependency: "direct main" + description: + name: flutter_tts + sha256: aed2a00c48c43af043ed81145fd8503ddd793dafa7088ab137dbef81a703e53d + url: "https://pub.dev" + source: hosted + version: "4.0.2" flutter_typeahead: dependency: "direct main" description: @@ -1276,15 +1332,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.1" - keyboard_shortcuts: - dependency: "direct main" - description: - path: "." - ref: null-safety - resolved-ref: a3d4020911860ff091d90638ab708604b71d2c5a - url: "https://github.com/TheOneWithTheBraid/keyboard_shortcuts.git" - source: git - version: "0.1.4" language_tool: dependency: "direct main" description: @@ -1434,7 +1481,7 @@ packages: description: path: "." ref: main - resolved-ref: "3b77012ba93d6ff8f98dfdff00663aabeddf077e" + resolved-ref: "59895c358743fa23d8b52a61a8ec9755139951f8" url: "https://github.com/pangeachat/matrix-dart-sdk.git" source: git version: "0.32.4" @@ -2603,14 +2650,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.1" - visibility_detector: - dependency: transitive - description: - name: visibility_detector - sha256: "15c54a459ec2c17b4705450483f3d5a2858e733aee893dcee9d75fd04814940d" - url: "https://pub.dev" - source: hosted - version: "0.3.3" vm_service: dependency: transitive description: @@ -2684,7 +2723,7 @@ packages: source: hosted version: "1.2.0" win32: - dependency: "direct overridden" + dependency: transitive description: name: win32 sha256: "015002c060f1ae9f41a818f2d5640389cc05283e368be19dc8d77cecb43c40c9" @@ -2740,5 +2779,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.4.0 <4.0.0" - flutter: ">=3.22.0" + dart: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" diff --git a/pubspec.yaml b/pubspec.yaml index 39a7e9700..8e7242893 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,7 +6,7 @@ description: Learn a language while texting your friends. # Pangea# publish_to: none # On version bump also increase the build number for F-Droid -version: 1.21.5+3541 +version: 1.23.3+3562 environment: sdk: ">=3.0.0 <4.0.0" @@ -32,6 +32,7 @@ dependencies: emojis: ^0.9.9 #fcm_shared_isolate: ^0.1.0 file_picker: ^8.0.6 + file_selector: ^1.0.3 flutter: sdk: flutter flutter_app_badger: ^1.5.0 @@ -67,7 +68,9 @@ dependencies: image_picker: ^1.1.0 intl: any just_audio: ^0.9.39 - keyboard_shortcuts: ^0.1.4 + # #Pangea + # keyboard_shortcuts: ^0.1.4 + # Pangea# latlong2: ^0.9.1 linkify: ^5.0.0 # #Pangea @@ -107,8 +110,10 @@ dependencies: wakelock_plus: ^1.2.2 webrtc_interface: ^1.0.13 # #Pangea + android_intent_plus: ^5.2.0 country_picker: ^2.0.25 csv: ^6.0.0 + dropdown_button2: ^2.3.9 fl_chart: ^0.67.0 firebase_analytics: ^11.0.1 firebase_core: ^3.1.0 @@ -128,6 +133,7 @@ dependencies: shimmer: ^3.0.0 syncfusion_flutter_xlsio: ^25.1.40 rive: 0.11.11 + flutter_tts: ^4.0.2 # Pangea# dev_dependencies: @@ -213,8 +219,10 @@ dependency_overrides: version: ^1.0.1 # waiting for null safety # Upstream pull request: https://github.com/AntoineMarcel/keyboard_shortcuts/pull/13 - keyboard_shortcuts: - git: - url: https://github.com/TheOneWithTheBraid/keyboard_shortcuts.git - ref: null-safety - win32: 5.5.3 + # #Pangea + # keyboard_shortcuts: + # git: + # url: https://github.com/TheOneWithTheBraid/keyboard_shortcuts.git + # ref: null-safety + # win32: 5.5.3 + # Pangea# diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 8fbbffa18..f8f7f9c80 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -33,6 +34,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + FlutterTtsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterTtsPlugin")); FlutterWebRTCPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterWebRTCPlugin")); PasteboardPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 315ce5112..f55c3d296 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -8,6 +8,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_windows firebase_core flutter_secure_storage_windows + flutter_tts flutter_webrtc pasteboard permission_handler_windows