1
0
mirror of https://gitlab.com/comunic/comunicmobile synced 2025-07-01 06:03:29 +00:00

100 Commits
1.1.0 ... 1.1.2

Author SHA1 Message Date
06ab90de1e Fix typo 2021-03-13 19:08:23 +01:00
6e4207f517 Fix translation issue 2021-03-13 19:02:08 +01:00
bc0dcbdbb1 Use cleartext traffic in beta 2021-03-13 18:37:39 +01:00
a994d9978c Add missing translations 2021-03-13 18:31:28 +01:00
30b3dc6921 Fix bad translation settings 2021-03-13 18:28:56 +01:00
b1b7772532 Add new french translations 2021-03-13 18:23:12 +01:00
f8910c8f8b Force square account image 2021-03-13 18:11:28 +01:00
e70aaabbc9 Simplify image picking code 2021-03-13 18:03:20 +01:00
ea45bf828c Can crop image 2021-03-13 17:50:59 +01:00
2a00530126 Finish to fix deprecation warnings 2021-03-13 15:42:19 +01:00
ad2cf6d4f9 Continue to fix deprecation warnings 2021-03-13 15:38:43 +01:00
230cb2c018 Continue to fix deprecation warnings 2021-03-13 15:28:34 +01:00
819e2a7590 Start Flutter update 2021-03-13 15:14:54 +01:00
fb80f3bd52 Handle conversation deleted event 2021-03-13 13:52:18 +01:00
f9db9aa632 Remove misplaced try catch block 2021-03-13 13:32:38 +01:00
a630a5ae79 Handle user removed from conversation events 2021-03-13 12:16:57 +01:00
ed9f5e396c Delete conversation => leave conversation 2021-03-13 12:07:16 +01:00
4614f3ae2e Add call notice on conversations list 2021-03-13 12:02:24 +01:00
d10b1d0d22 Fix issue with large screens 2021-03-13 11:48:33 +01:00
99ae726c0a Can remove conversation logo 2021-03-13 11:42:58 +01:00
642f5e11fc Can change conversation image 2021-03-13 11:33:25 +01:00
cbbda7237b Fix invalid check 2021-03-13 11:09:54 +01:00
0b2f939376 Can update conversation settings 2021-03-13 11:08:08 +01:00
fdec22c28a Can update admin status of a user 2021-03-13 11:02:44 +01:00
bd73e265cc Can add members to conversation 2021-03-13 10:52:53 +01:00
5d0ead5889 Can remove members from conversation 2021-03-13 10:48:59 +01:00
ba60fa9e37 Can create conversations 2021-03-13 10:32:11 +01:00
f54cc22fc6 Can change conversation color 2021-03-13 10:09:17 +01:00
d8b2dd2599 Show who is writing messages 2021-03-13 09:29:54 +01:00
7ccc7a492e Fix icon color 2021-03-13 09:10:55 +01:00
1e0e2fca52 Send writing messages event 2021-03-13 09:09:26 +01:00
dbb2a3f1a1 Can cancel file sending 2021-03-13 09:00:00 +01:00
bd5ed8fb33 Change send messages color 2021-03-13 08:41:54 +01:00
3546bacc83 Fix issues on white mode 2021-03-13 08:40:37 +01:00
201200299c Improve messages appearance 2021-03-13 08:37:59 +01:00
c1196a6359 Show files thumbnails, if available 2021-03-13 08:33:55 +01:00
bedc3f5277 Add user account image 2021-03-13 08:26:56 +01:00
5a25769b71 Start to improve messages appearance 2021-03-13 08:17:54 +01:00
05c806b358 Fix colors 2021-03-12 23:09:40 +01:00
70eb088756 Remove useless code 2021-03-12 23:07:26 +01:00
b0cfeec513 Apply conversation color 2021-03-12 22:45:55 +01:00
e35a0d2fd4 Improve send message form 2021-03-12 22:40:33 +01:00
e638398b2e Work progress on conversation form 2021-03-12 22:18:32 +01:00
f3626f233f Remove intrusive permission 2021-03-12 21:39:24 +01:00
ece9164d93 Fix audio record 2021-03-12 21:19:40 +01:00
e7b1beca50 Start to record MP3 files 2021-03-12 20:52:26 +01:00
6fc1a263d2 Enforce thumbnail width for videos 2021-03-12 19:37:25 +01:00
b84eba59e3 Generate video thumbnails 2021-03-12 19:36:42 +01:00
8f7ca14586 Can take videos 2021-03-12 19:10:10 +01:00
19d4e1d31c Extend the possibilities of file picker 2021-03-12 18:54:15 +01:00
701d5d3c27 File update system 2021-03-12 17:47:09 +01:00
ec4ca238de Improve update message dialog 2021-03-12 16:37:21 +01:00
f70717a987 Hide useless menu options 2021-03-12 16:21:45 +01:00
e02ab259b6 Updated conversations policy 2021-03-12 16:19:07 +01:00
a4181e3d42 Remove un-used dependency 2021-03-11 20:35:22 +01:00
858f81d05e Add a video player 2021-03-11 20:31:06 +01:00
46affd4e68 Avoid too big dialog 2021-03-11 20:20:41 +01:00
3518594eea Integrate an audio player in the application 2021-03-11 20:15:49 +01:00
8f2574a555 Fix unread conversations drop down 2021-03-11 18:50:36 +01:00
3257fd865f Fix conversations appeareance in sidebar 2021-03-11 18:18:58 +01:00
f9502d1700 Apply conversation icon to more places 2021-03-11 18:11:24 +01:00
b9babd43a8 Display icons for all conversations 2021-03-11 18:00:06 +01:00
c8ca80f6e7 Added conversation message statistics 2021-03-11 17:27:20 +01:00
217111e3fd Can copy message to clipboard 2021-03-11 17:04:18 +01:00
8705aa1b0d Resize conversation images 2021-03-11 00:25:58 +01:00
0458d5431c Fix files background color 2021-03-11 00:23:11 +01:00
75a80b1018 Fix date appearance 2021-03-11 00:15:04 +01:00
52d217a89c Simplify conversation files appearance 2021-03-11 00:13:05 +01:00
1f1ed0cda4 Fix display issue 2021-03-11 00:06:29 +01:00
6c00e0bcab Show message files 2021-03-11 00:02:41 +01:00
2989e98c50 Add support for server conversation message 2021-03-10 19:08:18 +01:00
08c77340a0 Add support for conversation image 2021-03-10 18:12:56 +01:00
a23b76b552 Parse correctly conversation color 2021-03-10 18:04:29 +01:00
dacccf57b5 Start conversation upgrade 2021-03-10 17:54:41 +01:00
b094361f5a Fix bad translation 2021-02-20 12:39:22 +01:00
1ee9a2c5cc Update build script 2021-02-20 12:32:03 +01:00
25a25e4c70 Update makefile 2021-02-20 12:12:19 +01:00
d4a0748249 Add missing french translations 2021-02-20 10:12:01 +01:00
1ea286f3ef Add download link on deprecation dialog 2021-02-20 09:59:21 +01:00
a32e968992 Terms of use are not hard coded anymore 2021-02-20 09:35:03 +01:00
0cd9371460 Show deprecation warning 2021-02-20 09:24:51 +01:00
1b0a3fd24b Add new build configuration 2021-02-20 09:03:17 +01:00
54e37b3e69 Display current build version on startup screen 2021-02-20 08:58:03 +01:00
2519adeef4 Fix typo 2021-02-20 08:41:56 +01:00
4f9001cb2b Remove useless configuration load checks 2021-02-19 17:38:12 +01:00
613ceadfaa Load configuration at application startup 2021-02-19 17:32:38 +01:00
459757b292 Fix issues with WebSocket 2021-02-18 19:36:51 +01:00
e399f71a78 Fix issue when WebSocket is closed 2021-02-18 19:11:50 +01:00
c5d1512375 Apply password policy on all forms 2021-02-18 18:58:47 +01:00
16ec9a8e00 Rename route with a more logical name 2021-02-18 18:28:57 +01:00
c19cbaac88 Fix invalid call 2021-02-18 18:27:24 +01:00
277c08048d Implement password policy for account creation 2021-02-18 18:20:50 +01:00
482e938744 Fix bad layout 2021-02-16 19:36:14 +01:00
1d0bd45632 Finish data conservation policy screen 2021-02-16 19:35:52 +01:00
3a39387365 Start to integrate data conservation policy 2021-02-16 19:30:06 +01:00
4d885affb9 Add Makefile 2021-02-13 17:13:09 +01:00
0ad8d5c393 Fix bad sign out order 2021-02-13 16:07:23 +01:00
e5ed4fadda Upgrade login system 2021-02-13 16:03:07 +01:00
581059cb1d Deprecate old movie system 2021-02-13 11:27:13 +01:00
0c526abfe8 Start to work on next version 2021-02-13 08:45:29 +01:00
131 changed files with 4992 additions and 1666 deletions

19
Makefile Normal file
View File

@ -0,0 +1,19 @@
beta_online_release:
flutter build apk --flavor beta -t lib/main_online.dart
beta_dev_release:
flutter build apk --flavor beta -t lib/main_dev.dart
beta_online_release_split_per_abi:
flutter build apk --flavor beta -t lib/main_online.dart --target-platform android-arm,android-arm64,android-x64 --split-per-abi
stable_release_split_per_abi:
flutter build apk --flavor stable -t lib/main_online.dart --target-platform android-arm,android-arm64,android-x64 --split-per-abi
stable_release:
flutter build apk --flavor stable -t lib/main_online.dart
.PHONY: beta_offline_release beta_online_release_split_per_abi stable_release_split_per_abi

View File

@ -3,7 +3,9 @@
package="org.communiquons.comunic">
<application
tools:replace="android:label"
android:label="Comunic Beta" />
android:label="Comunic Beta"
android:usesCleartextTraffic="true"
tools:replace="android:label" />
</manifest>

View File

@ -4,4 +4,7 @@
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- Use clear connection in dev mode -->
<application android:usesCleartextTraffic="true" />
</manifest>

View File

@ -14,6 +14,16 @@
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- This is required on Android 11+ for image picker -->
<queries>
<intent>
<action android:name="android.media.action.IMAGE_CAPTURE" />
</intent>
<intent>
<action android:name="android.media.action.VIDEO_CAPTURE" />
</intent>
</queries>
<!-- io.flutter.app.FlutterApplication is an android.app.Application that
calls FlutterMain.startInitialization(this); in its onCreate method.
@ -26,6 +36,8 @@
android:roundIcon="@mipmap/ic_launcher_round"
tools:ignore="GoogleAppIndexingWarning">
<meta-data
android:name="flutterEmbedding"
android:value="2" />
@ -58,5 +70,11 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- This let the image cropper work -->
<activity
android:name="com.yalantis.ucrop.UCropActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.AppCompat.Light.NoActionBar"/>
</application>
</manifest>

View File

@ -1,4 +1,11 @@
{
"%1% : %2%": "%1% : %2%",
"%1% added %2% to the conversation": "%1% a ajouté %2% à la conversation",
"%1% and %2% are writing...": "%1% et %2% sont en train d'écrire...",
"%1% created the conversation": "%1% a créé la conversation",
"%1% is writing...": "%1% est en train d'écrire...",
"%1% left the conversation": "%1% a quitté la conversation",
"%1% removed %2% from the conversation": "%1% a retiré %2% de la conversation",
"%days% Days %hours% Hours %minutes% Minutes %seconds% Seconds": "\"%days% Jours %hours% Heures %minutes% Minutes %seconds% Secondes\"",
"%days%d": "%days% j",
"%hours% h": "%hours% h",
@ -9,13 +16,23 @@
"%num% members": "%num% membres",
"%secs%s": "%secs% s",
"%years% years": "%years% ans",
"'%1%' copied to clipboard!": "'%1%' copié dans le presse papier !",
"1 Like": "1 personne aime",
"1 member": "1 membre",
"1 month": "1 mois",
"1 year": "1 an",
"10 years": "10 ans",
"15 days": "15 jours",
"2 years": "2 ans",
"3 months": "3 mois",
"5 years": "5 ans",
"50 years": "50 ans",
"6 months": "6 mois",
"7 days": "7 jours",
":yourShortcut:": ":votreRaccourcis:",
"A network error occured!": "Une erreur de réseau s'est produite !",
"A registration is required to access this group page.": "Une inscription est nécessaire pour accéder à cette page de groupe !",
"ALL": "TOUS",
"About this application": "A propos de cette application",
"Accept": "Accepter",
"Accept request": "Accepter la demande",
@ -29,6 +46,7 @@
"Add image": "Ajouter une image",
"Add member": "Ajouter un membre",
"Add new emoji": "Ajouter un nouvel émoticon",
"Admin": "Admin",
"Administrator": "Administrateur",
"All members": "Tous les membres",
"All the members of the group can create posts on the group": "Tous les membres du groupe peuvent créer des posts",
@ -41,6 +59,7 @@
"An error occured while creating your account. Please try again.": "Une erreur s'est produite lors de la création du compte. Veuillez ré-essayer.",
"An error occurred while checking your options !": "Erreur lors de la récupération de vos options de récupération !",
"An error occurred while checking your recovery options !": "Erreur lors de la récupération de vos options de récupération !",
"An error occurred while creating your account. Please try again.": "Erreur lors de la création de votre compte. Veuillez réessayer",
"Answer %num%": "Réponse %num%",
"Answer 1": "Réponse 1",
"Answer 2": "Réponse 2",
@ -49,7 +68,16 @@
"Appearance": "Apparence",
"Application settings": "Paramètres de l'application",
"Are you sure do you want to remove this friend from your list of friends ? A friendship request will have to be sent to get this user back to your list!": "Voulez-vous vraiment supprimer cet ami de votre liste d'amis ? Il faudra une demande d'ami pour réintégrer cet utilisateur à votre liste !",
"Audio Player": "Lecteur audio",
"Audio record": "Enregistrement audio",
"Automatically delete unread notifications after": "Supprimer automatiquement les notifications non lues après",
"Automatically delete your account if you have been inactive for": "Supprimer votre compte si vous avez été déconnecté pendant",
"Automatically delete your comments after": "Supprimer automatiquement vos commentaires après",
"Automatically delete your conversation messages after": "Supprimer automatiqumenet vos messages de conversations après",
"Automatically delete your likes after": "Supprimer automatiquement vos \"J'aime\" après",
"Automatically delete your posts after": "Supprimer automatiquement vos posts après",
"Block the creation of new responses": "Bloquer la création de nouvelles réponses",
"Browse files": "Parcourir les fichiers",
"Camera": "Caméra",
"Can access to all group posts": "Peut accéder à tous les posts du groupe",
"Can always create posts, invite users and respond to membership request": "Peut toujours envoyer des posts, inviter des utilisateurs et répondre aux demande d'inscription au groupe",
@ -59,14 +87,17 @@
"Cancel response to survey": "Annuler la réponse au sondage",
"Change account image visibility": "Changer la visibilité de l'image de compte",
"Change level": "Changer le niveau",
"Change logo": "Changer",
"Change password": "Changer le mot de passe",
"Change your password": "Changer de mot de passe",
"Change your security questions": "Changer les questions de sécurité",
"Checking availability...": "Vérification de la disponibilité...",
"Choose a new password": "Choisir un nouveau mot de passe",
"Choose a user": "Choisir un utilisateur",
"Choose a video": "Choisir une vidéo",
"Choose a virtual directory": "Choisir un répertoire virtuel",
"Choose an image": "Choisir une image",
"Close": "Fermer",
"Closed registration": "Inscription fermée",
"Comunic": "Comunic",
"Comunic is a free and OpenSource social network that respect your privacy.": "Comunic est un réseau social libre qui respecte votre vie privée.",
@ -79,9 +110,14 @@
"Confirm your password": "Confirmer le mot de passe",
"Congratulations! Your password has now been successfully changed!": "Félicitations ! Votre mot de passe a bien été changé !",
"Connected users": "Utilisateurs connectés",
"Conversation color (optional)": "Couleur de conversation (optionnel)",
"Conversation logo": "Logo de la conversation",
"Conversation members": "Membres de la conversation",
"Conversation name (optional)": "Nom de la conversation (optionnel)",
"Conversation name (optionnal)": "Nom de la conversation (optionnel)",
"Conversations": "Conversations",
"Copy URL": "Copier l'URL",
"Copy message": "Copier le message",
"Could not block the creation of new choices!": "Erreur lors du bloquage de la création de nouveaux choix !",
"Could not cancel invitation!": "Erreur lors de l'annulation de l'invitation !",
"Could not cancel your membership request!": "Erreur lors de l'annulation de votre demande à rejoindre ce groupe !",
@ -121,6 +157,7 @@
"Could not get user information!": "Impossible de récupérer les informations de l'utilisateur !",
"Could not initialize call!": "Erreur lors de l'initialisation de l'appel !",
"Could not invite a user!": "Erreur lors de l'envoi de l'invitation pour l'utilisateur !",
"Could not leave the conversation!": "Erreur lors du retrait de la conversation !",
"Could not load conversation information!": "Erreur lors de la récupération des informations sur la conversation !",
"Could not load friendship information!": "Erreur lors de la récupération des informations sur cet ami !",
"Could not load general settings!": "Erreur lors du chargement des paramètres généraux !",
@ -174,6 +211,9 @@
"Create a new post...": "Créer un nouveau post...",
"Create an account": "Créer un compte",
"Create the conversation": "Créer la conversation",
"Created on": "Créé le",
"Creator": "Créateur",
"Crop Photo": "Rogner la photo",
"Current account image": "Image de compte actuelle",
"Current choices:": "Choix actuels :",
"Current level: %level%": "Niveau actuel : %level%",
@ -181,6 +221,7 @@
"Custom emojis": "Emoticons personnalisés",
"Customize your account image": "Personalisez votre image de compte",
"Danger zone": "Zone de danger",
"Data conservation policy": "Politique de conservation des données",
"Debug features": "Fonctionnalités de développement",
"Delete": "Supprimer",
"Delete account image": "Supprimer l'image de compte",
@ -190,6 +231,8 @@
"Delete group": "Supprimer le groupe",
"Delete logo": "Supprimer le logo",
"Delete your account": "Supprimer votre compte",
"Deprecated application version": "Version obsolète de l'application",
"Did not get permission to access microphone!": "Permission d'accéder au microphone refusée !",
"Disconnect all your devices": "Déconnecter tous vos appareils",
"Disconnect all your devices from Comunic, including the current one. Use this option if one of the device you use for Comunic was stolen.": "Déconnecte tous vos appareils de Comunic, en incluant l'appareil actuel. Nous vous recommandons d'utiliser cette option si vous avez des raisons de penser que l'un des appareils que vous utiliser pour accéder à Comunic a été volé.",
"Do you really want to block new choices creation?": "Voulez-vous vraiment bloquer la création de nouveaux choix ?",
@ -201,27 +244,56 @@
"Do you really want to delete this custom emoji ?": "Voulez-vous vraiment supprimer cet émoticon personnalisé ?",
"Do you really want to delete this group ? All the posts related to it will be permanently deleted!": "Voulez-vous vraiment supprimer ce groupe ? Tous les posts s'y rapportant seront également supprimés !",
"Do you really want to delete this group membership ?": "Voulez-vous vraiment quitter ce groupe ?",
"Do you really want to delete this logo?": "Voulez-vous vraiment supprimer ce logo ?",
"Do you really want to delete this message ? The operation can not be cancelled !": "Voulez-vous vraiment supprimer ce message ? Cette opération est irréversible !",
"Do you really want to delete this post ? The operation can not be reverted !": "Voulez-vous vraiment supprimer ce post ? Cette opération est irréversible !",
"Do you really want to delete your account image ?": "Voulez-vous vraiment supprimer votre image de compte ?",
"Do you really want to delete your account? This operation CAN NOT be reverted!": "Voulez-vous vraiment supprimer votre compte ? Cette opération NE PEUT PAS être annulée !",
"Do you really want to disconnect all your devices from Comunic ?": "Voulez-vous vraiment déconnecter tous vos appareils de Comunic ?",
"Do you really want to leave this call ?": "Voulez-vous vraiment quitter cet appel ?",
"Do you really want to leave this conversation ?": "Voulez-vous vraiment quitter cette conversation ?",
"Do you really want to leave this conversation ? As you are its last admin, it will be completely deleted!": "Voulez-vous vraiment quitter cette conversation ? Comme vous êtes son dernier administrateur, celle-ci sera complètement supprimée !",
"Do you really want to reject this friendship request?": "Voulez-vous vraiment rejeter cette demande d'amis ?",
"Do you really want to reject this invitation?": "Voulez-vous vraiment refuser cette invitation ?",
"Do you really want to remove this conversation from your list of conversations ? If you are the owner of this conversation, it will be completely deleted!": "Voulez-vous vraiment supprimer la conversation de votre liste ? Si vous êtes le créateur de cette conversation, elle sera définitivement supprimée !",
"Do you really want to remove this membership ?": "Voulez-vous vraiment supprimer cette inscription ?",
"Do you really want to sign out from the application ?": "Voulez-vous vraiment vous déconnecter de l'application ?",
"Do you want to unselected currently selected image ?": "Voulez-vous désélectionner l'image ?",
"Done": "Terminé",
"Download update outside Play Store": "Télécharger la mise hors du Play Store",
"Email address": "Adresse e-mail",
"Email address...": "Adresse mail...",
"Enable dark theme": "Activer le thème sombre",
"Error": "Erreur",
"Error while creating your account": "Une erreur s'est produite lors de la création de votre compte.",
"Error while pausing playback!": "Erreur lors de la pause de la lecture !",
"Error while playing record!": "Erreur lors de la lecture de l'enregistrement !",
"Error while processing new signal!": "Erreur lors du traitement d'un signal !",
"Error while recording!": "Erreur lors de l'enregistrement !",
"Error while resuming playback!": "Erreur lors de la reprise de la lecture !",
"Error while stopping playback!": "Erreur lors de l'arrêt de la lecture !",
"Everyone": "Tout le monde",
"Everyone can choose to join the group without moderator approval": "Tout le monde peut rejoindre le groupe, sans l'approbation d'un modérateur",
"Everyone can request a membership, but a moderator review the request": "Tout le monde peut demander à rejoindre le groupe, mais un modérateur doit accepter les demandes",
"Failed to add member to conversation!": "Echec de l'ajout d'un membre à la conversation !",
"Failed to change conversation logo !": "Erreur lors du changement de logo pour la conversation !",
"Failed to choose an image!": "Erreur lors du choix d'une image !",
"Failed to execute image cropper!": "Echec de l'exécution du rogneur d'image !",
"Failed to initialize audio player!": "Echec de l'initialisation du lecteur audio !",
"Failed to initialize video!": "Erreur lors de l'initialisation de la vidéo !",
"Failed to load conversation settings!": "Echec du chargement des paramètres de la conversation !",
"Failed to load message information!": "Echec du chargement des informations du message !",
"Failed to load privacy settings!": "Erreur lors du chargement des paramètres de vie privée !",
"Failed to pick an image for the post!": "Echec de la sélection d'une image pour le post !",
"Failed to pick an image!": "Echec de la sélection d'une image !",
"Failed to remove conversation logo!": "Erreur lors de la suppression du logo de la conversation !",
"Failed to remove member!": "Echec de la suppression d'un membre !",
"Failed to send a file!": "Erreur lors de l'envoi d'un fichier !",
"Failed to start recording!": "Erreur lors du lancement de l'enregistrement !",
"Failed to toggle admin status of user!": "Echec du changement du status administrateur d'un membre !",
"Failed to update conversation settings!": "Echec de la mise à jour des paramètres de la conversation !",
"Failed to update data conservation policy!": "Echec de la mise à jour des paramètres de vie privée !",
"Failed to upload new account image!": "Echec de l'envoi de la nouvelle image de compte !",
"First name": "Prénom",
"Follow": "Suivre",
"Follow conversation": "Suivre la conversation",
@ -229,7 +301,7 @@
"Force mobile mode": "Forcer l'utilisation du mode mobile",
"Force the smartphone mode of the application to be used, even when tablet mode could be used.": "Forcer l'utilisation du mode smartphone de l'application, même lorsque le mode tablette est disponible.",
"Form can not be submitted at this point!": "Impossible de soumettre le formulaire à ce stade !",
"Free social network that respect your privacy": "Réseau sociale libre qui respecte votre vie privée",
"Free social network that respect your privacy": "Réseau social libre qui respecte votre vie privée",
"Friends": "Amis",
"Friends of %name%": "Amis de %name%",
"Friends only": "Amis seulement",
@ -238,6 +310,7 @@
"General settings": "Paramètres généraux",
"Generate a new random logo": "Générer un logo aléatoire",
"Generate a random account image": "Générer une image de compte aléatoire",
"Go to the Play Store": "Accéder au Play Store",
"Group": "Groupe",
"Group ID": "Identifiant du gorupe",
"Group URL (optional)": "URL du groupe (optionnelle)",
@ -269,6 +342,7 @@
"Invited": "Invité",
"Last name": "Nom",
"Learn more about us": "En savoir plus sur nous",
"Leave": "Quitter",
"Let us ask you one last time. Do you really want to delete your account? If you decide to do so, your data will be permanently removed from our servers, so we will not be able to recover your account. If you decide to proceed, the deletion process will start immediatly and you will automatically get disconnected from your account.": "Laissez-nous vous demander une dernière fois. Voulez-vous vraiment supprimer votre compte ? Si vous décidez de continuer, les données liées à votre compte vont être supprimées de manière permanente de nos serveurs, et nous ne seront pas en mesure de les restaurer. Si vous décidez de poursuivre, le processus de supprimer vas débuter immédiatement et vous serez automatiquement déconnecté de votre compte.",
"Like": "J'aime",
"Loading": "Chargement",
@ -284,7 +358,10 @@
"Members": "Membres",
"Membership": "Inscription",
"Menu": "Menu",
"Message not seen yet": "Message non vu",
"Message rejected by the server!": "Message rejeté par le serveur !",
"Message seen": "Message vu",
"Message statistics": "Statistiques du message",
"Moderated registration": "Inscription modérée",
"Moderator": "Modérateur",
"Moderators only": "Modérateurs uniquement",
@ -293,13 +370,16 @@
"My friends only": "Mes amis uniquement",
"Name of the group": "Nom du groupe",
"Name of the group to create": "Nom du groupe à créer",
"Never": "Jamais",
"New choice": "Nouveau choix",
"New choice...": "Nouveau choix...",
"New comment...": "Nouveau commentaire...",
"New content...": "Nouveau contenu...",
"New content:": "Nouveau contenu :",
"New file": "Nouveau fichier",
"New membership level": "Nouveau niveau d'appartenance au groupe",
"New message": "Nouveau message",
"New message...": "Nouveau message...",
"New password": "Nouveau mot de passe",
"New survey": "Nouveau sondage",
"Newest": "Plus récent",
@ -327,6 +407,8 @@
"Password required": "Mot de passe requis",
"Permanently delete your account and all data related to it.": "Supprimer de manière permanente votre compte et toute les données qui y sont rattachées",
"Personal website URL (optional)": "Site web personnel (optionnel)",
"Playback paused...": "Lecture en pause",
"Playing...": "Lecture...",
"Please answer now your security questions:": "Veuillez répondre à vos questions de sécurité :",
"Please choose new account image visibility level:": "Veuillez choisir un nouveau niveau de visibilité pour votre image de compte :",
"Please enter message content: ": "Veuillez entrer le contenu du message :",
@ -349,6 +431,9 @@
"Question": "Question",
"Question 1": "Question 1",
"Question 2": "Question 2",
"Ready": "Prêt",
"Record audio": "Faire un enregistrement audio",
"Recording...": "Enregistrement...",
"Reject": "Rejeter",
"Reject request": "Rejeter la demande",
"Remove": "Supprimer",
@ -378,20 +463,26 @@
"Sign out": "Déconnexion",
"Specified email address was not found!": "L'adresse mail spécifiée n'a pas été trouvée !",
"Specify URL": "Spécifier l'URL",
"Statistics": "Statistiques",
"Stop streaming": "Arrêter de partager ma vidéo & mon audio",
"Submit": "Valider",
"Switch camera": "Changer de caméra",
"Take a picture": "Prendre une photo",
"Take a video": "Prendre une vidéo",
"The group is accessible to accepted members only.": "Le groupe n'est accessible qu'à ses membres",
"The group is visible only to invited members.": "Le groupe n'apparaît qu'à ses membres, et aux personnes invitées à le rejoindre.",
"The only way to join the group is to be invited by a moderator": "Seul un modérateur peut inviter quelqu'un à rejoindre le groupe",
"The password and its confirmation do not match!": "La confirmation ne correspond pas au mot de passe !",
"The post has been successfully created!": "Le post a été créé avec succès !",
"There is no message yet in this converation.": "Il y n'a pas encore de message dans cette conversation.",
"There is no message yet in this conversation.": "Il y n'a pas encore de message dans cette conversation.",
"There is no post to display here yet.": "Il n'y a pas encore de post à afficher.",
"This account is private.": "Ce compte est privé.",
"This file could not be sent: it is too big! (Max allowed size: %1%)": "Ce fichier ne peut pas être envoyé : il est trop lourd ! (Taille maximale autorisée: %1%)",
"This kind of notification is not supported yet by this application.": "Ce type de notification n'est pas encore supportée par l'application.",
"This password is not the same as the other one!": "Ce mot de passe est différent de l'autre",
"This version of the Comunic application is deprecated. You might still be able to use it, but some features may not work. We recommend you to update to the latest version of the application.": "Cette version de l'application Comunic est obsolète. Vous pouvez continuer à l'utiliser, mais certaines fonctionalités pourront ne plus fonctionner. Nous vous recommandons d'installer la dernière version de l'applicatioon.",
"This virtual directory is invalid / unvailable !": "Ce répertoire virtuel est invalide / indisponible !",
"Toggle admin status": "Changer le status d'admin",
"Too many accounts have been created from this IP address for now. Please try again later.": "Trop de comptes ont été créés avec cette addresse IP pour l'instant. Veuillez ré-essayer plus tard.",
"Too many unsuccessfull login attempts! Please try again later...": "Trop de tentatives de connexion ont échoué. Veuillez ré-essayer plus tard...",
"Try again": "Essayer à nouveau",
@ -400,6 +491,7 @@
"Update a conversation": "Modifier une conversation",
"Update comment content": "Modifier le contenu du commentaire",
"Update content": "Modifier le contenu",
"Update conversation": "Mise à jour d'une conversation",
"Update message": "Modifier un message",
"Update post content": "Modifier le contenu du post",
"Update security questions": "Mise à jour des questions de sécurité",
@ -407,7 +499,9 @@
"Upload a new logo": "Envoyer un nouveau logo",
"Upload an account image": "Envoyer une nouvelle image de compte",
"Upload new account image": "Changer l'image de compte",
"Use the old application anyway": "Utiliser l'ancienne version",
"User ID": "Numéro d'utilisateur",
"Version %version% - Build %build%": "Version %version% - Build %build%",
"Virtual directory": "Répertoire virtuel",
"Virtual directory (optional)": "Dossier virtuel (optionnel)",
"Visitor": "Visiteur",
@ -421,7 +515,7 @@
"You security questions have been successfully updated!": "Vos questions de sécurité ont été mises avec succès !",
"You will need to restart the application to apply changes": "Vous aurez besoin de redémarrer l'application pour appliquer les changements",
"YouTube movie": "Vidéo YouTube",
"Your account has been successfully created. You can now login to start to use it.": "Votre compte a été créé avec succès. Vous pouvez à présent vous connecter et le gérer.",
"Your account has been successfully created. You can now login to start to use it.": "Votre compte a été créé avec succès. Vous pouvez à présent vous connecter pour commencer à l'utiliser !",
"Your account image is visible by everyone, including users external to Comunic.": "Votre image de compte est visible par tout le monde, ainsi que les personnes non connectées.",
"Your account image is visible only by your friends.": "Votre image de compte n'est visible que par vos amis",
"Your account image is visible only to connected Comunic users.": "Votre image de compte n'est accessible qu'aux personnes connectées.",
@ -430,6 +524,11 @@
"Your new password": "Votre nouveau mot de passe",
"Your page settings": "Paramètres de votre page",
"Your password has been successfully changed!": "Votre mot de passe a été changé avec succès !",
"Your password must be composed of at least %num% characters!": "Votre mot de passe doit être composé d'au moins %num% caractères !",
"Your password must contains characters of at least %num% of the following categories : %upper% upper case letter, %lower% lowercase letter, %digit% digit, %special% special character.": "Votre mot de passe doit contenir des caractères d'au moins %num% des catégories suivants : %upper% lettre majuscule, %lower% lettre minuscule, %digit% chiffre, %special% caractères spéciaux.",
"Your password must not contains part of your email address!": "Votre mot de passe ne doit pas contenir des parties de votre adresse mail !",
"Your password must not contains your first name!": "Votre mot de passe ne doit pas contenir votre prénom !",
"Your password must not contains your last name!": "Votre mot de passe ne doit pas contenir votre nom !",
"Your response: %response%": "Votre réponse : %response%",
"Your security questions can be used to recover an access to your account when you loose your password...": "Vos questions de sécurité peuvent être utilisées pour récupérer l'accès à votre compte lorsque vous perdez votre mot de passe...",
"accepted his invitation to join the group": "a accepté son invitation à rejoindre le groupe",

6
lib/constants.dart Normal file
View File

@ -0,0 +1,6 @@
/// Comunic mobile constants
///
/// @author Pierre Hubert
/// Data serialisation directory
const SERIALIZATION_DIRECTORY = "serialization";

View File

@ -2,13 +2,4 @@
///
/// @author Pierre HUBERT
enum PostKind {
TEXT,
IMAGE,
WEB_LINK,
PDF,
MOVIE,
COUNTDOWN,
SURVEY,
YOUTUBE
}
enum PostKind { TEXT, IMAGE, WEB_LINK, PDF, COUNTDOWN, SURVEY, YOUTUBE }

View File

@ -3,9 +3,8 @@ import 'package:comunic/helpers/preferences_helper.dart';
import 'package:comunic/helpers/websocket_helper.dart';
import 'package:comunic/models/api_request.dart';
import 'package:comunic/models/authentication_details.dart';
import 'package:comunic/models/login_tokens.dart';
import 'package:comunic/models/new_account.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:comunic/models/res_check_password_reset_token.dart';
/// Account helper
///
@ -26,8 +25,6 @@ enum CreateAccountResult {
}
class AccountHelper {
static const _USER_ID_PREFERENCE_NAME = "user_id";
// Current user ID
static int _currentUserID = -1;
@ -36,7 +33,7 @@ class AccountHelper {
/// Warning : This method MUST BE CALLED AT LEAST ONCE AFTER APP START !!!
Future<bool> signedIn() async {
bool signedIn =
(await PreferencesHelper.getInstance()).getLoginTokens() != null;
(await PreferencesHelper.getInstance()).getLoginToken() != null;
// Load current user ID for later use
if (signedIn && _currentUserID == -1) await _loadCurrentUserID();
@ -47,8 +44,8 @@ class AccountHelper {
/// Sign in user
Future<AuthResult> signIn(AuthenticationDetails auth) async {
final request = APIRequest(uri: "account/login");
request.addString("userMail", auth.email);
request.addString("userPassword", auth.password);
request.addString("mail", auth.email);
request.addString("password", auth.password);
final response = await APIHelper().exec(request);
@ -59,10 +56,9 @@ class AccountHelper {
return AuthResult.TOO_MANY_ATTEMPTS;
else if (response.code != 200) return AuthResult.NETWORK_ERROR;
// Save login tokens
final tokensObj = response.getObject()["tokens"];
// Save login token
await (await PreferencesHelper.getInstance())
.setLoginTokens(LoginTokens(tokensObj["token1"], tokensObj["token2"]));
.setLoginToken(response.getObject()["token"]);
// Get current user ID
final userID = await _downloadCurrentUserID();
@ -72,8 +68,8 @@ class AccountHelper {
}
// Save current user ID
final preferences = await SharedPreferences.getInstance();
await preferences.setInt(_USER_ID_PREFERENCE_NAME, userID);
final preferences = await PreferencesHelper.getInstance();
await preferences.setInt(PreferencesKeyList.USER_ID, userID);
_currentUserID = userID;
return AuthResult.SUCCESS;
@ -81,7 +77,11 @@ class AccountHelper {
/// Sign out user
Future<void> signOut() async {
await (await PreferencesHelper.getInstance()).setLoginTokens(null);
await APIRequest.withLogin("account/logout").exec();
final preferencesHelper = await PreferencesHelper.getInstance();
await preferencesHelper.setLoginToken(null);
await preferencesHelper.setInt(PreferencesKeyList.USER_ID, -1);
_currentUserID = 0;
// Close current web socket
@ -125,6 +125,11 @@ class AccountHelper {
.execWithThrow())
.getObject()["exists"];
/// Get current user email address
static Future<String> getCurrentAccountEmailAddress() async =>
(await APIRequest.withLogin("account/mail")
.execWithThrowGetObject())["mail"];
/// Check out whether security questions have been set for an account or not
///
/// Throws in case of failure
@ -161,10 +166,19 @@ class AccountHelper {
/// Check a password reset token
///
/// Throws in case failure
static Future<void> validatePasswordResetToken(String token) async =>
await APIRequest.withoutLogin("account/check_password_reset_token")
.addString("token", token)
.execWithThrow();
static Future<ResCheckPasswordToken> validatePasswordResetToken(
String token) async {
final response =
await APIRequest.withoutLogin("account/check_password_reset_token")
.addString("token", token)
.execWithThrowGetObject();
return ResCheckPasswordToken(
firstName: response["first_name"],
lastName: response["last_name"],
email: response["mail"],
);
}
/// Change account password using password reset token
///
@ -178,10 +192,7 @@ class AccountHelper {
/// Get current user ID from the server
Future<int> _downloadCurrentUserID() async {
final response = await APIRequest(
uri: "user/getCurrentUserID",
needLogin: true,
).exec();
final response = await APIRequest.withLogin("account/id").exec();
if (response.code != 200) return null;
@ -190,8 +201,8 @@ class AccountHelper {
/// Get the ID of the currently signed in user
Future<void> _loadCurrentUserID() async {
final preferences = await SharedPreferences.getInstance();
_currentUserID = preferences.getInt(_USER_ID_PREFERENCE_NAME);
final preferences = await PreferencesHelper.getInstance();
_currentUserID = preferences.getInt(PreferencesKeyList.USER_ID);
}
/// Check if current user ID is loaded or not

View File

@ -19,15 +19,13 @@ class APIHelper {
Future<APIResponse> exec(APIRequest request, {bool multipart = false}) async {
try {
//Add API tokens
request.addString("serviceName", config().serviceName);
request.addString("serviceToken", config().serviceToken);
request.addString("client", config().clientName);
//Add user tokens (if required)
if (request.needLogin) {
final tokens = (await PreferencesHelper.getInstance()).getLoginTokens();
assert(tokens != null);
request.addString("userToken1", tokens.tokenOne);
request.addString("userToken2", tokens.tokenTwo);
final token = (await PreferencesHelper.getInstance()).getLoginToken();
assert(token != null);
request.addString("token", token);
}
// Determine server URL
@ -62,23 +60,14 @@ class APIHelper {
contentType: v.type,
)));
}
// Process picked files
for (final key in request.pickedFiles.keys) {
var v = request.pickedFiles[key];
data.files.add(MapEntry(
key,
MultipartFile.fromBytes(
await v.readAsBytes(),
filename: v.path.split("/").last,
)));
}
}
// Execute the request
final response = await Dio().post(
url.toString(),
data: data,
cancelToken: request.cancelToken,
onSendProgress: request.progressCallback,
options: Options(
receiveDataWhenStatusError: true,
validateStatus: (s) => true,

View File

@ -17,7 +17,7 @@ class CommentsHelper {
"content": comment.hasContent ? comment.content : "",
});
if (comment.hasImage) request.addPickedFile("image", comment.image);
if (comment.hasImage) request.addBytesFile("image", comment.image);
final response = await request.execWithFiles();

View File

@ -1,5 +1,5 @@
import 'package:comunic/helpers/database/conversation_messages_database_helper.dart';
import 'package:comunic/helpers/database/conversations_database_helper.dart';
import 'package:comunic/helpers/serialization/conversation_message_serialization_helper.dart';
import 'package:comunic/helpers/serialization/conversations_serialization_helper.dart';
import 'package:comunic/helpers/users_helper.dart';
import 'package:comunic/helpers/websocket_helper.dart';
import 'package:comunic/lists/conversation_messages_list.dart';
@ -9,11 +9,17 @@ import 'package:comunic/lists/users_list.dart';
import 'package:comunic/models/api_request.dart';
import 'package:comunic/models/api_response.dart';
import 'package:comunic/models/conversation.dart';
import 'package:comunic/models/conversation_member.dart';
import 'package:comunic/models/conversation_message.dart';
import 'package:comunic/models/displayed_content.dart';
import 'package:comunic/models/new_conversation.dart';
import 'package:comunic/models/new_conversation_message.dart';
import 'package:comunic/models/new_conversation_settings.dart';
import 'package:comunic/models/unread_conversation.dart';
import 'package:comunic/utils/account_utils.dart';
import 'package:comunic/utils/color_utils.dart';
import 'package:comunic/utils/dart_color.dart';
import 'package:dio/dio.dart';
import 'package:meta/meta.dart';
/// Conversation helper
@ -25,140 +31,147 @@ enum SendMessageResult { SUCCESS, MESSAGE_REJECTED, FAILED }
class ConversationsHelper {
static final _registeredConversations = Map<int, int>();
final ConversationsDatabaseHelper _conversationsDatabaseHelper =
ConversationsDatabaseHelper();
final ConversationMessagesDatabaseHelper _conversationMessagesDatabaseHelper =
ConversationMessagesDatabaseHelper();
/// Create a new conversation
///
/// Return the ID of the newly created conversation or -1 in case of failure
Future<int> createConversation(Conversation settings) async {
final response =
await APIRequest(uri: "conversations/create", needLogin: true, args: {
"name": settings.hasName ? settings.name : "false",
"follow": settings.following ? "true" : "false",
/// Return the ID of the newly created conversation
///
/// Throws in case of failure
static Future<int> createConversation(NewConversation settings) async {
final response = await APIRequest.withLogin("conversations/create", args: {
"name": settings.name ?? "",
"follow": settings.follow ? "true" : "false",
"users": settings.members.join(","),
}).addBool("canEveryoneAddMembers", settings.canEveryoneAddMembers).exec();
if (response.code != 200) return -1;
"color": colorToHex(settings.color)
})
.addBool("canEveryoneAddMembers", settings.canEveryoneAddMembers)
.execWithThrow();
return response.getObject()["conversationID"];
}
/// Add a member to a conversation.
///
/// Throws in case of failure
static Future<void> addMember(int convID, int userID) async =>
await APIRequest.withLogin("conversations/addMember")
.addInt("convID", convID)
.addInt("userID", userID)
.execWithThrow();
/// Remove a member from a conversation.
///
/// Throws in case of failure
static Future<void> removeMember(int convID, int userID) async =>
await APIRequest.withLogin("conversations/removeMember")
.addInt("convID", convID)
.addInt("userID", userID)
.execWithThrow();
/// Update admin status of a user in a conversation
///
/// Throws in case of failure
static Future<void> setAdmin(int convID, int userID, bool admin) async =>
await APIRequest.withLogin("conversations/setAdmin")
.addInt("convID", convID)
.addInt("userID", userID)
.addBool("setAdmin", admin)
.execWithThrow();
/// Update an existing conversation
///
/// Returns a boolean depending of the success of the operation
Future<bool> updateConversation(Conversation settings) async {
final request =
APIRequest(uri: "conversations/updateSettings", needLogin: true, args: {
"conversationID": settings.id.toString(),
"following": settings.following ? "true" : "false"
});
/// Throws in case of failure
static Future<void> updateConversation(
NewConversationsSettings settings) async {
final request = APIRequest.withLogin("conversations/updateSettings")
.addInt("conversationID", settings.convID)
.addBool("following", settings.following);
if (settings.isOwner || settings.canEveryoneAddMembers)
request.addString("members", settings.members.join(","));
// Update conversation settings
if (settings.isComplete)
request
.addString("name", settings.name ?? "")
.addBool("canEveryoneAddMembers", settings.canEveryoneAddMembers)
.addString("color", colorToHex(settings.color));
// Update all conversation settings, if possible
if (settings.isOwner) {
request.addString("name", settings.hasName ? settings.name : "false");
request.addBool("canEveryoneAddMembers", settings.canEveryoneAddMembers);
}
await request.execWithThrow();
final response = await request.exec();
if (response.code != 200) return false;
//Delete old conversation entry from the database
await _conversationsDatabaseHelper.delete(settings.id);
// Success
return true;
// Delete old conversation entry from the database
await ConversationsSerializationHelper()
.removeElement((t) => t.id == settings.convID);
}
/// Set a new conversation logo
///
/// Throws in case of failure
static Future<void> changeImage(int convID, BytesFile file) async =>
await APIRequest.withLogin("conversations/change_image")
.addInt("convID", convID)
.addBytesFile("file", file)
.execWithFilesAndThrow();
/// Remove conversation logo
///
/// Throws in case of failure
static Future<void> removeLogo(int convID) async =>
await APIRequest.withLogin("conversations/delete_image")
.addInt("convID", convID)
.execWithThrow();
/// Delete a conversation specified by its [id]
Future<bool> deleteConversation(int id) async {
final response = await APIRequest(
uri: "conversations/delete",
needLogin: true,
args: {
"conversationID": id.toString(),
},
).exec();
return response.code == 200;
}
Future<void> deleteConversation(int id) async =>
await APIRequest.withLogin("conversations/delete")
.addInt("conversationID", id)
.execWithThrow();
/// Download the list of conversations from the server
///
/// Throws an exception in case of failure
Future<ConversationsList> downloadList() async {
final response =
await APIRequest(uri: "conversations/getList", needLogin: true).exec();
await APIRequest.withLogin("conversations/getList").execWithThrow();
if (response.code != 200) return null;
ConversationsList list = ConversationsList();
response.getArray().forEach((f) => list.add(apiToConversation(f)));
try {
ConversationsList list = ConversationsList();
response.getArray().forEach((f) => list.add(apiToConversation(f)));
// Update the database
await ConversationsSerializationHelper().setList(list);
// Update the database
await _conversationsDatabaseHelper.clearTable();
await _conversationsDatabaseHelper.insertAll(list);
return list;
} on Exception catch (e) {
print(e.toString());
return null;
}
return list;
}
/// Get the local list of conversations
Future<ConversationsList> getCachedList() async {
final list = await _conversationsDatabaseHelper.getAll();
final list = await ConversationsSerializationHelper().getList();
list.sort();
return list;
}
/// Get information about a single conversation specified by its [id]
Future<Conversation> _downloadSingle(int id) async {
try {
final response = await APIRequest(
uri: "conversations/getInfoOne",
needLogin: true,
args: {"conversationID": id.toString()}).exec();
final response = await APIRequest(
uri: "conversations/get_single",
needLogin: true,
args: {"conversationID": id.toString()}).execWithThrow();
if (response.code != 200) return null;
final conversation = apiToConversation(response.getObject());
final conversation = apiToConversation(response.getObject());
_conversationsDatabaseHelper.insertOrUpdate(conversation);
return conversation;
} on Exception catch (e) {
print(e.toString());
print("Could not get information about a single conversation !");
return null;
}
await ConversationsSerializationHelper()
.insertOrReplaceElement((c) => c.id == conversation.id, conversation);
return conversation;
}
/// Get information about a single conversation. If [force] is set to false,
/// Get information about a conversation. If [force] is set to false, a
/// cached version of the conversation will be used, else it will always get
/// the information from the server
Future<Conversation> getSingle(int id, {bool force = false}) async {
if (force || !await _conversationsDatabaseHelper.has(id))
return await _downloadSingle(id);
else
return _conversationsDatabaseHelper.get(id);
}
/// Get information about a conversation. The method throws an [Exception] in
/// the information from the server. The method throws an [Exception] in
/// case of failure
///
/// Return value of this method is never null.
Future<Conversation> getSingleOrThrow(int id, {bool force = false}) async {
final conv = await this.getSingle(id, force: force);
if (conv == null)
throw Exception("Could not get information about the conversation!");
return conv;
Future<Conversation> getSingle(int id, {bool force = false}) async {
if (force ||
!await ConversationsSerializationHelper().any((c) => c.id == id))
return await _downloadSingle(id);
else
return await ConversationsSerializationHelper().get(id);
}
/// Get the name of a [conversation]. This requires information
@ -170,9 +183,9 @@ class ConversationsHelper {
String name = "";
int count = 0;
for (int i = 0; i < 3 && i < conversation.members.length; i++)
if (conversation.members[i] != userID()) {
if (conversation.members[i].userID != userID()) {
name += (count > 0 ? ", " : "") +
users.getUser(conversation.members[i]).fullName;
users.getUser(conversation.members[i].userID).fullName;
count++;
}
@ -184,6 +197,8 @@ class ConversationsHelper {
/// Search and return a private conversation with a given [userID]. If such
/// conversation does not exists, it is created if [allowCreate] is set to
/// true
///
/// Throws an exception in case of failure
Future<int> getPrivate(int userID, {bool allowCreate = true}) async {
final response = await APIRequest(
uri: "conversations/getPrivate",
@ -192,17 +207,10 @@ class ConversationsHelper {
"otherUser": userID.toString(),
"allowCreate": allowCreate.toString()
},
).exec();
if (response.code != 200) return null;
).execWithThrow();
// Get and return conversation ID
try {
return int.parse(response.getObject()["conversationsID"][0].toString());
} catch (e) {
e.toString();
return null;
}
return int.parse(response.getObject()["conversationsID"][0].toString());
}
/// Asynchronously get the name of the conversation
@ -210,15 +218,13 @@ class ConversationsHelper {
/// Unlike the synchronous method, this method does not need information
/// about the members of the conversation
///
/// Returns null in case of failure
/// Throws an exception in case of failure
static Future<String> getConversationNameAsync(
Conversation conversation) async {
if (conversation.hasName) return conversation.name;
//Get information about the members of the conversation
final members = await UsersHelper().getUsersInfo(conversation.members);
if (members == null) return null;
final members = await UsersHelper().getList(conversation.membersID);
return ConversationsHelper.getConversationName(conversation, members);
}
@ -226,14 +232,18 @@ class ConversationsHelper {
/// Turn an API entry into a [Conversation] object
static Conversation apiToConversation(Map<String, dynamic> map) {
return Conversation(
id: map["ID"],
ownerID: map["ID_owner"],
lastActive: map["last_active"],
name: map["name"] == false ? null : map["name"],
following: map["following"] == 1,
sawLastMessage: map["saw_last_message"] == 1,
members: List<int>.from(map["members"]),
canEveryoneAddMembers: map["canEveryoneAddMembers"],
id: map["id"],
lastActivity: map["last_activity"],
name: map["name"],
color: map["color"] == null ? null : HexColor(map["color"]),
logoURL: map["logo"],
groupID: map["group_id"],
members: map["members"]
.cast<Map<String, dynamic>>()
.map(apiToConversationMember)
.toList()
.cast<ConversationMember>(),
canEveryoneAddMembers: map["can_everyone_add_members"],
callCapabilities: map["can_have_video_call"]
? CallCapabilities.VIDEO
: (map["can_have_call"]
@ -242,10 +252,21 @@ class ConversationsHelper {
isHavingCall: map["has_call_now"]);
}
static ConversationMember apiToConversationMember(Map<String, dynamic> map) =>
ConversationMember(
userID: map["user_id"],
lastMessageSeen: map["last_message_seen"],
lastAccessTime: map["last_access"],
following: map["following"],
isAdmin: map["is_admin"],
);
/// Parse a list of messages given by the server
///
/// Throws an exception in case of failure
Future<ConversationMessagesList> _parseConversationMessageFromServer(
int conversationID, APIResponse response) async {
if (response.code != 200) return null;
response.assertOk();
// Parse the response of the server
ConversationMessagesList list = ConversationMessagesList();
@ -256,7 +277,8 @@ class ConversationsHelper {
});
// Save messages in the cache
_conversationMessagesDatabaseHelper.insertOrUpdateAll(list);
await ConversationsMessagesSerializationHelper(conversationID)
.insertOrReplaceAll(list);
return list;
}
@ -265,6 +287,8 @@ class ConversationsHelper {
///
/// Set [lastMessageID] to 0 to specify that we do not have any message of the
/// conversation yet or another value else
///
/// Throws an exception in case of failure
Future<ConversationMessagesList> _downloadNewMessagesSingle(
int conversationID,
{int lastMessageID = 0}) async {
@ -275,26 +299,26 @@ class ConversationsHelper {
args: {
"conversationID": conversationID.toString(),
"last_message_id": lastMessageID.toString()
}).exec();
}).execWithThrow();
return await _parseConversationMessageFromServer(conversationID, response);
}
/// Get older messages for a given conversation from an online source
///
/// Throws in case of failure
Future<ConversationMessagesList> getOlderMessages({
@required int conversationID,
@required int oldestMessagesID,
int limit = 15,
}) async {
// Perform the request online
final response = await APIRequest(
uri: "conversations/get_older_messages",
needLogin: true,
args: {
"conversationID": conversationID.toString(),
"oldest_message_id": oldestMessagesID.toString(),
"limit": limit.toString()
}).exec();
final response =
await APIRequest.withLogin("conversations/get_older_messages", args: {
"conversationID": conversationID.toString(),
"oldest_message_id": oldestMessagesID.toString(),
"limit": limit.toString()
}).execWithThrow();
return await _parseConversationMessageFromServer(conversationID, response);
}
@ -304,6 +328,8 @@ class ConversationsHelper {
/// If [lastMessageID] is set to 0 then we retrieve the last messages of
/// the conversation.
/// Otherwise [lastMessageID] contains the ID of the last known message
///
/// Throws in case of failure
Future<ConversationMessagesList> getNewMessages(
{@required int conversationID,
int lastMessageID = 0,
@ -312,35 +338,32 @@ class ConversationsHelper {
return await _downloadNewMessagesSingle(conversationID,
lastMessageID: lastMessageID);
else
return await _conversationMessagesDatabaseHelper
.getAllMessagesConversations(conversationID,
lastMessageID: lastMessageID);
}
/// Get a single conversation message from the local database
///
/// Returns the message if found or null in case of failure
Future<ConversationMessage> getSingleMessageFromCache(int messageID) async {
return await _conversationMessagesDatabaseHelper.get(messageID);
return await ConversationsMessagesSerializationHelper(conversationID)
.getList();
}
/// Send a new message to the server
Future<SendMessageResult> sendMessage(NewConversationMessage message) async {
final request = APIRequest(
uri: "conversations/sendMessage",
needLogin: true,
args: {
"conversationID": message.conversationID.toString(),
"message": message.hasMessage ? message.message : ""
},
);
Future<SendMessageResult> sendMessage(
NewConversationMessage message, {
ProgressCallback sendProgress,
CancelToken cancelToken,
}) async {
final request = APIRequest.withLogin("conversations/sendMessage")
.addInt("conversationID", message.conversationID)
.addString("message", message.hasMessage ? message.message : "");
// Check for image
if (message.hasImage) request.addPickedFile("image", message.image);
request.progressCallback = sendProgress;
request.cancelToken = cancelToken;
// Check for file
if (message.hasFile) request.addBytesFile("file", message.file);
if (message.hasThumbnail)
request.addBytesFile("thumbnail", message.thumbnail);
//Send the message
APIResponse response;
if (!message.hasImage)
if (!message.hasFile)
response = await request.exec();
else
response = await request.execWithFiles();
@ -353,14 +376,13 @@ class ConversationsHelper {
}
/// Save / Update a message into the database
Future<void> saveMessage(ConversationMessage msg) async {
await _conversationMessagesDatabaseHelper.insertOrUpdate(msg);
}
Future<void> saveMessage(ConversationMessage msg) async =>
await ConversationsMessagesSerializationHelper(msg.convID)
.insertOrReplace(msg);
/// Remove a message from the database
Future<void> removeMessage(int msgID) async {
await _conversationMessagesDatabaseHelper.delete(msgID);
}
Future<void> removeMessage(ConversationMessage msg) async =>
await ConversationsMessagesSerializationHelper(msg.convID).remove(msg);
/// Update a message content
Future<bool> updateMessage(int id, String newContent) async {
@ -397,11 +419,8 @@ class ConversationsHelper {
return UnreadConversationsList()
..addAll(list.map((f) => UnreadConversation(
id: f["id"],
convName: f["conv_name"],
lastActive: f["last_active"],
userID: f["userID"],
message: f["message"],
conv: apiToConversation(f["conv"]),
message: apiToConversationMessage(f["message"]),
)));
}
@ -428,17 +447,70 @@ class ConversationsHelper {
}
}
/// Send a notification to inform that the user is writing a message
static Future<void> sendWritingEvent(int convID) async =>
await ws("conversations/is_writing", {"convID": convID});
/// Turn an API response into a ConversationMessage object
static ConversationMessage apiToConversationMessage(
Map<String, dynamic> map,
) {
var file;
if (map["file"] != null) {
final fileMap = map["file"];
file = ConversationMessageFile(
url: fileMap["url"],
size: fileMap["size"],
name: fileMap["name"],
thumbnail: fileMap["thumbnail"],
type: fileMap["type"],
);
}
var serverMessage;
if (map["server_message"] != null) {
final srvMessageMap = map["server_message"];
var messageType;
switch (srvMessageMap["type"]) {
case "user_created_conv":
messageType = ConversationServerMessageType.USER_CREATED_CONVERSATION;
break;
case "user_added_another":
messageType = ConversationServerMessageType.USER_ADDED_ANOTHER_USER;
break;
case "user_left":
messageType = ConversationServerMessageType.USER_LEFT_CONV;
break;
case "user_removed_another":
messageType = ConversationServerMessageType.USER_REMOVED_ANOTHER_USER;
break;
default:
throw Exception(
"${srvMessageMap["type"]} is an unknown server message type!");
}
serverMessage = ConversationServerMessage(
type: messageType,
userID: srvMessageMap["user_id"],
userWhoAdded: srvMessageMap["user_who_added"],
userAdded: srvMessageMap["user_added"],
userWhoRemoved: srvMessageMap["user_who_removed"],
userRemoved: srvMessageMap["user_removed"],
);
}
return ConversationMessage(
id: map["ID"],
conversationID: map["convID"],
userID: map["ID_user"],
timeInsert: map["time_insert"],
message: DisplayedString(map["message"]),
imageURL: map["image_path"],
);
id: map["id"],
convID: map["conv_id"],
userID: map["user_id"],
timeSent: map["time_sent"],
message: DisplayedString(map["message"] ?? ""),
file: file,
serverMessage: serverMessage);
}
}

View File

@ -1,37 +0,0 @@
import 'package:comunic/helpers/database/database_contract.dart';
import 'package:comunic/helpers/database/model_database_helper.dart';
import 'package:comunic/lists/conversation_messages_list.dart';
import 'package:comunic/models/conversation_message.dart';
/// Conversation messages database helper
///
/// @author Pierre HUBERT
class ConversationMessagesDatabaseHelper
extends ModelDatabaseHelper<ConversationMessage> {
@override
ConversationMessage initializeFromMap(Map<String, dynamic> map) {
return ConversationMessage.fromMap(map);
}
@override
String tableName() {
return ConversationsMessagesTableContract.TABLE_NAME;
}
/// Get all the message cached for a given conversation
Future<ConversationMessagesList> getAllMessagesConversations(
int conversationID,
{int lastMessageID = 0}) async {
final list = await getMultiple(
where: "${ConversationsMessagesTableContract.C_CONVERSATION_ID} = ? "
"AND ${BaseTableContract.C_ID} > ?",
whereArgs: [conversationID, lastMessageID],
);
// Turn the list into a conversation messages list
ConversationMessagesList finalList = ConversationMessagesList();
finalList.addAll(list);
return finalList;
}
}

View File

@ -1,29 +0,0 @@
import 'package:comunic/helpers/database/database_contract.dart';
import 'package:comunic/helpers/database/model_database_helper.dart';
import 'package:comunic/lists/conversations_list.dart';
import 'package:comunic/models/conversation.dart';
/// Conversations database helper
///
/// @author Pierre HUBERT
class ConversationsDatabaseHelper extends ModelDatabaseHelper<Conversation> {
@override
Conversation initializeFromMap(Map<String, dynamic> map) {
return Conversation.fromMap(map);
}
@override
String tableName() {
return ConversationTableContract.TABLE_NAME;
}
@override
Future<ConversationsList> getAll() async {
ConversationsList list = ConversationsList();
list.addAll(await super.getAll());
return list;
}
}

View File

@ -25,30 +25,6 @@ abstract class UserTableContract {
static const C_CUSTOM_EMOJIES = "custom_emojies";
}
/// Conversations table contract
abstract class ConversationTableContract {
static const TABLE_NAME = "conversations";
static const C_ID = BaseTableContract.C_ID;
static const C_OWNER_ID = "owner_id";
static const C_LAST_ACTIVE = "last_active";
static const C_NAME = "name";
static const C_FOLLOWING = "following";
static const C_SAW_LAST_MESSAGE = "saw_last_message";
static const C_MEMBERS = "members";
static const C_CAN_EVERYONE_ADD_MEMBERS = "can_everyone_add_members";
}
/// Conversations messages table contract
abstract class ConversationsMessagesTableContract {
static const TABLE_NAME = "conversations_messages";
static const C_ID = BaseTableContract.C_ID;
static const C_CONVERSATION_ID = "conversation_id";
static const C_USER_ID = "user_id";
static const C_TIME_INSERT = "time_insert";
static const C_MESSAGE = "message";
static const C_IMAGE_URL = "image_url";
}
/// Friends table contract
abstract class FriendsListTableContract {
static const TABLE_NAME = "friends";

View File

@ -45,14 +45,6 @@ abstract class DatabaseHelper {
// Drop users table
await db.execute("DROP TABLE IF EXISTS ${UserTableContract.TABLE_NAME}");
// Drop conversations table
await db.execute(
"DROP TABLE IF EXISTS ${ConversationTableContract.TABLE_NAME}");
// Drop conversations messages table
await db.execute(
"DROP TABLE IF EXISTS ${ConversationsMessagesTableContract.TABLE_NAME}");
// Drop friends list table
await db
.execute("DROP TABLE IF EXISTS ${FriendsListTableContract.TABLE_NAME}");
@ -74,29 +66,6 @@ abstract class DatabaseHelper {
"${UserTableContract.C_CUSTOM_EMOJIES} TEXT"
")");
// Create conversations table
await db.execute("CREATE TABLE ${ConversationTableContract.TABLE_NAME} ("
"${ConversationTableContract.C_ID} INTEGER PRIMARY KEY, "
"${ConversationTableContract.C_OWNER_ID} INTEGER, "
"${ConversationTableContract.C_LAST_ACTIVE} INTEGER, "
"${ConversationTableContract.C_NAME} TEXT, "
"${ConversationTableContract.C_FOLLOWING} INTEGER, "
"${ConversationTableContract.C_SAW_LAST_MESSAGE} INTEGER, "
"${ConversationTableContract.C_MEMBERS} TEXT, "
"${ConversationTableContract.C_CAN_EVERYONE_ADD_MEMBERS} INTEGER"
")");
// Create conversation messages table
await db.execute(
"CREATE TABLE ${ConversationsMessagesTableContract.TABLE_NAME} ("
"${ConversationsMessagesTableContract.C_ID} INTEGER PRIMARY KEY, "
"${ConversationsMessagesTableContract.C_CONVERSATION_ID} INTEGER, "
"${ConversationsMessagesTableContract.C_USER_ID} INTEGER, "
"${ConversationsMessagesTableContract.C_TIME_INSERT} INTEGER, "
"${ConversationsMessagesTableContract.C_MESSAGE} TEXT, "
"${ConversationsMessagesTableContract.C_IMAGE_URL} TEXT"
")");
// Friends list table
await db.execute("CREATE TABLE ${FriendsListTableContract.TABLE_NAME} ("
"${FriendsListTableContract.C_ID} INTEGER PRIMARY KEY, "

View File

@ -50,6 +50,14 @@ class DeletedCommentEvent {
DeletedCommentEvent(this.commentID);
}
/// Writing message in conversation event
class WritingMessageInConversationEvent {
final int convID;
final int userID;
WritingMessageInConversationEvent(this.convID, this.userID);
}
/// New conversation message
class NewConversationMessageEvent {
final ConversationMessage msg;
@ -71,6 +79,21 @@ class DeletedConversationMessageEvent {
DeletedConversationMessageEvent(this.msg);
}
/// Remove user from conversation
class RemovedUserFromConversationEvent {
final int convID;
final int userID;
RemovedUserFromConversationEvent(this.convID, this.userID);
}
/// Deleted conversation
class DeletedConversationEvent {
final int convID;
DeletedConversationEvent(this.convID);
}
/// User joined call event
class UserJoinedCallEvent {
final int callID;

View File

@ -16,7 +16,6 @@ const _NotificationElementTypeAPImapping = {
"post_text": NotificationElementType.POST_TEXT,
"post_img": NotificationElementType.POST_IMAGE,
"post_youtube": NotificationElementType.POST_YOUTUBE,
"post_movie": NotificationElementType.POST_MOVIE,
"post_weblink": NotificationElementType.POST_WEBLINK,
"post_pdf": NotificationElementType.POST_PDF,
"post_timer": NotificationElementType.POST_TIMER,

View File

@ -29,7 +29,6 @@ const _APIPostsKindsMap = {
"image": PostKind.IMAGE,
"weblink": PostKind.WEB_LINK,
"pdf": PostKind.PDF,
"movie": PostKind.MOVIE,
"countdown": PostKind.COUNTDOWN,
"survey": PostKind.SURVEY,
"youtube": PostKind.YOUTUBE
@ -143,7 +142,7 @@ class PostsHelper {
break;
case PostKind.IMAGE:
request.addPickedFile("image", post.image);
request.addBytesFile("image", post.image);
break;
case PostKind.WEB_LINK:

View File

@ -1,7 +1,4 @@
import 'dart:convert';
import 'package:comunic/models/application_preferences.dart';
import 'package:comunic/models/login_tokens.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Preferences helper
@ -11,14 +8,16 @@ import 'package:shared_preferences/shared_preferences.dart';
/// @author Pierre HUBERT
enum PreferencesKeyList {
LOGIN_TOKENS,
USER_ID,
LOGIN_TOKEN,
ENABLE_DARK_THEME,
FORCE_MOBILE_MODE,
SHOW_PERFORMANCE_OVERLAY,
}
const _PreferenceKeysName = {
PreferencesKeyList.LOGIN_TOKENS: "login_tokens",
PreferencesKeyList.USER_ID: "user_id",
PreferencesKeyList.LOGIN_TOKEN: "login_token",
PreferencesKeyList.ENABLE_DARK_THEME: "dark_theme",
PreferencesKeyList.FORCE_MOBILE_MODE: "force_mobile_mode",
PreferencesKeyList.SHOW_PERFORMANCE_OVERLAY: "perfs_overlay",
@ -45,23 +44,28 @@ class PreferencesHelper {
}
/// Set new login tokens
Future<void> setLoginTokens(LoginTokens tokens) async {
await setString(PreferencesKeyList.LOGIN_TOKENS,
tokens == null ? "null" : tokens.toString());
Future<void> setLoginToken(String token) async {
if (token != null)
await setString(PreferencesKeyList.LOGIN_TOKEN, token);
else
await removeKey(PreferencesKeyList.LOGIN_TOKEN);
}
/// Get current [LoginTokens]. Returns null if none or in case of failure
LoginTokens getLoginTokens() {
String getLoginToken() {
try {
final string = getString(PreferencesKeyList.LOGIN_TOKENS);
if (string == null || string == "null") return null;
return LoginTokens.fromJSON(jsonDecode(string));
final string = getString(PreferencesKeyList.LOGIN_TOKEN);
return string;
} on Exception catch (e) {
print(e.toString());
return null;
}
}
Future<bool> removeKey(PreferencesKeyList key) async {
return await _sharedPreferences.remove(_PreferenceKeysName[key]);
}
Future<bool> setString(PreferencesKeyList key, String value) async {
return await _sharedPreferences.setString(_PreferenceKeysName[key], value);
}
@ -74,6 +78,14 @@ class PreferencesHelper {
return await _sharedPreferences.setBool(_PreferenceKeysName[key], value);
}
Future<bool> setInt(PreferencesKeyList key, int value) async {
return await _sharedPreferences.setInt(_PreferenceKeysName[key], value);
}
int getInt(PreferencesKeyList key) {
return _sharedPreferences.getInt(_PreferenceKeysName[key]);
}
bool getBool(PreferencesKeyList key, {bool alternative = false}) {
final v = _sharedPreferences.getBool(_PreferenceKeysName[key]);
return v == null ? alternative : v;

View File

@ -0,0 +1,121 @@
import 'dart:convert';
import 'dart:io';
import 'package:comunic/constants.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
/// Base serialization helper
///
/// @author Pierre Hubert
abstract class SerializableElement<T> extends Comparable<T> {
Map<String, dynamic> toJson();
}
abstract class BaseSerializationHelper<T extends SerializableElement> {
/// List cache
List<T> _cache;
/// The name of the type of data to serialise
String get type;
/// Parse an json entry into a [T] object
T parse(Map<String, dynamic> m);
/// Get the file where data should be stored
Future<File> _getFilePath() async {
final dir = await getApplicationDocumentsDirectory();
final targetDir =
Directory(path.join(dir.absolute.path, SERIALIZATION_DIRECTORY));
targetDir.create(recursive: true);
return File(path.join(targetDir.absolute.path, type));
}
/// Load the cache
Future<void> _loadCache() async {
if (_cache != null) return;
try {
final file = await _getFilePath();
if (!await file.exists()) return _cache = [];
final List<dynamic> json = jsonDecode(await file.readAsString());
_cache = json.cast<Map<String, dynamic>>().map(parse).toList();
_cache.sort();
} catch (e, s) {
print("Failed to read serialized data! $e => $s");
_cache = [];
}
}
/// Save the cache to the persistent memory
Future<void> _saveCache() async {
final file = await _getFilePath();
await file.writeAsString(jsonEncode(
_cache.map((e) => e.toJson()).toList().cast<Map<String, dynamic>>()));
}
/// Get the current list of elements
Future<List<T>> getList() async {
await _loadCache();
return List.from(_cache);
}
/// Set a new list of conversations
Future<void> setList(List<T> list) async {
_cache = List.from(list);
await _saveCache();
}
/// Insert new element
Future<void> insert(T el) async {
await _loadCache();
_cache.add(el);
_cache.sort();
await _saveCache();
}
/// Insert new element
Future<void> insertMany(List<T> els) async {
await _loadCache();
_cache.addAll(els);
_cache.sort();
await _saveCache();
}
/// Check if any entry in the last match the predicate
Future<bool> any(bool isContained(T t)) async {
await _loadCache();
return _cache.any((element) => isContained(element));
}
/// Check if any entry in the last match the predicate
Future<T> first(bool filter(T t)) async {
await _loadCache();
return _cache.firstWhere((element) => filter(element));
}
/// Replace an element with another one
Future<void> insertOrReplaceElement(bool isToReplace(T t), T newEl) async {
await _loadCache();
// Insert or replace the element
_cache.where((element) => !isToReplace(element)).toList();
_cache.add(newEl);
_cache.sort();
await _saveCache();
}
/// Remove elements
Future<void> removeElement(bool isToRemove(T t)) async {
await _loadCache();
_cache.removeWhere((element) => isToRemove(element));
await _saveCache();
}
}

View File

@ -0,0 +1,51 @@
import 'dart:collection';
import 'package:comunic/helpers/serialization/base_serialization_helper.dart';
import 'package:comunic/lists/conversation_messages_list.dart';
import 'package:comunic/models/conversation_message.dart';
/// Conversations serialization helper
///
/// @author Pierre Hubert
HashMap<int, ConversationsMessagesSerializationHelper> _instances;
class ConversationsMessagesSerializationHelper
extends BaseSerializationHelper<ConversationMessage> {
final int convID;
ConversationsMessagesSerializationHelper._(int convID)
: convID = convID,
assert(convID != null);
factory ConversationsMessagesSerializationHelper(int convID) {
if (_instances == null) _instances = HashMap();
if (!_instances.containsKey(convID))
_instances[convID] = ConversationsMessagesSerializationHelper._(convID);
return _instances[convID];
}
@override
ConversationMessage parse(Map<String, dynamic> m) =>
ConversationMessage.fromJson(m);
@override
String get type => "conv-messages-$convID";
Future<ConversationMessagesList> getList() async =>
ConversationMessagesList()..addAll(await super.getList());
Future<void> insertOrReplace(ConversationMessage msg) async =>
await insertOrReplaceElement((t) => t.id == msg.id, msg);
Future<void> remove(ConversationMessage msg) async =>
await removeElement((t) => t.id == msg.id);
/// Insert or replace a list of messages
Future<void> insertOrReplaceAll(List<ConversationMessage> list) async {
for (var message in list)
await insertOrReplaceElement((t) => t.id == message.id, message);
}
}

View File

@ -0,0 +1,32 @@
import 'package:comunic/helpers/serialization/base_serialization_helper.dart';
import 'package:comunic/lists/conversations_list.dart';
import 'package:comunic/models/conversation.dart';
/// Conversations serialization helper
///
/// @author Pierre Hubert
var _cache;
class ConversationsSerializationHelper
extends BaseSerializationHelper<Conversation> {
/// Singleton
factory ConversationsSerializationHelper() {
if (_cache == null) _cache = ConversationsSerializationHelper._();
return _cache;
}
ConversationsSerializationHelper._();
@override
Conversation parse(Map<String, dynamic> m) => Conversation.fromJson(m);
@override
String get type => "conversations";
Future<ConversationsList> getList() async =>
ConversationsList()..addAll(await super.getList());
/// Get a conversation
Future<Conversation> get(int id) => first((t) => t.id == id);
}

View File

@ -0,0 +1,83 @@
import 'package:comunic/models/api_request.dart';
import 'package:comunic/models/server_config.dart';
import 'package:version/version.dart';
/// Server configuration helper
///
/// @author Pierre Hubert
class ServerConfigurationHelper {
static ServerConfig _config;
/// Make sure the configuration has been correctly loaded
static Future<void> ensureLoaded() async {
if (_config != null) return;
final response =
(await APIRequest.withoutLogin("server/config").execWithThrow())
.getObject();
final passwordPolicy = response["password_policy"];
final dataConservationPolicy = response["data_conservation_policy"];
final conversationsPolicy = response["conversations_policy"];
_config = ServerConfig(
minSupportedMobileVersion:
Version.parse(response["min_supported_mobile_version"]),
termsURL: response["terms_url"],
playStoreURL: response["play_store_url"],
androidDirectDownloadURL: response["android_direct_download_url"],
passwordPolicy: PasswordPolicy(
allowMailInPassword: passwordPolicy["allow_email_in_password"],
allowNameInPassword: passwordPolicy["allow_name_in_password"],
minPasswordLength: passwordPolicy["min_password_length"],
minNumberUpperCaseLetters:
passwordPolicy["min_number_upper_case_letters"],
minNumberLowerCaseLetters:
passwordPolicy["min_number_lower_case_letters"],
minNumberDigits: passwordPolicy["min_number_digits"],
minNumberSpecialCharacters:
passwordPolicy["min_number_special_characters"],
minCategoriesPresence: passwordPolicy["min_categories_presence"],
),
dataConservationPolicy: ServerDataConservationPolicy(
minInactiveAccountLifetime:
dataConservationPolicy["min_inactive_account_lifetime"],
minNotificationLifetime:
dataConservationPolicy["min_notification_lifetime"],
minCommentsLifetime: dataConservationPolicy["min_comments_lifetime"],
minPostsLifetime: dataConservationPolicy["min_posts_lifetime"],
minConversationMessagesLifetime:
dataConservationPolicy["min_conversation_messages_lifetime"],
minLikesLifetime: dataConservationPolicy["min_likes_lifetime"],
),
conversationsPolicy: ConversationsPolicy(
minMessageLen: conversationsPolicy["min_message_len"],
maxMessageLen: conversationsPolicy["max_message_len"],
allowedFilesType:
conversationsPolicy["allowed_files_type"].cast<String>(),
filesMaxSize: conversationsPolicy["files_max_size"],
writingEventInterval: conversationsPolicy["writing_event_interval"],
writingEventLifetime: conversationsPolicy["writing_event_lifetime"],
maxMessageImageWidth: conversationsPolicy["max_message_image_width"],
maxMessageImageHeight:
conversationsPolicy["max_message_image_height"],
maxThumbnailWidth: conversationsPolicy["max_thumbnail_width"],
maxThumbnailHeight: conversationsPolicy["max_thumbnail_height"],
maxLogoWidth: conversationsPolicy["max_logo_width"],
maxLogoHeight: conversationsPolicy["max_logo_height"],
));
}
/// Get current server configuration, throwing if it is not loaded yet
static ServerConfig get config {
if (_config == null)
throw Exception(
"Trying to access server configuration but it is not loaded yet!");
return _config;
}
}
/// Shortcut for server configuration
ServerConfig get srvConfig => ServerConfigurationHelper.config;

View File

@ -1,10 +1,12 @@
import 'package:comunic/enums/user_page_visibility.dart';
import 'package:comunic/models/account_image_settings.dart';
import 'package:comunic/models/api_request.dart';
import 'package:comunic/models/data_conservation_policy_settings.dart';
import 'package:comunic/models/general_settings.dart';
import 'package:comunic/models/new_emoji.dart';
import 'package:comunic/models/security_settings.dart';
import 'package:image_picker/image_picker.dart';
import '../models/api_request.dart';
/// Settings helper
///
@ -91,11 +93,10 @@ class SettingsHelper {
}
/// Upload a new account image
static Future<bool> uploadAccountImage(PickedFile newImage) async =>
(await APIRequest(uri: "settings/upload_account_image", needLogin: true)
.addPickedFile("picture", newImage)
.execWithFiles())
.isOK;
static Future<void> uploadAccountImage(BytesFile newImage) async =>
await APIRequest(uri: "settings/upload_account_image", needLogin: true)
.addBytesFile("picture", newImage)
.execWithFilesAndThrow();
/// Upload a new account image from memory
static Future<bool> uploadAccountImageFromMemory(List<int> bytes) async =>
@ -127,13 +128,12 @@ class SettingsHelper {
/// Upload a new custom emoji
static Future<void> uploadNewCustomEmoji(NewEmoji newEmoji) async =>
(await APIRequest(
uri: "settings/upload_custom_emoji",
needLogin: true,
args: {"shortcut": newEmoji.shortcut})
.addPickedFile("image", newEmoji.image)
.execWithFiles())
.assertOk();
await APIRequest(
uri: "settings/upload_custom_emoji",
needLogin: true,
args: {"shortcut": newEmoji.shortcut})
.addBytesFile("image", newEmoji.image)
.execWithFilesAndThrow();
/// Delete a custom emoji
///
@ -193,4 +193,43 @@ class SettingsHelper {
.addString("security_answer_2", newSettings.securityAnswer2)
.execWithThrow();
}
/// Get account data conservation policy settings
///
/// Throws in case of failure
static Future<DataConservationPolicySettings>
getDataConservationPolicy() async {
final response =
(await APIRequest.withLogin("settings/get_data_conservation_policy")
.execWithThrow())
.getObject();
return DataConservationPolicySettings(
inactiveAccountLifeTime: response["inactive_account_lifetime"],
notificationLifetime: response["notification_lifetime"],
commentsLifetime: response["comments_lifetime"],
postsLifetime: response["posts_lifetime"],
conversationMessagesLifetime:
response["conversation_messages_lifetime"],
likesLifetime: response["likes_lifetime"]);
}
/// Apply new data conservation policy settings
///
/// Throws in case of failure
static Future<void> setDataConservationPolicy(
String password, DataConservationPolicySettings newSettings) async {
await APIRequest(
uri: "settings/set_data_conservation_policy", needLogin: true)
.addString("password", password)
.addInt("inactive_account_lifetime",
newSettings.inactiveAccountLifeTime ?? 0)
.addInt("notification_lifetime", newSettings.notificationLifetime ?? 0)
.addInt("comments_lifetime", newSettings.commentsLifetime ?? 0)
.addInt("posts_lifetime", newSettings.postsLifetime ?? 0)
.addInt("conversation_messages_lifetime",
newSettings.conversationMessagesLifetime ?? 0)
.addInt("likes_lifetime", newSettings.likesLifetime ?? 0)
.execWithThrow();
}
}

View File

@ -91,9 +91,16 @@ class UsersHelper {
}
/// Get users information from a given [Set]
///
/// Throws in case of failure
Future<UsersList> getList(Set<int> users,
{bool forceDownload = false}) async {
return await getUsersInfo(users.toList());
final list = await getUsersInfo(users.toList());
if (list == null)
throw Exception("Failed to get the list of users!");
return list;
}
/// Get users information
@ -102,7 +109,7 @@ class UsersHelper {
/// the server, otherwise cached data will be used if available
Future<UsersList> getUsersInfo(List<int> users,
{bool forceDownload = false}) async {
List<int> toDownload = List();
List<int> toDownload = [];
UsersList list = UsersList();
// Check cache

View File

@ -0,0 +1,20 @@
import 'package:package_info/package_info.dart';
import 'package:version/version.dart';
/// Application version helper
///
/// @author Pierre Hubert
class VersionHelper {
static PackageInfo _info;
static Future<void> ensureLoaded() async {
_info = await PackageInfo.fromPlatform();
}
/// Get current version information
static PackageInfo get info => _info;
/// Get current application version, in parsed format
static Version get version => Version.parse(info.version);
}

View File

@ -67,6 +67,7 @@ class WebSocketHelper {
// Clear Futures queue
_requests.clear();
_ws = null;
EventsHelper.emit(WSClosedEvent());
},
);
@ -143,6 +144,12 @@ class WebSocketHelper {
EventsHelper.emit(DeletedCommentEvent(msg.data));
break;
// A user is writing a new message
case "writing_message_in_conv":
EventsHelper.emit(WritingMessageInConversationEvent(
msg.data["conv_id"], msg.data["user_id"]));
break;
// Created new conversation message
case "new_conv_message":
EventsHelper.emit(NewConversationMessageEvent(
@ -161,6 +168,17 @@ class WebSocketHelper {
ConversationsHelper.apiToConversationMessage(msg.data)));
break;
// Removed user from conversation
case "removed_user_from_conv":
EventsHelper.emit(RemovedUserFromConversationEvent(
msg.data["conv_id"], msg.data["user_id"]));
break;
// Conversation deleted
case "deleted_conversation":
EventsHelper.emit(DeletedConversationEvent(msg.data));
break;
// A user joined a call
case "user_joined_call":
EventsHelper.emit(

View File

@ -5,7 +5,7 @@ import 'dart:collection';
/// @author Pierre HUBERT
class AbstractList<E> extends ListBase<E> {
final _list = List<E>();
final _list = <E>[];
int get length => _list.length;

View File

@ -9,7 +9,7 @@ import 'package:comunic/models/comment.dart';
/// @author Pierre HUBERT
class CommentsList extends ListBase<Comment> {
List<Comment> _list = List();
List<Comment> _list = [];
int get length => _list.length;

View File

@ -7,7 +7,7 @@ import 'package:comunic/models/conversation_message.dart';
/// @author Pierre HUBERT
class ConversationMessagesList extends ListBase<ConversationMessage> {
final List<ConversationMessage> _list = List();
final List<ConversationMessage> _list = [];
set length(int v) => _list.length = v;
@ -24,11 +24,10 @@ class ConversationMessagesList extends ListBase<ConversationMessage> {
}
/// Get the list of the users ID who own a message in this list
List<int> getUsersID() {
final List<int> users = List();
Set<int> getUsersID() {
final Set<int> users = Set();
for (ConversationMessage message in this)
if (!users.contains(message.userID)) users.add(message.userID);
for (ConversationMessage message in this) users.addAll(message.usersID);
return users;
}

View File

@ -8,11 +8,11 @@ import 'package:comunic/models/conversation.dart';
/// @author Pierre HUBERT
class ConversationsList extends ListBase<Conversation> {
final List<Conversation> _list = List();
final List<Conversation> _list = [];
UsersList users;
set length(l) => _list.length = l;
int get length => _list.length;
@override
@ -22,12 +22,9 @@ class ConversationsList extends ListBase<Conversation> {
void operator []=(int index, Conversation value) => _list[index] = value;
/// Get the entire lists of users ID in this list
List<int> get allUsersID {
final List<int> list = List();
forEach((c) => c.members.forEach((id){
if(!list.contains(id))
list.add(id);
}));
Set<int> get allUsersID {
final Set<int> list = Set();
forEach((c) => c.members.forEach((member) => list.add(member.userID)));
return list;
}
}

View File

@ -7,7 +7,7 @@ import 'package:comunic/models/friend.dart';
/// @author Pierre HUBERT
class FriendsList extends ListBase<Friend> {
List<Friend> _list = List();
List<Friend> _list = [];
int get length => _list.length;

View File

@ -18,7 +18,7 @@ class MembershipList extends AbstractList<Membership> {
case MembershipType.GROUP:
break;
case MembershipType.CONVERSATION:
s.addAll(m.conversation.members);
s.addAll(m.conversation.membersID);
break;
}
});

View File

@ -9,7 +9,7 @@ import 'package:comunic/models/post.dart';
/// @author Pierre HUBERT
class PostsList extends ListBase<Post> {
List<Post> _list = List();
List<Post> _list = [];
int get length => _list.length;

View File

@ -7,5 +7,9 @@ import 'package:comunic/models/unread_conversation.dart';
class UnreadConversationsList extends AbstractList<UnreadConversation> {
/// Get the ID of the users included in this list
Set<int> get usersID => new Set<int>()..addAll(map((f) => f.userID));
Set<int> get usersID {
final set = Set<int>();
forEach((element) => set.addAll(element.message.usersID));
return set;
}
}

View File

@ -7,7 +7,7 @@ import 'package:comunic/models/user.dart';
/// @author Pierre HUBERT
class UsersList extends ListBase<User> {
List<User> _list = List();
List<User> _list = [];
int get length => _list.length;

View File

@ -1,7 +1,7 @@
import 'package:comunic/helpers/account_helper.dart';
import 'package:comunic/helpers/database/database_helper.dart';
import 'package:comunic/helpers/preferences_helper.dart';
import 'package:comunic/ui/routes/login_route.dart';
import 'package:comunic/helpers/version_helper.dart';
import 'package:comunic/ui/widgets/init_widget.dart';
import 'package:comunic/utils/intl_utils.dart';
import 'package:flutter/material.dart';
@ -13,6 +13,9 @@ import 'package:flutter/material.dart';
void subMain() async {
WidgetsFlutterBinding.ensureInitialized();
// Load package information
await VersionHelper.ensureLoaded();
// Connect to database
await DatabaseHelper.open();
await DatabaseHelper.cleanUpDatabase();
@ -51,7 +54,7 @@ class ComunicApplicationState extends State<ComunicApplication> {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: AccountHelper.isUserIDLoaded ? InitializeWidget() : LoginRoute(),
home: InitializeWidget(),
theme: prefs.enableDarkMode ? ThemeData.dark() : null,
showPerformanceOverlay: prefs.showPerformancesOverlay,
);

View File

@ -23,9 +23,7 @@ void main() {
apiServerName: "192.168.1.9:3000",
apiServerUri: "/",
apiServerSecure: false,
serviceName: "ComunicFlutter",
serviceToken: "G9sZCBmb3IgVWJ1bnR1CkNvbW1lbnRbbmVdPeCkieCkrOCkq",
termsOfServicesURL: "http://devweb.local/comunic/current/about.php?cgu",
clientName: "ComunicFlutter",
));
HttpOverrides.global = new MyHttpOverride();

View File

@ -10,9 +10,7 @@ void main() {
apiServerName: "api.communiquons.org",
apiServerUri: "/",
apiServerSecure: true,
serviceName: "ComunicFlutter",
serviceToken: "9KfSwmB76U9UUwjXngDG7PeYccNfy",
termsOfServicesURL: "https://about.communiquons.org/about/terms/",
clientName: "ComunicFlutter",
));
subMain();

View File

@ -2,8 +2,8 @@ import 'dart:io';
import 'package:comunic/helpers/api_helper.dart';
import 'package:comunic/models/api_response.dart';
import 'package:dio/dio.dart';
import 'package:http_parser/http_parser.dart';
import 'package:image_picker/image_picker.dart';
import 'package:meta/meta.dart';
/// API Request model
@ -27,9 +27,10 @@ class BytesFile {
class APIRequest {
final String uri;
final bool needLogin;
ProgressCallback progressCallback;
CancelToken cancelToken;
Map<String, String> args;
Map<String, File> files = Map();
Map<String, PickedFile> pickedFiles = Map();
Map<String, BytesFile> bytesFiles = Map();
APIRequest({@required this.uri, this.needLogin = false, this.args})
@ -70,11 +71,6 @@ class APIRequest {
return this;
}
APIRequest addPickedFile(String name, PickedFile file) {
pickedFiles[name] = file;
return this;
}
APIRequest addBytesFile(String name, BytesFile file) {
this.bytesFiles[name] = file;
return this;
@ -88,6 +84,10 @@ class APIRequest {
/// Execute the request, throws an exception in case of failure
Future<APIResponse> execWithThrow() async => (await exec()).assertOk();
/// Execute the request, throws an exception in case of failure
Future<Map<String, dynamic>> execWithThrowGetObject() async =>
(await execWithThrow()).getObject();
/// Execute the request with files
Future<APIResponse> execWithFiles() async => APIHelper().execWithFiles(this);

View File

@ -9,23 +9,17 @@ class Config {
final String apiServerName;
final String apiServerUri;
final bool apiServerSecure;
final String serviceName;
final String serviceToken;
final String termsOfServicesURL;
final String clientName;
const Config({
@required this.apiServerName,
@required this.apiServerUri,
@required this.apiServerSecure,
@required this.serviceName,
@required this.serviceToken,
@required this.termsOfServicesURL,
@required this.clientName,
}) : assert(apiServerName != null),
assert(apiServerUri != null),
assert(apiServerSecure != null),
assert(serviceName != null),
assert(serviceToken != null),
assert(termsOfServicesURL != null);
assert(clientName != null);
/// Get and set static configuration
static Config _config;

View File

@ -1,7 +1,7 @@
import 'package:comunic/helpers/database/database_contract.dart';
import 'package:comunic/models/cache_model.dart';
import 'package:comunic/helpers/serialization/base_serialization_helper.dart';
import 'package:comunic/models/conversation_member.dart';
import 'package:comunic/utils/account_utils.dart';
import 'package:comunic/utils/list_utils.dart';
import 'package:flutter/material.dart';
import 'package:meta/meta.dart';
/// Conversation model
@ -10,79 +10,100 @@ import 'package:meta/meta.dart';
enum CallCapabilities { NONE, AUDIO, VIDEO }
class Conversation extends CacheModel implements Comparable {
final int ownerID;
final int lastActive;
class Conversation extends SerializableElement<Conversation> {
final int id;
final int lastActivity;
final String name;
final bool following;
final bool sawLastMessage;
final List<int> members;
final Color color;
final String logoURL;
final int groupID;
final List<ConversationMember> members;
final bool canEveryoneAddMembers;
final CallCapabilities callCapabilities;
final bool isHavingCall;
const Conversation({
@required int id,
@required this.ownerID,
@required this.lastActive,
Conversation({
@required this.id,
@required this.lastActivity,
@required this.name,
@required this.following,
@required this.sawLastMessage,
@required this.color,
@required this.logoURL,
@required this.groupID,
@required this.members,
@required this.canEveryoneAddMembers,
this.callCapabilities = CallCapabilities.NONE,
this.isHavingCall = false,
}) : assert(id != null),
assert(ownerID != null),
assert(lastActive != null),
assert(following != null),
assert(sawLastMessage != null),
assert(lastActivity != null),
assert(members != null),
assert(canEveryoneAddMembers != null),
assert(callCapabilities != null),
assert(isHavingCall != null),
super(id: id);
assert(isHavingCall != null);
/// Check out whether a conversation has a fixed name or not
bool get hasName => this.name != null;
/// Check out whether current user of the application is the owner of it or
/// not
bool get isOwner => this.ownerID == userID();
/// Get current user membership
ConversationMember get membership =>
members.firstWhere((m) => m.userID == userID());
Conversation.fromMap(Map<String, dynamic> map)
: ownerID = map[ConversationTableContract.C_OWNER_ID],
lastActive = map[ConversationTableContract.C_LAST_ACTIVE],
name = map[ConversationTableContract.C_NAME],
following = map[ConversationTableContract.C_FOLLOWING] == 1,
sawLastMessage = map[ConversationTableContract.C_SAW_LAST_MESSAGE] == 1,
members =
listToIntList(map[ConversationTableContract.C_MEMBERS].split(",")),
canEveryoneAddMembers =
map[ConversationTableContract.C_CAN_EVERYONE_ADD_MEMBERS] == 1,
/// Check out whether current user of the application is an admin
bool get isAdmin => membership.isAdmin;
/// Check if current user is the last admin of the conversation
bool get isLastAdmin => isAdmin && adminsID.length == 1;
/// Check it current user is following the conversation or not
bool get following => membership.following;
/// Get the list of members in the conversation
Set<int> get membersID => members.map((e) => e.userID).toSet();
/// Get the list of admins in the conversation
Set<int> get adminsID =>
members.where((e) => e.isAdmin).map((e) => e.userID).toSet();
/// Get the list of the OTHER members of the conversation (all except current user)
Set<int> get otherMembersID => membersID..remove(userID());
/// Check if the last message has been seen or not
bool get sawLastMessage => lastActivity <= membership.lastAccessTime;
/// Check out whether a conversation is managed or not
bool get isManaged => groupID != null;
Conversation.fromJson(Map<String, dynamic> map)
: id = map["id"],
name = map["name"],
color = map["color"] == null ? null : Color(map["color"]),
logoURL = map["logoURL"],
groupID = map["groupID"],
lastActivity = map["lastActivity"],
members = map["members"]
.map((el) => ConversationMember.fromJSON(el))
.toList()
.cast<ConversationMember>(),
canEveryoneAddMembers = map["canEveryoneAddMembers"],
// By default, we can not do any call
callCapabilities = CallCapabilities.NONE,
isHavingCall = false,
super.fromMap(map);
isHavingCall = false;
@override
Map<String, dynamic> toMap() {
Map<String, dynamic> toJson() {
return {
ConversationTableContract.C_ID: id,
ConversationTableContract.C_OWNER_ID: ownerID,
ConversationTableContract.C_LAST_ACTIVE: lastActive,
ConversationTableContract.C_NAME: name,
ConversationTableContract.C_FOLLOWING: following ? 1 : 0,
ConversationTableContract.C_SAW_LAST_MESSAGE: sawLastMessage ? 1 : 0,
ConversationTableContract.C_MEMBERS: members.join(","),
ConversationTableContract.C_CAN_EVERYONE_ADD_MEMBERS:
canEveryoneAddMembers ? 1 : 0
"id": id,
"name": name,
"color": color?.value,
"logoURL": logoURL,
"groupID": groupID,
"lastActivity": lastActivity,
"members": members.map((e) => e.toJson()).toList(),
"canEveryoneAddMembers": canEveryoneAddMembers,
};
}
@override
int compareTo(other) {
return other.lastActive.compareTo(this.lastActive);
int compareTo(Conversation other) {
return other.lastActivity.compareTo(this.lastActivity);
}
}

View File

@ -0,0 +1,40 @@
import 'package:flutter/widgets.dart';
/// Conversation member
///
/// @author Pierre Hubert
class ConversationMember {
final int userID;
final int lastMessageSeen;
final int lastAccessTime;
final bool following;
final bool isAdmin;
const ConversationMember({
@required this.userID,
@required this.lastMessageSeen,
@required this.lastAccessTime,
@required this.following,
@required this.isAdmin,
}) : assert(userID != null),
assert(lastMessageSeen != null),
assert(lastAccessTime != null),
assert(following != null),
assert(isAdmin != null);
Map<String, dynamic> toJson() => {
'userID': userID,
'lastMessageSeen': lastMessageSeen,
'lastAccessTime': lastAccessTime,
'following': following,
'isAdmin': isAdmin,
};
ConversationMember.fromJSON(Map<String, dynamic> json)
: userID = json["userID"],
lastMessageSeen = json["lastMessageSeen"],
lastAccessTime = json["lastAccessTime"],
following = json["following"],
isAdmin = json["isAdmin"];
}

View File

@ -1,66 +1,260 @@
import 'package:comunic/helpers/database/database_contract.dart';
import 'package:comunic/models/cache_model.dart';
import 'package:comunic/helpers/serialization/base_serialization_helper.dart';
import 'package:comunic/lists/users_list.dart';
import 'package:comunic/models/displayed_content.dart';
import 'package:comunic/utils/account_utils.dart' as account;
import 'package:comunic/utils/intl_utils.dart';
import 'package:flutter/material.dart';
import 'package:meta/meta.dart';
/// Single conversation message
///
/// @author Pierre HUBERT
class ConversationMessage extends CacheModel implements Comparable {
final int id;
final int conversationID;
enum ConversationMessageFileType {
IMAGE,
VIDEO,
AUDIO,
PDF,
ZIP,
OTHER,
}
const _ConversationFileMimeTypeMapping = {
"image/jpeg": ConversationMessageFileType.IMAGE,
"image/png": ConversationMessageFileType.IMAGE,
"image/gif": ConversationMessageFileType.IMAGE,
"video/mp4": ConversationMessageFileType.VIDEO,
"audio/m4a": ConversationMessageFileType.AUDIO,
"audio/mpeg": ConversationMessageFileType.AUDIO,
"application/pdf": ConversationMessageFileType.PDF,
"application/zip": ConversationMessageFileType.ZIP,
};
class ConversationMessageFile {
final String url;
final int size;
final String name;
final String thumbnail;
final String type;
const ConversationMessageFile({
@required this.url,
@required this.size,
@required this.name,
@required this.thumbnail,
@required this.type,
}) : assert(url != null),
assert(size != null),
assert(name != null),
assert(type != null);
/// Get the type of file
ConversationMessageFileType get fileType {
if (type != null && _ConversationFileMimeTypeMapping.containsKey(type))
return _ConversationFileMimeTypeMapping[type];
else
return ConversationMessageFileType.OTHER;
}
/// Get the icon associated with file type
IconData get icon {
switch (fileType) {
case ConversationMessageFileType.IMAGE:
return Icons.image;
case ConversationMessageFileType.VIDEO:
return Icons.video_library;
case ConversationMessageFileType.AUDIO:
return Icons.audiotrack;
case ConversationMessageFileType.PDF:
return Icons.picture_as_pdf;
case ConversationMessageFileType.ZIP:
return Icons.archive;
default:
return Icons.insert_drive_file;
}
}
bool get hasThumbnail => thumbnail != null;
Map<String, dynamic> toJson() => {
"url": url,
"size": size,
"name": name,
"thumbnail": thumbnail,
"type": type
};
ConversationMessageFile.fromJson(Map<String, dynamic> json)
: url = json["url"],
size = json["size"],
name = json["name"],
thumbnail = json["thumbnail"],
type = json["type"];
}
enum ConversationServerMessageType {
USER_CREATED_CONVERSATION,
USER_ADDED_ANOTHER_USER,
USER_LEFT_CONV,
USER_REMOVED_ANOTHER_USER
}
class ConversationServerMessage {
final ConversationServerMessageType type;
final int userID;
final int timeInsert;
final DisplayedString message;
final String imageURL;
final int userWhoAdded;
final int userAdded;
final int userWhoRemoved;
final int userRemoved;
const ConversationMessage({
@required this.id,
@required this.conversationID,
const ConversationServerMessage({
@required this.type,
@required this.userID,
@required this.timeInsert,
@required this.message,
@required this.imageURL,
}) : assert(id != null),
assert(userID != null),
assert(timeInsert != null),
assert(message != null),
super(id: id);
@required this.userWhoAdded,
@required this.userAdded,
@required this.userWhoRemoved,
@required this.userRemoved,
}) : assert(type != null),
assert(userID != null ||
(type != ConversationServerMessageType.USER_CREATED_CONVERSATION &&
type != ConversationServerMessageType.USER_LEFT_CONV)),
assert((userWhoAdded != null && userAdded != null) ||
type != ConversationServerMessageType.USER_ADDED_ANOTHER_USER),
assert((userWhoRemoved != null && userRemoved != null) ||
type != ConversationServerMessageType.USER_REMOVED_ANOTHER_USER);
DateTime get date => DateTime.fromMillisecondsSinceEpoch(timeInsert * 1000);
Set<int> get usersID {
switch (type) {
case ConversationServerMessageType.USER_CREATED_CONVERSATION:
case ConversationServerMessageType.USER_LEFT_CONV:
return Set()..add(userID);
case ConversationServerMessageType.USER_ADDED_ANOTHER_USER:
return Set()..add(userWhoAdded)..add(userAdded);
case ConversationServerMessageType.USER_REMOVED_ANOTHER_USER:
return Set()..add(userWhoRemoved)..add(userRemoved);
}
throw Exception("Unsupported server message type!");
}
String getText(UsersList list) {
switch (type) {
case ConversationServerMessageType.USER_CREATED_CONVERSATION:
return tr("%1% created the conversation",
args: {"1": list.getUser(userID).fullName});
case ConversationServerMessageType.USER_ADDED_ANOTHER_USER:
return tr("%1% added %2% to the conversation", args: {
"1": list.getUser(userWhoAdded).fullName,
"2": list.getUser(userAdded).fullName,
});
case ConversationServerMessageType.USER_LEFT_CONV:
return tr("%1% left the conversation", args: {
"1": list.getUser(userID).fullName,
});
case ConversationServerMessageType.USER_REMOVED_ANOTHER_USER:
return tr("%1% removed %2% from the conversation", args: {
"1": list.getUser(userWhoRemoved).fullName,
"2": list.getUser(userRemoved).fullName,
});
}
throw Exception("Unsupported message type!");
}
Map<String, dynamic> toJson() => {
"type": type.toString(),
"userID": userID,
"userWhoAdded": userWhoAdded,
"userAdded": userAdded,
"userWhoRemoved": userWhoRemoved,
"userRemoved": userRemoved,
};
ConversationServerMessage.fromJson(Map<String, dynamic> json)
: type = ConversationServerMessageType.values
.firstWhere((el) => el.toString() == json["type"]),
userID = json["userID"],
userWhoAdded = json["userWhoAdded"],
userAdded = json["userAdded"],
userWhoRemoved = json["userWhoRemoved"],
userRemoved = json["userRemoved"];
}
class ConversationMessage extends SerializableElement<ConversationMessage> {
final int id;
final int convID;
final int userID;
final int timeSent;
final DisplayedString message;
final ConversationMessageFile file;
final ConversationServerMessage serverMessage;
ConversationMessage({
@required this.id,
@required this.convID,
@required this.userID,
@required this.timeSent,
@required this.message,
@required this.file,
@required this.serverMessage,
}) : assert(id != null),
assert(convID != null),
assert(userID != null || serverMessage != null),
assert(timeSent != null),
assert(message != null || file != null || serverMessage != null);
DateTime get date => DateTime.fromMillisecondsSinceEpoch(timeSent * 1000);
bool get hasMessage => !message.isNull && message.length > 0;
bool get hasImage => imageURL != null && imageURL != "null";
bool get hasFile => file != null;
bool get isOwner => account.userID() == userID;
bool get isServerMessage => serverMessage != null;
/// Get the list of the ID of the users implied in this message
Set<int> get usersID {
if (userID != null) return Set()..add(userID);
if (serverMessage != null) return serverMessage.usersID;
return Set();
}
@override
int compareTo(other) {
int compareTo(ConversationMessage other) {
return id.compareTo(other.id);
}
@override
Map<String, dynamic> toMap() {
Map<String, dynamic> toJson() {
return {
ConversationsMessagesTableContract.C_ID: id,
ConversationsMessagesTableContract.C_CONVERSATION_ID: conversationID,
ConversationsMessagesTableContract.C_USER_ID: userID,
ConversationsMessagesTableContract.C_TIME_INSERT: timeInsert,
ConversationsMessagesTableContract.C_MESSAGE: message.content,
ConversationsMessagesTableContract.C_IMAGE_URL: imageURL
"id": id,
"convID": convID,
"userID": userID,
"timeSent": timeSent,
"message": message.content,
"file": file?.toJson(),
"serverMessage": serverMessage?.toJson(),
};
}
ConversationMessage.fromMap(Map<String, dynamic> map)
: id = map[ConversationsMessagesTableContract.C_ID],
conversationID =
map[ConversationsMessagesTableContract.C_CONVERSATION_ID],
userID = map[ConversationsMessagesTableContract.C_USER_ID],
timeInsert = map[ConversationsMessagesTableContract.C_TIME_INSERT],
message = DisplayedString(map[ConversationsMessagesTableContract.C_MESSAGE]),
imageURL = map[ConversationsMessagesTableContract.C_IMAGE_URL],
super.fromMap(map);
ConversationMessage.fromJson(Map<String, dynamic> map)
: id = map["id"],
convID = map["convID"],
userID = map["userID"],
timeSent = map["timeSent"],
message = DisplayedString(map["message"]),
file = map["file"] == null
? null
: ConversationMessageFile.fromJson(map["file"]),
serverMessage = map["serverMessage"] == null
? null
: ConversationServerMessage.fromJson(map["serverMessage"]);
}

View File

@ -0,0 +1,21 @@
/// Data conservation policy settings
///
/// @author Pierre Hubert
class DataConservationPolicySettings {
int inactiveAccountLifeTime;
int notificationLifetime;
int commentsLifetime;
int postsLifetime;
int conversationMessagesLifetime;
int likesLifetime;
DataConservationPolicySettings({
this.inactiveAccountLifeTime,
this.notificationLifetime,
this.commentsLifetime,
this.postsLifetime,
this.conversationMessagesLifetime,
this.likesLifetime,
});
}

View File

@ -1,23 +0,0 @@
import 'dart:convert';
/// Login tokens model
///
/// @author Pierre HUBERT
class LoginTokens {
final String tokenOne;
final String tokenTwo;
const LoginTokens(this.tokenOne, this.tokenTwo)
: assert(tokenOne != null),
assert(tokenTwo != null);
LoginTokens.fromJSON(Map<String, dynamic> json)
: tokenOne = json["token_one"],
tokenTwo = json["token_two"];
@override
String toString() {
return jsonEncode({"token_one": tokenOne, "token_two": tokenTwo});
}
}

View File

@ -43,7 +43,7 @@ class Membership {
case MembershipType.GROUP:
return groupLastActive;
case MembershipType.CONVERSATION:
return conversation.lastActive;
return conversation.lastActivity;
default:
throw Exception("Unreachable statment!");
}

View File

@ -1,6 +1,7 @@
import 'package:image_picker/image_picker.dart';
import 'package:meta/meta.dart';
import 'api_request.dart';
/// New comment information
///
/// @author Pierre HUBERT
@ -8,7 +9,7 @@ import 'package:meta/meta.dart';
class NewComment {
final int postID;
final String content;
final PickedFile image;
final BytesFile image;
const NewComment({
@required this.postID,

View File

@ -0,0 +1,24 @@
import 'package:flutter/cupertino.dart';
/// New conversation information
///
/// @author Pierre Hubert
class NewConversation {
final String name;
final List<int> members;
final bool follow;
final bool canEveryoneAddMembers;
final Color color;
const NewConversation({
@required this.name,
@required this.members,
@required this.follow,
@required this.canEveryoneAddMembers,
@required this.color,
}) : assert(members != null),
assert(members.length > 0),
assert(follow != null),
assert(canEveryoneAddMembers != null);
}

View File

@ -1,4 +1,4 @@
import 'package:image_picker/image_picker.dart';
import 'package:comunic/models/api_request.dart';
import 'package:meta/meta.dart';
/// New conversation message model
@ -10,14 +10,20 @@ import 'package:meta/meta.dart';
class NewConversationMessage {
final int conversationID;
final String message;
final PickedFile image;
final BytesFile file;
final BytesFile thumbnail;
NewConversationMessage(
{@required this.conversationID, @required this.message, this.image})
: assert(conversationID != null),
assert(image != null || message != null);
NewConversationMessage({
@required this.conversationID,
@required this.message,
this.file,
this.thumbnail,
}) : assert(conversationID != null),
assert(file != null || message != null);
bool get hasMessage => message != null;
bool get hasImage => image != null;
bool get hasFile => file != null;
bool get hasThumbnail => thumbnail != null;
}

View File

@ -0,0 +1,27 @@
import 'package:flutter/widgets.dart';
/// Conversation settings update
///
/// @author Pierre Hubert
class NewConversationsSettings {
final int convID;
final bool following;
final bool isComplete;
final String name;
final bool canEveryoneAddMembers;
final Color color;
const NewConversationsSettings({
@required this.convID,
@required this.following,
@required this.isComplete,
@required this.name,
@required this.canEveryoneAddMembers,
@required this.color,
}) : assert(convID != null),
assert(convID > 0),
assert(following != null),
assert(isComplete != null),
assert(!isComplete || canEveryoneAddMembers != null);
}

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'api_request.dart';
/// New emoji information
///
@ -7,7 +8,7 @@ import 'package:image_picker/image_picker.dart';
class NewEmoji {
final String shortcut;
final PickedFile image;
final BytesFile image;
const NewEmoji({
@required this.shortcut,

View File

@ -1,9 +1,10 @@
import 'package:comunic/enums/post_kind.dart';
import 'package:comunic/enums/post_target.dart';
import 'package:comunic/enums/post_visibility_level.dart';
import 'package:image_picker/image_picker.dart';
import 'package:meta/meta.dart';
import 'api_request.dart';
/// New post information
///
/// @author Pierre HUBERT
@ -27,7 +28,7 @@ class NewPost {
final int targetID;
final PostVisibilityLevel visibility;
final String content;
final PickedFile image;
final BytesFile image;
final String url;
final List<int> pdf;
final PostKind kind;

View File

@ -13,7 +13,6 @@ enum NotificationElementType {
POST_TEXT,
POST_IMAGE,
POST_YOUTUBE,
POST_MOVIE,
POST_WEBLINK,
POST_PDF,
POST_TIMER,

View File

@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
/// Check password reset token result
///
/// @author Pierre Hubert
class ResCheckPasswordToken {
final String firstName;
final String lastName;
final String email;
const ResCheckPasswordToken({
@required this.firstName,
@required this.lastName,
@required this.email,
}) : assert(firstName != null),
assert(lastName != null),
assert(email != null);
}

View File

@ -0,0 +1,125 @@
import 'package:flutter/widgets.dart';
import 'package:version/version.dart';
/// Server static configuration
///
/// @author Pierre Hubert
class PasswordPolicy {
final bool allowMailInPassword;
final bool allowNameInPassword;
final int minPasswordLength;
final int minNumberUpperCaseLetters;
final int minNumberLowerCaseLetters;
final int minNumberDigits;
final int minNumberSpecialCharacters;
final int minCategoriesPresence;
const PasswordPolicy({
@required this.allowMailInPassword,
@required this.allowNameInPassword,
@required this.minPasswordLength,
@required this.minNumberUpperCaseLetters,
@required this.minNumberLowerCaseLetters,
@required this.minNumberDigits,
@required this.minNumberSpecialCharacters,
@required this.minCategoriesPresence,
}) : assert(allowMailInPassword != null),
assert(allowNameInPassword != null),
assert(minPasswordLength != null),
assert(minNumberUpperCaseLetters != null),
assert(minNumberLowerCaseLetters != null),
assert(minNumberDigits != null),
assert(minNumberSpecialCharacters != null),
assert(minCategoriesPresence != null);
}
class ServerDataConservationPolicy {
final int minInactiveAccountLifetime;
final int minNotificationLifetime;
final int minCommentsLifetime;
final int minPostsLifetime;
final int minConversationMessagesLifetime;
final int minLikesLifetime;
const ServerDataConservationPolicy({
@required this.minInactiveAccountLifetime,
@required this.minNotificationLifetime,
@required this.minCommentsLifetime,
@required this.minPostsLifetime,
@required this.minConversationMessagesLifetime,
@required this.minLikesLifetime,
}) : assert(minInactiveAccountLifetime != null),
assert(minNotificationLifetime != null),
assert(minCommentsLifetime != null),
assert(minPostsLifetime != null),
assert(minConversationMessagesLifetime != null),
assert(minLikesLifetime != null);
}
class ConversationsPolicy {
final int minMessageLen;
final int maxMessageLen;
final List<String> allowedFilesType;
final int filesMaxSize;
final int writingEventInterval;
final int writingEventLifetime;
final int maxMessageImageWidth;
final int maxMessageImageHeight;
final int maxThumbnailWidth;
final int maxThumbnailHeight;
final int maxLogoWidth;
final int maxLogoHeight;
const ConversationsPolicy({
@required this.minMessageLen,
@required this.maxMessageLen,
@required this.allowedFilesType,
@required this.filesMaxSize,
@required this.writingEventInterval,
@required this.writingEventLifetime,
@required this.maxMessageImageWidth,
@required this.maxMessageImageHeight,
@required this.maxThumbnailWidth,
@required this.maxThumbnailHeight,
@required this.maxLogoWidth,
@required this.maxLogoHeight,
}) : assert(minMessageLen != null),
assert(maxMessageLen != null),
assert(allowedFilesType != null),
assert(filesMaxSize != null),
assert(writingEventInterval != null),
assert(writingEventLifetime != null),
assert(maxMessageImageWidth != null),
assert(maxMessageImageHeight != null),
assert(maxThumbnailWidth != null),
assert(maxThumbnailHeight != null),
assert(maxLogoWidth != null),
assert(maxLogoHeight != null);
}
class ServerConfig {
final Version minSupportedMobileVersion;
final String termsURL;
final String playStoreURL;
final String androidDirectDownloadURL;
final PasswordPolicy passwordPolicy;
final ServerDataConservationPolicy dataConservationPolicy;
final ConversationsPolicy conversationsPolicy;
const ServerConfig({
@required this.minSupportedMobileVersion,
@required this.termsURL,
@required this.playStoreURL,
@required this.androidDirectDownloadURL,
@required this.passwordPolicy,
@required this.dataConservationPolicy,
@required this.conversationsPolicy,
}) : assert(minSupportedMobileVersion != null),
assert(termsURL != null),
assert(playStoreURL != null),
assert(androidDirectDownloadURL != null),
assert(passwordPolicy != null),
assert(dataConservationPolicy != null),
assert(conversationsPolicy != null);
}

View File

@ -1,3 +1,5 @@
import 'package:comunic/models/conversation.dart';
import 'package:comunic/models/conversation_message.dart';
import 'package:flutter/material.dart';
/// Unread conversation information
@ -5,21 +7,12 @@ import 'package:flutter/material.dart';
/// @author Pierre Hubert
class UnreadConversation {
final int id;
final String convName;
final int lastActive;
final int userID;
final String message;
final Conversation conv;
final ConversationMessage message;
const UnreadConversation({
@required this.id,
@required this.convName,
@required this.lastActive,
@required this.userID,
@required this.conv,
@required this.message,
}) : assert(id != null),
assert(convName != null),
assert(lastActive != null),
assert(userID != null),
}) : assert(conv != null),
assert(message != null);
}

View File

@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
/// Simple alert dialog
///
/// @author Pierre Hubert
Future<void> alert(BuildContext context, String msg) async {
await showDialog(context: context, builder: (c) => _AlertDialog(msg: msg));
}
class _AlertDialog extends StatelessWidget {
final String msg;
const _AlertDialog({Key key, @required this.msg})
: assert(msg != null),
super(key: key);
@override
Widget build(BuildContext context) {
return AlertDialog(
content: Text(msg),
actions: <Widget>[
MaterialButton(
child: Text("OK"),
onPressed: () => Navigator.of(context).pop(),
)
],
);
}
}

View File

@ -0,0 +1,82 @@
import 'package:chewie_audio/chewie_audio.dart';
import 'package:comunic/ui/widgets/async_screen_widget.dart';
import 'package:comunic/utils/intl_utils.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
/// Audio player dialog
///
/// @author Pierre Hubert
/// Show audio player dialog
Future<void> showAudioPlayerDialog(BuildContext context, String url) async {
showDialog(context: context, builder: (c) => _AudioPlayerDialog(url: url));
}
class _AudioPlayerDialog extends StatefulWidget {
final String url;
const _AudioPlayerDialog({
Key key,
@required this.url,
}) : assert(url != null),
super(key: key);
@override
__AudioPlayerDialogState createState() => __AudioPlayerDialogState();
}
class __AudioPlayerDialogState extends State<_AudioPlayerDialog> {
VideoPlayerController _videoPlayerController;
ChewieAudioController _chewieAudioController;
Future<void> _initialize() async {
_videoPlayerController = VideoPlayerController.network(widget.url);
await _videoPlayerController.initialize();
_chewieAudioController = ChewieAudioController(
videoPlayerController: _videoPlayerController,
autoPlay: true,
looping: false,
);
}
void _closeDialog() {
Navigator.pop(context);
}
@override
void dispose() {
if (_videoPlayerController != null) _videoPlayerController.dispose();
if (_chewieAudioController != null) _chewieAudioController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(tr("Audio Player")),
content: _buildContent(),
actions: [
MaterialButton(
onPressed: _closeDialog,
child: Text(tr("Close").toUpperCase()),
)
],
);
}
Widget _buildContent() => ConstrainedBox(
constraints: BoxConstraints(maxHeight: 50, maxWidth: 500),
child: AsyncScreenWidget(
onReload: _initialize,
onBuild: _buildReadyContent,
errorMessage: tr("Failed to initialize audio player!"),
),
);
Widget _buildReadyContent() =>
ChewieAudio(controller: _chewieAudioController);
}

View File

@ -0,0 +1,54 @@
import 'package:comunic/utils/intl_utils.dart';
import 'package:flutter/material.dart';
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
/// Color picker dialog
///
/// @author Pierre Hubert
Future<Color> showColorPickerDialog(
BuildContext context, Color initialColor) async =>
await showDialog(
context: context,
builder: (c) => _ColorPickerDialog(initialColor: initialColor),
);
class _ColorPickerDialog extends StatefulWidget {
final Color initialColor;
const _ColorPickerDialog({Key key, @required this.initialColor})
: super(key: key);
@override
__ColorPickerDialogState createState() => __ColorPickerDialogState();
}
class __ColorPickerDialogState extends State<_ColorPickerDialog> {
Color _newColor;
@override
void initState() {
_newColor = widget.initialColor;
super.initState();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
content: MaterialPicker(
pickerColor: _newColor ?? Colors.blue.shade900,
onColorChanged: (c) => setState(() => _newColor = c),
),
actions: [
MaterialButton(
onPressed: () => Navigator.pop(context, widget.initialColor),
child: Text(tr("Cancel").toUpperCase()),
),
MaterialButton(
onPressed: () => Navigator.pop(context, _newColor),
child: Text(tr("Ok").toUpperCase()),
),
],
);
}
}

View File

@ -0,0 +1,40 @@
import 'package:comunic/helpers/server_config_helper.dart';
import 'package:comunic/utils/intl_utils.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
/// Deprecation dialog
///
/// @author Pierre Hubert
/// Show a dialog to warn the user this version of the application is deprecated
Future<void> showDeprecationDialog(BuildContext context) async {
await showDialog(context: context, builder: (c) => _DeprecationDialog());
}
class _DeprecationDialog extends StatelessWidget {
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(tr("Deprecated application version")),
content: Text(tr(
"This version of the Comunic application is deprecated. You might still be able to use it, but some features may not work. We recommend you to update to the latest version of the application.")),
actions: [
MaterialButton(
onPressed: () =>
launch(ServerConfigurationHelper.config.playStoreURL),
child: Text(tr("Go to the Play Store")),
),
MaterialButton(
onPressed: () =>
launch(ServerConfigurationHelper.config.androidDirectDownloadURL),
child: Text(tr("Download update outside Play Store")),
),
MaterialButton(
onPressed: () => Navigator.pop(context),
child: Text(tr("Use the old application anyway")),
)
],
);
}
}

View File

@ -1,5 +1,5 @@
import 'package:comunic/ui/widgets/dialogs/auto_sized_dialog_content_widget.dart';
import 'package:comunic/utils/input_utils.dart';
import 'package:comunic/ui/widgets/new_password_input_widget.dart';
import 'package:comunic/utils/intl_utils.dart';
import 'package:flutter/material.dart';
@ -8,27 +8,44 @@ import 'package:flutter/material.dart';
/// @author Pierre HUBERT
/// Ask the user to enter a new password
Future<String> showInputNewPassword(BuildContext context) async {
Future<String> showInputNewPassword({
@required BuildContext context,
@required UserInfoForPassword userInfo,
}) async {
assert(context != null);
assert(userInfo != null);
return await showDialog(
context: context, builder: (c) => _InputNewPasswordDialog());
context: context,
builder: (c) => _InputNewPasswordDialog(
userInfo: userInfo,
));
}
class _InputNewPasswordDialog extends StatefulWidget {
final UserInfoForPassword userInfo;
const _InputNewPasswordDialog({
Key key,
@required this.userInfo,
}) : assert(userInfo != null),
super(key: key);
@override
__InputNewPasswordDialogState createState() =>
__InputNewPasswordDialogState();
}
class __InputNewPasswordDialogState extends State<_InputNewPasswordDialog> {
final _controller1 = TextEditingController();
final _controller1 = GlobalKey<NewPasswordInputWidgetState>();
final _controller2 = TextEditingController();
final _focusScopeNode = FocusScopeNode();
String get _password => _controller1.text;
String get _password => _controller1.currentState.value;
bool get _input1Valid => validatePassword(_password);
bool get _input1Valid =>
_controller1.currentState != null && _controller1.currentState.valid;
bool get _input2Valid => _controller1.text == _controller2.text;
bool get _input2Valid => _controller1.currentState.value == _controller2.text;
bool get _isValid => _input1Valid && _input2Valid;
@ -65,12 +82,10 @@ class __InputNewPasswordDialogState extends State<_InputNewPasswordDialog> {
child: Column(
children: <Widget>[
// Input 1
_buildPasswordField(
controller: _controller1,
NewPasswordInputWidget(
key: _controller1,
user: widget.userInfo,
label: tr("Your new password"),
errorText: _controller1.text.isNotEmpty && !_input1Valid
? tr("Invalid password!")
: null,
textInputAction: TextInputAction.next,
onSubmitted: () => _focusScopeNode.nextFocus(),
),

View File

@ -30,7 +30,7 @@ class __InputUserPasswordDialogState
String get _currPass => _controller.text;
bool get _canSubmit =>
validatePassword(_controller.text) && _status != _Status.CHECKING;
legacyValidatePassword(_controller.text) && _status != _Status.CHECKING;
void _setStatus(_Status s) => setState(() => _status = s);

View File

@ -0,0 +1,220 @@
import 'package:comunic/models/api_request.dart';
import 'package:comunic/ui/dialogs/record_audio_dialog.dart';
import 'package:comunic/ui/routes/image_editor_route.dart';
import 'package:comunic/utils/files_utils.dart';
import 'package:comunic/utils/intl_utils.dart';
import 'package:comunic/utils/ui_utils.dart';
import 'package:file_picker/file_picker.dart';
import 'package:filesize/filesize.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:image_cropper/image_cropper.dart';
import 'package:image_picker/image_picker.dart';
import 'package:mime/mime.dart';
/// Pick file dialog
///
/// @author Pierre Hubert
enum _FileChoices {
PICK_IMAGE,
TAKE_PICTURE,
PICK_VIDEO,
TAKE_VIDEO,
RECORD_AUDIO,
PICK_OTHER_FILE,
}
typedef _CanEnable = bool Function(List<String>);
typedef _OnOptionSelected = void Function(_FileChoices);
class _PickFileOption {
final _FileChoices value;
final String label;
final IconData icon;
final _CanEnable canEnable;
const _PickFileOption({
@required this.value,
@required this.label,
@required this.icon,
@required this.canEnable,
}) : assert(value != null),
assert(label != null),
assert(icon != null),
assert(canEnable != null);
}
List<_PickFileOption> get _optionsList => [
// Image
_PickFileOption(
value: _FileChoices.PICK_IMAGE,
label: tr("Choose an image"),
icon: Icons.image,
canEnable: (l) => l.any(isImage)),
_PickFileOption(
value: _FileChoices.TAKE_PICTURE,
label: tr("Take a picture"),
icon: Icons.camera_alt,
canEnable: (l) => l.any(isImage)),
// Video
_PickFileOption(
value: _FileChoices.PICK_VIDEO,
label: tr("Choose a video"),
icon: Icons.video_library,
canEnable: (l) => l.any(isVideo)),
_PickFileOption(
value: _FileChoices.TAKE_VIDEO,
label: tr("Take a video"),
icon: Icons.videocam,
canEnable: (l) => l.any(isVideo)),
// Audio
_PickFileOption(
value: _FileChoices.RECORD_AUDIO,
label: tr("Record audio"),
icon: Icons.mic,
canEnable: (l) => l.any(isAudio)),
// Other
_PickFileOption(
value: _FileChoices.PICK_OTHER_FILE,
label: tr("Browse files"),
icon: Icons.folder_open,
canEnable: (l) =>
l.any((el) => !isImage(el) && !isVideo(el) && !isAudio(el))),
];
Future<BytesFile> showPickFileDialog({
@required BuildContext context,
int maxFileSize,
List<String> allowedMimeTypes,
int imageMaxWidth,
int imageMaxHeight,
CropAspectRatio aspectRatio,
}) async {
assert(allowedMimeTypes != null);
// Get the list of allowed extension
final allowedExtensions = <String>[];
for (var mime in allowedExtensions) {
final ext = extensionFromMime(mime);
if (ext != mime) allowedExtensions.add(ext);
}
// Display bottom sheet
final choice = await showModalBottomSheet(
context: context,
builder: (c) => BottomSheet(
onClosing: () {},
builder: (c) => _BottomSheetPickOption(
options: _optionsList
.where((element) => element.canEnable(allowedMimeTypes))
.toList(),
onOptionSelected: (v) => Navigator.pop(c, v),
),
));
if (choice == null) return null;
BytesFile file;
switch (choice) {
// Pick an image
case _FileChoices.PICK_IMAGE:
case _FileChoices.TAKE_PICTURE:
final image = await ImagePicker().getImage(
source: choice == _FileChoices.PICK_IMAGE
? ImageSource.gallery
: ImageSource.camera,
maxWidth: imageMaxWidth.toDouble(),
maxHeight: imageMaxHeight.toDouble(),
);
if (image == null) return null;
file = BytesFile(image.path.split("/").last, await image.readAsBytes());
file = await showImageCropper(context, file, aspectRatio: aspectRatio);
break;
// Pick an video
case _FileChoices.PICK_VIDEO:
case _FileChoices.TAKE_VIDEO:
final image = await ImagePicker().getVideo(
source: choice == _FileChoices.PICK_VIDEO
? ImageSource.gallery
: ImageSource.camera,
);
if (image == null) return null;
file = BytesFile(image.path.split("/").last, await image.readAsBytes());
break;
// Record audio file
case _FileChoices.RECORD_AUDIO:
final bytes = await showRecordAudioDialog(context);
if (bytes == null) return null;
file = BytesFile("record.mp3", bytes);
break;
// Pick other files
case _FileChoices.PICK_OTHER_FILE:
final pickedFile = await FilePicker.platform.pickFiles(
type: FileType.any,
allowedExtensions: allowedExtensions,
allowMultiple: false,
withData: true,
);
if (pickedFile == null || pickedFile.files.length == 0) return null;
file = BytesFile(pickedFile.files[0].name, pickedFile.files[0].bytes);
break;
}
if (file == null) return null;
// Check file size
if (maxFileSize != null && file.bytes.length > maxFileSize) {
showSimpleSnack(
context,
tr("This file could not be sent: it is too big! (Max allowed size: %1%)",
args: {"1": filesize(file.bytes.length)}));
return null;
}
return file;
}
class _BottomSheetPickOption extends StatelessWidget {
final List<_PickFileOption> options;
final _OnOptionSelected onOptionSelected;
const _BottomSheetPickOption(
{Key key, @required this.options, @required this.onOptionSelected})
: assert(options != null),
assert(onOptionSelected != null),
super(key: key);
@override
Widget build(BuildContext context) => Container(
height: 255,
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 400),
child: ListView.builder(
itemCount: options.length,
itemBuilder: (c, i) => ListTile(
leading: Icon(options[i].icon),
title: Text(options[i].label),
onTap: () => onOptionSelected(options[i].value),
),
),
),
),
);
}

View File

@ -0,0 +1,302 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:comunic/ui/dialogs/alert_dialog.dart';
import 'package:comunic/utils/intl_utils.dart';
import 'package:comunic/utils/log_utils.dart';
import 'package:comunic/utils/permission_utils.dart';
import 'package:comunic/utils/ui_utils.dart';
import 'package:flutter/material.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:record_mp3/record_mp3.dart';
import 'package:video_player/video_player.dart';
/// Record audio dialog
///
/// @author Pierre Hubert
/// Record audio
Future<Uint8List> showRecordAudioDialog(BuildContext context) async {
// Request record permission
if (!await requestPermission(context, Permission.microphone)) {
alert(context, tr("Did not get permission to access microphone!"));
return null;
}
final res = await showDialog(
context: context,
builder: (c) => Scaffold(
body: _RecordAudioDialog(),
backgroundColor: Colors.transparent,
),
barrierDismissible: false,
);
return res;
}
class _RecordAudioDialog extends StatefulWidget {
@override
__RecordAudioDialogState createState() => __RecordAudioDialogState();
}
class __RecordAudioDialogState extends State<_RecordAudioDialog> {
String _recordPath;
File get _recordFile => _recordPath == null ? null : File(_recordPath);
bool _recording = false;
bool get _hasRecord => !_recording && _recordPath != null;
VideoPlayerController _videoPlayerController;
bool _playing = false;
bool _paused = false;
/// Get record data. This getter can be accessed only once
Uint8List get _bytes {
final bytes = _recordFile.readAsBytesSync();
File(_recordPath).deleteSync();
return bytes;
}
@override
void dispose() {
_disposePlayer();
RecordMp3.instance.stop();
super.dispose();
}
void _disposePlayer() {
if (_videoPlayerController != null) {
_videoPlayerController.dispose();
}
_videoPlayerController = null;
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(tr("Audio record")),
content: _buildContent(),
actions: <Widget>[
_ActionButton(
visible: !_recording,
text: tr("Cancel"),
onPressed: () => Navigator.of(context).pop(),
),
_ActionButton(
visible: _hasRecord,
text: tr("Send"),
onPressed: () => Navigator.of(context).pop(_bytes),
),
],
);
}
String get _status {
if (_recording)
return tr("Recording...");
else if (_paused)
return tr("Playback paused...");
else if (_playing)
return tr("Playing...");
else if (!_hasRecord)
return tr("Ready");
else
return tr("Done");
}
Widget _buildContent() => Row(
children: <Widget>[
Text(_status),
Spacer(),
// Start recording
_RecordAction(
visible: !_recording && !_playing,
icon: Icons.fiber_manual_record,
onTap: _startRecording,
color: Colors.red,
),
// Stop recording
_RecordAction(
visible: _recording,
icon: Icons.stop,
onTap: _stopRecording,
color: Colors.red,
),
// Play recording
_RecordAction(
visible: !_recording && _hasRecord && !_playing,
icon: Icons.play_arrow,
onTap: _playRecord,
),
// Pause playback
_RecordAction(
visible: _playing && !_paused,
icon: Icons.pause,
onTap: _pausePlayback,
),
// Resume recording
_RecordAction(
visible: _paused,
icon: Icons.play_arrow,
onTap: _resumePlayback,
),
// Stop recording
_RecordAction(
visible: _playing,
icon: Icons.stop,
onTap: _stopPlayback,
),
],
);
void _startRecording() async {
try {
if (_recordFile != null) _recordFile.deleteSync();
final dir = await getTemporaryDirectory();
_recordPath = path.join(dir.absolute.path, "tmp-audio-record.mp3");
RecordMp3.instance.start(_recordPath, (fail) {
print(fail);
snack(context, tr("Failed to start recording!"));
});
setState(() => _recording = true);
} catch (e, s) {
logError(e, s);
snack(context, tr("Error while recording!"));
}
}
void _stopRecording() async {
try {
RecordMp3.instance.stop();
setState(() => _recording = false);
} catch (e, s) {
logError(e, s);
snack(context, tr("Error while recording!"));
}
}
void _playRecord() async {
try {
_disposePlayer();
_videoPlayerController = VideoPlayerController.file(File(_recordPath));
await _videoPlayerController.initialize();
_videoPlayerController.addListener(() async {
if (_videoPlayerController == null) return;
if (_videoPlayerController.value.position ==
_videoPlayerController.value.duration) _stopPlayback();
});
await _videoPlayerController.play();
setState(() {
_playing = true;
_paused = false;
});
} catch (e, s) {
logError(e, s);
snack(context, tr("Error while playing record!"));
}
}
void _pausePlayback() async {
try {
await _videoPlayerController.pause();
setState(() => _paused = true);
} catch (e, s) {
logError(e, s);
snack(context, tr("Error while pausing playback!"));
}
}
void _resumePlayback() async {
try {
await _videoPlayerController.play();
setState(() => _paused = false);
} catch (e, s) {
logError(e, s);
snack(context, tr("Error while resuming playback!"));
}
}
void _stopPlayback() async {
try {
_disposePlayer();
setState(() {
_paused = false;
_playing = false;
});
} catch (e, s) {
logError(e, s);
snack(context, tr("Error while stopping playback!"));
}
}
}
class _RecordAction extends StatelessWidget {
final bool visible;
final IconData icon;
final void Function() onTap;
final Color color;
const _RecordAction({
Key key,
@required this.visible,
@required this.icon,
@required this.onTap,
this.color,
}) : assert(visible != null),
assert(icon != null),
super(key: key);
@override
Widget build(BuildContext context) {
if (!visible) return Container(width: 0, height: 0);
return IconButton(icon: Icon(icon, color: color), onPressed: onTap);
}
}
class _ActionButton extends StatelessWidget {
final bool visible;
final String text;
final void Function() onPressed;
const _ActionButton({
Key key,
this.visible,
this.text,
this.onPressed,
}) : assert(visible != null),
assert(text != null),
super(key: key);
@override
Widget build(BuildContext context) {
if (!visible) return Container();
return MaterialButton(
onPressed: onPressed,
child: Text(text.toUpperCase()),
);
}
}

View File

@ -0,0 +1,95 @@
import 'package:comunic/helpers/users_helper.dart';
import 'package:comunic/lists/users_list.dart';
import 'package:comunic/models/conversation.dart';
import 'package:comunic/models/conversation_message.dart';
import 'package:comunic/ui/routes/main_route/main_route.dart';
import 'package:comunic/ui/widgets/account_image_widget.dart';
import 'package:comunic/ui/widgets/async_screen_widget.dart';
import 'package:comunic/utils/date_utils.dart';
import 'package:comunic/utils/intl_utils.dart';
import 'package:flutter/material.dart';
/// Conversation message statistics route
///
/// @author Pierre Hubert
class ConversationMessageStatsRoute extends StatefulWidget {
final Conversation conv;
final ConversationMessage message;
const ConversationMessageStatsRoute({
Key key,
@required this.conv,
@required this.message,
}) : assert(conv != null),
assert(message != null),
super(key: key);
@override
_ConversationMessageStatsRouteState createState() =>
_ConversationMessageStatsRouteState();
}
class _ConversationMessageStatsRouteState
extends State<ConversationMessageStatsRoute> {
UsersList _users;
Future<void> _init() async {
_users = await UsersHelper()
.getList(widget.conv.membersID..add(widget.message.userID));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(tr("Message statistics")),
leading: IconButton(
icon: Icon(Icons.close),
onPressed: () => MainController.of(context).popPage(),
),
),
body: AsyncScreenWidget(
onReload: _init,
onBuild: _buildScreen,
errorMessage: tr("Failed to load message information!")),
);
}
List<Widget> get _firstItems => [
ListTile(
leading: Icon(Icons.access_time_rounded),
title: Text(tr("Created on")),
subtitle: Text(dateTimeToString(widget.message.date)),
),
ListTile(
leading: AccountImageWidget(
user: _users.getUser(widget.message.userID),
),
title: Text(_users.getUser(widget.message.userID).fullName),
subtitle: Text(tr("Creator")),
),
];
Widget _buildScreen() => ListView.builder(
itemCount: _firstItems.length + widget.conv.members.length,
itemBuilder: (c, i) {
final firstItems = _firstItems;
if (i < firstItems.length) return firstItems[i];
final convMember = widget.conv.members[i - firstItems.length];
if (convMember.userID == widget.message.userID) return Container();
return ListTile(
leading: AccountImageWidget(
user: _users.getUser(convMember.userID),
),
title: Text(_users.getUser(convMember.userID).fullName),
subtitle: Text(convMember.lastMessageSeen < widget.message.id
? tr("Message not seen yet")
: tr("Message seen")),
);
},
);
}

View File

@ -1,9 +1,13 @@
import 'package:comunic/helpers/conversations_helper.dart';
import 'package:comunic/helpers/users_helper.dart';
import 'package:comunic/lists/users_list.dart';
import 'package:comunic/models/conversation.dart';
import 'package:comunic/ui/routes/main_route/main_route.dart';
import 'package:comunic/ui/routes/update_conversation_route.dart';
import 'package:comunic/ui/screens/conversation_screen.dart';
import 'package:comunic/ui/widgets/comunic_back_button_widget.dart';
import 'package:comunic/ui/widgets/conversation_image_widget.dart';
import 'package:comunic/ui/widgets/safe_state.dart';
import 'package:comunic/utils/intl_utils.dart';
import 'package:comunic/utils/ui_utils.dart';
import 'package:flutter/material.dart';
@ -25,9 +29,10 @@ class ConversationRoute extends StatefulWidget {
State<StatefulWidget> createState() => _ConversationRouteState();
}
class _ConversationRouteState extends State<ConversationRoute> {
class _ConversationRouteState extends SafeState<ConversationRoute> {
final ConversationsHelper _conversationsHelper = ConversationsHelper();
Conversation _conversation;
UsersList _users;
String _conversationName;
bool _error = false;
@ -42,21 +47,22 @@ class _ConversationRouteState extends State<ConversationRoute> {
Future<void> _loadConversation() async {
setError(false);
_conversation = await _conversationsHelper.getSingle(widget.conversationID,
force: true);
try {
_conversation = await _conversationsHelper
.getSingle(widget.conversationID, force: true);
if (_conversation == null) return setError(true);
_users = await UsersHelper().getList(_conversation.membersID);
final conversationName =
await ConversationsHelper.getConversationNameAsync(_conversation);
final conversationName =
ConversationsHelper.getConversationName(_conversation, _users);
if (!this.mounted) return null;
if (!this.mounted) return null;
if (conversationName == null) return setError(true);
setState(() {
_conversationName = conversationName;
});
setState(() => _conversationName = conversationName);
} catch (e, s) {
print("Failed to get conversation name! $e => $s");
setError(true);
}
}
void _openSettings() {
@ -73,7 +79,7 @@ class _ConversationRouteState extends State<ConversationRoute> {
return buildErrorCard(
tr("Could not get conversation information!"),
actions: <Widget>[
FlatButton(
TextButton(
onPressed: _loadConversation,
child: Text(
tr("Try again").toUpperCase(),
@ -97,7 +103,12 @@ class _ConversationRouteState extends State<ConversationRoute> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: isTablet(context) ? null : ComunicBackButton(),
leading: isTablet(context)
? (_conversation == null || _users == null
? null
: ConversationImageWidget(
conversation: _conversation, users: _users))
: ComunicBackButton(),
title: Text(
_conversationName == null ? tr("Loading") : _conversationName,
),

View File

@ -1,6 +1,7 @@
import 'package:comunic/helpers/account_helper.dart';
import 'package:comunic/models/config.dart';
import 'package:comunic/helpers/server_config_helper.dart';
import 'package:comunic/models/new_account.dart';
import 'package:comunic/ui/widgets/new_password_input_widget.dart';
import 'package:comunic/utils/input_utils.dart';
import 'package:comunic/utils/intl_utils.dart';
import 'package:comunic/utils/ui_utils.dart';
@ -34,7 +35,7 @@ class __CreateAccountRouteBodyState extends State<_CreateAccountRouteBody> {
final _firstNameController = TextEditingController();
final _lastNameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _passwordInputKey = GlobalKey<NewPasswordInputWidgetState>();
final _verifyPasswordController = TextEditingController();
bool _acceptedTOS = false;
@ -49,10 +50,11 @@ class __CreateAccountRouteBodyState extends State<_CreateAccountRouteBody> {
bool get _isEmailValid => validateEmail(_emailController.text);
bool get _isPasswordValid => _passwordController.text.length > 3;
bool get _isPasswordValid => _passwordInputKey.currentState.valid;
bool get _isPasswordConfirmationValid =>
_passwordController.text == _verifyPasswordController.text;
_passwordInputKey.currentState != null &&
_passwordInputKey.currentState.value == _verifyPasswordController.text;
bool get _isFormValid =>
_isFirstNameValid &&
@ -69,7 +71,7 @@ class __CreateAccountRouteBodyState extends State<_CreateAccountRouteBody> {
? tr(
"Too many accounts have been created from this IP address for now. Please try again later.")
: tr(
"An error occured while creating your account. Please try again.");
"An error occurred while creating your account. Please try again.");
@override
Widget build(BuildContext context) {
@ -124,15 +126,16 @@ class __CreateAccountRouteBodyState extends State<_CreateAccountRouteBody> {
),
// Password
_InputEntry(
controller: _passwordController,
NewPasswordInputWidget(
key: _passwordInputKey,
label: tr("Password"),
onEdited: _updateUI,
icon: Icon(Icons.lock),
isPassword: true,
error: _showErrors && !_isPasswordValid
? tr("Invalid password!")
: null,
user: UserInfoForPassword(
firstName: _firstNameController.text,
lastName: _lastNameController.text,
email: _emailController.text,
),
),
// Verify password
@ -171,9 +174,7 @@ class __CreateAccountRouteBodyState extends State<_CreateAccountRouteBody> {
// Submit button
Center(
child: RaisedButton(
color: Colors.blue,
textColor: Colors.white,
child: ElevatedButton(
onPressed: _submitForm,
child: Text("Submit"),
),
@ -206,7 +207,7 @@ class __CreateAccountRouteBodyState extends State<_CreateAccountRouteBody> {
firstName: _firstNameController.text,
lastName: _lastNameController.text,
email: _emailController.text,
password: _passwordController.text,
password: _passwordInputKey.currentState.value,
));
setState(() {
@ -223,9 +224,7 @@ class __CreateAccountRouteBodyState extends State<_CreateAccountRouteBody> {
_accountCreated();
}
void _openTOS() {
launch(config().termsOfServicesURL);
}
void _openTOS() => launch(ServerConfigurationHelper.config.termsURL);
void _showCreateAccountError() async {
await showCupertinoDialog(

View File

@ -13,7 +13,7 @@ import 'package:flutter/rendering.dart';
///
/// @author Pierre Hubert
class ResetPasswordRoute extends StatelessWidget {
class ForgotPasswordRoute extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
@ -61,7 +61,7 @@ class _ResetPasswordBodyState extends SafeState<_ResetPasswordBody> {
/// Step 3b - Answer security questions
List<String> _questions;
var _questionsControllers = List<TextEditingController>();
var _questionsControllers = <TextEditingController>[];
List<String> get _answers =>
_questionsControllers.map((f) => f.text).toList();
@ -133,8 +133,8 @@ class _ResetPasswordBodyState extends SafeState<_ResetPasswordBody> {
_setLoading(false);
} catch (e, s) {
print("Could not check given email! $e\n$s");
showSimpleSnack(
context, tr("An error occurred while checking your recovery options !"));
showSimpleSnack(context,
tr("An error occurred while checking your recovery options !"));
_setLoading(false);
}
}
@ -146,14 +146,14 @@ class _ResetPasswordBodyState extends SafeState<_ResetPasswordBody> {
children: <Widget>[
Text(tr("Here are your options to reset your account:")),
_Spacer(),
OutlineButton.icon(
OutlinedButton.icon(
onPressed: _openSendEmailDialog,
icon: Icon(Icons.email),
label: Text(tr("Send us an email to ask for help")),
),
_Spacer(visible: _hasSecurityQuestions),
_hasSecurityQuestions
? OutlineButton.icon(
? OutlinedButton.icon(
onPressed: _loadSecurityQuestions,
icon: Icon(Icons.help_outline),
label: Text(tr("Answer your security questions")),
@ -199,7 +199,7 @@ class _ResetPasswordBodyState extends SafeState<_ResetPasswordBody> {
..add(_Spacer())
..addAll(List.generate(_questions.length, _buildSecurityQuestionField))
..add(_Spacer())
..add(OutlineButton(
..add(OutlinedButton(
onPressed: _canSubmitAnswers ? _submitSecurityAnswers : null,
child: Text(tr("Submit")),
)),

View File

@ -2,6 +2,7 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:comunic/utils/intl_utils.dart';
import 'package:flutter/material.dart';
import 'package:photo_view/photo_view.dart';
import 'package:url_launcher/url_launcher.dart';
/// Full screen image details
///
@ -22,6 +23,10 @@ class _FullScreenImageRouteState extends State<FullScreenImageRoute> {
return Scaffold(
appBar: AppBar(
title: Text(tr("Image")),
actions: [
IconButton(
icon: Icon(Icons.launch), onPressed: () => launch(widget.url))
],
),
body: PhotoView(imageProvider: CachedNetworkImageProvider(widget.url)),
);

View File

@ -0,0 +1,54 @@
import 'dart:io';
import 'package:comunic/models/api_request.dart';
import 'package:comunic/utils/intl_utils.dart';
import 'package:comunic/utils/log_utils.dart';
import 'package:comunic/utils/ui_utils.dart';
import 'package:flutter/material.dart';
import 'package:image_cropper/image_cropper.dart';
import '../../models/api_request.dart';
import '../../utils/files_utils.dart';
/// Image cropper route
///
/// @author Pierre Hubert
/// Attempt to crop image
///
/// Return original image in case of error / if the user did not crop the image
Future<BytesFile> showImageCropper(BuildContext context, BytesFile source,
{CropAspectRatio aspectRatio}) async {
assert(context != null);
assert(source != null);
File file;
File cropped;
try {
file = await generateTemporaryFile();
await file.writeAsBytes(source.bytes);
File cropped = await ImageCropper.cropImage(
sourcePath: file.absolute.path,
compressFormat: ImageCompressFormat.png,
aspectRatio: aspectRatio,
androidUiSettings: AndroidUiSettings(
toolbarColor: Colors.black,
toolbarTitle: tr("Crop Photo"),
toolbarWidgetColor: Colors.white,
),
);
if (cropped == null) return null;
return BytesFile("cropped.png", await cropped.readAsBytes());
} catch (e, s) {
logError(e, s);
snack(context, tr("Failed to execute image cropper!"));
return source;
} finally {
if (file != null && await file.exists()) file.delete();
if (cropped != null && await cropped.exists()) cropped.delete();
}
}

View File

@ -1,7 +1,7 @@
import 'package:comunic/helpers/account_helper.dart';
import 'package:comunic/models/authentication_details.dart';
import 'package:comunic/ui/routes/create_account_route.dart';
import 'package:comunic/ui/routes/reset_password_route.dart';
import 'package:comunic/ui/routes/forgot_password_route.dart';
import 'package:comunic/ui/widgets/init_widget.dart';
import 'package:comunic/ui/widgets/login_scaffold.dart';
import 'package:comunic/utils/input_utils.dart';
@ -69,7 +69,7 @@ class _LoginRouteState extends State<LoginRoute> {
void _openResetPasswordPage() {
Navigator.of(context)
.push(MaterialPageRoute(builder: (c) => ResetPasswordRoute()));
.push(MaterialPageRoute(builder: (c) => ForgotPasswordRoute()));
}
/// Build error card
@ -127,7 +127,7 @@ class _LoginRouteState extends State<LoginRoute> {
padding: EdgeInsets.all(8.0),
child: _loading
? CircularProgressIndicator()
: RaisedButton(
: ElevatedButton(
child: Text(tr("Sign in")),
onPressed: valid ? () => _submitForm(context) : null,
),

View File

@ -1,6 +1,8 @@
import 'package:comunic/helpers/account_helper.dart';
import 'package:comunic/models/conversation.dart';
import 'package:comunic/models/conversation_message.dart';
import 'package:comunic/ui/routes/conversation_message_stats_route.dart';
import 'package:comunic/ui/routes/conversation_route.dart';
import 'package:comunic/ui/routes/login_route.dart';
import 'package:comunic/ui/routes/main_route/page_info.dart';
import 'package:comunic/ui/routes/settings/account_settings_route.dart';
import 'package:comunic/ui/screens/call_screen.dart';
@ -35,7 +37,7 @@ mixin MainRoute implements StatefulWidget {}
/// Public interface of home controller
abstract class MainController extends State<MainRoute> {
final _pagesStack = List<PageInfo>();
final _pagesStack = <PageInfo>[];
/// Default page of the application
PageInfo get defaultPage;
@ -152,6 +154,18 @@ abstract class MainController extends State<MainRoute> {
hideNavBar: true,
));
/// Open a conversation message statistics page
void openConversationMessageStats(
Conversation conv, ConversationMessage message) =>
pushPage(PageInfo(
child: ConversationMessageStatsRoute(
conv: conv,
message: message,
),
hideNavBar: true,
canShowAsDialog: true,
));
/// Start a call for a given conversation
void startCall(int convID) =>
pushPage(PageInfo(child: CallScreen(convID: convID), hideNavBar: true));
@ -170,10 +184,6 @@ abstract class MainController extends State<MainRoute> {
popUntilMainRoute();
await AccountHelper().signOut();
Navigator.pushReplacement(context, MaterialPageRoute(builder: (c) {
return LoginRoute();
}));
}
/// Pop current page. Last page can not be popped

View File

@ -1,6 +1,8 @@
import 'package:comunic/helpers/account_helper.dart';
import 'package:comunic/models/res_check_password_reset_token.dart';
import 'package:comunic/ui/dialogs/input_new_password_dialog.dart';
import 'package:comunic/ui/widgets/async_screen_widget.dart';
import 'package:comunic/ui/widgets/new_password_input_widget.dart';
import 'package:comunic/ui/widgets/safe_state.dart';
import 'package:comunic/utils/intl_utils.dart';
import 'package:comunic/utils/ui_utils.dart';
@ -47,12 +49,13 @@ class __PasswordResetBodyState extends SafeState<_PasswordResetBody> {
final _key = GlobalKey<AsyncScreenWidgetState>();
var _status = _Status.BEFORE_CHANGE;
ResCheckPasswordToken _tokenInfo;
void _setStatus(_Status s) => setState(() => _status = s);
Future<void> _validateToken() async {
_status = _Status.BEFORE_CHANGE;
await AccountHelper.validatePasswordResetToken(widget.token);
_tokenInfo = await AccountHelper.validatePasswordResetToken(widget.token);
}
@override
@ -74,7 +77,7 @@ class __PasswordResetBodyState extends SafeState<_PasswordResetBody> {
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(tr("You can choose a new password.")),
OutlineButton(
OutlinedButton(
onPressed: _changePassword,
child: Text(tr("Choose a new password")),
)
@ -94,7 +97,7 @@ class __PasswordResetBodyState extends SafeState<_PasswordResetBody> {
tr("Congratulations! Your password has now been successfully changed!"),
textAlign: TextAlign.center,
),
OutlineButton(
OutlinedButton(
onPressed: _quitScreen,
child: Text(tr("Login")),
)
@ -108,7 +111,14 @@ class __PasswordResetBodyState extends SafeState<_PasswordResetBody> {
void _changePassword() async {
try {
// Ask for new password
final newPass = await showInputNewPassword(context);
final newPass = await showInputNewPassword(
context: context,
userInfo: UserInfoForPassword(
firstName: _tokenInfo.firstName,
lastName: _tokenInfo.lastName,
email: _tokenInfo.email,
),
);
if (newPass == null) return;
_setStatus(_Status.WHILE_CHANGE);

View File

@ -12,9 +12,13 @@ import 'package:comunic/utils/intl_utils.dart';
import 'package:comunic/utils/ui_utils.dart';
import 'package:flutter/material.dart';
import 'package:identicon/identicon.dart';
import 'package:image_cropper/image_cropper.dart';
import 'package:random_string/random_string.dart';
import 'package:settings_ui/settings_ui.dart';
import '../../../utils/log_utils.dart';
import '../../../utils/ui_utils.dart';
/// Account image settings section
///
/// @author Pierre Hubert
@ -156,15 +160,17 @@ class _AccountImageSettingsScreenState
/// Upload a new account image
void _uploadAccountImage() async {
final image = await pickImage(context);
try {
final image = await pickImage(context,
aspectRatio: CropAspectRatio(ratioX: 5, ratioY: 5));
if (image == null) return;
if (image == null) return;
if (!await SettingsHelper.uploadAccountImage(image)) {
showSimpleSnack(context, tr("Could not upload your account image!"));
return;
await SettingsHelper.uploadAccountImage(image);
} catch (e, s) {
logError(e, s);
snack(context, tr("Failed to upload new account image!"));
}
_key.currentState.refresh();
}

View File

@ -1,6 +1,13 @@
import 'package:comunic/helpers/account_helper.dart';
import 'package:comunic/helpers/server_config_helper.dart';
import 'package:comunic/helpers/settings_helper.dart';
import 'package:comunic/models/data_conservation_policy_settings.dart';
import 'package:comunic/models/server_config.dart';
import 'package:comunic/ui/dialogs/input_user_password_dialog.dart';
import 'package:comunic/ui/dialogs/multi_choices_dialog.dart';
import 'package:comunic/ui/widgets/async_screen_widget.dart';
import 'package:comunic/ui/widgets/settings/header_spacer_section.dart';
import 'package:comunic/ui/widgets/settings/multi_choices_settings_tile.dart';
import 'package:comunic/utils/intl_utils.dart';
import 'package:comunic/utils/ui_utils.dart';
import 'package:flutter/cupertino.dart';
@ -17,19 +24,117 @@ class AccountPrivacySettings extends StatefulWidget {
}
class _AccountPrivacySettingsState extends State<AccountPrivacySettings> {
final _key = GlobalKey<AsyncScreenWidgetState>();
ServerConfig _serverConfig;
DataConservationPolicySettings _userSettings;
String _cachedPassword;
Future<void> _loadSettings() async {
_serverConfig = ServerConfigurationHelper.config;
_userSettings = await SettingsHelper.getDataConservationPolicy();
}
@override
Widget build(BuildContext context) {
return SettingsList(sections: [
HeadSpacerSection(),
SettingsSection(title: tr("Privacy settings"), tiles: [
SettingsTile(
title: tr("Delete your account"),
subtitle:
tr("Permanently delete your account and all data related to it."),
onPressed: (_) => _deleteAccount(),
)
])
]);
return AsyncScreenWidget(
key: _key,
onReload: _loadSettings,
onBuild: () => SettingsList(sections: [
HeadSpacerSection(),
SettingsSection(
title: tr("Data conservation policy"),
tiles: _dataConservationPolicyTiles,
),
HeadSpacerSection(),
HeadSpacerSection(),
SettingsSection(title: tr("Danger zone"), tiles: [
SettingsTile(
title: tr("Delete your account"),
subtitle: tr(
"Permanently delete your account and all data related to it."),
onPressed: (_) => _deleteAccount(),
)
])
]),
errorMessage: tr("Failed to load privacy settings!"),
);
}
List<SettingsTile> get _dataConservationPolicyTiles => [
DataConservationPolicyTile(
value: _userSettings.notificationLifetime,
title: tr("Automatically delete unread notifications after"),
onChange: (val) {
_userSettings.notificationLifetime = val;
_updateDataConservationPolicy();
},
minValue:
_serverConfig.dataConservationPolicy.minNotificationLifetime,
),
DataConservationPolicyTile(
value: _userSettings.commentsLifetime,
title: tr("Automatically delete your comments after"),
onChange: (val) {
_userSettings.commentsLifetime = val;
_updateDataConservationPolicy();
},
minValue: _serverConfig.dataConservationPolicy.minCommentsLifetime,
),
DataConservationPolicyTile(
value: _userSettings.postsLifetime,
title: tr("Automatically delete your posts after"),
onChange: (val) {
_userSettings.postsLifetime = val;
_updateDataConservationPolicy();
},
minValue: _serverConfig.dataConservationPolicy.minPostsLifetime,
),
DataConservationPolicyTile(
value: _userSettings.conversationMessagesLifetime,
title: tr("Automatically delete your conversation messages after"),
onChange: (val) {
_userSettings.conversationMessagesLifetime = val;
_updateDataConservationPolicy();
},
minValue: _serverConfig
.dataConservationPolicy.minConversationMessagesLifetime,
),
DataConservationPolicyTile(
value: _userSettings.likesLifetime,
title: tr("Automatically delete your likes after"),
onChange: (val) {
_userSettings.likesLifetime = val;
_updateDataConservationPolicy();
},
minValue: _serverConfig.dataConservationPolicy.minLikesLifetime,
),
DataConservationPolicyTile(
value: _userSettings.inactiveAccountLifeTime,
title: tr(
"Automatically delete your account if you have been inactive for"),
onChange: (val) {
_userSettings.inactiveAccountLifeTime = val;
_updateDataConservationPolicy();
},
minValue:
_serverConfig.dataConservationPolicy.minInactiveAccountLifetime,
),
];
void _updateDataConservationPolicy() async {
try {
if (_cachedPassword == null)
_cachedPassword = await showUserPasswordDialog(context);
await SettingsHelper.setDataConservationPolicy(
_cachedPassword, _userSettings);
_key.currentState.refresh();
} catch (e, s) {
print("Could not update data conservation policy! $e\n$s");
showSimpleSnack(
context, tr("Failed to update data conservation policy!"));
}
}
/// Permanently delete user account
@ -61,6 +166,98 @@ class _AccountPrivacySettingsState extends State<AccountPrivacySettings> {
}
}
class DataConservationPolicyTile extends SettingsTile {
final int value;
final String title;
final Function(int) onChange;
final int minValue;
const DataConservationPolicyTile({
@required this.value,
@required this.title,
@required this.onChange,
@required this.minValue,
}) : assert(title != null),
assert(onChange != null),
assert(minValue != null);
@override
Widget build(BuildContext context) {
return MultiChoicesSettingsTile(
title: title,
choices: _choices,
currentValue: _roundValue,
onChanged: onChange,
);
}
int get _day => 60 * 60 * 24;
int get _month => _day * 30;
int get _year => _day * 365;
int get _roundValue {
if (this.value == null) return 0;
return _choices.firstWhere((element) => element.id >= this.value).id;
}
List<MultiChoiceEntry<int>> get _choices => [
MultiChoiceEntry(id: 0, title: tr("Never"), hidden: false),
MultiChoiceEntry(
id: _day * 7,
title: tr("7 days"),
hidden: _day * 7 < minValue,
),
MultiChoiceEntry(
id: _day * 15,
title: tr("15 days"),
hidden: _day * 15 < minValue,
),
MultiChoiceEntry(
id: _month,
title: tr("1 month"),
hidden: _month < minValue,
),
MultiChoiceEntry(
id: _month * 3,
title: tr("3 months"),
hidden: _month * 3 < minValue,
),
MultiChoiceEntry(
id: _month * 6,
title: tr("6 months"),
hidden: _month * 6 < minValue,
),
MultiChoiceEntry(
id: _year,
title: tr("1 year"),
hidden: _year < minValue,
),
MultiChoiceEntry(
id: _year * 2,
title: tr("2 years"),
hidden: _year * 5 < minValue,
),
MultiChoiceEntry(
id: _year * 5,
title: tr("5 years"),
hidden: _year * 5 < minValue,
),
MultiChoiceEntry(
id: _year * 10,
title: tr("10 years"),
hidden: _year * 10 < minValue,
),
MultiChoiceEntry(
id: _year * 50,
title: tr("50 years"),
hidden: _year * 50 < minValue,
),
];
}
class _LastChanceDeleteAccountDialog extends StatelessWidget {
@override
Widget build(BuildContext context) {

View File

@ -1,10 +1,13 @@
import 'package:comunic/helpers/account_helper.dart';
import 'package:comunic/helpers/settings_helper.dart';
import 'package:comunic/helpers/users_helper.dart';
import 'package:comunic/models/security_settings.dart';
import 'package:comunic/ui/dialogs/input_new_password_dialog.dart';
import 'package:comunic/ui/dialogs/input_user_password_dialog.dart';
import 'package:comunic/ui/widgets/dialogs/auto_sized_dialog_content_widget.dart';
import 'package:comunic/ui/widgets/new_password_input_widget.dart';
import 'package:comunic/ui/widgets/settings/header_spacer_section.dart';
import 'package:comunic/utils/account_utils.dart';
import 'package:comunic/utils/intl_utils.dart';
import 'package:comunic/utils/ui_utils.dart';
import 'package:flutter/material.dart';
@ -55,11 +58,21 @@ class _AccountSecuritySettingsScreenState
/// Change current user password
void _changePassword() async {
try {
final currEmail = await AccountHelper.getCurrentAccountEmailAddress();
final currUser = await UsersHelper().getSingleWithThrow(userID());
final currPassword = await showUserPasswordDialog(context);
if (currPassword == null) return;
final newPassword = await showInputNewPassword(context);
final newPassword = await showInputNewPassword(
context: context,
userInfo: UserInfoForPassword(
firstName: currUser.firstName,
lastName: currUser.lastName,
email: currEmail,
),
);
if (newPassword == null) return;

View File

@ -12,7 +12,9 @@ import 'package:comunic/utils/input_utils.dart';
import 'package:comunic/utils/intl_utils.dart';
import 'package:comunic/utils/ui_utils.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import '../../../models/api_request.dart';
import '../../../utils/ui_utils.dart';
/// Emojies account settings
///
@ -136,7 +138,7 @@ class _NewCustomEmojiDialog extends StatefulWidget {
class _NewCustomEmojiDialogState extends State<_NewCustomEmojiDialog> {
final _controller = TextEditingController();
PickedFile _file;
BytesFile _file;
bool get _hasImage => _file != null;
@ -209,6 +211,7 @@ class _NewCustomEmojiDialogState extends State<_NewCustomEmojiDialog> {
});
} catch (e, stack) {
print("Could not pick an image! $e\n$stack");
snack(context, tr("Failed to pick an image!"));
}
}
}

View File

@ -1,11 +1,4 @@
import 'package:comunic/helpers/conversations_helper.dart';
import 'package:comunic/helpers/users_helper.dart';
import 'package:comunic/lists/users_list.dart';
import 'package:comunic/models/conversation.dart';
import 'package:comunic/ui/screens/update_conversation_screen.dart';
import 'package:comunic/ui/widgets/comunic_back_button_widget.dart';
import 'package:comunic/utils/intl_utils.dart';
import 'package:comunic/utils/ui_utils.dart';
import 'package:flutter/material.dart';
/// Update a conversation route
@ -24,70 +17,7 @@ class UpdateConversationRoute extends StatefulWidget {
}
class _UpdateConversationRoute extends State<UpdateConversationRoute> {
Conversation _conversation;
UsersList _membersInfo;
bool _error = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_loadConversation();
}
void setError(bool e) => setState(() {
_error = e;
});
/// Load information about the being updated conversation
Future<void> _loadConversation() async {
setError(false);
final conversation = await ConversationsHelper()
.getSingle(widget.conversationID, force: true);
if (conversation == null) return setError(true);
//Load information about the members of the conversation
_membersInfo = await UsersHelper().getUsersInfo(conversation.members);
if (_membersInfo == null) return setError(true);
setState(() {
_conversation = conversation;
});
}
/// Build the body of this widget
Widget _buildBody() {
if (_error)
return buildErrorCard(
tr("Could not load information about the conversation"),
actions: [
FlatButton(
onPressed: _loadConversation,
child: Text(
tr("Retry").toUpperCase(),
style: TextStyle(color: Colors.white),
),
)
]);
if (_conversation == null) return buildLoadingPage();
return UpdateConversationScreen(
initialUsers: _membersInfo,
initialSettings: _conversation,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: ComunicBackButton(),
title: Text(tr("Update a conversation")),
),
body: _buildBody(),
);
}
Widget build(BuildContext context) =>
UpdateConversationScreen(convID: widget.conversationID);
}

View File

@ -0,0 +1,68 @@
import 'package:chewie/chewie.dart';
import 'package:comunic/ui/widgets/async_screen_widget.dart';
import 'package:comunic/ui/widgets/comunic_back_button_widget.dart';
import 'package:comunic/utils/intl_utils.dart';
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
/// Video player route
///
/// @author Pierre Hubert
class VideoPlayerRoute extends StatefulWidget {
final String url;
const VideoPlayerRoute({
Key key,
@required this.url,
}) : assert(url != null),
super(key: key);
@override
_VideoPlayerRouteState createState() => _VideoPlayerRouteState();
}
class _VideoPlayerRouteState extends State<VideoPlayerRoute> {
VideoPlayerController _videoPlayerController;
ChewieController _chewieController;
Future<void> _initialize() async {
_videoPlayerController = VideoPlayerController.network(widget.url);
await _videoPlayerController.initialize();
_chewieController = ChewieController(
videoPlayerController: _videoPlayerController,
looping: false,
allowFullScreen: true,
allowMuting: true,
allowedScreenSleep: false,
);
}
@override
void dispose() {
if (_videoPlayerController != null) _videoPlayerController.dispose();
if (_chewieController != null) _chewieController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: ComunicBackButton(),
title: Text("Video"),
),
body: _buildBody(),
);
}
Widget _buildBody() => AsyncScreenWidget(
onReload: _initialize,
onBuild: _showBody,
errorMessage: tr("Failed to initialize video!"),
);
Widget _showBody() => Chewie(controller: _chewieController);
}

View File

@ -110,7 +110,7 @@ class _CallScreenState extends SafeState<CallScreen> {
// First, load information about the conversation
_conversation =
await ConversationsHelper().getSingleOrThrow(convID, force: true);
await ConversationsHelper().getSingle(convID, force: true);
_convName =
await ConversationsHelper.getConversationNameAsync(_conversation);
assert(_convName != null);
@ -544,7 +544,7 @@ class _CallScreenState extends SafeState<CallScreen> {
.where((f) => f.hasVideoStream && _renderers.containsKey(f.userID))
.toList();
final rows = List<Row>();
final rows = <Row>[];
var numberRows = sqrt(availableVideos.length).ceil();
var numberCols = numberRows;

View File

@ -32,8 +32,7 @@ class _ConversationMembersScreenState extends State<ConversationMembersScreen> {
Future<void> _refresh() async {
_conversation =
await ConversationsHelper().getSingle(widget.convID, force: true);
_members =
await UsersHelper().getListWithThrow(_conversation.members.toSet());
_members = await UsersHelper().getListWithThrow(_conversation.membersID);
}
@override
@ -55,12 +54,12 @@ class _ConversationMembersScreenState extends State<ConversationMembersScreen> {
);
Widget _buildItem(BuildContext context, int index) {
final user = _members.getUser(_conversation.members[index]);
final member = _conversation.members[index];
final user = _members.getUser(member.userID);
return ListTile(
leading: AccountImageWidget(user: user),
title: Text(user.displayName),
subtitle:
Text(_conversation.ownerID == user.id ? tr("Owner") : tr("Member")),
subtitle: Text(member.isAdmin ? tr("Admin") : tr("Member")),
);
}
}

View File

@ -2,19 +2,34 @@ import 'dart:async';
import 'package:comunic/helpers/conversations_helper.dart';
import 'package:comunic/helpers/events_helper.dart';
import 'package:comunic/helpers/server_config_helper.dart';
import 'package:comunic/helpers/users_helper.dart';
import 'package:comunic/lists/conversation_messages_list.dart';
import 'package:comunic/lists/users_list.dart';
import 'package:comunic/models/api_request.dart';
import 'package:comunic/models/conversation.dart';
import 'package:comunic/models/conversation_message.dart';
import 'package:comunic/models/new_conversation_message.dart';
import 'package:comunic/ui/dialogs/pick_file_dialog.dart';
import 'package:comunic/ui/routes/main_route/main_route.dart';
import 'package:comunic/ui/tiles/conversation_message_tile.dart';
import 'package:comunic/ui/tiles/server_conversation_message_tile.dart';
import 'package:comunic/ui/widgets/account_image_widget.dart';
import 'package:comunic/ui/widgets/safe_state.dart';
import 'package:comunic/ui/widgets/scroll_watcher.dart';
import 'package:comunic/ui/widgets/user_writing_in_conv_notifier.dart';
import 'package:comunic/utils/account_utils.dart';
import 'package:comunic/utils/date_utils.dart';
import 'package:comunic/utils/files_utils.dart';
import 'package:comunic/utils/intl_utils.dart';
import 'package:comunic/utils/list_utils.dart';
import 'package:comunic/utils/log_utils.dart';
import 'package:comunic/utils/ui_utils.dart';
import 'package:comunic/utils/video_utils.dart';
import 'package:dio/dio.dart';
import 'package:emoji_picker/emoji_picker.dart';
import 'package:flutter/material.dart';
import 'package:mime/mime.dart';
/// Conversation screen
///
@ -40,15 +55,73 @@ class _ConversationScreenState extends SafeState<ConversationScreen> {
final UsersHelper _usersHelper = UsersHelper();
// Class members
Conversation _conversation;
ConversationMessagesList _messages;
UsersList _usersInfo = UsersList();
ErrorLevel _error = ErrorLevel.NONE;
bool _isMessageValid = false;
final _textFieldFocus = FocusNode();
bool _showEmojiPicker = false;
bool _isSendingMessage = false;
TextEditingController _textEditingController = TextEditingController();
TextEditingController _textController = TextEditingController();
ScrollWatcher _scrollController;
_OlderMessagesLevel _loadingOlderMessages = _OlderMessagesLevel.NONE;
int _lastWritingEventSent = 0;
CancelToken _sendCancel;
double _sendProgress;
String get textMessage => _textController.text;
bool get _isMessageValid =>
textMessage.length >=
ServerConfigurationHelper.config.conversationsPolicy.minMessageLen &&
textMessage.length <
ServerConfigurationHelper.config.conversationsPolicy.maxMessageLen;
showKeyboard() => _textFieldFocus.requestFocus();
hideKeyboard() => _textFieldFocus.unfocus();
hideEmojiContainer() => setState(() => _showEmojiPicker = false);
showEmojiContainer() => setState(() => _showEmojiPicker = true);
// Colors definition
Color get _senderColor =>
_conversation.color ??
/*(darkTheme() ? Color(0xff2b343b) :*/ Colors.blue.shade700; //);
Color get _receiverColor =>
darkTheme() ? Color(0xff3a3d40) : Colors.grey.shade600;
Color get _greyColor => Color(0xff8f8f8f);
Color get _gradientColorStart =>
_conversation.color ??
(darkTheme() ? Color(0xff00b6f3) : Colors.blue.shade300);
Color get _gradientColorEnd =>
_conversation.color?.withOpacity(0.7) ??
(darkTheme() ? Color(0xff0184dc) : Colors.blueAccent.shade700);
Color get _separatorColor =>
darkTheme() ? Color(0xff272c35) : Color(0xffBEBEBE);
LinearGradient get _fabGradient => LinearGradient(
colors: [_gradientColorStart, _gradientColorEnd],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
LinearGradient get _disabledGradient => LinearGradient(
colors: [_greyColor, _receiverColor],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
@override
void initState() {
super.initState();
@ -70,14 +143,16 @@ class _ConversationScreenState extends SafeState<ConversationScreen> {
});
/// Method called when an error occurred while loading messages
void _errorLoading() {
_setError(_messages == null ? ErrorLevel.MAJOR : ErrorLevel.MINOR);
}
void _errorLoading() =>
_setError(_messages == null ? ErrorLevel.MAJOR : ErrorLevel.MINOR);
/// Load the first conversations
Future<void> _init() async {
_scrollController = ScrollWatcher(onReachBottom: _loadOlderMessages);
_conversation =
await ConversationsHelper().getSingle(widget.conversationID);
// Fetch latest messages
await _loadMessages(false);
await _loadMessages(true);
@ -86,25 +161,42 @@ class _ConversationScreenState extends SafeState<ConversationScreen> {
.registerConversationEvents(widget.conversationID);
this.listen<NewConversationMessageEvent>((ev) async {
if (ev.msg.conversationID == widget.conversationID) {
await _conversationsHelper.saveMessage(ev.msg);
await _applyNewMessages(ConversationMessagesList()..add(ev.msg));
if (ev.msg.convID == widget.conversationID) {
try {
await _conversationsHelper.saveMessage(ev.msg);
await _applyNewMessages(ConversationMessagesList()..add(ev.msg));
} catch (e, s) {
print("Failed to show new message! $e => $s");
_errorLoading();
}
}
});
this.listen<UpdatedConversationMessageEvent>((ev) async {
if (ev.msg.conversationID == widget.conversationID) {
if (ev.msg.convID == widget.conversationID) {
await _conversationsHelper.saveMessage(ev.msg);
setState(() => _messages.replace(ev.msg));
}
});
this.listen<DeletedConversationMessageEvent>((ev) async {
if (ev.msg.conversationID == widget.conversationID) {
await _conversationsHelper.removeMessage(ev.msg.id);
if (ev.msg.convID == widget.conversationID) {
await _conversationsHelper.removeMessage(ev.msg);
setState(() => _messages.removeMsg(ev.msg.id));
}
});
this.listen<RemovedUserFromConversationEvent>((ev) {
if (ev.userID == userID() && ev.convID == widget.conversationID) {
setState(() => _error = ErrorLevel.MAJOR);
}
});
this.listen<DeletedConversationEvent>((ev) {
if (ev.convID == widget.conversationID) {
setState(() => _error = ErrorLevel.MAJOR);
}
});
}
/// Free resources when this conversation widget is no longer required
@ -116,19 +208,23 @@ class _ConversationScreenState extends SafeState<ConversationScreen> {
Future<void> _loadMessages(bool online) async {
if (!mounted) return;
//First, get the messages
final messages = await _conversationsHelper.getNewMessages(
try {
//First, get the messages
final messages = await _conversationsHelper.getNewMessages(
conversationID: widget.conversationID,
lastMessageID: _messages == null ? 0 : _messages.lastMessageID,
online: online);
online: online,
);
if (messages == null) return _errorLoading();
// In case we are offline and we did not get any message we do not do
// anything (we wait for the online request)
if (messages.length == 0 && !online) return;
// In case we are offline and we did not get any message we do not do
// anything (we wait for the online request)
if (messages.length == 0 && !online) return;
await _applyNewMessages(messages);
await _applyNewMessages(messages);
} catch (e, s) {
debugPrint("Failed to load messages! $e => $s", wrapWidth: 4096);
_errorLoading();
}
}
/// Get older messages
@ -136,45 +232,43 @@ class _ConversationScreenState extends SafeState<ConversationScreen> {
if (_loadingOlderMessages != _OlderMessagesLevel.NONE ||
_messages == null ||
_messages.length == 0) return;
try {
// Let's start to load older messages
_setLoadingOlderMessagesState(_OlderMessagesLevel.LOADING);
// Let's start to load older messages
_setLoadingOlderMessagesState(_OlderMessagesLevel.LOADING);
final messages = await _conversationsHelper.getOlderMessages(
conversationID: widget.conversationID,
oldestMessagesID: _messages.firstMessageID);
final messages = await _conversationsHelper.getOlderMessages(
conversationID: widget.conversationID,
oldestMessagesID: _messages.firstMessageID);
// Mark as not loading anymore
_setLoadingOlderMessagesState(_OlderMessagesLevel.NONE);
// Mark as not loading anymore
_setLoadingOlderMessagesState(_OlderMessagesLevel.NONE);
// Check if there is no more unread messages
if (messages.length == 0) {
_setLoadingOlderMessagesState(_OlderMessagesLevel.NO_MORE_AVAILABLE);
return;
}
// Check for errors
if (messages == null) {
// Apply the messages
_applyNewMessages(messages);
} catch (e, s) {
print("Failed to load older messages! $e => $s");
_errorLoading();
return;
}
// Check if there is no more unread messages
if (messages.length == 0) {
_setLoadingOlderMessagesState(_OlderMessagesLevel.NO_MORE_AVAILABLE);
return;
}
// Apply the messages
_applyNewMessages(messages);
}
/// Apply new messages [messages] must not be null
///
/// Throws in case of failure
Future<void> _applyNewMessages(ConversationMessagesList messages) async {
// We ignore new messages once the area is no longer visible
if (!this.mounted) return;
//Then get information about users
final usersToGet =
findMissingFromList(_usersInfo.usersID, messages.getUsersID());
findMissingFromSet(_usersInfo.usersID.toSet(), messages.getUsersID());
final users = await _usersHelper.getUsersInfo(usersToGet);
if (users == null) _errorLoading();
final users = await _usersHelper.getList(usersToGet);
// Save the new list of messages
setState(() {
@ -197,39 +291,63 @@ class _ConversationScreenState extends SafeState<ConversationScreen> {
_setError(ErrorLevel.NONE);
}
/// Pick and send an image
Future<void> _sendImage(BuildContext context) async {
final image = await pickImage(context);
/// Send a file message
Future<void> _sendFileMessage() async {
try {
final file = await showPickFileDialog(
context: context,
maxFileSize: srvConfig.conversationsPolicy.filesMaxSize,
allowedMimeTypes: srvConfig.conversationsPolicy.allowedFilesType,
imageMaxWidth: srvConfig.conversationsPolicy.maxMessageImageWidth,
imageMaxHeight: srvConfig.conversationsPolicy.maxMessageImageHeight,
);
if (image == null) return null;
if (file == null) return;
_submitMessage(
context,
NewConversationMessage(
conversationID: widget.conversationID,
message: null,
image: image,
),
);
BytesFile thumbnail;
// In case a message was already written in the input
_updatedText(_textEditingController.text);
if (isVideo(lookupMimeType(file.filename)))
thumbnail = await generateVideoThumbnail(
videoFile: file,
maxWidth: srvConfig.conversationsPolicy.maxThumbnailWidth,
);
_sendCancel = CancelToken();
final progressCb =
(count, total) => setState(() => _sendProgress = count / total);
final res = await ConversationsHelper().sendMessage(
NewConversationMessage(
conversationID: widget.conversationID,
message: null,
file: file,
thumbnail: thumbnail),
sendProgress: progressCb,
cancelToken: _sendCancel,
);
assert(res == SendMessageResult.SUCCESS);
} catch (e, s) {
logError(e, s);
showSimpleSnack(context, tr("Failed to send a file!"));
}
setState(() {
_sendCancel = null;
_sendProgress = null;
});
}
/// Send a new text message
Future<void> _submitTextMessage(BuildContext context, String content) async {
if (await _submitMessage(
context,
NewConversationMessage(
conversationID: widget.conversationID,
message: content,
)) ==
Future<void> _submitTextMessage() async {
if (await _submitMessage(NewConversationMessage(
conversationID: widget.conversationID,
message: textMessage,
)) ==
SendMessageResult.SUCCESS) _clearSendMessageForm();
}
/// Submit a new message
Future<SendMessageResult> _submitMessage(
BuildContext context, NewConversationMessage message) async {
NewConversationMessage message) async {
//Send the message
_setSending(true);
final result = await _conversationsHelper.sendMessage(message);
@ -237,7 +355,7 @@ class _ConversationScreenState extends SafeState<ConversationScreen> {
//Check the result of the operation
if (result != SendMessageResult.SUCCESS)
Scaffold.of(context).showSnackBar(
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
result == SendMessageResult.MESSAGE_REJECTED
@ -251,31 +369,9 @@ class _ConversationScreenState extends SafeState<ConversationScreen> {
return result;
}
void _updatedText(String text) {
setState(() {
_isMessageValid = text.length > 2;
});
}
/// Clear send message form
void _clearSendMessageForm() {
setState(() {
_textEditingController = TextEditingController();
_isMessageValid = false;
});
}
/// Check if a message is the last message of a user or not
bool _isLastMessage(int index) {
return index == 0 ||
(index > 0 && _messages[index - 1].userID != _messages[index].userID);
}
/// Check if a message is the first message of a user or not
bool _isFirstMessage(int index) {
return index == _messages.length - 1 ||
(index < _messages.length - 1 &&
_messages[index + 1].userID != _messages[index].userID);
setState(() => _textController = TextEditingController());
}
/// Error handling
@ -295,7 +391,7 @@ class _ConversationScreenState extends SafeState<ConversationScreen> {
Widget _buildNoMessagesNotice() {
return Expanded(
child: Center(
child: Text(tr("There is no message yet in this converation.")),
child: Text(tr("There is no message yet in this conversation.")),
),
);
}
@ -303,87 +399,253 @@ class _ConversationScreenState extends SafeState<ConversationScreen> {
/// Messages list
Widget _buildMessagesList() {
return Expanded(
child: ListView.builder(
controller: _scrollController,
reverse: true,
itemCount: _messages.length,
itemBuilder: (c, i) {
return ConversationMessageTile(
message: _messages.elementAt(i),
userInfo: _usersInfo.getUser(_messages[i].userID),
isLastMessage: _isLastMessage(i),
isFirstMessage: _isFirstMessage(i),
onRequestMessageUpdate: _updateMessage,
onRequestMessageDelete: _deleteMessage,
);
}),
child: ListView.builder(
controller: _scrollController,
reverse: true,
itemCount: _messages.length,
itemBuilder: (c, i) => _buildMessageItem(i),
));
}
Widget _buildMessageItem(int msgIndex) {
final msg = _messages[msgIndex];
final nextMessage =
msgIndex + 1 < _messages.length ? _messages[msgIndex + 1] : null;
return Column(
children: <Widget>[
Container(
child: !isSameDate(msg.date, nextMessage?.date)
? _buildDateWidget(msg.date)
: null,
),
msg.isServerMessage
? Container(
alignment: Alignment.center,
child: ServerConversationMessageTile(
message: msg.serverMessage, users: _usersInfo),
)
: Container(
margin: EdgeInsets.symmetric(vertical: 5),
alignment:
msg.isOwner ? Alignment.centerRight : Alignment.centerLeft,
child: msg.isOwner
? _buildSenderLayout(msg, nextMessage)
: _buildReceiverLayout(msg, nextMessage),
),
],
);
}
/// Send message form
Widget _buildSendMessageForm() {
return new Container(
margin: const EdgeInsets.symmetric(horizontal: 8.0),
child: new Row(
children: <Widget>[
// Image area
new Container(
margin: new EdgeInsets.symmetric(horizontal: 4.0),
child: new IconButton(
icon: new Icon(
Icons.photo_camera,
color: _isSendingMessage
? Theme.of(context).disabledColor
: Theme.of(context).accentColor,
),
onPressed: () => _sendImage(context),
),
),
Widget _buildSenderLayout(
ConversationMessage message, ConversationMessage previousMessage) {
final messageRadius = Radius.circular(10);
// Message area
new Flexible(
child: new TextField(
keyboardType: TextInputType.text,
maxLines: null,
maxLength: 200,
maxLengthEnforced: true,
// Show max length only when there is some text already typed
buildCounter: smartInputCounterWidgetBuilder,
enabled: !_isSendingMessage,
controller: _textEditingController,
onChanged: _updatedText,
onSubmitted: _isMessageValid
? (s) => _submitTextMessage(context, s)
: null,
decoration: new InputDecoration.collapsed(
hintText: tr("Send a message"),
),
),
),
// Send button
new Container(
margin: const EdgeInsets.symmetric(horizontal: 4.0),
child: new IconButton(
icon: new Icon(
Icons.send,
color: !_isSendingMessage && _isMessageValid
? Theme.of(context).accentColor
: Theme.of(context).disabledColor,
),
onPressed: !_isSendingMessage && _isMessageValid
? () =>
_submitTextMessage(context, _textEditingController.text)
: null,
),
),
],
return Container(
margin: EdgeInsets.only(
top: previousMessage?.isOwner == true ? 0 : 12, right: 5),
constraints:
BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.65),
decoration: BoxDecoration(
color: _senderColor,
borderRadius: BorderRadius.only(
topLeft: messageRadius,
topRight: messageRadius,
bottomLeft: messageRadius,
),
),
child: Padding(
padding: EdgeInsets.all(10),
child: _buildMessage(message),
),
);
}
Widget _buildReceiverLayout(
ConversationMessage message, ConversationMessage previousMessage) {
final messageRadius = Radius.circular(10);
return Row(children: [
SizedBox(width: 5),
AccountImageWidget(
user: _usersInfo.getUser(message.userID),
),
SizedBox(width: 5),
Container(
margin: EdgeInsets.only(
top: previousMessage == null ||
message.userID != previousMessage.userID
? 12
: 0),
constraints:
BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.65),
decoration: BoxDecoration(
color: _receiverColor,
borderRadius: BorderRadius.only(
bottomRight: messageRadius,
topRight: messageRadius,
bottomLeft: messageRadius,
),
),
child: Padding(
padding: EdgeInsets.all(10),
child: _buildMessage(message),
),
),
]);
}
Widget _buildMessage(ConversationMessage msg) => ConversationMessageTile(
message: msg,
user: _usersInfo.getUser(msg.userID),
onRequestMessageStats: _requestMessageStats,
onRequestMessageUpdate: _updateMessage,
onRequestMessageDelete: _deleteMessage,
);
Widget _buildDateWidget(DateTime dt) => Center(
child: Container(
child: Text(
formatDisplayDate(dt, time: false),
style: TextStyle(fontWeight: FontWeight.bold),
),
padding: EdgeInsets.only(top: 50, bottom: 5),
));
/// Send new message form
Widget _buildSendMessageForm() => Container(
padding: EdgeInsets.fromLTRB(10, 5, 10, 5),
child: Row(
children: <Widget>[
GestureDetector(
onTap: !_isSendingMessage ? _sendFileMessage : null,
child: Container(
padding: EdgeInsets.all(6),
decoration: BoxDecoration(
gradient:
_isSendingMessage ? _disabledGradient : _fabGradient,
shape: BoxShape.circle,
),
child: Icon(
Icons.add,
color: Colors.white,
),
),
),
SizedBox(width: 5),
Expanded(
child: Stack(
alignment: Alignment.centerRight,
children: [
TextField(
enabled: !_isSendingMessage,
maxLines: 10,
minLines: 1,
controller: _textController,
focusNode: _textFieldFocus,
onTap: () => hideEmojiContainer(),
textInputAction: TextInputAction.send,
onSubmitted: (s) => _submitTextMessage(),
style: TextStyle(
color: darkTheme() ? Colors.white : Colors.black,
),
onChanged: (s) {
_sendWritingEvent();
setState(() {});
},
decoration: InputDecoration(
hintText: tr("New message..."),
hintStyle: TextStyle(
color: _greyColor,
),
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(
const Radius.circular(50.0),
),
borderSide: BorderSide.none),
contentPadding: EdgeInsets.fromLTRB(20, 8, 32, 8),
filled: true,
fillColor: _separatorColor,
),
),
IconButton(
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
onPressed: () {
if (!_showEmojiPicker) {
// keyboard is visible
hideKeyboard();
Future.delayed(Duration(milliseconds: 100),
() => showEmojiContainer());
} else {
//keyboard is hidden
showKeyboard();
hideEmojiContainer();
}
},
icon: Icon(
Icons.face,
color: _showEmojiPicker
? (_conversation.color ?? Colors.blue)
: null,
),
),
],
),
),
SizedBox(width: 5),
GestureDetector(
onTap: _isMessageValid ? _submitTextMessage : null,
child: Container(
padding: EdgeInsets.all(8),
decoration: BoxDecoration(
gradient: !_isMessageValid ? _disabledGradient : _fabGradient,
shape: BoxShape.circle,
),
child: Icon(
Icons.send,
color: Colors.white,
),
),
),
],
),
);
Widget _buildEmojiContainer() => EmojiPicker(
bgColor: _conversation.color ?? Colors.blue.shade900,
indicatorColor: _conversation.color ?? Colors.blue.shade900,
rows: 3,
columns: 7,
onEmojiSelected: (emoji, category) {
_textController.text = _textController.text + emoji.emoji;
},
recommendKeywords: ["face", "happy", "party", "sad"],
numRecommended: 50,
);
Widget _buildSendingWidget() => Container(
height: 68,
color: _senderColor,
child: Row(
children: <Widget>[
Spacer(flex: 1),
Flexible(
child: LinearProgressIndicator(value: _sendProgress),
flex: 5,
),
Spacer(flex: 1),
Text("${(_sendProgress * 100).toInt()}%"),
Spacer(flex: 1),
OutlinedButton(
onPressed: () => _sendCancel.cancel(),
child: Text(tr("Cancel").toUpperCase()),
),
Spacer(flex: 1),
],
),
);
@override
Widget build(BuildContext context) {
if (_error == ErrorLevel.MAJOR) return _buildError();
@ -401,20 +663,47 @@ class _ConversationScreenState extends SafeState<ConversationScreen> {
: null,
),
_messages.length == 0 ? _buildNoMessagesNotice() : _buildMessagesList(),
Divider(),
_buildSendMessageForm()
UserWritingInConvNotifier(convID: _conversation.id),
_sendCancel != null ? _buildSendingWidget() : _buildSendMessageForm(),
_showEmojiPicker ? _buildEmojiContainer() : Container(),
],
);
}
void _sendWritingEvent() async {
try {
if (textMessage.isEmpty) return;
final t = time();
if (t - _lastWritingEventSent <
srvConfig.conversationsPolicy.writingEventInterval) return;
_lastWritingEventSent = t;
await ConversationsHelper.sendWritingEvent(_conversation.id);
} catch (e, s) {
logError(e, s);
}
}
/// Request message statistics
void _requestMessageStats(ConversationMessage message) async {
MainController.of(context)
.openConversationMessageStats(_conversation, message);
}
/// Request message content update
Future<void> _updateMessage(ConversationMessage message) async {
final newContent = await askUserString(
context: context,
title: tr("Update message"),
message: tr("Please enter new message content:"),
defaultValue: message.message.content,
hint: tr("New message"));
context: context,
title: tr("Update message"),
message: tr("Please enter new message content:"),
defaultValue: message.message.content,
hint: tr("New message"),
minLength:
ServerConfigurationHelper.config.conversationsPolicy.minMessageLen,
maxLength:
ServerConfigurationHelper.config.conversationsPolicy.maxMessageLen,
);
if (newContent == null) return;
@ -435,13 +724,13 @@ class _ConversationScreenState extends SafeState<ConversationScreen> {
textAlign: TextAlign.justify,
),
actions: <Widget>[
FlatButton(
TextButton(
child: Text(
tr("Cancel").toUpperCase(),
),
onPressed: () => Navigator.pop(c, false),
),
FlatButton(
TextButton(
child: Text(
tr("Confirm").toUpperCase(),
style: TextStyle(color: Colors.red),

View File

@ -62,32 +62,24 @@ class _ConversationScreenState extends SafeState<ConversationsListScreen> {
await _loadConversationsList(false);
}
void _gotLoadingError() {
setError(_list == null ? LoadErrorLevel.MAJOR : LoadErrorLevel.MINOR);
}
/// Load the list of conversations
Future<void> _loadConversationsList(bool cached) async {
setError(LoadErrorLevel.NONE);
//Get the list of conversations
var list;
if (cached)
list = await _conversationsHelper.getCachedList();
else
list = await _conversationsHelper.downloadList();
try {
ConversationsList list = cached
? await _conversationsHelper.getCachedList()
: await _conversationsHelper.downloadList();
assert(list != null);
if (list == null) return _gotLoadingError();
//Get information about the members of the conversations
list.users = await _usersHelper.getList(list.allUsersID);
//Get information about the members of the conversations
list.users = await _usersHelper.getUsersInfo(list.allUsersID);
if (list.users == null) return _gotLoadingError();
//Save list
setState(() {
_list = list;
});
setState(() => _list = list);
} catch (e, s) {
debugPrint("Failed to get conversations list! $e => $s", wrapWidth: 1024);
setError(_list == null ? LoadErrorLevel.MAJOR : LoadErrorLevel.MINOR);
}
}
/// Build an error card
@ -95,7 +87,7 @@ class _ConversationScreenState extends SafeState<ConversationsListScreen> {
return buildErrorCard(
tr("Could not retrieve the list of conversations!"),
actions: <Widget>[
FlatButton(
TextButton(
onPressed: () => _refreshIndicatorKey.currentState.show(),
child: Text(
tr("Retry").toUpperCase(),
@ -131,37 +123,24 @@ class _ConversationScreenState extends SafeState<ConversationsListScreen> {
}
/// Handle conversation deletion request
Future<void> _requestDeleteConversation(Conversation conversation) async {
final result = await showDialog<bool>(
context: context,
builder: (c) {
return AlertDialog(
title: Text(tr("Delete conversation")),
content: Text(tr(
"Do you really want to remove this conversation from your list of conversations ? If you are the owner of this conversation, it will be completely deleted!")),
actions: <Widget>[
FlatButton(
onPressed: () => Navigator.pop(context, false),
child: Text(tr("cancel").toUpperCase()),
),
FlatButton(
onPressed: () => Navigator.pop(context, true),
child: Text(
tr("delete").toUpperCase(),
style: TextStyle(color: Colors.red),
),
)
],
);
},
);
Future<void> _requestLeaveConversation(Conversation conversation) async {
final result = await showConfirmDialog(
context: context,
message: conversation.isLastAdmin
? tr(
"Do you really want to leave this conversation ? As you are its last admin, it will be completely deleted!")
: tr("Do you really want to leave this conversation ?"));
if (result == null || !result) return;
// Request the conversation to be deleted now
if (!await _conversationsHelper.deleteConversation(conversation.id))
Scaffold.of(context).showSnackBar(
SnackBar(content: Text(tr("Could not delete the conversation!"))));
try {
await _conversationsHelper.deleteConversation(conversation.id);
} catch (e, s) {
print("Failed to leave conversation! $e => $s");
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(tr("Could not leave the conversation!"))));
}
// Reload the list of conversations
_loadConversationsList(false);
@ -195,7 +174,7 @@ class _ConversationScreenState extends SafeState<ConversationsListScreen> {
_openConversation(c.id);
},
onRequestUpdate: _updateConversation,
onRequestDelete: _requestDeleteConversation,
onRequestLeave: _requestLeaveConversation,
);
},
itemCount: _list.length,

View File

@ -1,6 +1,4 @@
import 'package:comunic/ui/screens/update_conversation_screen.dart';
import 'package:comunic/ui/widgets/comunic_back_button_widget.dart';
import 'package:comunic/utils/intl_utils.dart';
import 'package:flutter/material.dart';
/// Create a new conversation route
@ -9,13 +7,5 @@ import 'package:flutter/material.dart';
class CreateConversationScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: ComunicBackButton(),
title: Text(tr("Create a conversation")),
),
body: UpdateConversationScreen(),
);
}
Widget build(BuildContext context) => UpdateConversationScreen();
}

View File

@ -91,7 +91,7 @@ class _FriendsListScreenState extends SafeState<FriendsListScreen> {
Widget _buildError() => buildErrorCard(
tr("Could not load your list of friends!"),
actions: [
FlatButton(
TextButton(
onPressed: _refreshList,
child: Text(
tr("Retry").toUpperCase(),
@ -171,11 +171,11 @@ class _FriendsListScreenState extends SafeState<FriendsListScreen> {
content: Text(tr(
"Are you sure do you want to remove this friend from your list of friends ? A friendship request will have to be sent to get this user back to your list!")),
actions: <Widget>[
FlatButton(
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(tr("Cancel").toUpperCase()),
),
FlatButton(
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text(
tr("Confirm").toUpperCase(),

View File

@ -259,19 +259,19 @@ class _GroupSettingsScreenState extends SafeState<GroupSettingsScreen> {
// Upload a new logo
SettingsTile(
title: tr("Upload a new logo"),
onPressed: (_) => _uploadNewLogo,
onPressed: (_) => _uploadNewLogo(),
),
// Generate a new random logo
SettingsTile(
title: tr("Generate a new random logo"),
onPressed: (_) => _generateRandomLogo,
onPressed: (_) => _generateRandomLogo(),
),
// Delete current logo
SettingsTile(
title: tr("Delete logo"),
onPressed: (_) => _deleteLogo,
onPressed: (_) => _deleteLogo(),
),
],
);
@ -281,8 +281,8 @@ class _GroupSettingsScreenState extends SafeState<GroupSettingsScreen> {
void _uploadNewLogo() async {
try {
final logo = await pickImage(context);
final bytes = await logo.readAsBytes();
await _doUploadLogo(bytes);
if (logo == null) return;
await _doUploadLogo(logo.bytes);
} catch (e, stack) {
print("Could not upload new logo! $e\n$stack");
showSimpleSnack(context, tr("Could not upload new logo!"));
@ -328,7 +328,7 @@ class _GroupSettingsScreenState extends SafeState<GroupSettingsScreen> {
tiles: [
SettingsTile(
title: tr("Delete group"),
onPressed: (_) => _deleteGroup,
onPressed: (_) => _deleteGroup(),
),
],
);

View File

@ -97,7 +97,7 @@ class _OtherUserFriendsListScreenState
"Could not get the list of friends of this user !",
),
actions: [
FlatButton(
TextButton(
child: Text(
tr("Try again").toUpperCase(),
style: TextStyle(color: Colors.white),

View File

@ -4,8 +4,8 @@ import 'package:comunic/helpers/users_helper.dart';
import 'package:comunic/lists/unread_conversations_list.dart';
import 'package:comunic/lists/users_list.dart';
import 'package:comunic/ui/routes/main_route/main_route.dart';
import 'package:comunic/ui/widgets/account_image_widget.dart';
import 'package:comunic/ui/widgets/async_screen_widget.dart';
import 'package:comunic/ui/widgets/conversation_image_widget.dart';
import 'package:comunic/ui/widgets/safe_state.dart';
import 'package:comunic/utils/date_utils.dart';
import 'package:comunic/utils/intl_utils.dart';
@ -71,21 +71,40 @@ class _UnreadConversationsScreenState
Widget _tileBuilder(BuildContext context, int index) {
final conv = _list[index];
final user = _users.getUser(conv.userID);
final message = _list[index].message;
final singleUserConv = conv.conv.members.length < 3;
String messageStr;
if (message.hasFile)
messageStr = tr("New file");
else if (message.hasMessage)
messageStr = singleUserConv
? message.message.content
: tr("%1% : %2%", args: {
"1": _users.getUser(message.userID).fullName,
"2": message.message.content,
});
else
message.serverMessage.getText(_users);
return ListTile(
leading: AccountImageWidget(user: user),
title: Text(user.displayName),
leading: ConversationImageWidget(
conversation: conv.conv,
users: _users,
),
title: Text(ConversationsHelper.getConversationName(conv.conv, _users)),
subtitle: RichText(
text: TextSpan(style: Theme.of(context).textTheme.bodyText2, children: [
TextSpan(text: conv.convName.isNotEmpty ? conv.convName + "\n" : ""),
TextSpan(
text: conv.message,
text: messageStr,
style: TextStyle(fontStyle: FontStyle.italic),
),
]),
),
trailing: Text(diffTimeFromNowToStr(conv.lastActive)),
onTap: () => MainController.of(context).openConversation(conv.id),
trailing: Text(diffTimeFromNowToStr(conv.message.timeSent)),
onTap: () => MainController.of(context).openConversation(conv.conv.id),
);
}
}

View File

@ -1,27 +1,39 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:comunic/helpers/conversations_helper.dart';
import 'package:comunic/helpers/server_config_helper.dart';
import 'package:comunic/helpers/users_helper.dart';
import 'package:comunic/lists/users_list.dart';
import 'package:comunic/models/conversation.dart';
import 'package:comunic/models/new_conversation.dart';
import 'package:comunic/models/new_conversation_settings.dart';
import 'package:comunic/models/user.dart';
import 'package:comunic/ui/dialogs/color_picker_dialog.dart';
import 'package:comunic/ui/dialogs/pick_file_dialog.dart';
import 'package:comunic/ui/routes/main_route/main_route.dart';
import 'package:comunic/ui/tiles/simple_user_tile.dart';
import 'package:comunic/ui/widgets/async_screen_widget.dart';
import 'package:comunic/ui/widgets/comunic_back_button_widget.dart';
import 'package:comunic/ui/widgets/pick_user_widget.dart';
import 'package:comunic/utils/account_utils.dart';
import 'package:comunic/utils/color_utils.dart';
import 'package:comunic/utils/dart_color.dart';
import 'package:comunic/utils/intl_utils.dart';
import 'package:comunic/utils/log_utils.dart';
import 'package:comunic/utils/ui_utils.dart';
import 'package:flutter/material.dart';
/// Create / Update conversation screen
///
/// @author Pierre HUBERT
enum _MembersMenuChoices { REMOVE }
enum _MembersMenuChoices { TOGGLE_ADMIN_STATUS, REMOVE }
class UpdateConversationScreen extends StatefulWidget {
final Conversation initialSettings;
final UsersList initialUsers;
final convID;
const UpdateConversationScreen({
Key key,
this.initialSettings,
this.initialUsers,
this.convID,
}) : super(key: key);
@override
@ -29,34 +41,82 @@ class UpdateConversationScreen extends StatefulWidget {
}
class _UpdateConversationScreen extends State<UpdateConversationScreen> {
Conversation _conversation;
TextEditingController _nameController = TextEditingController();
TextEditingController _colorController = TextEditingController();
UsersList _members = UsersList();
Set<int> _admins = Set();
bool _followConversation = true;
bool _canEveryoneAddMembers = true;
String _image;
get isUpdating => widget.initialSettings != null;
String get _conversationColor => _colorController.text;
get isOwner => !isUpdating || widget.initialSettings.isOwner;
Color get _color {
if (_conversationColor == null || _conversationColor.isEmpty) return null;
Conversation get _initialSettings => widget.initialSettings;
bool get _canAddMembers => isOwner || _initialSettings.canEveryoneAddMembers;
@override
void initState() {
super.initState();
// Check if we are updating an existing conversation
if (widget.initialSettings != null) {
_nameController.text = widget.initialSettings.name;
_members = widget.initialUsers;
_followConversation = widget.initialSettings.following;
_canEveryoneAddMembers = widget.initialSettings.canEveryoneAddMembers;
try {
return HexColor(_conversationColor);
} catch (e, s) {
logError(e, s);
return null;
}
}
get isUpdating => widget.convID != null;
get isAdmin => !isUpdating || _conversation.isAdmin;
bool get _canAddMembers =>
(isAdmin || _conversation.canEveryoneAddMembers) &&
(!isUpdating || !_conversation.isManaged);
get _isValid => _members.length > 0;
Future<void> _init() async {
if (!isUpdating) {
_admins.add(userID());
return;
}
_conversation =
await ConversationsHelper().getSingle(widget.convID, force: true);
_nameController.text = _conversation.name ?? "";
_colorController.text = _conversation.color == null
? ""
: "#${colorToHex(_conversation.color)}";
_members = await UsersHelper().getList(_conversation.membersID);
_admins = _conversation.adminsID;
_followConversation = _conversation.following;
_canEveryoneAddMembers = _conversation.canEveryoneAddMembers;
_image = _conversation.logoURL;
setState(() {});
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
leading: ComunicBackButton(),
title: Text(isUpdating
? tr("Update conversation")
: tr("Create a conversation")),
actions: [
IconButton(
icon: Icon(Icons.check),
onPressed: _isValid ? _submitForm : null)
],
),
body: AsyncScreenWidget(
onReload: _init,
onBuild: _buildBody,
errorMessage: tr("Failed to load conversation settings!"),
),
);
Widget _buildBody() {
return SingleChildScrollView(
child: Container(
padding: EdgeInsets.all(8.0),
@ -66,12 +126,27 @@ class _UpdateConversationScreen extends State<UpdateConversationScreen> {
TextField(
controller: _nameController,
decoration: InputDecoration(
labelText: tr("Conversation name (optionnal)"),
labelText: tr("Conversation name (optional)"),
alignLabelWithHint: true,
enabled: isOwner,
enabled: isAdmin,
),
),
// Conversation color
TextField(
controller: _colorController,
onChanged: (s) => setState(() {}),
decoration: InputDecoration(
labelText: tr("Conversation color (optional)"),
alignLabelWithHint: true,
enabled: isAdmin,
suffixIcon: IconButton(
icon: Icon(Icons.colorize),
color: _color,
onPressed: isAdmin ? _pickColor : null,
)),
),
// Follow conversation ?
Row(
children: <Widget>[
@ -90,7 +165,7 @@ class _UpdateConversationScreen extends State<UpdateConversationScreen> {
children: <Widget>[
Switch.adaptive(
value: _canEveryoneAddMembers,
onChanged: isOwner
onChanged: isAdmin
? (b) => setState(() {
_canEveryoneAddMembers = b;
})
@ -102,102 +177,223 @@ class _UpdateConversationScreen extends State<UpdateConversationScreen> {
// Add a member to the conversation
PickUserWidget(
resetOnChoose: true,
keepFocusOnChoose: true,
label: tr("Add member"),
enabled: _canAddMembers,
onSelectUser: (user) => setState(() {
if (!_members.contains(user)) _members.insert(0, user);
}),
),
resetOnChoose: true,
keepFocusOnChoose: true,
label: tr("Add member"),
enabled: _canAddMembers,
onSelectUser: (user) => _addMember(user)),
//Conversation members
Column(
children: _members
.map((f) => SimpleUserTile(
user: f,
trailing: _canAddMembers
? PopupMenuButton<_MembersMenuChoices>(
captureInheritedThemes: false,
onSelected: (choice) =>
_membersMenuItemSelected(f, choice),
itemBuilder: (c) =>
<PopupMenuEntry<_MembersMenuChoices>>[
PopupMenuItem(
child: Text(tr("Remove")),
value: _MembersMenuChoices.REMOVE,
enabled: isOwner ||
(_canEveryoneAddMembers &&
!_initialSettings.members
.contains(f.id)),
)
],
)
: null,
))
.toList(),
children: _members.map((f) => _buildMemberTile(f)).toList(),
),
// Submit button
RaisedButton(
onPressed: _members.length < 1 ? null : _submitForm,
child: Text(isUpdating
? tr("Update the conversation")
: tr("Create the conversation")),
)
// Conversation image
isUpdating ? _buildConversationImageWidget() : Container(),
],
),
),
);
}
Widget _buildMemberTile(User user) => SimpleUserTile(
user: user,
subtitle: _admins.contains(user.id) ? tr("Admin") : tr("Member"),
trailing: _canAddMembers
? PopupMenuButton<_MembersMenuChoices>(
onSelected: (choice) => _membersMenuItemSelected(user, choice),
itemBuilder: (c) => <PopupMenuEntry<_MembersMenuChoices>>[
PopupMenuItem(
child: Text(tr("Toggle admin status")),
value: _MembersMenuChoices.TOGGLE_ADMIN_STATUS,
enabled: isUpdating && isAdmin && user.id != userID(),
),
PopupMenuItem(
child: Text(tr("Remove")),
value: _MembersMenuChoices.REMOVE,
enabled: isAdmin && user.id != userID(),
),
],
)
: null,
);
void _pickColor() async {
final color = await showColorPickerDialog(context, _color);
setState(() =>
_colorController.text = color == null ? "" : "#${colorToHex(color)}");
}
/// An option of the members menu has been selected
void _membersMenuItemSelected(User user, _MembersMenuChoices choice) {
if (choice == null) return;
if (choice == _MembersMenuChoices.REMOVE)
switch (choice) {
case _MembersMenuChoices.REMOVE:
_removeMember(user);
break;
case _MembersMenuChoices.TOGGLE_ADMIN_STATUS:
_toggleAdminStatus(user);
break;
}
}
void _addMember(User user) async {
try {
if (_members.contains(user)) return;
if (isUpdating)
await ConversationsHelper.addMember(_conversation.id, user.id);
setState(() => _members.insert(0, user));
} catch (e, s) {
logError(e, s);
snack(context, tr("Failed to add member to conversation!"));
}
}
void _removeMember(User user) async {
try {
if (isUpdating)
await ConversationsHelper.removeMember(_conversation.id, user.id);
setState(() {
_members.removeWhere((u) => u.id == user.id);
_admins.remove(user.id);
});
} catch (e, s) {
logError(e, s);
snack(context, tr("Failed to remove member!"));
}
}
void _toggleAdminStatus(User user) async {
try {
final setAdmin = !_admins.contains(user.id);
await ConversationsHelper.setAdmin(_conversation.id, user.id, setAdmin);
setState(() {
if (!setAdmin)
_admins.remove(user.id);
else
_admins.add(user.id);
});
} catch (e, s) {
logError(e, s);
snack(context, tr("Failed to toggle admin status of user!"));
}
}
/// Submit the conversation
Future<void> _submitForm() async {
final settings = Conversation(
id: isUpdating ? widget.initialSettings.id : 0,
ownerID: isUpdating ? widget.initialSettings.ownerID : 0,
name: _nameController.text,
try {
// Create the conversation
if (!isUpdating) {
final conversationID = await ConversationsHelper.createConversation(
NewConversation(
name: _nameController.text,
members: _members.map((element) => element.id).toList(),
follow: _followConversation,
canEveryoneAddMembers: _canEveryoneAddMembers,
color: _color));
MainController.of(context).popPage();
MainController.of(context).openConversation(conversationID);
return;
}
// Update conversation settings
final newSettings = NewConversationsSettings(
convID: _conversation.id,
following: _followConversation,
members: _members.usersID,
isComplete: isAdmin,
name: _nameController.text,
canEveryoneAddMembers: _canEveryoneAddMembers,
color: _color,
);
// Give random value to these fields as they are ignored here
lastActive: 0,
sawLastMessage: true);
await ConversationsHelper.updateConversation(newSettings);
// Create the conversation
var conversationID = settings.id;
bool error = false;
if (isUpdating)
error = !(await ConversationsHelper().updateConversation(settings));
else {
conversationID = await ConversationsHelper().createConversation(settings);
if (conversationID < 1) error = true;
MainController.of(context).popPage();
} catch (e, s) {
logError(e, s);
snack(context, tr("Failed to update conversation settings!"));
}
}
// Check for errors
if (error)
return Scaffold.of(context).showSnackBar(SnackBar(
content: Text(isUpdating
? tr("Could not update the conversation!")
: tr("Could not create the conversation!")),
duration: Duration(seconds: 1),
));
/// Conversation image management
Widget _buildConversationImageWidget() => Column(
children: [
SizedBox(height: 10),
Text(tr("Conversation logo"),
style: TextStyle(fontWeight: FontWeight.bold)),
SizedBox(height: 5),
_image == null
? Text("No logo defined yet.")
: CachedNetworkImage(imageUrl: _image),
SizedBox(height: 5),
isAdmin
? Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
OutlinedButton(
onPressed: _uploadNewLogo,
child: Text(tr("Change logo")),
),
SizedBox(width: 5),
_image == null
? Container()
: ElevatedButton(
onPressed: _deleteLogo,
child: Text(tr("Delete logo")),
style: ButtonStyle(
backgroundColor:
MaterialStateProperty.all(Colors.red)),
),
],
)
: Container(),
SizedBox(height: 10),
],
);
// Open the conversation
/// Upload new conversation logo
Future<void> _uploadNewLogo() async {
try {
final newLogo = await showPickFileDialog(
context: context,
allowedMimeTypes: ["image/png", "image/jpeg", "image/gif"],
imageMaxWidth: srvConfig.conversationsPolicy.maxLogoWidth,
imageMaxHeight: srvConfig.conversationsPolicy.maxLogoHeight,
);
MainController.of(context).popPage();
if (!isUpdating)
MainController.of(context).openConversation(conversationID);
if (newLogo == null) return;
await ConversationsHelper.changeImage(_conversation.id, newLogo);
final newConvSettings =
await ConversationsHelper().getSingle(_conversation.id, force: true);
setState(() => _image = newConvSettings.logoURL);
} catch (e, s) {
logError(e, s);
snack(context, tr("Failed to change conversation logo !"));
}
}
/// Delete conversation logo
Future<void> _deleteLogo() async {
try {
if (!await showConfirmDialog(
context: context,
message: tr("Do you really want to delete this logo?"))) return;
await ConversationsHelper.removeLogo(_conversation.id);
setState(() => _image = null);
} catch (e, s) {
logError(e, s);
snack(context, tr("Failed to remove conversation logo!"));
}
}
}

View File

@ -61,7 +61,7 @@ class _UserAccessDeniedScreenState extends SafeState<UserAccessDeniedScreen> {
Widget _buildPage() {
final size = MediaQuery.of(context).size;
return Container(
constraints: BoxConstraints.loose(size),
width: size.width,
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(8.0),

View File

@ -99,7 +99,7 @@ class _UserPageScreenState extends SafeState<UserPageScreen> {
body: Center(
child:
buildErrorCard(tr("Could not get user information!"), actions: [
FlatButton(
TextButton(
onPressed: _getUserInfo,
child: Text(
tr("Retry").toUpperCase(),

View File

@ -1,253 +1,135 @@
import 'package:comunic/models/conversation_message.dart';
import 'package:comunic/models/user.dart';
import 'package:comunic/ui/widgets/account_image_widget.dart';
import 'package:comunic/ui/widgets/network_image_widget.dart';
import 'package:comunic/ui/widgets/conversation_file_tile.dart';
import 'package:comunic/ui/widgets/text_widget.dart';
import 'package:comunic/utils/clipboard_utils.dart';
import 'package:comunic/utils/date_utils.dart';
import 'package:comunic/utils/intl_utils.dart';
import 'package:comunic/utils/ui_utils.dart';
import 'package:flutter/material.dart';
/// Conversation message tile
///
/// @author Pierre HUBERT
enum _MenuChoices { DELETE, REQUEST_UPDATE_CONTENT }
enum _MenuChoices {
COPY_URL,
COPY_MESSAGE,
DELETE,
REQUEST_UPDATE_CONTENT,
GET_STATS,
}
typedef OnRequestMessageStats = void Function(ConversationMessage);
typedef OnRequestMessageUpdate = void Function(ConversationMessage);
typedef OnRequestMessageDelete = void Function(ConversationMessage);
class ConversationMessageTile extends StatelessWidget {
final ConversationMessage message;
final User userInfo;
final bool isLastMessage;
final bool isFirstMessage;
final User user;
final OnRequestMessageStats onRequestMessageStats;
final OnRequestMessageUpdate onRequestMessageUpdate;
final OnRequestMessageDelete onRequestMessageDelete;
const ConversationMessageTile({
Key key,
@required this.message,
@required this.userInfo,
@required this.isLastMessage,
@required this.isFirstMessage,
@required this.user,
@required this.onRequestMessageStats,
@required this.onRequestMessageUpdate,
@required this.onRequestMessageDelete,
}) : assert(message != null),
assert(userInfo != null),
assert(isLastMessage != null),
assert(isFirstMessage != null),
assert(user != null),
assert(onRequestMessageStats != null),
assert(onRequestMessageUpdate != null),
assert(onRequestMessageDelete != null),
super(key: key);
/// Build account image
Widget _buildAccountImage(BuildContext context) {
return Container(
margin: EdgeInsets.all(10.0),
child: PopupMenuButton<_MenuChoices>(
child: AccountImageWidget(
user: userInfo,
width: 35.0,
),
itemBuilder: (c) => [
// Update message content
PopupMenuItem(
enabled: message.isOwner,
value: _MenuChoices.REQUEST_UPDATE_CONTENT,
child: Text(tr("Update")),
),
// Delete the message
PopupMenuItem(
enabled: message.isOwner,
value: _MenuChoices.DELETE,
child: Text(tr("Delete")),
),
],
onSelected: _menuOptionSelected,
),
);
}
/// Build widget image
Widget _buildMessageImage(BuildContext context) {
return Container(
margin: EdgeInsets.only(bottom: 2),
child: NetworkImageWidget(
url: message.imageURL,
allowFullScreen: true,
width: 200,
height: 200,
),
);
}
/// Build message date
Widget _buildMessageDate() {
return isLastMessage
? Container(
margin: EdgeInsets.only(top: 5.0),
child: Text(
dateTimeToString(message.date),
style: TextStyle(color: Colors.black54, fontSize: 12.0),
textAlign: TextAlign.center,
),
)
: Container();
}
/// Build a message of the current user
Widget _buildRightMessage(BuildContext context) {
return Container(
margin: EdgeInsets.only(bottom: isLastMessage ? 20.0 : 2.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
Column(
children: <Widget>[
Row(
children: <Widget>[
Column(
children: <Widget>[
// Text image
Container(
child: message.hasImage
? _buildMessageImage(context)
: null,
),
// Text message
Container(
child: message.hasMessage
? Container(
width: 200.0,
alignment: Alignment.centerRight,
child: Container(
child: TextWidget(
content: message.message,
textAlign: TextAlign.justify,
style: TextStyle(color: Colors.white),
linksColor: Colors.indigo,
),
padding: EdgeInsets.fromLTRB(
15.0, 10.0, 15.0, 10.0),
decoration: BoxDecoration(
color: Colors.blueAccent,
borderRadius: BorderRadius.circular(8.0),
),
),
)
: null,
),
],
),
// Account image
_buildAccountImage(context)
],
),
// Date
Container(
child: _buildMessageDate(),
margin: EdgeInsets.only(right: 45.0),
)
],
),
],
),
);
// Text
}
/// Build a message of a peer user
Widget _buildLeftMessage(BuildContext context) {
return Container(
margin: EdgeInsets.only(bottom: isLastMessage ? 20.0 : 5.0),
child: Column(
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
//User name
Container(
margin: EdgeInsets.only(left: 55.0),
child: isFirstMessage
? Text(
userInfo.fullName,
textAlign: TextAlign.left,
style: TextStyle(fontSize: 12.0),
RichText(
text: TextSpan(
text:
"${user.fullName} - ${formatDisplayDate(message.date, date: false)}",
style: TextStyle(color: Colors.white, fontSize: 11),
children: [
WidgetSpan(
child: PopupMenuButton<_MenuChoices>(
child: Icon(
Icons.more_vert,
color: Colors.white,
size: 14,
),
onSelected: (v) => _menuOptionSelected(context, v),
itemBuilder: (c) => <PopupMenuItem<_MenuChoices>>[
PopupMenuItem(
enabled: (message.message?.content ?? "") != "",
value: _MenuChoices.COPY_MESSAGE,
child: Text(tr("Copy message")),
),
PopupMenuItem(
enabled: message.file != null,
value: _MenuChoices.COPY_URL,
child: Text(tr("Copy URL")),
),
PopupMenuItem(
value: _MenuChoices.GET_STATS,
child: Text(tr("Statistics")),
),
// Update message content
PopupMenuItem(
enabled: message.isOwner &&
message.message != null &&
message.message.content.isNotEmpty,
value: _MenuChoices.REQUEST_UPDATE_CONTENT,
child: Text(tr("Update")),
),
// Delete the message
PopupMenuItem(
enabled: message.isOwner,
value: _MenuChoices.DELETE,
child: Text(tr("Delete")),
),
]..removeWhere((element) => !element.enabled),
),
)
: null,
]),
),
Row(
children: <Widget>[
// Account image
_buildAccountImage(context),
Column(
children: <Widget>[
// Text image
Container(
child:
message.hasImage ? _buildMessageImage(context) : null,
),
// Text message
Container(
child: message.hasMessage
? Container(
width: 200.0,
alignment: Alignment.centerLeft,
child: Container(
child: TextWidget(
content: message.message,
textAlign: TextAlign.justify,
style: TextStyle(
color: darkTheme()
? Colors.white
: Colors.black),
),
padding:
EdgeInsets.fromLTRB(15.0, 10.0, 15.0, 10.0),
decoration: BoxDecoration(
color: darkTheme()
? Colors.white12
: Colors.black12,
borderRadius: BorderRadius.circular(8.0),
),
),
)
: null,
),
],
),
],
),
// Date
Container(
margin: EdgeInsets.only(left: 50.0),
child: Align(
alignment: Alignment.topLeft,
child: _buildMessageDate(),
),
)
_buildMessageContent(),
],
),
);
}
);
@override
Widget build(BuildContext context) {
return message.isOwner
? _buildRightMessage(context)
: _buildLeftMessage(context);
Widget _buildMessageContent() {
if (!message.hasFile)
return TextWidget(
content: message.message,
textAlign: TextAlign.justify,
style: TextStyle(color: Colors.white),
);
return ConversationFileWidget(messageID: message.id, file: message.file);
}
/// Process menu choice
void _menuOptionSelected(_MenuChoices value) {
void _menuOptionSelected(BuildContext context, _MenuChoices value) {
switch (value) {
case _MenuChoices.COPY_MESSAGE:
copyToClipboard(context, message.message.content);
break;
case _MenuChoices.COPY_URL:
copyToClipboard(context, message.message.content);
break;
case _MenuChoices.GET_STATS:
onRequestMessageStats(message);
break;
case _MenuChoices.REQUEST_UPDATE_CONTENT:
onRequestMessageUpdate(message);
break;

View File

@ -1,10 +1,11 @@
import 'package:comunic/helpers/conversations_helper.dart';
import 'package:comunic/lists/users_list.dart';
import 'package:comunic/models/conversation.dart';
import 'package:comunic/ui/routes/main_route/main_route.dart';
import 'package:comunic/ui/widgets/conversation_image_widget.dart';
import 'package:comunic/ui/widgets/custom_list_tile.dart';
import 'package:comunic/utils/date_utils.dart';
import 'package:comunic/utils/intl_utils.dart';
import 'package:comunic/utils/ui_utils.dart';
import 'package:flutter/material.dart';
/// Single conversation tile
@ -12,17 +13,17 @@ import 'package:flutter/material.dart';
/// @author Pierre HUBERT
typedef OpenConversationCallback = void Function(Conversation);
typedef RequestDeleteConversationCallback = void Function(Conversation);
typedef RequestLeaveConversationCallback = void Function(Conversation);
typedef RequestUpdateConversationCallback = void Function(Conversation);
enum _PopupMenuChoices { UPDATE, DELETE }
enum _PopupMenuChoices { UPDATE, LEAVE }
class ConversationTile extends StatelessWidget {
final Conversation conversation;
final UsersList usersList;
final OpenConversationCallback onOpen;
final RequestUpdateConversationCallback onRequestUpdate;
final RequestDeleteConversationCallback onRequestDelete;
final RequestLeaveConversationCallback onRequestLeave;
const ConversationTile({
Key key,
@ -30,12 +31,12 @@ class ConversationTile extends StatelessWidget {
@required this.usersList,
@required this.onOpen,
@required this.onRequestUpdate,
@required this.onRequestDelete,
@required this.onRequestLeave,
}) : assert(conversation != null),
assert(usersList != null),
assert(onOpen != null),
assert(onRequestUpdate != null),
assert(onRequestDelete != null),
assert(onRequestLeave != null),
super(key: key);
_buildSubInformation(IconData icon, String content) {
@ -52,64 +53,83 @@ class ConversationTile extends StatelessWidget {
}
@override
Widget build(BuildContext context) {
return CustomListTile(
onTap: () => onOpen(conversation),
// Conversation name
title: Text(
ConversationsHelper.getConversationName(
conversation,
usersList,
),
style: TextStyle(
fontWeight: conversation.sawLastMessage ? null : FontWeight.bold,
),
),
Widget build(BuildContext context) => Column(
children: [_buildMainTile(context), _buildCallTile(context)],
);
// Leading icon
leading: Icon(
conversation.sawLastMessage ? Icons.check_circle : Icons.lens,
color: conversation.sawLastMessage
? (darkTheme() ? darkAccentColor : null)
: Colors.blue,
),
// Conversation information
isThreeLine: true,
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
_buildSubInformation(
Icons.access_time, diffTimeFromNowToStr(conversation.lastActive)),
_buildSubInformation(
Icons.group,
conversation.members.length == 1
? tr("1 member")
: tr(
"%num% members",
args: {
"num": conversation.members.length.toString(),
},
),
Widget _buildMainTile(BuildContext context) => CustomListTile(
onTap: () => onOpen(conversation),
// Conversation name
title: Text(
ConversationsHelper.getConversationName(
conversation,
usersList,
),
],
),
style: TextStyle(
fontWeight: conversation.sawLastMessage ? null : FontWeight.bold,
),
),
onLongPressOpenMenu: (position) {
showMenu<_PopupMenuChoices>(
context: context,
position: position,
items: [
PopupMenuItem(
child: Text(tr("Update")),
value: _PopupMenuChoices.UPDATE,
),
PopupMenuItem(
child: Text(tr("Delete")),
value: _PopupMenuChoices.DELETE,
)
]).then(_conversationMenuCallback);
},
// Tile color
tileColor: conversation.sawLastMessage
? null
: (conversation.color ?? Colors.blue).withOpacity(0.2),
// Leading icon
leading: ConversationImageWidget(
conversation: conversation, users: usersList),
// Conversation information
isThreeLine: true,
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
_buildSubInformation(Icons.access_time,
diffTimeFromNowToStr(conversation.lastActivity)),
_buildSubInformation(
Icons.group,
conversation.members.length == 1
? tr("1 member")
: tr(
"%num% members",
args: {
"num": conversation.members.length.toString(),
},
),
),
],
),
onLongPressOpenMenu: (position) {
showMenu<_PopupMenuChoices>(
context: context,
position: position,
items: [
PopupMenuItem(
child: Text(tr("Update")),
value: _PopupMenuChoices.UPDATE,
),
PopupMenuItem(
child: Text(tr("Leave")),
value: _PopupMenuChoices.LEAVE,
)
]).then(_conversationMenuCallback);
},
);
/// Build call tile, in case of ongoing call
Widget _buildCallTile(BuildContext context) {
if (!conversation.isHavingCall) return Container();
return Padding(
padding: EdgeInsets.only(bottom: 20),
child: ListTile(
onTap: () => MainController.of(context).startCall(conversation.id),
dense: true,
title: Text(tr("Ongoing call")),
leading: Icon(Icons.call),
tileColor: Colors.yellow.withOpacity(0.2),
),
);
}
@ -120,8 +140,8 @@ class ConversationTile extends StatelessWidget {
onRequestUpdate(conversation);
break;
case _PopupMenuChoices.DELETE:
onRequestDelete(conversation);
case _PopupMenuChoices.LEAVE:
onRequestLeave(conversation);
break;
}
}

View File

@ -39,21 +39,25 @@ class PendingFriendTile extends StatelessWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
FlatButton(
ElevatedButton(
child: Text(
tr("Accept").toUpperCase(),
style: TextStyle(color: Colors.white),
),
color: Colors.green,
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(Colors.green)),
onPressed: () => onRespond(friend, true),
),
Container(width: 8.0,),
FlatButton(
Container(
width: 8.0,
),
ElevatedButton(
child: Text(
tr("Reject").toUpperCase(),
style: TextStyle(color: Colors.white),
),
color: Colors.red,
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(Colors.red)),
onPressed: () => onRespond(friend, false),
)
],

View File

@ -25,9 +25,12 @@ import 'package:comunic/utils/navigation_utils.dart';
import 'package:comunic/utils/post_utils.dart';
import 'package:comunic/utils/ui_utils.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:flutter/rendering.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../models/api_request.dart';
import '../../utils/log_utils.dart';
/// Single posts tile
///
/// @author Pierre HUBERT
@ -76,7 +79,7 @@ class _PostTileState extends State<PostTile> {
// Class members
TextEditingController _commentController = TextEditingController();
PickedFile _commentImage;
BytesFile _commentImage;
bool _submitting = false;
int _maxNumberOfCommentToShow = 10;
@ -264,9 +267,9 @@ class _PostTileState extends State<PostTile> {
}
Widget _buildPostYouTube() {
return RaisedButton(
color: Colors.red,
textColor: Colors.white,
return ElevatedButton(
style:
ButtonStyle(backgroundColor: MaterialStateProperty.all(Colors.red)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
@ -321,7 +324,7 @@ class _PostTileState extends State<PostTile> {
}
Widget _buildPostPDF() {
return RaisedButton.icon(
return ElevatedButton.icon(
onPressed: () {
launch(widget.post.fileURL);
},
@ -442,8 +445,7 @@ class _PostTileState extends State<PostTile> {
// Image button
Container(
width: 30,
child: FlatButton(
padding: EdgeInsets.only(),
child: TextButton(
onPressed: _pickImageForComment,
child: Icon(
Icons.image,
@ -455,8 +457,7 @@ class _PostTileState extends State<PostTile> {
// Submit button
Container(
width: 40,
child: FlatButton(
padding: EdgeInsets.only(),
child: TextButton(
onPressed: _canSubmitComment ? () => _submitComment() : null,
child: Icon(
Icons.send,
@ -494,11 +495,16 @@ class _PostTileState extends State<PostTile> {
return;
}
// Pick a new image
final newImage = await pickImage(context);
setState(() {
_commentImage = newImage;
});
try {
// Pick a new image
final newImage = await pickImage(context);
setState(() {
_commentImage = newImage;
});
} catch (e, s) {
logError(e, s);
snack(context, tr("Failed to choose an image!"));
}
}
/// Submit comment entered by the user

View File

@ -0,0 +1,33 @@
import 'package:comunic/lists/users_list.dart';
import 'package:comunic/models/conversation_message.dart';
import 'package:flutter/material.dart';
/// Server conversation message list
///
/// @author Pierre hubert
class ServerConversationMessageTile extends StatelessWidget {
final ConversationServerMessage message;
final UsersList users;
const ServerConversationMessageTile({
Key key,
@required this.message,
@required this.users,
}) : assert(message != null),
assert(users != null),
super(key: key);
@override
Widget build(BuildContext context) {
return Center(
child: Text(
message.getText(users),
style: TextStyle(
fontStyle: FontStyle.italic,
fontSize: 12,
),
),
);
}
}

View File

@ -14,9 +14,15 @@ class SimpleUserTile extends StatelessWidget {
final User user;
final OnUserTap onTap;
final Widget trailing;
final String subtitle;
const SimpleUserTile({Key key, this.user, this.onTap, this.trailing})
: assert(user != null),
const SimpleUserTile({
Key key,
this.user,
this.onTap,
this.trailing,
this.subtitle,
}) : assert(user != null),
super(key: key);
@override
@ -27,6 +33,7 @@ class SimpleUserTile extends StatelessWidget {
user: user,
),
title: Text(user.fullName),
subtitle: subtitle == null ? null : Text(subtitle),
trailing: trailing,
);
}

View File

@ -47,7 +47,7 @@ class _FriendshipStatusWidgetState extends State<FriendshipStatusWidget> {
// No request sent yet
if (widget.status.noRequestExchanged) {
return RaisedButton(
return ElevatedButton(
child: Text(tr("Send request").toUpperCase()),
onPressed: () =>
executeRequest(() => _friendsHelper.sendRequest(friendID)),
@ -56,12 +56,13 @@ class _FriendshipStatusWidgetState extends State<FriendshipStatusWidget> {
// Already sent a friendship request
if (widget.status.sentRequest) {
return RaisedButton(
return ElevatedButton(
child: Text(
tr("Cancel request").toUpperCase(),
style: WhiteTextColorStyle,
),
color: Colors.red,
style:
ButtonStyle(backgroundColor: MaterialStateProperty.all(Colors.red)),
onPressed: () =>
executeRequest(() => _friendsHelper.cancelRequest(friendID)),
);
@ -71,21 +72,23 @@ class _FriendshipStatusWidgetState extends State<FriendshipStatusWidget> {
if (widget.status.receivedRequest) {
return Column(
children: <Widget>[
RaisedButton(
ElevatedButton(
child: Text(
tr("Accept request").toUpperCase(),
style: WhiteTextColorStyle,
),
color: Colors.green,
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(Colors.green)),
onPressed: () => executeRequest(
() => _friendsHelper.respondRequest(friendID, true)),
),
RaisedButton(
ElevatedButton(
child: Text(
tr("Reject request").toUpperCase(),
style: WhiteTextColorStyle,
),
color: Colors.red,
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(Colors.red)),
onPressed: () => executeRequest(
() => _friendsHelper.respondRequest(friendID, false)),
)
@ -94,7 +97,7 @@ class _FriendshipStatusWidgetState extends State<FriendshipStatusWidget> {
}
// The two users are friends, offers to follow him
return RaisedButton(
return ElevatedButton(
child: Text((widget.status.following ? tr("Following") : tr("Follow"))
.toUpperCase()),
onPressed: () => executeRequest(() =>

View File

@ -0,0 +1,126 @@
import 'package:cached_network_image/cached_network_image.dart';
/// Chat file tile
///
/// @author Pierre Hubert
import 'package:comunic/models/conversation_message.dart';
import 'package:comunic/ui/dialogs/audio_player_dialog.dart';
import 'package:comunic/ui/routes/main_route/main_route.dart';
import 'package:comunic/ui/routes/video_player_route.dart';
import 'package:comunic/ui/widgets/network_image_widget.dart';
import 'package:filesize/filesize.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
const _AreaWidth = 150.0;
const _AreaHeight = 100.0;
class ConversationFileWidget extends StatefulWidget {
final int messageID;
final ConversationMessageFile file;
const ConversationFileWidget({
Key key,
@required this.messageID,
@required this.file,
}) : assert(messageID != null),
assert(file != null),
super(key: key);
@override
_ConversationFileWidgetState createState() => _ConversationFileWidgetState();
}
class _ConversationFileWidgetState extends State<ConversationFileWidget> {
ConversationMessageFile get file => widget.file;
@override
Widget build(BuildContext context) => Stack(
children: [
!file.hasThumbnail ||
file.fileType == ConversationMessageFileType.IMAGE
? Container(
width: 0,
)
: Opacity(
opacity: 0.8,
child: CachedNetworkImage(
imageUrl: file.thumbnail,
width: _AreaWidth,
height: _AreaHeight,
fit: BoxFit.cover,
),
),
Container(
width: _AreaWidth,
height: _AreaHeight,
child: _buildContent(),
)
],
);
Widget _buildContent() {
switch (file.fileType) {
// Images
case ConversationMessageFileType.IMAGE:
return Center(
child: NetworkImageWidget(
url: file.url,
thumbnailURL: file.thumbnail,
allowFullScreen: true,
),
);
// We open it in the browser
default:
return Container(
child: Center(
child: MaterialButton(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Spacer(flex: 2),
Icon(file.icon, color: Colors.white),
Spacer(),
Text(
file.name.length < 23
? file.name
: file.name.substring(0, 20) + "...",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white),
),
Spacer(),
Text(
filesize(file.size),
style: TextStyle(fontSize: 10, color: Colors.white),
),
Spacer(flex: 2),
],
),
onPressed: _openFile,
),
),
);
break;
}
}
void _openFile() {
switch (file.fileType) {
case ConversationMessageFileType.AUDIO:
showAudioPlayerDialog(context, file.url);
break;
case ConversationMessageFileType.VIDEO:
MainController.of(context).push(
VideoPlayerRoute(url: file.url),
hideNavBar: true,
canShowAsDialog: true,
);
break;
default:
launch(file.url);
}
}
}

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