diff --git a/.gitignore b/.gitignore index d40d999..59a0b56 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,76 @@ npm-debug.* *.orig.* web-build/ web-report/ +/.expo-shared/ +/package-lock.json + +!/.idea/ +/.idea/* +!/.idea/runConfigurations + +# The following contents were automatically generated by expo-cli during eject +# ---------------------------------------------------------------------------- + +# OSX +# +.DS_Store + +# Xcode +# +build/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata +*.xccheckout +*.moved-aside +DerivedData +*.hmap +*.ipa +*.xcuserstate +project.xcworkspace + +# Android/IntelliJ +# +build/ +.idea +.gradle +local.properties +*.iml + +# node.js +# +node_modules/ +npm-debug.log +yarn-error.log + +# BUCK +buck-out/ +\.buckd/ +*.keystore + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the +# screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/ + +*/fastlane/report.xml +*/fastlane/Preview.html +*/fastlane/screenshots + +# Bundle artifacts +*.jsbundle + +# CocoaPods +/ios/Pods/ + +# Expo +.expo/* +/android/gradle.properties diff --git a/.idea/runConfigurations/All_Tests.xml b/.idea/runConfigurations/All_Tests.xml new file mode 100644 index 0000000..3cbf681 --- /dev/null +++ b/.idea/runConfigurations/All_Tests.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Expo.xml b/.idea/runConfigurations/Expo.xml new file mode 100644 index 0000000..284d1e4 --- /dev/null +++ b/.idea/runConfigurations/Expo.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/App.js b/App.js index 585b137..459e45c 100644 --- a/App.js +++ b/App.js @@ -1,19 +1,31 @@ // @flow import * as React from 'react'; -import {Platform, StatusBar} from 'react-native'; -import LocaleManager from './utils/LocaleManager'; -import AsyncStorageManager from "./utils/AsyncStorageManager"; -import CustomIntroSlider from "./components/CustomIntroSlider"; -import {SplashScreen} from 'expo'; -import ThemeManager from './utils/ThemeManager'; +import {Platform, StatusBar, View, YellowBox} from 'react-native'; +import LocaleManager from './src/managers/LocaleManager'; +import AsyncStorageManager from "./src/managers/AsyncStorageManager"; +import CustomIntroSlider from "./src/components/Overrides/CustomIntroSlider"; +import type {CustomTheme} from "./src/managers/ThemeManager"; +import ThemeManager from './src/managers/ThemeManager'; import {NavigationContainer} from '@react-navigation/native'; -import {createStackNavigator} from '@react-navigation/stack'; -import DrawerNavigator from './navigation/DrawerNavigator'; -import NotificationsManager from "./utils/NotificationsManager"; +import MainNavigator from './src/navigation/MainNavigator'; import {Provider as PaperProvider} from 'react-native-paper'; -import AprilFoolsManager from "./utils/AprilFoolsManager"; -import Update from "./constants/Update"; +import AprilFoolsManager from "./src/managers/AprilFoolsManager"; +import Update from "./src/constants/Update"; +import ConnectionManager from "./src/managers/ConnectionManager"; +import URLHandler from "./src/utils/URLHandler"; +import {setSafeBounceHeight} from "react-navigation-collapsible"; +import SplashScreen from 'react-native-splash-screen' +import {OverflowMenuProvider} from "react-navigation-header-buttons"; + +// Native optimizations https://reactnavigation.org/docs/react-native-screens +// Crashes app when navigating away from webview on android 9+ +// enableScreens(true); + + +YellowBox.ignoreWarnings([ // collapsible headers cause this warning, just ignore as it is not an issue + 'Non-serializable values were found in the navigation state', +]); type Props = {}; @@ -22,11 +34,9 @@ type State = { showIntro: boolean, showUpdate: boolean, showAprilFools: boolean, - currentTheme: ?Object, + currentTheme: CustomTheme | null, }; -const Stack = createStackNavigator(); - export default class App extends React.Component { state = { @@ -37,75 +47,131 @@ export default class App extends React.Component { currentTheme: null, }; - onIntroDone: Function; - onUpdateTheme: Function; + navigatorRef: { current: null | NavigationContainer }; + + defaultHomeRoute: string | null; + defaultHomeData: { [key: string]: any } + + createDrawerNavigator: () => React.Node; + + urlHandler: URLHandler; + storageManager: AsyncStorageManager; constructor() { super(); LocaleManager.initTranslations(); - this.onIntroDone = this.onIntroDone.bind(this); - this.onUpdateTheme = this.onUpdateTheme.bind(this); - SplashScreen.preventAutoHide(); + this.navigatorRef = React.createRef(); + this.defaultHomeRoute = null; + this.defaultHomeData = {}; + this.storageManager = AsyncStorageManager.getInstance(); + this.urlHandler = new URLHandler(this.onInitialURLParsed, this.onDetectURL); + this.urlHandler.listen(); + setSafeBounceHeight(Platform.OS === 'ios' ? 100 : 20); + this.loadAssetsAsync().then(() => { + this.onLoadFinished(); + }); } /** - * Updates the theme + * THe app has been started by an url, and it has been parsed. + * Set a new default start route based on the data parsed. + * + * @param parsedData The data parsed from the url */ - onUpdateTheme() { + onInitialURLParsed = (parsedData: { route: string, data: { [key: string]: any } }) => { + this.defaultHomeRoute = parsedData.route; + this.defaultHomeData = parsedData.data; + }; + + /** + * An url has been opened and parsed while the app was active. + * Redirect the user to the screen according to parsed data. + * + * @param parsedData The data parsed from the url + */ + onDetectURL = (parsedData: { route: string, data: { [key: string]: any } }) => { + // Navigate to nested navigator and pass data to the index screen + if (this.navigatorRef.current != null) { + this.navigatorRef.current.navigate('home', { + screen: 'index', + params: {nextScreen: parsedData.route, data: parsedData.data} + }); + } + }; + + /** + * Updates the current theme + */ + onUpdateTheme = () => { this.setState({ currentTheme: ThemeManager.getCurrentTheme() }); this.setupStatusBar(); - } + }; + /** + * Updates status bar content color if on iOS only, + * as the android status bar is always set to black. + */ setupStatusBar() { - if (Platform.OS === 'ios') { - if (ThemeManager.getNightMode()) { - StatusBar.setBarStyle('light-content', true); - } else { - StatusBar.setBarStyle('dark-content', true); - } + if (ThemeManager.getNightMode()) { + StatusBar.setBarStyle('light-content', true); + } else { + StatusBar.setBarStyle('dark-content', true); } + if (Platform.OS === "android") + StatusBar.setBackgroundColor(ThemeManager.getCurrentTheme().colors.surface, true); } /** - * Callback when user ends the intro. Save in preferences to avaoid showing back the introSlides + * Callback when user ends the intro. Save in preferences to avoid showing back the introSlides */ - onIntroDone() { + onIntroDone = () => { this.setState({ showIntro: false, showUpdate: false, showAprilFools: false, }); - AsyncStorageManager.getInstance().savePref(AsyncStorageManager.getInstance().preferences.showIntro.key, '0'); - AsyncStorageManager.getInstance().savePref(AsyncStorageManager.getInstance().preferences.updateNumber.key, Update.number.toString()); - AsyncStorageManager.getInstance().savePref(AsyncStorageManager.getInstance().preferences.showAprilFoolsStart.key, '0'); - } - - async componentDidMount() { - await this.loadAssetsAsync(); - } - - async loadAssetsAsync() { - // Wait for custom fonts to be loaded before showing the app - await AsyncStorageManager.getInstance().loadPreferences(); - ThemeManager.getInstance().setUpdateThemeCallback(this.onUpdateTheme); - await NotificationsManager.initExpoToken(); - this.onLoadFinished(); + this.storageManager.savePref(this.storageManager.preferences.showIntro.key, '0'); + this.storageManager.savePref(this.storageManager.preferences.updateNumber.key, Update.number.toString()); + this.storageManager.savePref(this.storageManager.preferences.showAprilFoolsStart.key, '0'); + }; + + /** + * Loads every async data + * + * @returns {Promise} + */ + loadAssetsAsync = async () => { + await this.storageManager.loadPreferences(); + try { + await ConnectionManager.getInstance().recoverLogin(); + } catch (e) { + } } + /** + * Async loading is done, finish processing startup data + */ onLoadFinished() { - // console.log("finished"); // Only show intro if this is the first time starting the app + this.createDrawerNavigator = () => ; + ThemeManager.getInstance().setUpdateThemeCallback(this.onUpdateTheme); + // Status bar goes dark if set too fast on ios + if (Platform.OS === 'ios') + setTimeout(this.setupStatusBar, 1000); + else + this.setupStatusBar(); this.setState({ isLoading: false, currentTheme: ThemeManager.getCurrentTheme(), - showIntro: AsyncStorageManager.getInstance().preferences.showIntro.current === '1', - showUpdate: AsyncStorageManager.getInstance().preferences.updateNumber.current !== Update.number.toString(), - showAprilFools: AprilFoolsManager.getInstance().isAprilFoolsEnabled() && AsyncStorageManager.getInstance().preferences.showAprilFoolsStart.current === '1', + showIntro: this.storageManager.preferences.showIntro.current === '1', + showUpdate: this.storageManager.preferences.updateNumber.current !== Update.number.toString(), + showAprilFools: AprilFoolsManager.getInstance().isAprilFoolsEnabled() && this.storageManager.preferences.showAprilFoolsStart.current === '1', }); - // Status bar goes dark if set too fast - setTimeout(this.setupStatusBar, 1000); SplashScreen.hide(); } @@ -124,11 +190,16 @@ export default class App extends React.Component { } else { return ( - - - - - + + + + + + + ); } diff --git a/Changelog.md b/Changelog.md index 71fce28..586942b 100644 --- a/Changelog.md +++ b/Changelog.md @@ -2,6 +2,32 @@ Pensez à garder l'appli à jour pour profiter des dernières fonctionnalités ! + - **v3.0.0** - _TBA_ + - Nouvelle barre de navigation ! + - Suppression du menu déroulant gauche + - Création d'une nouvelle catégorie dans la barre de navigation pour regrouper tous les services + - Ajout d'animations un peu partout parce que c'est joli et j'ai compris comment faire :D + - Ajout de la connexion au compte Amicale + - Ajout de la liste des clubs, des élections et du profil utilisateur à travers son compte Amicale + - Amélioration importante de la vitesse de démarrage et des performances sur Android + - Réduction importante de la taille de l'application à télécharger et une fois installée + - _Notes de développement :_ + - Migration de Expo Managed Workflow à React Native Bare Workflow + + - **v2.0.0** - _12/03/2020_ + - Nouvelle interface ! + - Amélioration des performances + - Amélioration de la vitesse de démarrage + - _Notes de développement :_ + - Utilisation de react-native-paper à la place de native base + + - **v1.5.2** - _25/02/2020_ + - Correction d'un problème d'affichage des détail du Proximo + + - **v1.5.1** - _24/02/2020_ + - Amélioration des performances + - Utilisation d'un tri des catégories du Proximo plus cohérent + - **v1.5.0** - _05/02/2020_ - Amélioration des performances de l'application - Amélioration du menu gauche diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..bb9526a --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,123 @@ +# Installer l'application depuis ce dépot + +**Vous allez devoir installer git, node et npm sur votre machine, puis cloner ce dépôt.** + +Tout est expliqué dans ce guide, si vous avez un problème ou une question, merci de me contacter par mail : app@amicale-insat.fr + +## Table des matières +* [Installation de Git](#installation-de-git) +* [Installation de node](#installation-de-node) +* [Installation de React Native](#installation-de-react-native) + * [Configuration de NPM](#configuration-de-npm) + * [Installation](#installation) +* [Téléchargement du dépot](#téléchargement-du-dépot) +* [Téléchargement des dépendances](#téléchargement-des-dépendances) +* [Lancement de l'appli](#lancement-de-lappli) +* [Tester sur un appareil](#tester-sur-un-appareil) + +## Installation de Git + +Entrez la commande suivante pour l'installer : +```shell script +sudo apt install git +``` + +## Installation de node + +Vous devez avoir une version de node > 12.0. +Pour cela, vérifiez avec la commande : +```shell script +nodejs -v +``` + +Si ce n'est pas le cas, entrez les commandes suivantes pour installer la version 12 ([plus d'informations sur ce lien](https://github.com/nodesource/distributions/blob/master/README.md#debinstall)): + +```shell script +curl -sL https://deb.nodesource.com/setup_12.x | sudo -E bash - +sudo apt-get install -y nodejs +``` + +## Installation de React Native + +Merci de suivre les [instructions d'installation](https://reactnative.dev/docs/environment-setup) sur le site officiel. + +## Téléchargement du dépôt + +Clonez ce dépôt à l'aide de la commande suivante : +````shell script +git clone https://git.etud.insa-toulouse.fr/vergnet/application-amicale.git +```` + +## Téléchargement des dépendances + +Une fois le dépôt sur votre machine, ouvrez le terminal dans le dossier du dépôt cloné et tapez : +````shell script +npm install +```` +Ceci installera toutes les dépendances listées dans le fichier _package.json_. Cette opération peut prendre quelques minutes et utilisera beaucoup d'espace disque (plus de 300Mo). + +### Instructions pour iOS + +Pour iOS, en plus de la commande précédente, il faut aussi installer les dépendances iOS. Pour cela, allez dans le dossier `ios` et installez les pods : +```shell script +cd ios && pod install +``` + +## Lancement de l'appli + +Il est conseillé d'utiliser un logiciel comme **WebStorm** (logiciel pro gratuit pour les étudiants) pour éditer l'application car ce logiciel est compatible avec les technologies utilisées. + +Vous aurez besoin de 2 consoles : +* Une pour lancer le *Bundler*, qui permet de mettre à jour l'application en temps réel (vous pouvez le laisser tout le temps ouvert). +* Une autre pour installer l'application sur votre appareil/simulateur. + +Pour lancer le *Bundler*, assurez vous d'être dans le dossier de l'application, et lancez cette commande : +````shell script +npx react-native start +```` + +### Android +Dans la deuxième console, lancez la commande suivante : +````shell script +npx react-native run-android +```` + +### iOS +Dans la deuxième console, lancez la commande suivante (valable que sur Mac) : +````shell script +npx react-native run-ios +```` + +**Ne stoppez pas le Metro Bundler dans la console à chaque changement !** Toutes les modifications sont appliquées automatiquement, pas besoin de stopper et de redémarrer pour des petits changements ! Il est seulement nécessaire de redémarrer le Metro Bundler quand vous changez des librairies ou des fichiers. + +## Tester sur un appareil + +Assurez vous d'avoir installé et lancé le projet comme expliqué plus haut. + +### Android + +#### Émulateur + +[Suivez la procédure sur ce lien pour installer un émulateur](https://docs.expo.io/versions/latest/workflow/android-studio-emulator/). + +Une fois l'emulateur installé et démarré, lancez l'application comme expliqué plus haut. + +#### Appareil Physique + +Branchez votre appareil, allez dans les options développeurs et activer le *USB Debugging*. Une fois qu'il est activé et branché, lancez l'appli comme expliqué plus haut. + +### iOS + +#### Émulateur + +Installez le logiciel Xcode et téléchargez l'émulateur de votre choix. Ensuite, lancez la commande suivante pour lancer l'application sur votre émulateur. +````shell script +npx react-native run-ios --simulator="NOM DU SIMULATEUR" +```` +En remplaçant `NOM DU SIMULATEUR` par le simulateur que vous voulez. + +#### Appareil Physique + +Aucune idée je suis pauvre je n'ai pas de Mac. + +[reference]: ##Installation de Git diff --git a/README.md b/README.md index 3304eb2..d9e601b 100644 --- a/README.md +++ b/README.md @@ -10,85 +10,29 @@ Créée pendant l'été 2019, cette application compatible Android et iOS permet - Disponibilité des salles libre accès - Réservation des Bib'Box -Ce dépot contient la source de cette application, modifiable par les étudiants de l'INSA Toulouse, sous licence GPLv3. +Ce dépot contient la source de cette application, sous licence GPLv3. ## Contribuer Vous voulez influencer le développement ? C'est très simple ! -Pas besoin de connaissance, il est possible d'aider simplement en proposant des améliorations ou en rapportant des bugs par mail (vergnet@etud.insa-toulouse.fr) ou sur [cette page](https://git.etud.insa-toulouse.fr/vergnet/application-amicale/issues), en vous connectant avec vos login INSA. +Pas besoin de connaissance, il est possible d'aider simplement en proposant des améliorations ou en rapportant des bugs par mail ([app@amicale-insat.fr](mailto:app@amicale-insat.fr)) ou sur [cette page](https://git.etud.insa-toulouse.fr/vergnet/application-amicale/issues), en vous connectant avec vos login INSA. -Si vous avez assez de connaissances et vous souhaitez proposer des modification dans le code, installez l'application sur votre machine, réalisez votre modification et créez une 'pull request'. +Si vous avez assez de connaissances et vous souhaitez proposer des modifications dans le code, [installez l'application](INSTALL.md) sur votre machine, réalisez votre modification et créez une 'pull request'. Si vous avez des problèmes ou des questions, n'hésitez pas à me contacter par mail ([app@amicale-insat.fr](mailto:app@amicale-insat.fr)). ## Technologies Utilisées -Cette application est faite en JavaScript avec React Native (framework Open Source créé par Facebook), combinée avec Expo. +Cette application est faite en JavaScript avec React Native (framework Open Source créé par Facebook). -Cette combinaison permet de n'avoir qu'un seul code JavaScript à écrire pour Android et iOS. Pour compiler pour la plateforme souhaitée, il suffit d'effectuer une commande, qui envoie le code sur les serveurs d'Expo pour compilation (voir section Installer). Plus besoin de Mac pour développer une application iOS ! (Mais toujours besoin d'un pour publier sur l'App store...) +React Native permet de n'avoir qu'un seul code JavaScript à écrire pour Android et iOS. Pour compiler pour la plateforme souhaitée, il suffit d'effectuer une simple commande. Plus besoin de Mac pour développer une application iOS ! (Mais toujours besoin d'un pour compiler et publier sur l'App store...) +Cette application utilisait initialement Expo, permettant de simplifier grandement le développement et le déploiement, mais il a été abandonné à cause de ses limitations et de son impact sur les performances. Revenir sur Expo n'est pas possible sans un gros travail et une suppression de fonctionnalités non compatibles. -## Installer l'application depuis ce dépot +## [Installer l'application depuis ce dépot](INSTALL.md) -**Avant de commencer, installez git, node et npm sur votre machine, puis clonez ce dépot.** - -### Téléchargement du dépot et des dépendances - -Il est conseillé d'utiliser un logiciel comme **PHPStorm** (logiciel pro gratuit pour les étudiants) pour éditer l'application car ce logiciel est compatible avec les technologies utilisées. - -Une fois le dépot sur votre machine, ouvrez le projet dans PHPStorm, ouvrez le terminal et tapez `npm install`. Ceci installera toutes les dépendances listées dans le fichier _package.json_. Cette opération peut prendre quelques minutes et utilisera beaucoup d'espace disque (plus de 300Mo). - -### Lancement de l'appli - -#### En console - -Ouvrez simplement une console dans le répertoire du projet et tapez : - -`expo start` - -Cette commande va démarrer le Metro Bundler permettant de lancer l'appli. Attendez quelques instants, quand un QR code apparait, l'application est prête à être lancée sur votre téléphone. - -**Ne stoppez pas le Metro Bundler dans la console a chaque changement !** Toutes les modifications sont appliquées automatiquement, pas besoin de stopper et de redémarrer pour des petits changements ! Il est seulement nécessaire de redémarrer le Metro Bundler quand vous changez des librairies ou des fichiers. - -#### Directement avec PHPStorm - -Si vous n'aimez pas la console et voulez utiliser le merveilleux bouton play de PHPStorm, il faut le paramétrer. Nous utilisons ici expo, il faut donc dire à PHPStorm de lancer une commande expo quand nous cliquons sur le bouton play. - -Pour cela, cliquez sur **Edit Configurations** en haut à droite, dans la nouvelle fenêtre, cliquez sur **+**, et choisissez **React Native**. - -Donnez un petit nom à cette configuration, décochez **Build and launch application** (nous utilisons expo pour ça, pas react native), mettez `127.0.0.1` dans le champ **Bundler Host**, et `19001` dans **Bundler Port**. - -Ensuite, dans **Before Launch**; cliquez sur **+** pour ajouter une nouvelle configuration, et choisissez **Start React Native Bundler** si il n'est pas déjà présent. Une fois ajouté, cliquez dessus, puis sur le bouton éditer (une icone de crayon). Dans la nouvelle fenetre, choisissez **npm script** dans le champ **Command** et **start** dans **Script**. Vérifiez que vous utilisez bien l'interpreteur Node associé au projet (pour utiliser les bonnes dépendances installées précédement), et cliquez sur OK. - -[Plus d'informations ici](https://www.jetbrains.com/help/phpstorm/react-native.html) - -Le projet est maintenant pret, quand vous cliquez sur run (ou shift+F10), le projet sera lancé (cela peut prendre plusieurs minutes). -Quand un QR code apparait, vous pouvez tester sur un appareil. - -**Ne stoppez pas le Metro Bundler dans la console a chaque changement !** Toutes les modifications sont appliquées automatiquement, pas besoin de stopper et de redémarrer pour des petits changements ! Il est seulement nécessaire de redémarrer le Metro Bundler quand vous changez des librairies ou des fichiers. - -### Tester sur un appareil - -Assurez vous d'avoir installé et lancé le projet comme expliqué plus haut. - -#### Émulateur android - -[Suivez la procédure sur ce lien pour installer un émulateur](https://docs.expo.io/versions/latest/workflow/android-studio-emulator/). - -Une fois l'emulateur installé et démarré, lancez le projet, puis appuyez sur la touche **a** dans la console, cela lancera l'aplication dans l'émulateur. - -#### Appareil Physique - -Installez l'application **Expo** sur votre appareil (android ou iOS), assurez vous d'avoir démarré le projet et d'avoir votre machine de développement et le téléphone sur le même réseau wifi (non publique). Ouvrez l'application expo, Votre projet devrait apparaitre dans la liste. Cliquez dessus et c'est bon ! - -Si vous utilisez le réseau Wifirst des résidences INSA (ou tout autre wifi publique), il y a une méthode très simple pour créer un réseau privé entre votre PC et votre téléphone (en tout cas avec un téléphone android). Connectez votre téléphone en Wifi au réseau, puis connectez le en USB à votre PC. Une fois connecté, allez dans les paramètres et activez le "USB Tethering". Votre PC est maintenant connecté en réseau filaire à votre téléphone, qui lui est connecté à Internet par la wifi. Si vous voulez connecter d'autres appareils, il suffit de créer un Hotspot sur votre PC et de connecter vos autres appareils à ce Hotspot. Profitez de votre réseau privé dans votre Promolo ! - -## Compilation - -Avant de compiler, créez vous un compte Expo. Ensuite, lancez le Metro Bundler et connectez vous a votre compte dans la console (les touches sont indiquées). - -Pour compiler sur android, vous avez deux solutions: - - Vous voulez générer un `.apk` pour pour l'installer sur votre téléphone, lancez cette commande dans un terminal dans le projet : `expo build:android`. Cette commande va générer les paquets nécessaires à Expo et les envoyer sur leurs serveurs. Ne touchez à rien pendant la création des paquets (cela peut prendre une à deux minutes). Une fois que vous voyez écrit `Build in progress...`, vous pouvez fermer votre console : les serveurs ont pris la main et vous avez un lien pour analyser la progression. Ce processus dure en général 8 minutes. Si vous ne fermez pas la console, vous aurez un lien direct pour télécharger le fichier `.apk`, sinon connectez vous sur votre compte Expo, rubrique Builds pour le télécharger. - - - Vous voulez compiler pour ensuite publier sur le Play Store, lancez cette commande dans un terminal dans le projet : `expo build:android -t app-bundle`. Cette commande fait exactement la même chose que la précédente à une chose près. Vous obtiendre un fichier `.aab`, qui est un format optimisé pour le Play Store. Ce fichier est plus volumineux mais permet au Play Store de générer les apk les plus optimisés possible pour différentes architectures de téléphone. - - -Pou compiler sur iOS, vous aurez besoin du compte développeur de l'amicale car un tel compte est payant. +## Liens utiles +* [Documentation React Native](https://reactnative.dev/docs/getting-started) +* [Documentation Expo](https://docs.expo.io/versions/latest/) +* [Documentation React Native Paper](https://callstack.github.io/react-native-paper/) +* [Documentation React navigation](https://reactnavigation.org/docs/getting-started) +* [Documentation Jest](https://jestjs.io/docs/en/getting-started) +* [Documentation Flow](https://flow.org/en/docs/react/) diff --git a/__mocks__/react-native-keychain/index.js b/__mocks__/react-native-keychain/index.js new file mode 100644 index 0000000..3d30acb --- /dev/null +++ b/__mocks__/react-native-keychain/index.js @@ -0,0 +1,7 @@ +const keychainMock = { + SECURITY_LEVEL_ANY: "MOCK_SECURITY_LEVEL_ANY", + SECURITY_LEVEL_SECURE_SOFTWARE: "MOCK_SECURITY_LEVEL_SECURE_SOFTWARE", + SECURITY_LEVEL_SECURE_HARDWARE: "MOCK_SECURITY_LEVEL_SECURE_HARDWARE", +} + +export default keychainMock; \ No newline at end of file diff --git a/__tests__/managers/ConnectionManager.test.js b/__tests__/managers/ConnectionManager.test.js new file mode 100644 index 0000000..e307fd5 --- /dev/null +++ b/__tests__/managers/ConnectionManager.test.js @@ -0,0 +1,210 @@ +jest.mock('react-native-keychain'); + +import React from 'react'; +import ConnectionManager from "../../src/managers/ConnectionManager"; +import {ERROR_TYPE} from "../../src/utils/WebData"; + +let fetch = require('isomorphic-fetch'); // fetch is not implemented in nodeJS but in react-native + +const c = ConnectionManager.getInstance(); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +test('isLoggedIn yes', () => { + jest.spyOn(ConnectionManager.prototype, 'getToken').mockImplementationOnce(() => { + return 'token'; + }); + return expect(c.isLoggedIn()).toBe(true); +}); + +test('isLoggedIn no', () => { + jest.spyOn(ConnectionManager.prototype, 'getToken').mockImplementationOnce(() => { + return null; + }); + return expect(c.isLoggedIn()).toBe(false); +}); + +test("isConnectionResponseValid", () => { + let json = { + error: 0, + data: {token: 'token'} + }; + expect(c.isConnectionResponseValid(json)).toBeTrue(); + json = { + error: 2, + data: {} + }; + expect(c.isConnectionResponseValid(json)).toBeTrue(); + json = { + error: 0, + data: {token: ''} + }; + expect(c.isConnectionResponseValid(json)).toBeFalse(); + json = { + error: 'prout', + data: {token: ''} + }; + expect(c.isConnectionResponseValid(json)).toBeFalse(); +}); + +test("connect bad credentials", () => { + jest.spyOn(global, 'fetch').mockImplementationOnce(() => { + return Promise.resolve({ + json: () => { + return { + error: ERROR_TYPE.BAD_CREDENTIALS, + data: {} + }; + }, + }) + }); + return expect(c.connect('email', 'password')) + .rejects.toBe(ERROR_TYPE.BAD_CREDENTIALS); +}); + +test("connect good credentials", () => { + jest.spyOn(global, 'fetch').mockImplementationOnce(() => { + return Promise.resolve({ + json: () => { + return { + error: ERROR_TYPE.SUCCESS, + data: {token: 'token'} + }; + }, + }) + }); + jest.spyOn(ConnectionManager.prototype, 'saveLogin').mockImplementationOnce(() => { + return Promise.resolve(true); + }); + return expect(c.connect('email', 'password')).resolves.toBeTruthy(); +}); + +test("connect good credentials no consent", () => { + jest.spyOn(global, 'fetch').mockImplementationOnce(() => { + return Promise.resolve({ + json: () => { + return { + error: ERROR_TYPE.NO_CONSENT, + data: {} + }; + }, + }) + }); + return expect(c.connect('email', 'password')) + .rejects.toBe(ERROR_TYPE.NO_CONSENT); +}); + +test("connect good credentials, fail save token", () => { + jest.spyOn(global, 'fetch').mockImplementationOnce(() => { + return Promise.resolve({ + json: () => { + return { + error: ERROR_TYPE.SUCCESS, + data: {token: 'token'} + }; + }, + }) + }); + jest.spyOn(ConnectionManager.prototype, 'saveLogin').mockImplementationOnce(() => { + return Promise.reject(false); + }); + return expect(c.connect('email', 'password')).rejects.toBe(ERROR_TYPE.UNKNOWN); +}); + +test("connect connection error", () => { + jest.spyOn(global, 'fetch').mockImplementationOnce(() => { + return Promise.reject(); + }); + return expect(c.connect('email', 'password')) + .rejects.toBe(ERROR_TYPE.CONNECTION_ERROR); +}); + +test("connect bogus response 1", () => { + jest.spyOn(global, 'fetch').mockImplementationOnce(() => { + return Promise.resolve({ + json: () => { + return { + thing: true, + wrong: '', + } + }, + }) + }); + return expect(c.connect('email', 'password')) + .rejects.toBe(ERROR_TYPE.CONNECTION_ERROR); +}); + + +test("authenticatedRequest success", () => { + jest.spyOn(ConnectionManager.prototype, 'getToken').mockImplementationOnce(() => { + return 'token'; + }); + jest.spyOn(global, 'fetch').mockImplementationOnce(() => { + return Promise.resolve({ + json: () => { + return { + error: ERROR_TYPE.SUCCESS, + data: {coucou: 'toi'} + }; + }, + }) + }); + return expect(c.authenticatedRequest('https://www.amicale-insat.fr/api/token/check')) + .resolves.toStrictEqual({coucou: 'toi'}); +}); + +test("authenticatedRequest error wrong token", () => { + jest.spyOn(ConnectionManager.prototype, 'getToken').mockImplementationOnce(() => { + return 'token'; + }); + jest.spyOn(global, 'fetch').mockImplementationOnce(() => { + return Promise.resolve({ + json: () => { + return { + error: ERROR_TYPE.BAD_TOKEN, + data: {} + }; + }, + }) + }); + return expect(c.authenticatedRequest('https://www.amicale-insat.fr/api/token/check')) + .rejects.toBe(ERROR_TYPE.BAD_TOKEN); +}); + +test("authenticatedRequest error bogus response", () => { + jest.spyOn(ConnectionManager.prototype, 'getToken').mockImplementationOnce(() => { + return 'token'; + }); + jest.spyOn(global, 'fetch').mockImplementationOnce(() => { + return Promise.resolve({ + json: () => { + return { + error: ERROR_TYPE.SUCCESS, + }; + }, + }) + }); + return expect(c.authenticatedRequest('https://www.amicale-insat.fr/api/token/check')) + .rejects.toBe(ERROR_TYPE.CONNECTION_ERROR); +}); + +test("authenticatedRequest connection error", () => { + jest.spyOn(ConnectionManager.prototype, 'getToken').mockImplementationOnce(() => { + return 'token'; + }); + jest.spyOn(global, 'fetch').mockImplementationOnce(() => { + return Promise.reject() + }); + return expect(c.authenticatedRequest('https://www.amicale-insat.fr/api/token/check')) + .rejects.toBe(ERROR_TYPE.CONNECTION_ERROR); +}); + +test("authenticatedRequest error no token", () => { + jest.spyOn(ConnectionManager.prototype, 'getToken').mockImplementationOnce(() => { + return null; + }); + return expect(c.authenticatedRequest('https://www.amicale-insat.fr/api/token/check')) + .rejects.toBe(ERROR_TYPE.UNKNOWN); +}); diff --git a/__tests__/utils/PlanningEventManager.test.js b/__tests__/utils/PlanningEventManager.test.js new file mode 100644 index 0000000..21d972a --- /dev/null +++ b/__tests__/utils/PlanningEventManager.test.js @@ -0,0 +1,210 @@ +import React from 'react'; +import * as Planning from "../../src/utils/Planning"; + +test('isDescriptionEmpty', () => { + expect(Planning.isDescriptionEmpty("")).toBeTrue(); + expect(Planning.isDescriptionEmpty(" ")).toBeTrue(); + // noinspection CheckTagEmptyBody + expect(Planning.isDescriptionEmpty("

")).toBeTrue(); + expect(Planning.isDescriptionEmpty("

")).toBeTrue(); + expect(Planning.isDescriptionEmpty("


")).toBeTrue(); + expect(Planning.isDescriptionEmpty("



")).toBeTrue(); + expect(Planning.isDescriptionEmpty("




")).toBeTrue(); + expect(Planning.isDescriptionEmpty("


")).toBeTrue(); + expect(Planning.isDescriptionEmpty(null)).toBeTrue(); + expect(Planning.isDescriptionEmpty(undefined)).toBeTrue(); + expect(Planning.isDescriptionEmpty("coucou")).toBeFalse(); + expect(Planning.isDescriptionEmpty("

coucou

")).toBeFalse(); +}); + +test('isEventDateStringFormatValid', () => { + expect(Planning.isEventDateStringFormatValid("2020-03-21 09:00")).toBeTrue(); + expect(Planning.isEventDateStringFormatValid("3214-64-12 01:16")).toBeTrue(); + + expect(Planning.isEventDateStringFormatValid("3214-64-12 01:16:00")).toBeFalse(); + expect(Planning.isEventDateStringFormatValid("3214-64-12 1:16")).toBeFalse(); + expect(Planning.isEventDateStringFormatValid("3214-f4-12 01:16")).toBeFalse(); + expect(Planning.isEventDateStringFormatValid("sqdd 09:00")).toBeFalse(); + expect(Planning.isEventDateStringFormatValid("2020-03-21")).toBeFalse(); + expect(Planning.isEventDateStringFormatValid("2020-03-21 truc")).toBeFalse(); + expect(Planning.isEventDateStringFormatValid("3214-64-12 1:16:65")).toBeFalse(); + expect(Planning.isEventDateStringFormatValid("garbage")).toBeFalse(); + expect(Planning.isEventDateStringFormatValid("")).toBeFalse(); + expect(Planning.isEventDateStringFormatValid(undefined)).toBeFalse(); + expect(Planning.isEventDateStringFormatValid(null)).toBeFalse(); +}); + +test('stringToDate', () => { + let testDate = new Date(); + expect(Planning.stringToDate(undefined)).toBeNull(); + expect(Planning.stringToDate("")).toBeNull(); + expect(Planning.stringToDate("garbage")).toBeNull(); + expect(Planning.stringToDate("2020-03-21")).toBeNull(); + expect(Planning.stringToDate("09:00:00")).toBeNull(); + expect(Planning.stringToDate("2020-03-21 09:g0")).toBeNull(); + expect(Planning.stringToDate("2020-03-21 09:g0:")).toBeNull(); + testDate.setFullYear(2020, 2, 21); + testDate.setHours(9, 0, 0, 0); + expect(Planning.stringToDate("2020-03-21 09:00")).toEqual(testDate); + testDate.setFullYear(2020, 0, 31); + testDate.setHours(18, 30, 0, 0); + expect(Planning.stringToDate("2020-01-31 18:30")).toEqual(testDate); + testDate.setFullYear(2020, 50, 50); + testDate.setHours(65, 65, 0, 0); + expect(Planning.stringToDate("2020-51-50 65:65")).toEqual(testDate); +}); + +test('getFormattedEventTime', () => { + expect(Planning.getFormattedEventTime(null, null)) + .toBe('/ - /'); + expect(Planning.getFormattedEventTime(undefined, undefined)) + .toBe('/ - /'); + expect(Planning.getFormattedEventTime("20:30", "23:00")) + .toBe('/ - /'); + expect(Planning.getFormattedEventTime("2020-03-30", "2020-03-31")) + .toBe('/ - /'); + + + expect(Planning.getFormattedEventTime("2020-03-21 09:00", "2020-03-21 09:00")) + .toBe('09:00'); + expect(Planning.getFormattedEventTime("2020-03-21 09:00", "2020-03-22 17:00")) + .toBe('09:00 - 23:59'); + expect(Planning.getFormattedEventTime("2020-03-30 20:30", "2020-03-30 23:00")) + .toBe('20:30 - 23:00'); +}); + +test('getDateOnlyString', () => { + expect(Planning.getDateOnlyString("2020-03-21 09:00")).toBe("2020-03-21"); + expect(Planning.getDateOnlyString("2021-12-15 09:00")).toBe("2021-12-15"); + expect(Planning.getDateOnlyString("2021-12-o5 09:00")).toBeNull(); + expect(Planning.getDateOnlyString("2021-12-15 09:")).toBeNull(); + expect(Planning.getDateOnlyString("2021-12-15")).toBeNull(); + expect(Planning.getDateOnlyString("garbage")).toBeNull(); +}); + +test('isEventBefore', () => { + expect(Planning.isEventBefore( + "2020-03-21 09:00", "2020-03-21 10:00")).toBeTrue(); + expect(Planning.isEventBefore( + "2020-03-21 10:00", "2020-03-21 10:15")).toBeTrue(); + expect(Planning.isEventBefore( + "2020-03-21 10:15", "2021-03-21 10:15")).toBeTrue(); + expect(Planning.isEventBefore( + "2020-03-21 10:15", "2020-05-21 10:15")).toBeTrue(); + expect(Planning.isEventBefore( + "2020-03-21 10:15", "2020-03-30 10:15")).toBeTrue(); + + expect(Planning.isEventBefore( + "2020-03-21 10:00", "2020-03-21 10:00")).toBeFalse(); + expect(Planning.isEventBefore( + "2020-03-21 10:00", "2020-03-21 09:00")).toBeFalse(); + expect(Planning.isEventBefore( + "2020-03-21 10:15", "2020-03-21 10:00")).toBeFalse(); + expect(Planning.isEventBefore( + "2021-03-21 10:15", "2020-03-21 10:15")).toBeFalse(); + expect(Planning.isEventBefore( + "2020-05-21 10:15", "2020-03-21 10:15")).toBeFalse(); + expect(Planning.isEventBefore( + "2020-03-30 10:15", "2020-03-21 10:15")).toBeFalse(); + + expect(Planning.isEventBefore( + "garbage", "2020-03-21 10:15")).toBeFalse(); + expect(Planning.isEventBefore( + undefined, undefined)).toBeFalse(); +}); + +test('dateToString', () => { + let testDate = new Date(); + testDate.setFullYear(2020, 2, 21); + testDate.setHours(9, 0, 0, 0); + expect(Planning.dateToString(testDate)).toBe("2020-03-21 09:00"); + testDate.setFullYear(2021, 0, 12); + testDate.setHours(9, 10, 0, 0); + expect(Planning.dateToString(testDate)).toBe("2021-01-12 09:10"); + testDate.setFullYear(2022, 11, 31); + testDate.setHours(9, 10, 15, 0); + expect(Planning.dateToString(testDate)).toBe("2022-12-31 09:10"); +}); + +test('generateEmptyCalendar', () => { + jest.spyOn(Date, 'now') + .mockImplementation(() => + new Date('2020-01-14T00:00:00.000Z').getTime() + ); + let calendar = Planning.generateEmptyCalendar(1); + expect(calendar).toHaveProperty("2020-01-14"); + expect(calendar).toHaveProperty("2020-01-20"); + expect(calendar).toHaveProperty("2020-02-10"); + expect(Object.keys(calendar).length).toBe(32); + calendar = Planning.generateEmptyCalendar(3); + expect(calendar).toHaveProperty("2020-01-14"); + expect(calendar).toHaveProperty("2020-01-20"); + expect(calendar).toHaveProperty("2020-02-10"); + expect(calendar).toHaveProperty("2020-02-14"); + expect(calendar).toHaveProperty("2020-03-20"); + expect(calendar).toHaveProperty("2020-04-12"); + expect(Object.keys(calendar).length).toBe(92); +}); + +test('pushEventInOrder', () => { + let eventArray = []; + let event1 = {date_begin: "2020-01-14 09:15"}; + Planning.pushEventInOrder(eventArray, event1); + expect(eventArray.length).toBe(1); + expect(eventArray[0]).toBe(event1); + + let event2 = {date_begin: "2020-01-14 10:15"}; + Planning.pushEventInOrder(eventArray, event2); + expect(eventArray.length).toBe(2); + expect(eventArray[0]).toBe(event1); + expect(eventArray[1]).toBe(event2); + + let event3 = {date_begin: "2020-01-14 10:15", title: "garbage"}; + Planning.pushEventInOrder(eventArray, event3); + expect(eventArray.length).toBe(3); + expect(eventArray[0]).toBe(event1); + expect(eventArray[1]).toBe(event2); + expect(eventArray[2]).toBe(event3); + + let event4 = {date_begin: "2020-01-13 09:00"}; + Planning.pushEventInOrder(eventArray, event4); + expect(eventArray.length).toBe(4); + expect(eventArray[0]).toBe(event4); + expect(eventArray[1]).toBe(event1); + expect(eventArray[2]).toBe(event2); + expect(eventArray[3]).toBe(event3); +}); + +test('generateEventAgenda', () => { + jest.spyOn(Date, 'now') + .mockImplementation(() => + new Date('2020-01-14T00:00:00.000Z').getTime() + ); + let eventList = [ + {date_begin: "2020-01-14 09:15"}, + {date_begin: "2020-02-01 09:15"}, + {date_begin: "2020-01-15 09:15"}, + {date_begin: "2020-02-01 09:30"}, + {date_begin: "2020-02-01 08:30"}, + ]; + const calendar = Planning.generateEventAgenda(eventList, 2); + expect(calendar["2020-01-14"].length).toBe(1); + expect(calendar["2020-01-14"][0]).toBe(eventList[0]); + expect(calendar["2020-01-15"].length).toBe(1); + expect(calendar["2020-01-15"][0]).toBe(eventList[2]); + expect(calendar["2020-02-01"].length).toBe(3); + expect(calendar["2020-02-01"][0]).toBe(eventList[4]); + expect(calendar["2020-02-01"][1]).toBe(eventList[1]); + expect(calendar["2020-02-01"][2]).toBe(eventList[3]); +}); + +test('getCurrentDateString', () => { + jest.spyOn(Date, 'now') + .mockImplementation(() => { + let date = new Date(); + date.setFullYear(2020, 0, 14); + date.setHours(15, 30, 54, 65); + return date.getTime(); + }); + expect(Planning.getCurrentDateString()).toBe('2020-01-14 15:30'); +}); diff --git a/__tests__/utils/Proxiwash.test.js b/__tests__/utils/Proxiwash.test.js new file mode 100644 index 0000000..4aca8e7 --- /dev/null +++ b/__tests__/utils/Proxiwash.test.js @@ -0,0 +1,142 @@ +import React from 'react'; +import {getCleanedMachineWatched, getMachineEndDate, getMachineOfId, isMachineWatched} from "../../src/utils/Proxiwash"; + +test('getMachineEndDate', () => { + jest.spyOn(Date, 'now') + .mockImplementation(() => + new Date('2020-01-14T15:00:00.000Z').getTime() + ); + let expectDate = new Date('2020-01-14T15:00:00.000Z'); + expectDate.setHours(23); + expectDate.setMinutes(10); + expect(getMachineEndDate({endTime: "23:10"}).getTime()).toBe(expectDate.getTime()); + + expectDate.setHours(16); + expectDate.setMinutes(30); + expect(getMachineEndDate({endTime: "16:30"}).getTime()).toBe(expectDate.getTime()); + + expect(getMachineEndDate({endTime: "15:30"})).toBeNull(); + + expect(getMachineEndDate({endTime: "13:10"})).toBeNull(); + + jest.spyOn(Date, 'now') + .mockImplementation(() => + new Date('2020-01-14T23:00:00.000Z').getTime() + ); + expectDate = new Date('2020-01-14T23:00:00.000Z'); + expectDate.setHours(0); + expectDate.setMinutes(30); + expect(getMachineEndDate({endTime: "00:30"}).getTime()).toBe(expectDate.getTime()); +}); + +test('isMachineWatched', () => { + let machineList = [ + { + number: "0", + endTime: "23:30", + }, + { + number: "1", + endTime: "20:30", + }, + ]; + expect(isMachineWatched({number: "0", endTime: "23:30"}, machineList)).toBeTrue(); + expect(isMachineWatched({number: "1", endTime: "20:30"}, machineList)).toBeTrue(); + expect(isMachineWatched({number: "3", endTime: "20:30"}, machineList)).toBeFalse(); + expect(isMachineWatched({number: "1", endTime: "23:30"}, machineList)).toBeFalse(); +}); + +test('getMachineOfId', () => { + let machineList = [ + { + number: "0", + }, + { + number: "1", + }, + ]; + expect(getMachineOfId("0", machineList)).toStrictEqual({number: "0"}); + expect(getMachineOfId("1", machineList)).toStrictEqual({number: "1"}); + expect(getMachineOfId("3", machineList)).toBeNull(); +}); + +test('getCleanedMachineWatched', () => { + let machineList = [ + { + number: "0", + endTime: "23:30", + }, + { + number: "1", + endTime: "20:30", + }, + { + number: "2", + endTime: "", + }, + ]; + let watchList = [ + { + number: "0", + endTime: "23:30", + }, + { + number: "1", + endTime: "20:30", + }, + { + number: "2", + endTime: "", + }, + ]; + let cleanedList = watchList; + expect(getCleanedMachineWatched(watchList, machineList)).toStrictEqual(cleanedList); + + watchList = [ + { + number: "0", + endTime: "23:30", + }, + { + number: "1", + endTime: "20:30", + }, + { + number: "2", + endTime: "15:30", + }, + ]; + cleanedList = [ + { + number: "0", + endTime: "23:30", + }, + { + number: "1", + endTime: "20:30", + }, + ]; + expect(getCleanedMachineWatched(watchList, machineList)).toStrictEqual(cleanedList); + + watchList = [ + { + number: "0", + endTime: "23:30", + }, + { + number: "1", + endTime: "20:31", + }, + { + number: "3", + endTime: "15:30", + }, + ]; + cleanedList = [ + { + number: "0", + endTime: "23:30", + }, + ]; + expect(getCleanedMachineWatched(watchList, machineList)).toStrictEqual(cleanedList); +}); \ No newline at end of file diff --git a/__tests__/utils/WebData.js b/__tests__/utils/WebData.js new file mode 100644 index 0000000..9f8fb5b --- /dev/null +++ b/__tests__/utils/WebData.js @@ -0,0 +1,45 @@ +import React from 'react'; +import {isResponseValid} from "../../src/utils/WebData"; + +let fetch = require('isomorphic-fetch'); // fetch is not implemented in nodeJS but in react-native + +test('isRequestResponseValid', () => { + let json = { + error: 0, + data: {} + }; + expect(isResponseValid(json)).toBeTrue(); + json = { + error: 1, + data: {} + }; + expect(isResponseValid(json)).toBeTrue(); + json = { + error: 50, + data: {} + }; + expect(isResponseValid(json)).toBeTrue(); + json = { + error: 50, + data: {truc: 'machin'} + }; + expect(isResponseValid(json)).toBeTrue(); + json = { + message: 'coucou' + }; + expect(isResponseValid(json)).toBeFalse(); + json = { + error: 'coucou', + data: {truc: 'machin'} + }; + expect(isResponseValid(json)).toBeFalse(); + json = { + error: 0, + data: 'coucou' + }; + expect(isResponseValid(json)).toBeFalse(); + json = { + error: 0, + }; + expect(isResponseValid(json)).toBeFalse(); +}); diff --git a/android/app/BUCK b/android/app/BUCK new file mode 100644 index 0000000..d959762 --- /dev/null +++ b/android/app/BUCK @@ -0,0 +1,55 @@ +# To learn about Buck see [Docs](https://buckbuild.com/). +# To run your application with Buck: +# - install Buck +# - `npm start` - to start the packager +# - `cd android` +# - `keytool -genkey -v -keystore keystores/debug.keystore -storepass android -alias androiddebugkey -keypass android -dname "CN=Android Debug,O=Android,C=US"` +# - `./gradlew :app:copyDownloadableDepsToLibs` - make all Gradle compile dependencies available to Buck +# - `buck install -r android/app` - compile, install and run application +# + +load(":build_defs.bzl", "create_aar_targets", "create_jar_targets") + +lib_deps = [] + +create_aar_targets(glob(["libs/*.aar"])) + +create_jar_targets(glob(["libs/*.jar"])) + +android_library( + name = "all-libs", + exported_deps = lib_deps, +) + +android_library( + name = "app-code", + srcs = glob([ + "src/main/java/**/*.java", + ]), + deps = [ + ":all-libs", + ":build_config", + ":res", + ], +) + +android_build_config( + name = "build_config", + package = "fr.amicaleinsat.application", +) + +android_resource( + name = "res", + package = "fr.amicaleinsat.application", + res = "src/main/res", +) + +android_binary( + name = "app", + keystore = "//android/keystores:debug", + manifest = "src/main/AndroidManifest.xml", + package_type = "debug", + deps = [ + ":app-code", + ], +) diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..b8fa57f --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,215 @@ +apply plugin: "com.android.application" + +import com.android.build.OutputFile + +/** + * The react.gradle file registers a task for each build variant (e.g. bundleDebugJsAndAssets + * and bundleReleaseJsAndAssets). + * These basically call `react-native bundle` with the correct arguments during the Android build + * cycle. By default, bundleDebugJsAndAssets is skipped, as in debug/dev mode we prefer to load the + * bundle directly from the development server. Below you can see all the possible configurations + * and their defaults. If you decide to add a configuration block, make sure to add it before the + * `apply from: "../../node_modules/react-native/react.gradle"` line. + * + * project.ext.react = [ + * // the name of the generated asset file containing your JS bundle + * bundleAssetName: "index.android.bundle", + * + * // the entry file for bundle generation + * entryFile: "index.android.js", + * + * // https://facebook.github.io/react-native/docs/performance#enable-the-ram-format + * bundleCommand: "ram-bundle", + * + * // whether to bundle JS and assets in debug mode + * bundleInDebug: false, + * + * // whether to bundle JS and assets in release mode + * bundleInRelease: true, + * + * // whether to bundle JS and assets in another build variant (if configured). + * // See http://tools.android.com/tech-docs/new-build-system/user-guide#TOC-Build-Variants + * // The configuration property can be in the following formats + * // 'bundleIn${productFlavor}${buildType}' + * // 'bundleIn${buildType}' + * // bundleInFreeDebug: true, + * // bundleInPaidRelease: true, + * // bundleInBeta: true, + * + * // whether to disable dev mode in custom build variants (by default only disabled in release) + * // for example: to disable dev mode in the staging build type (if configured) + * devDisabledInStaging: true, + * // The configuration property can be in the following formats + * // 'devDisabledIn${productFlavor}${buildType}' + * // 'devDisabledIn${buildType}' + * + * // the root of your project, i.e. where "package.json" lives + * root: "../../", + * + * // where to put the JS bundle asset in debug mode + * jsBundleDirDebug: "$buildDir/intermediates/assets/debug", + * + * // where to put the JS bundle asset in release mode + * jsBundleDirRelease: "$buildDir/intermediates/assets/release", + * + * // where to put drawable resources / React Native assets, e.g. the ones you use via + * // require('./image.png')), in debug mode + * resourcesDirDebug: "$buildDir/intermediates/res/merged/debug", + * + * // where to put drawable resources / React Native assets, e.g. the ones you use via + * // require('./image.png')), in release mode + * resourcesDirRelease: "$buildDir/intermediates/res/merged/release", + * + * // by default the gradle tasks are skipped if none of the JS files or assets change; this means + * // that we don't look at files in android/ or ios/ to determine whether the tasks are up to + * // date; if you have any other folders that you want to ignore for performance reasons (gradle + * // indexes the entire tree), add them here. Alternatively, if you have JS files in android/ + * // for example, you might want to remove it from here. + * inputExcludes: ["android/**", "ios/**"], + * + * // override which node gets called and with what additional arguments + * nodeExecutableAndArgs: ["node"], + * + * // supply additional arguments to the packager + * extraPackagerArgs: [] + * ] + */ + +project.ext.react = [ + entryFile: "index.js", + enableHermes: false, +] + +apply from: "../../node_modules/react-native/react.gradle" + +project.ext.vectoricons = [ + iconFontNames: [ 'MaterialCommunityIcons.ttf'] // Name of the font files you want to copy +] +apply from: "../../node_modules/react-native-vector-icons/fonts.gradle" + +/** + * Set this to true to create two separate APKs instead of one: + * - An APK that only works on ARM devices + * - An APK that only works on x86 devices + * The advantage is the size of the APK is reduced by about 4MB. + * Upload all the APKs to the Play Store and people will download + * the correct one based on the CPU architecture of their device. + */ +def enableSeparateBuildPerCPUArchitecture = false + +/** + * Run Proguard to shrink the Java bytecode in release builds. + */ +def enableProguardInReleaseBuilds = false + +/** + * The preferred build flavor of JavaScriptCore. + * + * For example, to use the international variant, you can use: + * `def jscFlavor = 'org.webkit:android-jsc-intl:+'` + * + * The international variant includes ICU i18n library and necessary data + * allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that + * give correct results when using with locales other than en-US. Note that + * this variant is about 6MiB larger per architecture than default. + */ +def jscFlavor = 'org.webkit:android-jsc:+' + +/** + * Whether to enable the Hermes VM. + * + * This should be set on project.ext.react and mirrored here. If it is not set + * on project.ext.react, JavaScript will not be compiled to Hermes Bytecode + * and the benefits of using Hermes will therefore be sharply reduced. + */ +def enableHermes = project.ext.react.get("enableHermes", false); + +android { + compileSdkVersion rootProject.ext.compileSdkVersion + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + defaultConfig { + applicationId 'fr.amicaleinsat.application' + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 19 + versionName "3.0.2" + missingDimensionStrategy 'react-native-camera', 'general' + } + splits { + abi { + reset() + enable enableSeparateBuildPerCPUArchitecture + universalApk false // If true, also generate a universal APK + include "armeabi-v7a", "x86", "arm64-v8a", "x86_64" + } + } + signingConfigs { + debug { + storeFile file('debug.keystore') + storePassword 'android' + keyAlias 'androiddebugkey' + keyPassword 'android' + } + release { + if (project.hasProperty('MYAPP_UPLOAD_STORE_FILE')) { + storeFile file(MYAPP_UPLOAD_STORE_FILE) + storePassword MYAPP_UPLOAD_STORE_PASSWORD + keyAlias MYAPP_UPLOAD_KEY_ALIAS + keyPassword MYAPP_UPLOAD_KEY_PASSWORD + } + } + } + buildTypes { + debug { + signingConfig signingConfigs.debug + } + release { + // Caution! In production, you need to generate your own keystore file. + // see https://facebook.github.io/react-native/docs/signed-apk-android. + signingConfig signingConfigs.release + minifyEnabled enableProguardInReleaseBuilds + proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" + } + } + // applicationVariants are e.g. debug, release + applicationVariants.all { variant -> + variant.outputs.each { output -> + // For each separate APK per architecture, set a unique version code as described here: + // https://developer.android.com/studio/build/configure-apk-splits.html + def versionCodes = ["armeabi-v7a": 1, "x86": 2, "arm64-v8a": 3, "x86_64": 4] + def abi = output.getFilter(OutputFile.ABI) + if (abi != null) { // null for the universal-debug, universal-release variants + output.versionCodeOverride = + versionCodes.get(abi) * 1048576 + defaultConfig.versionCode + } + + } + } +} + +dependencies { + implementation fileTree(dir: "libs", include: ["*.jar"]) + implementation "com.facebook.react:react-native:+" // From node_modules + + if (enableHermes) { + def hermesPath = "../../node_modules/hermes-engine/android/"; + debugImplementation files(hermesPath + "hermes-debug.aar") + releaseImplementation files(hermesPath + "hermes-release.aar") + } else { + implementation jscFlavor + } +} + +// Run this once to be able to run the application with BUCK +// puts all compile dependencies into folder libs for BUCK to use +task copyDownloadableDepsToLibs(type: Copy) { + from configurations.compile + into 'libs' +} + +apply from: file("../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project) diff --git a/android/app/build_defs.bzl b/android/app/build_defs.bzl new file mode 100644 index 0000000..fff270f --- /dev/null +++ b/android/app/build_defs.bzl @@ -0,0 +1,19 @@ +"""Helper definitions to glob .aar and .jar targets""" + +def create_aar_targets(aarfiles): + for aarfile in aarfiles: + name = "aars__" + aarfile[aarfile.rindex("/") + 1:aarfile.rindex(".aar")] + lib_deps.append(":" + name) + android_prebuilt_aar( + name = name, + aar = aarfile, + ) + +def create_jar_targets(jarfiles): + for jarfile in jarfiles: + name = "jars__" + jarfile[jarfile.rindex("/") + 1:jarfile.rindex(".jar")] + lib_deps.append(":" + name) + prebuilt_jar( + name = name, + binary_jar = jarfile, + ) diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..f8690bc --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,13 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +-keep class com.facebook.hermes.unicode.** { *; } +-keep class com.facebook.jni.** { *; } \ No newline at end of file diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..99e38fc --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..cff76b4 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/assets/app.bundle b/android/app/src/main/assets/app.bundle new file mode 100644 index 0000000..e69de29 diff --git a/android/app/src/main/assets/app.manifest b/android/app/src/main/assets/app.manifest new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/android/app/src/main/assets/app.manifest @@ -0,0 +1 @@ +{} diff --git a/android/app/src/main/java/fr/amicaleinsat/application/MainActivity.java b/android/app/src/main/java/fr/amicaleinsat/application/MainActivity.java new file mode 100644 index 0000000..59aaa6f --- /dev/null +++ b/android/app/src/main/java/fr/amicaleinsat/application/MainActivity.java @@ -0,0 +1,48 @@ +package fr.amicaleinsat.application; + +import android.os.Bundle; +import com.facebook.react.ReactActivity; +import com.facebook.react.ReactActivityDelegate; +import com.facebook.react.ReactRootView; +import com.swmansion.gesturehandler.react.RNGestureHandlerEnabledRootView; +import android.content.Intent; +import android.content.res.Configuration; + +import org.devio.rn.splashscreen.SplashScreen; + +public class MainActivity extends ReactActivity { + + // Added automatically by Expo Config + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + Intent intent = new Intent("onConfigurationChanged"); + intent.putExtra("newConfig", newConfig); + sendBroadcast(intent); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + SplashScreen.show(this); + super.onCreate(savedInstanceState); + } + + /** + * Returns the name of the main component registered from JavaScript. + * This is used to schedule rendering of the component. + */ + @Override + protected String getMainComponentName() { + return "main"; + } + + @Override + protected ReactActivityDelegate createReactActivityDelegate() { + return new ReactActivityDelegate(this, getMainComponentName()) { + @Override + protected ReactRootView createRootView() { + return new RNGestureHandlerEnabledRootView(MainActivity.this); + } + }; + } +} diff --git a/android/app/src/main/java/fr/amicaleinsat/application/MainApplication.java b/android/app/src/main/java/fr/amicaleinsat/application/MainApplication.java new file mode 100644 index 0000000..01abb07 --- /dev/null +++ b/android/app/src/main/java/fr/amicaleinsat/application/MainApplication.java @@ -0,0 +1,75 @@ +package fr.amicaleinsat.application; + +import android.app.Application; +import android.content.Context; + +import com.facebook.react.PackageList; +import com.facebook.react.ReactApplication; +import com.facebook.react.ReactNativeHost; +import com.facebook.react.ReactPackage; +import com.facebook.react.shell.MainReactPackage; +import com.facebook.soloader.SoLoader; + +import java.lang.reflect.InvocationTargetException; +import java.util.Arrays; +import java.util.List; +import javax.annotation.Nullable; + +public class MainApplication extends Application implements ReactApplication { + + private final ReactNativeHost mReactNativeHost = new ReactNativeHost(this) { + @Override + public boolean getUseDeveloperSupport() { + return BuildConfig.DEBUG; + } + + @Override + protected List getPackages() { + List packages = new PackageList(this).getPackages(); + return packages; + } + + @Override + protected String getJSMainModuleName() { + return "index"; + } + }; + + @Override + public ReactNativeHost getReactNativeHost() { + return mReactNativeHost; + } + + @Override + public void onCreate() { + super.onCreate(); + SoLoader.init(this, /* native exopackage */ false); + initializeFlipper(this); // Remove this line if you don't want Flipper enabled + } + + /** + * Loads Flipper in React Native templates. + * + * @param context + */ + private static void initializeFlipper(Context context) { + if (BuildConfig.DEBUG) { + try { + /* + We use reflection here to pick up the class that initializes Flipper, + since Flipper library is not available in release mode + */ + Class aClass = Class.forName("com.facebook.flipper.ReactNativeFlipper"); + aClass.getMethod("initializeFlipper", Context.class).invoke(null, context); + } catch (ClassNotFoundException e) { + e.printStackTrace(); + } catch (NoSuchMethodException e) { + e.printStackTrace(); + } catch (IllegalAccessException e) { + e.printStackTrace(); + } catch (InvocationTargetException e) { + e.printStackTrace(); + } + } + } +} diff --git a/android/app/src/main/res/drawable-xxhdpi/launch_screen.png b/android/app/src/main/res/drawable-xxhdpi/launch_screen.png new file mode 100644 index 0000000..7629d1a Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/launch_screen.png differ diff --git a/android/app/src/main/res/layout/launch_screen.xml b/android/app/src/main/res/layout/launch_screen.xml new file mode 100644 index 0000000..cf02f24 --- /dev/null +++ b/android/app/src/main/res/layout/launch_screen.xml @@ -0,0 +1,12 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..0db7e83 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..4879934 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..052303d Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..ab81379 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..f9a1257 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..5a781b4 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..04aa9e9 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..d35adb9 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..007aba2 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..88ddc36 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..918724f --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,8 @@ + + + #be1522 + #121212 + #be1522 + #be1522 + #be1522 + \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..70c3a6c --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Campus + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..d577698 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/android/app/src/release/AndroidManifest.xml b/android/app/src/release/AndroidManifest.xml new file mode 100644 index 0000000..0ea1411 --- /dev/null +++ b/android/app/src/release/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..8a8c05e --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,41 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + ext { + buildToolsVersion = "28.0.3" + minSdkVersion = 21 + compileSdkVersion = 28 + targetSdkVersion = 28 + } + repositories { + google() + jcenter() + } + dependencies { + classpath("com.android.tools.build:gradle:3.5.3") + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +allprojects { + repositories { + mavenLocal() + maven { + // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm + url("$rootDir/../node_modules/react-native/android") + } + maven { + // Android JSC is installed from npm + url("$rootDir/../node_modules/jsc-android/dist") + } + maven { + // expo-camera bundles a custom com.google.android:cameraview + url "$rootDir/../node_modules/expo-camera/android/maven" + } + google() + jcenter() + maven { url 'https://jitpack.io' } + } +} diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..2c6137b Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..3a54a33 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.3-all.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..b0d6d0a --- /dev/null +++ b/android/gradlew @@ -0,0 +1,188 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..9991c50 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,100 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem http://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..634b331 --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,6 @@ +rootProject.name = 'Campus' + +apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); +applyNativeModulesSettingsGradle(settings) + +include ':app' diff --git a/app.json b/app.json index 31a7e78..af893bb 100644 --- a/app.json +++ b/app.json @@ -1,51 +1,4 @@ { - "expo": { - "name": "Campus", - "description": "Application mobile compatible Android et iOS pour l'Amicale INSA Toulouse. Grâce à cette application, vous avez facilement accès aux news du campus, aux emplois du temps, à l'état de la laverie, et bien d'autres services ! Ceci est une version Beta, Toutes les fonctionnalités ne sont pas encore implémentées, et il est possible de rencontrer quelques bugs.", - "slug": "application-amicale", - "privacy": "public", - "sdkVersion": "36.0.0", - "platforms": [ - "ios", - "android", - "web" - ], - "version": "2.0.0", - "orientation": "portrait", - "primaryColor": "#be1522", - "userInterfaceStyle": "automatic", - "icon": "./assets/android.icon.png", - "splash": { - "backgroundColor": "#be1522", - "resizeMode": "contain", - "image": "./assets/splash.png" - }, - "notification": { - "icon": "./assets/icon-notification.png", - "color": "#be1522", - "androidMode": "default" - }, - "updates": { - "enabled": false - }, - "assetBundlePatterns": [ - "**/*" - ], - "ios": { - "bundleIdentifier": "fr.amicaleinsat.application", - "icon": "./assets/ios.icon.png" - }, - "android": { - "package": "fr.amicaleinsat.application", - "versionCode": 16, - "icon": "./assets/android.icon.png", - "adaptiveIcon": { - "foregroundImage": "./assets/android.adaptive-icon.png", - "backgroundColor": "#be1522" - }, - "permissions": [ - "VIBRATE" - ] - } - } -} + "name": "Campus", + "displayName": "Campus" +} \ No newline at end of file diff --git a/assets/amicale.png b/assets/amicale.png index 696e801..9bc64b5 100644 Binary files a/assets/amicale.png and b/assets/amicale.png differ diff --git a/assets/android.adaptive-icon.png b/assets/android.adaptive-icon.png deleted file mode 100644 index 75752b2..0000000 Binary files a/assets/android.adaptive-icon.png and /dev/null differ diff --git a/assets/drawer-cover.png b/assets/drawer-cover.png deleted file mode 100644 index 70a4ffd..0000000 Binary files a/assets/drawer-cover.png and /dev/null differ diff --git a/assets/ios.icon.png b/assets/ios.icon.png deleted file mode 100644 index 5266412..0000000 Binary files a/assets/ios.icon.png and /dev/null differ diff --git a/assets/proximo-logo.png b/assets/proximo-logo.png deleted file mode 100644 index 8a8c2bf..0000000 Binary files a/assets/proximo-logo.png and /dev/null differ diff --git a/assets/proxiwash-logo.png b/assets/proxiwash-logo.png deleted file mode 100644 index f769b7c..0000000 Binary files a/assets/proxiwash-logo.png and /dev/null differ diff --git a/assets/splash.png b/assets/splash.png index 6bad40e..7629d1a 100644 Binary files a/assets/splash.png and b/assets/splash.png differ diff --git a/assets/tab-icon-outline.png b/assets/tab-icon-outline.png new file mode 100644 index 0000000..3f5db22 Binary files /dev/null and b/assets/tab-icon-outline.png differ diff --git a/assets/tab-icon.png b/assets/tab-icon.png new file mode 100644 index 0000000..8e127c8 Binary files /dev/null and b/assets/tab-icon.png differ diff --git a/babel.config.js b/babel.config.js index 5a71250..f842b77 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,11 +1,3 @@ -module.exports = function(api) { - api.cache(true); - return { - presets: ['babel-preset-expo'], - env: { - production: { - plugins: ['react-native-paper/babel'], - }, - }, - }; +module.exports = { + presets: ['module:metro-react-native-babel-preset'], }; diff --git a/clear-node-cache.sh b/clear-node-cache.sh index c9bd8af..63975cf 100755 --- a/clear-node-cache.sh +++ b/clear-node-cache.sh @@ -1 +1,18 @@ -rm -rf node_modules/ && rm -f package-lock.json && rm -f yarn.lock && npm cache verify && npm install && expo r -c +#!/bin/bash + +echo "Removing node_modules..." +rm -rf node_modules/ +echo -e "Done\n" + +echo "Removing locks..." +rm -f package-lock.json && rm -f yarn.lock +echo -e "Done\n" + +#echo "Verifying npm cache..." +#npm cache verify +#echo -e "Done\n" + +echo "Installing dependencies..." +npm install +echo -e "Done\n" + diff --git a/components/CustomAgenda.js b/components/CustomAgenda.js deleted file mode 100644 index ee2b61c..0000000 --- a/components/CustomAgenda.js +++ /dev/null @@ -1,40 +0,0 @@ -import * as React from 'react'; -import {withTheme} from 'react-native-paper'; -import {Agenda} from "react-native-calendars"; - -function CustomAgenda(props) { - const { colors } = props.theme; - return ( - - ); -} - -export default withTheme(CustomAgenda); diff --git a/components/EmptyWebSectionListItem.js b/components/EmptyWebSectionListItem.js deleted file mode 100644 index ecba4e0..0000000 --- a/components/EmptyWebSectionListItem.js +++ /dev/null @@ -1,41 +0,0 @@ -import * as React from 'react'; -import {ActivityIndicator, Subheading, withTheme} from 'react-native-paper'; -import {View} from "react-native"; -import {MaterialCommunityIcons} from "@expo/vector-icons"; - -function EmptyWebSectionListItem(props) { - const { colors } = props.theme; - return ( - - - {props.refreshing ? - - : - } - - - - {props.text} - - - ); -} - -export default withTheme(EmptyWebSectionListItem); diff --git a/components/EventDashboardItem.js b/components/EventDashboardItem.js deleted file mode 100644 index cfcd2ee..0000000 --- a/components/EventDashboardItem.js +++ /dev/null @@ -1,44 +0,0 @@ -// @flow - -import * as React from 'react'; -import {Avatar, Card, withTheme} from 'react-native-paper'; - -function EventDashBoardItem(props) { - const {colors} = props.theme; - const iconColor = props.isAvailable ? - colors.planningColor : - colors.textDisabled; - const textColor = props.isAvailable ? - colors.text : - colors.textDisabled; - return ( - - - - } - /> - - {props.children} - - - ); -} - -export default withTheme(EventDashBoardItem); diff --git a/components/FeedItem.js b/components/FeedItem.js deleted file mode 100644 index 58294a9..0000000 --- a/components/FeedItem.js +++ /dev/null @@ -1,48 +0,0 @@ -import * as React from 'react'; -import {Avatar, Button, Card, withTheme} from 'react-native-paper'; -import {TouchableOpacity, View} from "react-native"; -import Autolink from "react-native-autolink"; -import i18n from "i18n-js"; - -const ICON_AMICALE = require('../assets/amicale.png'); - -function getAvatar() { - return ( - - ); -} - -function FeedItem(props) { - const {colors} = props.theme; - return ( - - - {props.full_picture !== '' && props.full_picture !== undefined ? - - - : } - - {props.message !== undefined ? - : - } - - - - - - ); -} - -export default withTheme(FeedItem); diff --git a/components/HeaderButton.js b/components/HeaderButton.js deleted file mode 100644 index 663dd44..0000000 --- a/components/HeaderButton.js +++ /dev/null @@ -1,16 +0,0 @@ -import * as React from 'react'; -import {IconButton, withTheme} from 'react-native-paper'; - -function HeaderButton(props) { - const { colors } = props.theme; - return ( - - ); -} - -export default withTheme(HeaderButton); diff --git a/components/PreviewEventDashboardItem.js b/components/PreviewEventDashboardItem.js deleted file mode 100644 index 37b0845..0000000 --- a/components/PreviewEventDashboardItem.js +++ /dev/null @@ -1,66 +0,0 @@ -// @flow - -import * as React from 'react'; -import {View} from "react-native"; -import HTML from "react-native-render-html"; -import i18n from "i18n-js"; -import {Avatar, Button, Card, withTheme} from 'react-native-paper'; -import PlanningEventManager from "../utils/PlanningEventManager"; - - -function PreviewEventDashboardItem(props) { - const {colors} = props.theme; - const isEmpty = props.event === undefined ? true : PlanningEventManager.isDescriptionEmpty(props.event['description']); - if (props.event !== undefined && props.event !== null) { - const hasImage = props.event['logo'] !== '' && props.event['logo'] !== null; - const getImage = () => ; - return ( - - {hasImage ? - : - } - {!isEmpty ? - - " + props.event['description'] + ""} - tagsStyles={{ - p: {color: colors.text,}, - div: {color: colors.text}, - }}/> - - : null} - - - - - - ); - } else - return -} - -export default withTheme(PreviewEventDashboardItem); diff --git a/components/ProxiwashListItem.js b/components/ProxiwashListItem.js deleted file mode 100644 index e4d091c..0000000 --- a/components/ProxiwashListItem.js +++ /dev/null @@ -1,89 +0,0 @@ -import * as React from 'react'; -import {Avatar, Card, Text, withTheme} from 'react-native-paper'; -import {View} from "react-native"; -import ProxiwashConstants from "../constants/ProxiwashConstants"; - -function ProxiwashListItem(props) { - const {colors} = props.theme; - let stateColors = {}; - stateColors[ProxiwashConstants.machineStates.TERMINE] = colors.proxiwashFinishedColor; - stateColors[ProxiwashConstants.machineStates.DISPONIBLE] = colors.proxiwashReadyColor; - stateColors[ProxiwashConstants.machineStates["EN COURS"]] = colors.proxiwashRunningColor; - stateColors[ProxiwashConstants.machineStates.HS] = colors.proxiwashBrokenColor; - stateColors[ProxiwashConstants.machineStates.ERREUR] = colors.proxiwashErrorColor; - const icon = ( - props.isWatched ? - : - - ); - return ( - - {ProxiwashConstants.machineStates[props.state] === ProxiwashConstants.machineStates["EN COURS"] ? - : null - } - - - icon} - right={() => ( - - - - {props.statusText} - - - - - )} - /> - - ); -} - -export default withTheme(ProxiwashListItem); diff --git a/components/PureFlatList.js b/components/PureFlatList.js deleted file mode 100644 index 8389d5e..0000000 --- a/components/PureFlatList.js +++ /dev/null @@ -1,31 +0,0 @@ -import * as React from 'react'; -import {FlatList} from "react-native"; - -type Props = { - data: Array, - keyExtractor: Function, - renderItem: Function, - updateData: number, -} - -/** - * This is a pure component, meaning it will only update if a shallow comparison of state and props is different. - * To force the component to update, change the value of updateData. - */ -export default class PureFlatList extends React.PureComponent{ - - static defaultProps = { - updateData: null, - }; - - render() { - return ( - - ); - } -} diff --git a/components/Sidebar.js b/components/Sidebar.js deleted file mode 100644 index 4f8fb9f..0000000 --- a/components/Sidebar.js +++ /dev/null @@ -1,190 +0,0 @@ -// @flow - -import * as React from 'react'; -import {Dimensions, FlatList, Image, Platform, StyleSheet, View} from 'react-native'; -import i18n from "i18n-js"; -import * as WebBrowser from 'expo-web-browser'; -import SidebarDivider from "./SidebarDivider"; -import SidebarItem from "./SidebarItem"; - -const deviceWidth = Dimensions.get("window").width; - -type Props = { - navigation: Object, - state: Object, -}; - -type State = { - active: string, -}; - -/** - * Class used to define a navigation drawer - */ -export default class SideBar extends React.PureComponent { - - dataSet: Array; - - state = { - active: 'Home', - }; - - getRenderItem: Function; - - /** - * Generate the datasets - * - * @param props - */ - constructor(props: Props) { - super(props); - // Dataset used to render the drawer - this.dataSet = [ - { - name: i18n.t('screens.home'), - route: "Main", - icon: "home", - }, - { - name: i18n.t('sidenav.divider2'), - route: "Divider2" - }, - { - name: i18n.t('screens.menuSelf'), - route: "SelfMenuScreen", - icon: "silverware-fork-knife", - }, - { - name: i18n.t('screens.availableRooms'), - route: "AvailableRoomScreen", - icon: "calendar-check", - }, - { - name: i18n.t('screens.bib'), - route: "BibScreen", - icon: "book", - }, - { - name: i18n.t('screens.bluemind'), - route: "BlueMindScreen", - link: "https://etud-mel.insa-toulouse.fr/webmail/", - icon: "email", - }, - { - name: i18n.t('screens.ent'), - route: "EntScreen", - link: "https://ent.insa-toulouse.fr/", - icon: "notebook", - }, - { - name: i18n.t('sidenav.divider1'), - route: "Divider1" - }, - { - name: "Amicale", - route: "AmicaleScreen", - link: "https://amicale-insat.fr/", - icon: "alpha-a-box", - }, - { - name: "Élus Étudiants", - route: "ElusEtudScreen", - link: "https://etud.insa-toulouse.fr/~eeinsat/", - icon: "alpha-e-box", - }, - { - name: "Wiketud", - route: "WiketudScreen", - link: "https://wiki.etud.insa-toulouse.fr", - icon: "wikipedia", - }, - { - name: "Tutor'INSA", - route: "TutorInsaScreen", - link: "https://www.etud.insa-toulouse.fr/~tutorinsa/", - icon: "school", - }, - { - name: i18n.t('sidenav.divider3'), - route: "Divider3" - }, - { - name: i18n.t('screens.settings'), - route: "SettingsScreen", - icon: "settings", - }, - { - name: i18n.t('screens.about'), - route: "AboutScreen", - icon: "information", - }, - ]; - this.getRenderItem = this.getRenderItem.bind(this); - } - - onListItemPress(item: Object) { - if (item.link === undefined) - this.props.navigation.navigate(item.route); - else - WebBrowser.openBrowserAsync(item.link); - } - - - listKeyExtractor(item: Object) { - return item.route; - } - - - getRenderItem({item}: Object) { - const onListItemPress = this.onListItemPress.bind(this, item); - if (item.icon !== undefined) { - return ( - - ); - } else { - return ( - - ); - } - - } - - render() { - return ( - - - - - ); - } -} - -const styles = StyleSheet.create({ - drawerCover: { - height: deviceWidth / 3, - width: 2 * deviceWidth / 3, - position: "relative", - marginBottom: 10, - marginTop: 20 - }, - text: { - fontWeight: Platform.OS === "ios" ? "500" : "400", - fontSize: 16, - marginLeft: 20 - }, - badgeText: { - fontSize: Platform.OS === "ios" ? 13 : 11, - fontWeight: "400", - textAlign: "center", - marginTop: Platform.OS === "android" ? -3 : undefined - } -}); diff --git a/components/SidebarDivider.js b/components/SidebarDivider.js deleted file mode 100644 index ec0927b..0000000 --- a/components/SidebarDivider.js +++ /dev/null @@ -1,23 +0,0 @@ -import * as React from 'react'; -import { withTheme } from 'react-native-paper'; -import {DrawerItem} from "@react-navigation/drawer"; - -function SidebarDivider(props) { - const { colors } = props.theme; - return ( - - ); -} - -export default withTheme(SidebarDivider); diff --git a/components/SidebarItem.js b/components/SidebarItem.js deleted file mode 100644 index 3781d29..0000000 --- a/components/SidebarItem.js +++ /dev/null @@ -1,28 +0,0 @@ -import * as React from 'react'; -import {withTheme} from 'react-native-paper'; -import {DrawerItem} from "@react-navigation/drawer"; -import {MaterialCommunityIcons} from "@expo/vector-icons"; - -function SidebarItem(props) { - const {colors} = props.theme; - return ( - - } - style={{ - marginLeft: 0, - marginRight: 0, - padding: 0, - borderRadius: 0, - }} - labelStyle={{ - color: colors.text, - }} - /> - ); -} - -export default withTheme(SidebarItem); diff --git a/components/SquareDashboardItem.js b/components/SquareDashboardItem.js deleted file mode 100644 index da23cfb..0000000 --- a/components/SquareDashboardItem.js +++ /dev/null @@ -1,33 +0,0 @@ -import * as React from 'react'; -import {Badge, IconButton, withTheme} from 'react-native-paper'; -import {View} from "react-native"; - -function SquareDashboardItem(props) { - const {colors} = props.theme; - return ( - - - { - props.badgeNumber > 0 ? - {props.badgeNumber} : null - } - - - ); -} - -export default withTheme(SquareDashboardItem); diff --git a/components/WebSectionList.js b/components/WebSectionList.js deleted file mode 100644 index 3e2c58d..0000000 --- a/components/WebSectionList.js +++ /dev/null @@ -1,228 +0,0 @@ -// @flow - -import * as React from 'react'; -import WebDataManager from "../utils/WebDataManager"; -import i18n from "i18n-js"; -import {Snackbar} from 'react-native-paper'; -import {RefreshControl, SectionList, View} from "react-native"; -import EmptyWebSectionListItem from "./EmptyWebSectionListItem"; - -type Props = { - navigation: Object, - fetchUrl: string, - autoRefreshTime: number, - refreshOnFocus: boolean, - renderItem: React.Node, - renderSectionHeader: React.Node, - stickyHeader: boolean, - createDataset: Function, - updateData: number, -} - -type State = { - refreshing: boolean, - firstLoading: boolean, - fetchedData: Object, - snackbarVisible: boolean -}; - - -const MIN_REFRESH_TIME = 5 * 1000; -/** - * This is a pure component, meaning it will only update if a shallow comparison of state and props is different. - * To force the component to update, change the value of updateData. - */ -export default class WebSectionList extends React.PureComponent { - - static defaultProps = { - renderSectionHeader: null, - stickyHeader: false, - updateData: null, - }; - - webDataManager: WebDataManager; - - refreshInterval: IntervalID; - lastRefresh: Date; - - state = { - refreshing: false, - firstLoading: true, - fetchedData: {}, - snackbarVisible: false - }; - - onRefresh: Function; - onFetchSuccess: Function; - onFetchError: Function; - getEmptyRenderItem: Function; - getEmptySectionHeader: Function; - showSnackBar: Function; - hideSnackBar: Function; - - constructor() { - super(); - // creating references to functions used in render() - this.onRefresh = this.onRefresh.bind(this); - this.onFetchSuccess = this.onFetchSuccess.bind(this); - this.onFetchError = this.onFetchError.bind(this); - this.getEmptyRenderItem = this.getEmptyRenderItem.bind(this); - this.getEmptySectionHeader = this.getEmptySectionHeader.bind(this); - this.showSnackBar = this.showSnackBar.bind(this); - this.hideSnackBar = this.hideSnackBar.bind(this); - } - - /** - * Register react navigation events on first screen load. - * Allows to detect when the screen is focused - */ - componentDidMount() { - this.webDataManager = new WebDataManager(this.props.fetchUrl); - const onScreenFocus = this.onScreenFocus.bind(this); - const onScreenBlur = this.onScreenBlur.bind(this); - this.props.navigation.addListener('focus', onScreenFocus); - this.props.navigation.addListener('blur', onScreenBlur); - this.onRefresh(); - } - - /** - * Refresh data when focusing the screen and setup a refresh interval if asked to - */ - onScreenFocus() { - if (this.props.refreshOnFocus && this.lastRefresh !== undefined) - this.onRefresh(); - if (this.props.autoRefreshTime > 0) - this.refreshInterval = setInterval(this.onRefresh, this.props.autoRefreshTime) - } - - /** - * Remove any interval on un-focus - */ - onScreenBlur() { - clearInterval(this.refreshInterval); - } - - - onFetchSuccess(fetchedData: Object) { - this.setState({ - fetchedData: fetchedData, - refreshing: false, - firstLoading: false - }); - this.lastRefresh = new Date(); - } - - onFetchError() { - this.setState({ - fetchedData: {}, - refreshing: false, - firstLoading: false - }); - this.showSnackBar(); - // this.webDataManager.showUpdateToast(this.props.updateErrorText); - } - - /** - * Refresh data and show a toast if any error occurred - * @private - */ - onRefresh() { - let canRefresh; - if (this.lastRefresh !== undefined) - canRefresh = (new Date().getTime() - this.lastRefresh.getTime()) > MIN_REFRESH_TIME; - else - canRefresh = true; - if (canRefresh) { - this.setState({refreshing: true}); - this.webDataManager.readData() - .then(this.onFetchSuccess) - .catch(this.onFetchError); - } - } - - getEmptySectionHeader({section}: Object) { - return ; - } - - getEmptyRenderItem({item}: Object) { - return ( - - ); - } - - createEmptyDataset() { - return [ - { - title: '', - data: [ - { - text: this.state.refreshing ? - i18n.t('general.loading') : - i18n.t('general.networkError'), - isSpinner: this.state.refreshing, - icon: this.state.refreshing ? - 'refresh' : - 'access-point-network-off' - } - ], - keyExtractor: this.datasetKeyExtractor, - } - ]; - } - - datasetKeyExtractor(item: Object) { - return item.text - } - - showSnackBar() { - this.setState({snackbarVisible: true}) - } - - hideSnackBar() { - this.setState({snackbarVisible: false}) - } - - render() { - let dataset = this.props.createDataset(this.state.fetchedData); - const isEmpty = dataset[0].data.length === 0; - const shouldRenderHeader = !isEmpty && (this.props.renderSectionHeader !== null); - if (isEmpty) - dataset = this.createEmptyDataset(); - return ( - - - {i18n.t("homeScreen.listUpdateFail")} - - - } - renderSectionHeader={shouldRenderHeader ? this.props.renderSectionHeader : this.getEmptySectionHeader} - renderItem={isEmpty ? this.getEmptyRenderItem : this.props.renderItem} - style={{minHeight: 300, width: '100%'}} - stickySectionHeadersEnabled={this.props.stickyHeader} - contentContainerStyle={ - isEmpty ? - {flexGrow: 1, justifyContent: 'center', alignItems: 'center'} : {} - } - /> - - ); - } -} diff --git a/components/WebViewScreen.js b/components/WebViewScreen.js deleted file mode 100644 index bdeb5b5..0000000 --- a/components/WebViewScreen.js +++ /dev/null @@ -1,136 +0,0 @@ -// @flow - -import * as React from 'react'; -import {View} from 'react-native'; -import WebView from "react-native-webview"; -import {ActivityIndicator, withTheme} from 'react-native-paper'; -import HeaderButton from "./HeaderButton"; - -type Props = { - navigation: Object, - data: Array<{ - url: string, - icon: string, - name: string, - customJS: string - }>, - headerTitle: string, - hasHeaderBackButton: boolean, - hasSideMenu: boolean, - hasFooter: boolean, -} - -/** - * Class defining a webview screen. - */ -class WebViewScreen extends React.PureComponent { - - static defaultProps = { - hasBackButton: false, - hasSideMenu: true, - hasFooter: true, - }; - webviewRef: Object; - - onRefreshClicked: Function; - onWebviewRef: Function; - onGoBackWebview: Function; - onGoForwardWebview: Function; - getRenderLoading: Function; - - colors: Object; - - constructor(props) { - super(props); - this.onRefreshClicked = this.onRefreshClicked.bind(this); - this.onWebviewRef = this.onWebviewRef.bind(this); - this.onGoBackWebview = this.onGoBackWebview.bind(this); - this.onGoForwardWebview = this.onGoForwardWebview.bind(this); - this.getRenderLoading = this.getRenderLoading.bind(this); - this.colors = props.theme.colors; - } - - componentDidMount() { - const rightButton = this.getRefreshButton.bind(this); - this.props.navigation.setOptions({ - headerRight: rightButton, - }); - } - - getHeaderButton(clickAction: Function, icon: string) { - return ( - - ); - } - - getRefreshButton() { - return ( - - {this.getHeaderButton(this.onRefreshClicked, 'refresh')} - - ); - }; - - onRefreshClicked() { - if (this.webviewRef !== null) - this.webviewRef.reload(); - } - - onGoBackWebview() { - if (this.webviewRef !== null) - this.webviewRef.goBack(); - } - - onGoForwardWebview() { - if (this.webviewRef !== null) - this.webviewRef.goForward(); - } - - onWebviewRef(ref: Object) { - this.webviewRef = ref - } - - getRenderLoading() { - return ( - - - - ); - } - - render() { - // console.log("rendering WebViewScreen"); - return ( - - ); - } -} - -export default withTheme(WebViewScreen); diff --git a/constants/ProxiwashConstants.js b/constants/ProxiwashConstants.js deleted file mode 100644 index ae2599f..0000000 --- a/constants/ProxiwashConstants.js +++ /dev/null @@ -1,10 +0,0 @@ - -export default { - machineStates: { - "TERMINE": "0", - "DISPONIBLE": "1", - "EN COURS": "2", - "HS": "3", - "ERREUR": "4" - }, -}; diff --git a/constants/Update.js b/constants/Update.js deleted file mode 100644 index 072a50e..0000000 --- a/constants/Update.js +++ /dev/null @@ -1,25 +0,0 @@ -import i18n from "i18n-js"; - -export default class Update { - - static number = 5; - static icon = 'surround-sound-2-0'; - - static instance: Update | null = null; - - constructor() { - this.title = i18n.t('intro.updateSlide.title'); - this.description = i18n.t('intro.updateSlide.text'); - } - - /** - * Get this class instance or create one if none is found - * @returns {Update} - */ - static getInstance(): Update { - return Update.instance === null ? - Update.instance = new Update() : - Update.instance; - } - -}; diff --git a/eject.txt b/eject.txt new file mode 100644 index 0000000..b7285ab --- /dev/null +++ b/eject.txt @@ -0,0 +1,37 @@ +Your git working tree is clean +To revert the changes after this command completes, you can run the following: + git clean --force && git reset --hard + +✔ App configuration (app.json) updated. +✔ Created native project directories (./ios and ./android) and updated .gitignore. +✔ Updated package.json and added index.js entry point for iOS and Android. +✔ Installed JavaScript dependencies. + +⚠️ iOS configuration applied with warnings that should be fixed: +- icon: This is the image that your app uses on your home screen, you will need to configure it manually. +- splash: This is the image that your app uses on the loading screen, we recommend installing and using expo-splash-screen. Details. (​https://github.com/expo/expo/blob/master/packages/expo-splash-screen/README.md​) + +⚠️ Android configuration applied with warnings that should be fixed: +- splash: This is the image that your app uses on the loading screen, we recommend installing and using expo-splash-screen. Details. (​https://github.com/expo/expo/blob/master/packages/expo-splash-screen/README.md​) +- icon: This is the image that your app uses on your home screen, you will need to configure it manually. +- android.adaptiveIcon: This is the image that your app uses on your home screen, you will need to configure it manually. + +✔ Skipped installing CocoaPods because operating system is not on macOS. + +⚠️ Your app includes 3 packages that require additional setup in order to run: +- expo-camera: https://github.com/expo/expo/tree/master/packages/expo-camera +- react-native-appearance: https://github.com/expo/react-native-appearance +- react-native-webview: https://github.com/react-native-community/react-native-webview + +➡️ Next steps +- 👆 Review the logs above and look for any warnings (⚠️ ) that might need follow-up. +- 💡 You may want to run npx @react-native-community/cli doctor to help install any tools that your app may need to run your native projects. +- 🍫 When CocoaPods is installed, initialize the project workspace: cd ios && pod install +- 🔑 Download your Android keystore (if you're not sure if you need to, just run the command and see): expo fetch:android:keystore +- 🚀 expo-updates (​https://github.com/expo/expo/blob/master/packages/expo-updates/README.md​) has been configured in your project. Before you do a release build, make sure you run expo publish. Learn more. (​https://expo.fyi/release-builds-with-expo-updates​) + +☑️ When you are ready to run your project +To compile and run your project in development, execute one of the following commands: +- npm run ios +- npm run android +- npm run web diff --git a/index.js b/index.js new file mode 100644 index 0000000..afe81fd --- /dev/null +++ b/index.js @@ -0,0 +1,4 @@ +import {AppRegistry} from 'react-native'; +import App from './App'; + +AppRegistry.registerComponent('main', () => App); \ No newline at end of file diff --git a/ios/Campus.xcodeproj/project.pbxproj b/ios/Campus.xcodeproj/project.pbxproj new file mode 100644 index 0000000..bb59446 --- /dev/null +++ b/ios/Campus.xcodeproj/project.pbxproj @@ -0,0 +1,498 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 46; + objects = { + +/* Begin PBXBuildFile section */ + 074F4BDC2432833400BDB9FE /* app.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 07C2E6E4243282B30028AF0A /* app.bundle */; }; + 074F4BDD2432833400BDB9FE /* app.manifest in Resources */ = {isa = PBXBuildFile; fileRef = 07C2E6E3243282B30028AF0A /* app.manifest */; }; + 13B07FBC1A68108700A75B9A /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.m */; }; + 13B07FBD1A68108700A75B9A /* LaunchScreen.xib in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB11A68108700A75B9A /* LaunchScreen.xib */; }; + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; + 13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; }; + 3DE4DAD41476765101945408 /* libPods-Campus.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D43FF9D506E70904424FA7E9 /* libPods-Campus.a */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 008F07F21AC5B25A0029DE68 /* main.jsbundle */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = main.jsbundle; sourceTree = ""; }; + 07C2E6E3243282B30028AF0A /* app.manifest */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; name = app.manifest; path = Campus/Supporting/app.manifest; sourceTree = ""; }; + 07C2E6E4243282B30028AF0A /* app.bundle */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; name = app.bundle; path = Campus/Supporting/app.bundle; sourceTree = ""; }; + 13B07F961A680F5B00A75B9A /* application.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = application.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = Campus/AppDelegate.h; sourceTree = ""; }; + 13B07FB01A68108700A75B9A /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = AppDelegate.m; path = Campus/AppDelegate.m; sourceTree = ""; }; + 13B07FB21A68108700A75B9A /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/LaunchScreen.xib; sourceTree = ""; }; + 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Campus/Images.xcassets; sourceTree = ""; }; + 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Campus/Info.plist; sourceTree = ""; }; + 13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = Campus/main.m; sourceTree = ""; }; + 2D16E6891FA4F8E400B85C8A /* libReact.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; path = libReact.a; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B47C5AFCB8BDE514B7D1AC6 /* Pods-Campus.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Campus.debug.xcconfig"; path = "Target Support Files/Pods-Campus/Pods-Campus.debug.xcconfig"; sourceTree = ""; }; + 8AC623DBF3A3E2CB072F81F2 /* Pods-Campus.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Campus.release.xcconfig"; path = "Target Support Files/Pods-Campus/Pods-Campus.release.xcconfig"; sourceTree = ""; }; + D43FF9D506E70904424FA7E9 /* libPods-Campus.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Campus.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; + ED2971642150620600B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS12.0.sdk/System/Library/Frameworks/JavaScriptCore.framework; sourceTree = DEVELOPER_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 13B07F8C1A680F5B00A75B9A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 3DE4DAD41476765101945408 /* libPods-Campus.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 13B07FAE1A68108700A75B9A /* Campus */ = { + isa = PBXGroup; + children = ( + 008F07F21AC5B25A0029DE68 /* main.jsbundle */, + 13B07FAF1A68108700A75B9A /* AppDelegate.h */, + 13B07FB01A68108700A75B9A /* AppDelegate.m */, + 13B07FB51A68108700A75B9A /* Images.xcassets */, + 13B07FB61A68108700A75B9A /* Info.plist */, + 13B07FB11A68108700A75B9A /* LaunchScreen.xib */, + 13B07FB71A68108700A75B9A /* main.m */, + 07C2E6E4243282B30028AF0A /* app.bundle */, + 07C2E6E3243282B30028AF0A /* app.manifest */, + ); + name = Campus; + sourceTree = ""; + }; + 2D16E6871FA4F8E400B85C8A /* Frameworks */ = { + isa = PBXGroup; + children = ( + ED297162215061F000B7C4FE /* JavaScriptCore.framework */, + ED2971642150620600B7C4FE /* JavaScriptCore.framework */, + 2D16E6891FA4F8E400B85C8A /* libReact.a */, + D43FF9D506E70904424FA7E9 /* libPods-Campus.a */, + ); + name = Frameworks; + sourceTree = ""; + }; + 72E5486571395D51695C2A02 /* Pods */ = { + isa = PBXGroup; + children = ( + 3B47C5AFCB8BDE514B7D1AC6 /* Pods-Campus.debug.xcconfig */, + 8AC623DBF3A3E2CB072F81F2 /* Pods-Campus.release.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + 832341AE1AAA6A7D00B99B32 /* Libraries */ = { + isa = PBXGroup; + children = ( + ); + name = Libraries; + sourceTree = ""; + }; + 83CBB9F61A601CBA00E9B192 = { + isa = PBXGroup; + children = ( + 13B07FAE1A68108700A75B9A /* Campus */, + 832341AE1AAA6A7D00B99B32 /* Libraries */, + 83CBBA001A601CBA00E9B192 /* Products */, + 2D16E6871FA4F8E400B85C8A /* Frameworks */, + 72E5486571395D51695C2A02 /* Pods */, + ); + indentWidth = 2; + sourceTree = ""; + tabWidth = 2; + usesTabs = 0; + }; + 83CBBA001A601CBA00E9B192 /* Products */ = { + isa = PBXGroup; + children = ( + 13B07F961A680F5B00A75B9A /* application.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 13B07F861A680F5B00A75B9A /* Campus */ = { + isa = PBXNativeTarget; + buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Campus" */; + buildPhases = ( + F8BC737F2AD7A05944D9E2A1 /* [CP] Check Pods Manifest.lock */, + FD4C38642228810C00325AF5 /* Start Packager */, + 13B07F871A680F5B00A75B9A /* Sources */, + 13B07F8C1A680F5B00A75B9A /* Frameworks */, + 13B07F8E1A680F5B00A75B9A /* Resources */, + 00DD1BFF1BD5951E006B06BC /* Bundle Expo Assets */, + 58CDB7AB66969EE82AA3E3B0 /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Campus; + productName = "Hello World"; + productReference = 13B07F961A680F5B00A75B9A /* application.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 83CBB9F71A601CBA00E9B192 /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 940; + ORGANIZATIONNAME = Facebook; + TargetAttributes = { + 13B07F861A680F5B00A75B9A = { + DevelopmentTeam = 6JA7CLNUV6; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Campus" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = English; + hasScannedForEncodings = 0; + knownRegions = ( + English, + en, + Base, + ); + mainGroup = 83CBB9F61A601CBA00E9B192; + productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 13B07F861A680F5B00A75B9A /* Campus */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 13B07F8E1A680F5B00A75B9A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 074F4BDC2432833400BDB9FE /* app.bundle in Resources */, + 074F4BDD2432833400BDB9FE /* app.manifest in Resources */, + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, + 13B07FBD1A68108700A75B9A /* LaunchScreen.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 00DD1BFF1BD5951E006B06BC /* Bundle Expo Assets */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Bundle Expo Assets"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "../node_modules/react-native/scripts/react-native-xcode.sh\n"; + }; + 58CDB7AB66969EE82AA3E3B0 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Campus/Pods-Campus-resources.sh", + "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/AntDesign.ttf", + "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Entypo.ttf", + "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/EvilIcons.ttf", + "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Feather.ttf", + "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/FontAwesome.ttf", + "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/FontAwesome5_Brands.ttf", + "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/FontAwesome5_Regular.ttf", + "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/FontAwesome5_Solid.ttf", + "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Fontisto.ttf", + "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Foundation.ttf", + "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Ionicons.ttf", + "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/MaterialCommunityIcons.ttf", + "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/MaterialIcons.ttf", + "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Octicons.ttf", + "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/SimpleLineIcons.ttf", + "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Zocial.ttf", + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AntDesign.ttf", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Entypo.ttf", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EvilIcons.ttf", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Feather.ttf", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome.ttf", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome5_Brands.ttf", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome5_Regular.ttf", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome5_Solid.ttf", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Fontisto.ttf", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Foundation.ttf", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Ionicons.ttf", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/MaterialCommunityIcons.ttf", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/MaterialIcons.ttf", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Octicons.ttf", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SimpleLineIcons.ttf", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Zocial.ttf", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Campus/Pods-Campus-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + F8BC737F2AD7A05944D9E2A1 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Campus-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + FD4C38642228810C00325AF5 /* Start Packager */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Start Packager"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [ \"$CONFIGURATION\" == \"Release\" ]; then\n exit 0;\nfi\nexport RCT_METRO_PORT=\"${RCT_METRO_PORT:=8081}\"\necho \"export RCT_METRO_PORT=${RCT_METRO_PORT}\" > \"${SRCROOT}/../node_modules/react-native/scripts/.packager.env\"\nif [ -z \"${RCT_NO_LAUNCH_PACKAGER+xxx}\" ] ; then\n if nc -w 5 -z localhost ${RCT_METRO_PORT} ; then\n if ! curl -s \"http://localhost:${RCT_METRO_PORT}/status\" | grep -q \"packager-status:running\" ; then\n echo \"Port ${RCT_METRO_PORT} already in use, packager is either not running or not running correctly\"\n exit 2\n fi\n else\n open \"$SRCROOT/../node_modules/react-native/scripts/launchPackager.command\" || echo \"Can't start packager automatically\"\n fi\nfi\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 13B07F871A680F5B00A75B9A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 13B07FBC1A68108700A75B9A /* AppDelegate.m in Sources */, + 13B07FC11A68108700A75B9A /* main.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 13B07FB11A68108700A75B9A /* LaunchScreen.xib */ = { + isa = PBXVariantGroup; + children = ( + 13B07FB21A68108700A75B9A /* Base */, + ); + name = LaunchScreen.xib; + path = Campus; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 13B07F941A680F5B00A75B9A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 3B47C5AFCB8BDE514B7D1AC6 /* Pods-Campus.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = Campus/application.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 4; + DEAD_CODE_STRIPPING = NO; + DEVELOPMENT_TEAM = 6JA7CLNUV6; + INFOPLIST_FILE = Campus/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + MARKETING_VERSION = 2.0.1; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = fr.amicaleinsat.application; + PRODUCT_NAME = application; + PROVISIONING_PROFILE_SPECIFIER = ""; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 13B07F951A680F5B00A75B9A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 8AC623DBF3A3E2CB072F81F2 /* Pods-Campus.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = Campus/application.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 4; + DEVELOPMENT_TEAM = 6JA7CLNUV6; + INFOPLIST_FILE = Campus/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + MARKETING_VERSION = 2.0.1; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = fr.amicaleinsat.application; + PRODUCT_NAME = application; + PROVISIONING_PROFILE_SPECIFIER = ""; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; + 83CBBA201A601CBA00E9B192 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_ENTITLEMENTS = Campus/application.entitlements; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_SYMBOLS_PRIVATE_EXTERN = NO; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_BUNDLE_IDENTIFIER = fr.amicaleinsat.application; + PRODUCT_NAME = application; + SDKROOT = iphoneos; + }; + name = Debug; + }; + 83CBBA211A601CBA00E9B192 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_ENTITLEMENTS = Campus/application.entitlements; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = YES; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; + MTL_ENABLE_DEBUG_INFO = NO; + PRODUCT_BUNDLE_IDENTIFIER = fr.amicaleinsat.application; + PRODUCT_NAME = application; + SDKROOT = iphoneos; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Campus" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 13B07F941A680F5B00A75B9A /* Debug */, + 13B07F951A680F5B00A75B9A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Campus" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 83CBBA201A601CBA00E9B192 /* Debug */, + 83CBBA211A601CBA00E9B192 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; +} diff --git a/ios/Campus.xcodeproj/xcshareddata/xcschemes/Campus.xcscheme b/ios/Campus.xcodeproj/xcshareddata/xcschemes/Campus.xcscheme new file mode 100644 index 0000000..2c23aa5 --- /dev/null +++ b/ios/Campus.xcodeproj/xcshareddata/xcschemes/Campus.xcscheme @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Campus/AppDelegate.h b/ios/Campus/AppDelegate.h new file mode 100644 index 0000000..0d6cd95 --- /dev/null +++ b/ios/Campus/AppDelegate.h @@ -0,0 +1,16 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import +#import +#import + +@interface AppDelegate : UIResponder + +@property (nonatomic, strong) UIWindow *window; + +@end diff --git a/ios/Campus/AppDelegate.m b/ios/Campus/AppDelegate.m new file mode 100644 index 0000000..55ba1e5 --- /dev/null +++ b/ios/Campus/AppDelegate.m @@ -0,0 +1,89 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "AppDelegate.h" + +#import +#import + +#import +#import "RNSplashScreen.h" +#import +#import + +@implementation AppDelegate + +@synthesize window = _window; + +- (BOOL)application:(UIApplication *)application + openURL:(NSURL *)url + options:(NSDictionary *)options +{ + return [RCTLinkingManager application:application openURL:url options:options]; +} + + +- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions +{ + RCTBridge *bridge = [[RCTBridge alloc] initWithDelegate:self launchOptions:launchOptions]; + RCTRootView *rootView = [[RCTRootView alloc] initWithBridge:bridge moduleName:@"main" initialProperties:nil]; + rootView.backgroundColor = [[UIColor alloc] initWithRed:1.0f green:1.0f blue:1.0f alpha:1]; + + self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds]; + UIViewController *rootViewController = [UIViewController new]; + rootViewController.view = rootView; + self.window.rootViewController = rootViewController; + [self.window makeKeyAndVisible]; + +// Define UNUserNotificationCenter + UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter]; + center.delegate = self; + [RNSplashScreen show]; + return YES; +} + +//Called when a notification is delivered to a foreground app. +-(void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler +{ + completionHandler(UNAuthorizationOptionSound | UNAuthorizationOptionAlert | UNAuthorizationOptionBadge); +} +// Required to register for notifications +- (void)application:(UIApplication *)application didRegisterUserNotificationSettings:(UIUserNotificationSettings *)notificationSettings +{ + [RNCPushNotificationIOS didRegisterUserNotificationSettings:notificationSettings]; +} +// Required for the register event. +- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken +{ + [RNCPushNotificationIOS didRegisterForRemoteNotificationsWithDeviceToken:deviceToken]; +} +// Required for the notification event. You must call the completion handler after handling the remote notification. +- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo +fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler +{ + [RNCPushNotificationIOS didReceiveRemoteNotification:userInfo fetchCompletionHandler:completionHandler]; +} +// Required for the registrationError event. +- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error +{ + [RNCPushNotificationIOS didFailToRegisterForRemoteNotificationsWithError:error]; +} +// Required for the localNotification event. +- (void)application:(UIApplication *)application didReceiveLocalNotification:(UILocalNotification *)notification +{ + [RNCPushNotificationIOS didReceiveLocalNotification:notification]; +} + +- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge { +#ifdef DEBUG + return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index" fallbackResource:nil]; +#else + return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"]; +#endif +} + +@end diff --git a/ios/Campus/Base.lproj/LaunchScreen.xib b/ios/Campus/Base.lproj/LaunchScreen.xib new file mode 100644 index 0000000..7505051 --- /dev/null +++ b/ios/Campus/Base.lproj/LaunchScreen.xib @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Campus/Images.xcassets/AppIcon.appiconset/1024.png b/ios/Campus/Images.xcassets/AppIcon.appiconset/1024.png new file mode 100644 index 0000000..f22d590 Binary files /dev/null and b/ios/Campus/Images.xcassets/AppIcon.appiconset/1024.png differ diff --git a/ios/Campus/Images.xcassets/AppIcon.appiconset/120-1.png b/ios/Campus/Images.xcassets/AppIcon.appiconset/120-1.png new file mode 100644 index 0000000..6592816 Binary files /dev/null and b/ios/Campus/Images.xcassets/AppIcon.appiconset/120-1.png differ diff --git a/ios/Campus/Images.xcassets/AppIcon.appiconset/120.png b/ios/Campus/Images.xcassets/AppIcon.appiconset/120.png new file mode 100644 index 0000000..6592816 Binary files /dev/null and b/ios/Campus/Images.xcassets/AppIcon.appiconset/120.png differ diff --git a/ios/Campus/Images.xcassets/AppIcon.appiconset/180.png b/ios/Campus/Images.xcassets/AppIcon.appiconset/180.png new file mode 100644 index 0000000..8509e51 Binary files /dev/null and b/ios/Campus/Images.xcassets/AppIcon.appiconset/180.png differ diff --git a/ios/Campus/Images.xcassets/AppIcon.appiconset/40.png b/ios/Campus/Images.xcassets/AppIcon.appiconset/40.png new file mode 100644 index 0000000..e99a8f1 Binary files /dev/null and b/ios/Campus/Images.xcassets/AppIcon.appiconset/40.png differ diff --git a/ios/Campus/Images.xcassets/AppIcon.appiconset/58.png b/ios/Campus/Images.xcassets/AppIcon.appiconset/58.png new file mode 100644 index 0000000..3dc95ae Binary files /dev/null and b/ios/Campus/Images.xcassets/AppIcon.appiconset/58.png differ diff --git a/ios/Campus/Images.xcassets/AppIcon.appiconset/60.png b/ios/Campus/Images.xcassets/AppIcon.appiconset/60.png new file mode 100644 index 0000000..a919345 Binary files /dev/null and b/ios/Campus/Images.xcassets/AppIcon.appiconset/60.png differ diff --git a/ios/Campus/Images.xcassets/AppIcon.appiconset/80.png b/ios/Campus/Images.xcassets/AppIcon.appiconset/80.png new file mode 100644 index 0000000..249cf58 Binary files /dev/null and b/ios/Campus/Images.xcassets/AppIcon.appiconset/80.png differ diff --git a/ios/Campus/Images.xcassets/AppIcon.appiconset/87.png b/ios/Campus/Images.xcassets/AppIcon.appiconset/87.png new file mode 100644 index 0000000..05ee519 Binary files /dev/null and b/ios/Campus/Images.xcassets/AppIcon.appiconset/87.png differ diff --git a/ios/Campus/Images.xcassets/AppIcon.appiconset/Contents.json b/ios/Campus/Images.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..7ee3905 --- /dev/null +++ b/ios/Campus/Images.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,62 @@ +{ + "images" : [ + { + "filename" : "40.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "60.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "filename" : "58.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "87.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "filename" : "80.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "120-1.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "filename" : "120.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "filename" : "180.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "filename" : "1024.png", + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Campus/Images.xcassets/Contents.json b/ios/Campus/Images.xcassets/Contents.json new file mode 100644 index 0000000..2d92bd5 --- /dev/null +++ b/ios/Campus/Images.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Campus/Images.xcassets/LaunchScreen.imageset/Contents.json b/ios/Campus/Images.xcassets/LaunchScreen.imageset/Contents.json new file mode 100644 index 0000000..84c528a --- /dev/null +++ b/ios/Campus/Images.xcassets/LaunchScreen.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "splash.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Campus/Images.xcassets/LaunchScreen.imageset/splash.png b/ios/Campus/Images.xcassets/LaunchScreen.imageset/splash.png new file mode 100644 index 0000000..7629d1a Binary files /dev/null and b/ios/Campus/Images.xcassets/LaunchScreen.imageset/splash.png differ diff --git a/ios/Campus/Info.plist b/ios/Campus/Info.plist new file mode 100644 index 0000000..91812ec --- /dev/null +++ b/ios/Campus/Info.plist @@ -0,0 +1,79 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Campus + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleURLSchemes + + campus-insat + + + + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + FacebookAdvertiserIDCollectionEnabled + + FacebookAutoInitEnabled + + FacebookAutoLogAppEventsEnabled + + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSExceptionDomains + + localhost + + NSExceptionAllowsInsecureHTTPLoads + + + + + NSCameraUsageDescription + Allow Campus to use the camera + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + + armv7 + + UIRequiresFullScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + UIUserInterfaceStyle + Automatic + UIViewControllerBasedStatusBarAppearance + + UIAppFonts + + MaterialCommunityIcons.ttf + + + diff --git a/ios/Campus/Supporting/app.bundle b/ios/Campus/Supporting/app.bundle new file mode 100644 index 0000000..e69de29 diff --git a/ios/Campus/Supporting/app.manifest b/ios/Campus/Supporting/app.manifest new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/ios/Campus/Supporting/app.manifest @@ -0,0 +1 @@ +{} diff --git a/ios/Campus/application.entitlements b/ios/Campus/application.entitlements new file mode 100644 index 0000000..f683276 --- /dev/null +++ b/ios/Campus/application.entitlements @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/ios/Campus/main.m b/ios/Campus/main.m new file mode 100644 index 0000000..c316cf8 --- /dev/null +++ b/ios/Campus/main.m @@ -0,0 +1,16 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +#import "AppDelegate.h" + +int main(int argc, char * argv[]) { + @autoreleasepool { + return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class])); + } +} diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..bfc5ec4 --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,49 @@ +platform :ios, '9.0' + +require_relative '../node_modules/@react-native-community/cli-platform-ios/native_modules' + +target 'Campus' do + rnPrefix = "../node_modules/react-native" + + # React Native and its dependencies + pod 'FBLazyVector', :path => "#{rnPrefix}/Libraries/FBLazyVector" + pod 'FBReactNativeSpec', :path => "#{rnPrefix}/Libraries/FBReactNativeSpec" + pod 'RCTRequired', :path => "#{rnPrefix}/Libraries/RCTRequired" + pod 'RCTTypeSafety', :path => "#{rnPrefix}/Libraries/TypeSafety" + pod 'React', :path => "#{rnPrefix}/" + pod 'React-Core', :path => "#{rnPrefix}/" + pod 'React-CoreModules', :path => "#{rnPrefix}/React/CoreModules" + pod 'React-RCTActionSheet', :path => "#{rnPrefix}/Libraries/ActionSheetIOS" + pod 'React-RCTAnimation', :path => "#{rnPrefix}/Libraries/NativeAnimation" + pod 'React-RCTBlob', :path => "#{rnPrefix}/Libraries/Blob" + pod 'React-RCTImage', :path => "#{rnPrefix}/Libraries/Image" + pod 'React-RCTLinking', :path => "#{rnPrefix}/Libraries/LinkingIOS" + pod 'React-RCTNetwork', :path => "#{rnPrefix}/Libraries/Network" + pod 'React-RCTSettings', :path => "#{rnPrefix}/Libraries/Settings" + pod 'React-RCTText', :path => "#{rnPrefix}/Libraries/Text" + pod 'React-RCTVibration', :path => "#{rnPrefix}/Libraries/Vibration" + pod 'React-Core/RCTWebSocket', :path => "#{rnPrefix}/" + pod 'React-Core/DevSupport', :path => "#{rnPrefix}/" + pod 'React-cxxreact', :path => "#{rnPrefix}/ReactCommon/cxxreact" + pod 'React-jsi', :path => "#{rnPrefix}/ReactCommon/jsi" + pod 'React-jsiexecutor', :path => "#{rnPrefix}/ReactCommon/jsiexecutor" + pod 'React-jsinspector', :path => "#{rnPrefix}/ReactCommon/jsinspector" + pod 'ReactCommon/jscallinvoker', :path => "#{rnPrefix}/ReactCommon" + pod 'ReactCommon/turbomodule/core', :path => "#{rnPrefix}/ReactCommon" + pod 'Yoga', :path => "#{rnPrefix}/ReactCommon/yoga" + pod 'DoubleConversion', :podspec => "#{rnPrefix}/third-party-podspecs/DoubleConversion.podspec" + pod 'glog', :podspec => "#{rnPrefix}/third-party-podspecs/glog.podspec" + pod 'Folly', :podspec => "#{rnPrefix}/third-party-podspecs/Folly.podspec" + + # Other native modules + + # react-native-cli autolinking + use_native_modules! + + # Permissions + permissions_path = '../node_modules/react-native-permissions/ios' + + pod 'Permission-Notifications', :path => "#{permissions_path}/Notifications.podspec" + pod 'Permission-Camera', :path => "#{permissions_path}/Camera.podspec" + +end diff --git a/metro.config.js b/metro.config.js new file mode 100644 index 0000000..783f349 --- /dev/null +++ b/metro.config.js @@ -0,0 +1,17 @@ +/** + * Metro configuration for React Native + * https://github.com/facebook/react-native + * + * @format + */ + +module.exports = { + transformer: { + getTransformOptions: async () => ({ + transform: { + experimentalImportSupport: false, + inlineRequires: false, + }, + }), + }, +}; diff --git a/navigation/DrawerNavigator.js b/navigation/DrawerNavigator.js deleted file mode 100644 index 71235ee..0000000 --- a/navigation/DrawerNavigator.js +++ /dev/null @@ -1,207 +0,0 @@ -// @flow - -import * as React from 'react'; -import {createDrawerNavigator} from '@react-navigation/drawer'; -import TabNavigator from './MainTabNavigator'; -import SettingsScreen from '../screens/SettingsScreen'; -import AboutScreen from '../screens/About/AboutScreen'; -import AboutDependenciesScreen from '../screens/About/AboutDependenciesScreen'; -import SelfMenuScreen from '../screens/SelfMenuScreen'; -import AvailableRoomScreen from "../screens/Websites/AvailableRoomScreen"; -import BibScreen from "../screens/Websites/BibScreen"; -import DebugScreen from '../screens/About/DebugScreen'; -import Sidebar from "../components/Sidebar"; -import {createStackNavigator, TransitionPresets} from "@react-navigation/stack"; -import HeaderButton from "../components/HeaderButton"; -import i18n from "i18n-js"; - -const defaultScreenOptions = { - gestureEnabled: true, - cardOverlayEnabled: true, - ...TransitionPresets.SlideFromRightIOS, -}; - -function getDrawerButton(navigation: Object) { - return ( - - ); -} - -const AboutStack = createStackNavigator(); - -function AboutStackComponent() { - return ( - - { - const openDrawer = getDrawerButton.bind(this, navigation); - return { - title: i18n.t('screens.about'), - headerLeft: openDrawer - }; - }} - /> - - - - ); -} - -const SettingsStack = createStackNavigator(); - -function SettingsStackComponent() { - return ( - - { - const openDrawer = getDrawerButton.bind(this, navigation); - return { - title: i18n.t('screens.settings'), - headerLeft: openDrawer - }; - }} - /> - - ); -} - -const SelfMenuStack = createStackNavigator(); - -function SelfMenuStackComponent() { - return ( - - { - const openDrawer = getDrawerButton.bind(this, navigation); - return { - title: i18n.t('screens.menuSelf'), - headerLeft: openDrawer - }; - }} - /> - - ); -} - -const AvailableRoomStack = createStackNavigator(); - -function AvailableRoomStackComponent() { - return ( - - { - const openDrawer = getDrawerButton.bind(this, navigation); - return { - title: i18n.t('screens.availableRooms'), - headerLeft: openDrawer - }; - }} - /> - - ); -} - -const BibStack = createStackNavigator(); - -function BibStackComponent() { - return ( - - { - const openDrawer = getDrawerButton.bind(this, navigation); - return { - title: i18n.t('screens.bib'), - headerLeft: openDrawer - }; - }} - /> - - ); -} - -const Drawer = createDrawerNavigator(); - -function getDrawerContent(props) { - return -} - -export default function DrawerNavigator() { - return ( - getDrawerContent(props)} - screenOptions={defaultScreenOptions} - > - - - - - - - - - ); -} diff --git a/navigation/MainTabNavigator.js b/navigation/MainTabNavigator.js deleted file mode 100644 index 3392976..0000000 --- a/navigation/MainTabNavigator.js +++ /dev/null @@ -1,244 +0,0 @@ -import * as React from 'react'; -import {createStackNavigator, TransitionPresets} from '@react-navigation/stack'; -import {createMaterialBottomTabNavigator} from "@react-navigation/material-bottom-tabs"; - -import HomeScreen from '../screens/HomeScreen'; -import PlanningScreen from '../screens/Planning/PlanningScreen'; -import PlanningDisplayScreen from '../screens/Planning/PlanningDisplayScreen'; -import ProxiwashScreen from '../screens/Proxiwash/ProxiwashScreen'; -import ProxiwashAboutScreen from '../screens/Proxiwash/ProxiwashAboutScreen'; -import ProximoMainScreen from '../screens/Proximo/ProximoMainScreen'; -import ProximoListScreen from "../screens/Proximo/ProximoListScreen"; -import ProximoAboutScreen from "../screens/Proximo/ProximoAboutScreen"; -import PlanexScreen from '../screens/Websites/PlanexScreen'; -import {MaterialCommunityIcons} from "@expo/vector-icons"; -import AsyncStorageManager from "../utils/AsyncStorageManager"; -import HeaderButton from "../components/HeaderButton"; -import {withTheme} from 'react-native-paper'; -import i18n from "i18n-js"; - - -const TAB_ICONS = { - Home: 'triangle', - Planning: 'calendar-range', - Proxiwash: 'tshirt-crew', - Proximo: 'cart', - Planex: 'clock', -}; - -const defaultScreenOptions = { - gestureEnabled: true, - cardOverlayEnabled: true, - ...TransitionPresets.SlideFromRightIOS, -}; - -function getDrawerButton(navigation: Object) { - return ( - - ); -} - -const ProximoStack = createStackNavigator(); - -function ProximoStackComponent() { - return ( - - { - const openDrawer = getDrawerButton.bind(this, navigation); - return { - title: 'Proximo', - headerLeft: openDrawer - }; - }} - component={ProximoMainScreen} - /> - - - - ); -} - -const ProxiwashStack = createStackNavigator(); - -function ProxiwashStackComponent() { - return ( - - { - const openDrawer = getDrawerButton.bind(this, navigation); - return { - title: 'Proxiwash', - headerLeft: openDrawer - }; - }} - /> - - - ); -} - -const PlanningStack = createStackNavigator(); - -function PlanningStackComponent() { - return ( - - { - const openDrawer = getDrawerButton.bind(this, navigation); - return { - title: 'Planning', - headerLeft: openDrawer - }; - }} - /> - - - ); -} - -const HomeStack = createStackNavigator(); - -function HomeStackComponent() { - return ( - - { - const openDrawer = getDrawerButton.bind(this, navigation); - return { - title: i18n.t('screens.home'), - headerLeft: openDrawer - }; - }} - /> - - - ); -} - -const PlanexStack = createStackNavigator(); - -function PlanexStackComponent() { - return ( - - { - const openDrawer = getDrawerButton.bind(this, navigation); - return { - title: 'Planex', - headerLeft: openDrawer - }; - }} - /> - - ); -} - -const Tab = createMaterialBottomTabNavigator(); - -function TabNavigator(props) { - const {colors} = props.theme; - return ( - ({ - tabBarIcon: ({focused, color, size}) => { - let icon = TAB_ICONS[route.name]; - // tintColor is ignoring activeColor and inactiveColor for some reason - icon = focused ? icon : icon + ('-outline'); - return ; - }, - })} - activeColor={colors.primary} - inactiveColor={colors.tabIcon} - > - - - - - - - ); -} - -export default withTheme(TabNavigator); diff --git a/package.json b/package.json index 7c6672f..71ebf13 100644 --- a/package.json +++ b/package.json @@ -1,44 +1,72 @@ { - "main": "node_modules/expo/AppEntry.js", + "name": "campus", + "version": "3.0.2", + "private": true, "scripts": { - "start": "expo start", - "android": "expo start --android", - "ios": "expo start --ios", - "web": "expo start --web", - "eject": "expo eject" + "start": "react-native start", + "android": "react-native run-android", + "ios": "react-native run-ios", + "test": "jest", + "lint": "eslint ." + }, + "jest": { + "preset": "react-native", + "transformIgnorePatterns": [ + "node_modules/(?!(jest-)?react-native|react-clone-referenced-element|@react-native-community|expo(nent)?|@expo(nent)?/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base)" + ], + "setupFilesAfterEnv": [ + "jest-extended" + ] }, "dependencies": { - "@expo/vector-icons": "~10.0.0", - "@react-native-community/masked-view": "0.1.5", - "@react-navigation/bottom-tabs": "^5.1.1", - "@react-navigation/drawer": "^5.1.1", - "@react-navigation/material-bottom-tabs": "^5.1.1", - "@react-navigation/native": "^5.0.9", - "@react-navigation/stack": "^5.1.1", - "expo": "^36.0.0", - "expo-localization": "~8.0.0", - "expo-permissions": "~8.0.0", - "expo-web-browser": "~8.0.0", + "@nartc/react-native-barcode-mask": "^1.1.9", + "@react-native-community/async-storage": "^1.9.0", + "@react-native-community/masked-view": "^0.1.10", + "@react-native-community/push-notification-ios": "^1.1.1", + "@react-native-community/slider": "^2.0.9", + "@react-navigation/bottom-tabs": "^5.3.2", + "@react-navigation/native": "^5.2.2", + "@react-navigation/stack": "^5.2.17", "i18n-js": "^3.3.0", - "react": "16.9.0", + "react": "~16.9.0", "react-dom": "16.9.0", - "react-native": "https://github.com/expo/react-native/archive/sdk-36.0.1.tar.gz", - "react-native-app-intro-slider": "^3.0.0", - "react-native-autolink": "^1.8.1", + "react-native": "~0.61.5", + "react-native-animatable": "^1.3.3", + "react-native-app-intro-slider": "^4.0.0", + "react-native-appearance": "~0.3.3", + "react-native-autolink": "^3.0.0", "react-native-calendars": "^1.260.0", - "react-native-gesture-handler": "~1.5.0", - "react-native-modalize": "^1.3.6", - "react-native-paper": "^3.6.0", - "react-native-reanimated": "~1.4.0", + "react-native-camera": "^3.23.1", + "react-native-collapsible": "^1.5.2", + "react-native-gesture-handler": "~1.6.0", + "react-native-image-modal": "^1.0.6", + "react-native-keychain": "^6.0.0", + "react-native-linear-gradient": "^2.5.6", + "react-native-localize": "^1.4.0", + "react-native-modalize": "^2.0.4", + "react-native-paper": "^3.10.1", + "react-native-permissions": "^2.1.4", + "react-native-push-notification": "^3.3.1", + "react-native-reanimated": "^1.8.0", "react-native-render-html": "^4.1.2", - "react-native-safe-area-context": "0.6.0", - "react-native-screens": "2.0.0-alpha.12", - "react-native-webview": "7.4.3", - "react-native-appearance": "~0.3.1", - "expo-linear-gradient": "~8.0.0" + "react-native-safe-area-context": "0.7.3", + "react-native-screens": "^2.7.0", + "react-native-splash-screen": "^3.2.0", + "react-native-vector-icons": "^6.6.0", + "react-native-webview": "^9.4.0", + "react-navigation-collapsible": "^5.6.0", + "react-navigation-header-buttons": "^4.0.2" }, "devDependencies": { - "babel-preset-expo": "^8.0.0" - }, - "private": true + "@babel/core": "^7.9.6", + "@babel/runtime": "^7.9.6", + "@react-native-community/eslint-config": "^1.1.0", + "babel-jest": "^25.5.1", + "eslint": "^6.5.1", + "flow-bin": "^0.123.0", + "jest": "^25.5.3", + "jest-extended": "^0.11.5", + "metro-react-native-babel-preset": "^0.59.0", + "react-test-renderer": "16.9.0" + } } diff --git a/screens/About/AboutDependenciesScreen.js b/screens/About/AboutDependenciesScreen.js deleted file mode 100644 index e351280..0000000 --- a/screens/About/AboutDependenciesScreen.js +++ /dev/null @@ -1,43 +0,0 @@ -// @flow - -import * as React from 'react'; -import {FlatList} from "react-native"; -import packageJson from '../../package'; -import {List} from 'react-native-paper'; - -function generateListFromObject(object) { - let list = []; - let keys = Object.keys(object); - let values = Object.values(object); - for (let i = 0; i < keys.length; i++) { - list.push({name: keys[i], version: values[i]}); - } - return list; -} - -type Props = { - navigation: Object, - route: Object -} - -/** - * Class defining a screen showing the list of libraries used by the app, taken from package.json - */ -export default class AboutDependenciesScreen extends React.Component { - - render() { - const data = generateListFromObject(packageJson.dependencies); - return ( - item.name} - style={{minHeight: 300, width: '100%'}} - renderItem={({item}) => - } - /> - ); - } -} diff --git a/screens/About/DebugScreen.js b/screens/About/DebugScreen.js deleted file mode 100644 index 09df31e..0000000 --- a/screens/About/DebugScreen.js +++ /dev/null @@ -1,172 +0,0 @@ -// @flow - -import * as React from 'react'; -import {Alert, Clipboard, ScrollView, View} from "react-native"; -import AsyncStorageManager from "../../utils/AsyncStorageManager"; -import NotificationsManager from "../../utils/NotificationsManager"; -import CustomModal from "../../components/CustomModal"; -import {Button, Card, List, Subheading, TextInput, Title, withTheme} from 'react-native-paper'; - -type Props = { - navigation: Object, -}; - -type State = { - modalCurrentDisplayItem: Object, - currentPreferences: Object, -} - -/** - * Class defining the Debug screen. This screen allows the user to get detailed information on the app/device. - */ -class DebugScreen extends React.Component { - - modalRef: Object; - modalInputValue = ''; - state = { - modalCurrentDisplayItem: {}, - currentPreferences: JSON.parse(JSON.stringify(AsyncStorageManager.getInstance().preferences)) - }; - - onModalRef: Function; - - colors: Object; - - constructor(props) { - super(props); - this.onModalRef = this.onModalRef.bind(this); - this.colors = props.theme.colors; - } - - static getGeneralItem(onPressCallback: Function, icon: ?string, title: string, subtitle: string) { - if (icon !== undefined) { - return ( - } - onPress={onPressCallback} - /> - ); - } else { - return ( - - ); - } - } - - alertCurrentExpoToken() { - let token = AsyncStorageManager.getInstance().preferences.expoToken.current; - Alert.alert( - 'Expo Token', - token, - [ - {text: 'Copy', onPress: () => Clipboard.setString(token)}, - {text: 'OK'} - ] - ); - } - - async forceExpoTokenUpdate() { - await NotificationsManager.forceExpoTokenUpdate(); - this.alertCurrentExpoToken(); - } - - showEditModal(item: Object) { - this.setState({ - modalCurrentDisplayItem: item - }); - if (this.modalRef) { - this.modalRef.open(); - } - } - - getModalContent() { - return ( - - {this.state.modalCurrentDisplayItem.key} - Default: {this.state.modalCurrentDisplayItem.default} - Current: {this.state.modalCurrentDisplayItem.current} - this.modalInputValue = text} - /> - - - - - - - ); - } - - saveNewPrefs(key: string, value: string) { - this.setState((prevState) => { - let currentPreferences = {...prevState.currentPreferences}; - currentPreferences[key].current = value; - return {currentPreferences}; - }); - AsyncStorageManager.getInstance().savePref(key, value); - } - - onModalRef(ref: Object) { - this.modalRef = ref; - } - - render() { - return ( - - - {this.getModalContent()} - - - - - - {DebugScreen.getGeneralItem(() => this.alertCurrentExpoToken(), 'bell', 'Get current Expo Token', '')} - {DebugScreen.getGeneralItem(() => this.forceExpoTokenUpdate(), 'bell-ring', 'Force Expo token update', '')} - - - - - - {Object.values(this.state.currentPreferences).map((object) => - - {DebugScreen.getGeneralItem(() => this.showEditModal(object), undefined, object.key, 'Click to edit')} - - )} - - - - - ); - } -} - -export default withTheme(DebugScreen); diff --git a/screens/HomeScreen.js b/screens/HomeScreen.js deleted file mode 100644 index 464f196..0000000 --- a/screens/HomeScreen.js +++ /dev/null @@ -1,420 +0,0 @@ -// @flow - -import * as React from 'react'; -import {View} from 'react-native'; -import i18n from "i18n-js"; -import DashboardItem from "../components/EventDashboardItem"; -import * as WebBrowser from 'expo-web-browser'; -import WebSectionList from "../components/WebSectionList"; -import {Text, withTheme} from 'react-native-paper'; -import FeedItem from "../components/FeedItem"; -import SquareDashboardItem from "../components/SquareDashboardItem"; -import PreviewEventDashboardItem from "../components/PreviewEventDashboardItem"; -// import DATA from "../dashboard_data.json"; - - -const NAME_AMICALE = 'Amicale INSA Toulouse'; -const DATA_URL = "https://etud.insa-toulouse.fr/~amicale_app/dashboard/dashboard_data.json"; - -const SECTIONS_ID = [ - 'dashboard', - 'news_feed' -]; - -const REFRESH_TIME = 1000 * 20; // Refresh every 20 seconds - -type Props = { - navigation: Object, - theme: Object, -} - -/** - * Class defining the app's home screen - */ -class HomeScreen extends React.Component { - - onProxiwashClick: Function; - onTutorInsaClick: Function; - onMenuClick: Function; - onProximoClick: Function; - getRenderItem: Function; - createDataset: Function; - - colors : Object; - - constructor(props) { - super(props); - this.onProxiwashClick = this.onProxiwashClick.bind(this); - this.onTutorInsaClick = this.onTutorInsaClick.bind(this); - this.onMenuClick = this.onMenuClick.bind(this); - this.onProximoClick = this.onProximoClick.bind(this); - this.getRenderItem = this.getRenderItem.bind(this); - this.createDataset = this.createDataset.bind(this); - this.colors = props.theme.colors; - } - - /** - * Converts a dateString using Unix Timestamp to a formatted date - * @param dateString {string} The Unix Timestamp representation of a date - * @return {string} The formatted output date - */ - static getFormattedDate(dateString: string) { - let date = new Date(Number.parseInt(dateString) * 1000); - return date.toLocaleString(); - } - - onProxiwashClick() { - this.props.navigation.navigate('Proxiwash'); - } - - onTutorInsaClick() { - WebBrowser.openBrowserAsync("https://www.etud.insa-toulouse.fr/~tutorinsa/"); - } - - onProximoClick() { - this.props.navigation.navigate('Proximo'); - } - - onMenuClick() { - this.props.navigation.navigate('SelfMenuScreen'); - } - - getKeyExtractor(item: Object) { - return item !== undefined ? item.id : undefined; - } - - createDataset(fetchedData: Object) { - // fetchedData = DATA; - let newsData = []; - let dashboardData = []; - if (fetchedData['news_feed'] !== undefined) - newsData = fetchedData['news_feed']['data']; - if (fetchedData['dashboard'] !== undefined) - dashboardData = this.generateDashboardDataset(fetchedData['dashboard']); - return [ - { - title: '', - data: dashboardData, - extraData: super.state, - keyExtractor: this.getKeyExtractor, - id: SECTIONS_ID[0] - }, - { - title: i18n.t('homeScreen.newsFeed'), - data: newsData, - extraData: super.state, - keyExtractor: this.getKeyExtractor, - id: SECTIONS_ID[1] - } - ]; - } - - generateDashboardDataset(dashboardData: Object) { - let dataset = [ - - { - id: 'middle', - content: [] - }, - { - id: 'event', - content: undefined - }, - ]; - for (let [key, value] of Object.entries(dashboardData)) { - switch (key) { - case 'today_events': - dataset[1]['content'] = value; - break; - case 'available_machines': - dataset[0]['content'][0] = {id: key, data: value}; - break; - case 'available_tutorials': - dataset[0]['content'][1] = {id: key, data: value}; - break; - case 'proximo_articles': - dataset[0]['content'][2] = {id: key, data: value}; - break; - case 'today_menu': - dataset[0]['content'][3] = {id: key, data: value}; - break; - - } - } - return dataset - } - - getDashboardItem(item: Object) { - let content = item['content']; - if (item['id'] === 'event') - return this.getDashboardEventItem(content); - else if (item['id'] === 'middle') - return this.getDashboardMiddleItem(content); - } - - /** - * Convert the date string given by in the event list json to a date object - * @param dateString - * @return {Date} - */ - stringToDate(dateString: ?string): ?Date { - let date = new Date(); - if (dateString === undefined || dateString === null) - date = undefined; - else if (dateString.split(' ').length > 1) { - let timeStr = dateString.split(' ')[1]; - date.setHours(parseInt(timeStr.split(':')[0]), parseInt(timeStr.split(':')[1]), 0); - } else - date = undefined; - return date; - } - - /** - * Get the time limit depending on the current day: - * 17:30 for every day of the week except for thursday 11:30 - * 00:00 on weekends - */ - getTodayEventTimeLimit() { - let now = new Date(); - if (now.getDay() === 4) // Thursday - now.setHours(11, 30, 0); - else if (now.getDay() === 6 || now.getDay() === 0) // Weekend - now.setHours(0, 0, 0); - else - now.setHours(17, 30, 0); - return now; - } - - /** - * Get the duration (in milliseconds) of an event - * @param event {Object} - * @return {number} The number of milliseconds - */ - getEventDuration(event: Object): number { - let start = this.stringToDate(event['date_begin']); - let end = this.stringToDate(event['date_end']); - let duration = 0; - if (start !== undefined && start !== null && end !== undefined && end !== null) - duration = end - start; - return duration; - } - - /** - * Get events starting after the limit - * - * @param events - * @param limit - * @return {Array} - */ - getEventsAfterLimit(events: Object, limit: Date): Array { - let validEvents = []; - for (let event of events) { - let startDate = this.stringToDate(event['date_begin']); - if (startDate !== undefined && startDate !== null && startDate >= limit) { - validEvents.push(event); - } - } - return validEvents; - } - - /** - * Get the event with the longest duration in the given array. - * If all events have the same duration, return the first in the array. - * @param events - */ - getLongestEvent(events: Array): Object { - let longestEvent = events[0]; - let longestTime = 0; - for (let event of events) { - let time = this.getEventDuration(event); - if (time > longestTime) { - longestTime = time; - longestEvent = event; - } - } - return longestEvent; - } - - /** - * Get events that have not yet ended/started - * - * @param events - */ - getFutureEvents(events: Array): Array { - let validEvents = []; - let now = new Date(); - for (let event of events) { - let startDate = this.stringToDate(event['date_begin']); - let endDate = this.stringToDate(event['date_end']); - if (startDate !== undefined && startDate !== null) { - if (startDate > now) - validEvents.push(event); - else if (endDate !== undefined && endDate !== null) { - if (endDate > now || endDate < startDate) // Display event if it ends the following day - validEvents.push(event); - } - } - } - return validEvents; - } - - /** - * - * - * @param events - * @return {Object} - */ - getDisplayEvent(events: Array): Object { - let displayEvent = undefined; - if (events.length > 1) { - let eventsAfterLimit = this.getEventsAfterLimit(events, this.getTodayEventTimeLimit()); - if (eventsAfterLimit.length > 0) { - if (eventsAfterLimit.length === 1) - displayEvent = eventsAfterLimit[0]; - else - displayEvent = this.getLongestEvent(events); - } else { - displayEvent = this.getLongestEvent(events); - } - } else if (events.length === 1) { - displayEvent = events[0]; - } - return displayEvent; - } - - - getDashboardEventItem(content: Array) { - let icon = 'calendar-range'; - let title = i18n.t('homeScreen.dashboard.todayEventsTitle'); - let subtitle; - let futureEvents = this.getFutureEvents(content); - let isAvailable = futureEvents.length > 0; - if (isAvailable) { - subtitle = - - {futureEvents.length} - - { - futureEvents.length > 1 ? - i18n.t('homeScreen.dashboard.todayEventsSubtitlePlural') : - i18n.t('homeScreen.dashboard.todayEventsSubtitle') - } - - ; - } else - subtitle = i18n.t('homeScreen.dashboard.todayEventsSubtitleNA'); - - let displayEvent = this.getDisplayEvent(futureEvents); - const clickContainerAction = () => this.props.navigation.navigate('Planning'); - const clickPreviewAction = () => this.props.navigation.navigate('PlanningDisplayScreen', {data: displayEvent}); - - return ( - - - - ); - } - - - getDashboardMiddleItem(content: Array) { - let proxiwashData = content[0]['data']; - let tutorinsaData = content[1]['data']; - let proximoData = content[2]['data']; - let menuData = content[3]['data']; - return ( - - 0} - badgeNumber={proxiwashData['washers']} - /> - 0} - badgeNumber={proxiwashData['dryers']} - /> - 0} - badgeNumber={tutorinsaData} - /> - 0} - badgeNumber={parseInt(proximoData)} - /> - 0} - badgeNumber={0} - /> - - ); - } - - openLink(link: string) { - WebBrowser.openBrowserAsync(link); - } - - getFeedItem(item: Object) { - const onImagePress = this.openLink.bind(this, item.full_picture); - const onOutLinkPress = this.openLink.bind(this, item.permalink_url); - return ( - - ); - } - - getRenderItem({item, section}: Object) { - return (section['id'] === SECTIONS_ID[0] ? - this.getDashboardItem(item) : this.getFeedItem(item)); - } - - render() { - const nav = this.props.navigation; - return ( - - ); - } -} - -export default withTheme(HomeScreen); diff --git a/screens/Planning/PlanningDisplayScreen.js b/screens/Planning/PlanningDisplayScreen.js deleted file mode 100644 index bbb4a2b..0000000 --- a/screens/Planning/PlanningDisplayScreen.js +++ /dev/null @@ -1,65 +0,0 @@ -// @flow - -import * as React from 'react'; -import {Image, ScrollView, View} from 'react-native'; -import ThemeManager from "../../utils/ThemeManager"; -import HTML from "react-native-render-html"; -import {Linking} from "expo"; -import PlanningEventManager from '../../utils/PlanningEventManager'; -import {Card, withTheme} from 'react-native-paper'; - -type Props = { - navigation: Object, - route: Object -}; - -function openWebLink(event, link) { - Linking.openURL(link).catch((err) => console.error('Error opening link', err)); -} - -/** - * Class defining an about screen. This screen shows the user information about the app and it's author. - */ -class PlanningDisplayScreen extends React.Component { - - displayData = this.props.route.params['data']; - - colors: Object; - - constructor(props) { - super(props); - this.colors = props.theme.colors; - } - - render() { - // console.log("rendering planningDisplayScreen"); - return ( - - - {this.displayData.logo !== null ? - - - - : } - - {this.displayData.description !== null ? - // Surround description with div to allow text styling if the description is not html - - " + this.displayData.description + ""} - tagsStyles={{ - p: {color: this.colors.text,}, - div: {color: this.colors.text} - }} - onLinkPress={openWebLink}/> - - : } - - ); - } -} - -export default withTheme(PlanningDisplayScreen); diff --git a/screens/Proximo/ProximoListScreen.js b/screens/Proximo/ProximoListScreen.js deleted file mode 100644 index fc0b3cc..0000000 --- a/screens/Proximo/ProximoListScreen.js +++ /dev/null @@ -1,331 +0,0 @@ -// @flow - -import * as React from 'react'; -import {Platform, Image, ScrollView, View} from "react-native"; -import i18n from "i18n-js"; -import CustomModal from "../../components/CustomModal"; -import {Avatar, IconButton, List, RadioButton, Searchbar, Subheading, Text, Title, withTheme} from "react-native-paper"; -import PureFlatList from "../../components/PureFlatList"; - -function sortPrice(a, b) { - return a.price - b.price; -} - -function sortPriceReverse(a, b) { - return b.price - a.price; -} - -function sortName(a, b) { - if (a.name.toLowerCase() < b.name.toLowerCase()) - return -1; - if (a.name.toLowerCase() > b.name.toLowerCase()) - return 1; - return 0; -} - -function sortNameReverse(a, b) { - if (a.name.toLowerCase() < b.name.toLowerCase()) - return 1; - if (a.name.toLowerCase() > b.name.toLowerCase()) - return -1; - return 0; -} - -type Props = { - navigation: Object, - route: Object, -} - -type State = { - currentSortMode: number, - modalCurrentDisplayItem: React.Node, - currentlyDisplayedData: Array, -}; - -/** - * Class defining proximo's article list of a certain category. - */ -class ProximoListScreen extends React.Component { - - modalRef: Object; - originalData: Array; - shouldFocusSearchBar: boolean; - - onSearchStringChange: Function; - onSortMenuPress: Function; - renderItem: Function; - onModalRef: Function; - - colors: Object; - - constructor(props) { - super(props); - this.originalData = this.props.route.params['data']['data']; - this.shouldFocusSearchBar = this.props.route.params['shouldFocusSearchBar']; - this.state = { - currentlyDisplayedData: this.originalData.sort(sortName), - currentSortMode: 3, - modalCurrentDisplayItem: null, - }; - - this.onSearchStringChange = this.onSearchStringChange.bind(this); - this.onSortMenuPress = this.onSortMenuPress.bind(this); - this.renderItem = this.renderItem.bind(this); - this.onModalRef = this.onModalRef.bind(this); - this.colors = props.theme.colors; - } - - - /** - * Set the sort mode from state when components are ready - */ - componentDidMount() { - const button = this.getSortMenu.bind(this); - const title = this.getSearchBar.bind(this); - this.props.navigation.setOptions({ - headerRight: button, - headerTitle: title, - headerBackTitleVisible: false, - headerTitleContainerStyle: Platform.OS === 'ios' ? - {marginHorizontal: 0, width: '70%'} : - {marginHorizontal: 0, right: 50, left: 50}, - }); - } - - /** - * Set the current sort mode. - * - * @param mode The number representing the mode - */ - setSortMode(mode: number) { - this.setState({ - currentSortMode: mode, - }); - let data = this.state.currentlyDisplayedData; - switch (mode) { - case 1: - data.sort(sortPrice); - break; - case 2: - data.sort(sortPriceReverse); - break; - case 3: - data.sort(sortName); - break; - case 4: - data.sort(sortNameReverse); - break; - } - if (this.modalRef && mode !== this.state.currentSortMode) { - this.modalRef.close(); - } - } - - getSearchBar() { - return ( - - ); - } - - /** - * get color depending on quantity available - * - * @param availableStock - * @return - */ - getStockColor(availableStock: number) { - let color: string; - if (availableStock > 3) - color = this.colors.success; - else if (availableStock > 0) - color = this.colors.warning; - else - color = this.colors.danger; - return color; - } - - sanitizeString(str: string) { - return str.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, ""); - } - - /** - * Returns only the articles whose name contains str. Case and accents insensitive. - * @param str - * @returns {[]} - */ - filterData(str: string) { - let filteredData = []; - const testStr = this.sanitizeString(str); - const articles = this.originalData; - for (const article of articles) { - const name = this.sanitizeString(article.name); - if (name.includes(testStr)) { - filteredData.push(article) - } - } - return filteredData; - } - - onSearchStringChange(str: string) { - this.setState({ - currentlyDisplayedData: this.filterData(str) - }) - } - - getModalItemContent(item: Object) { - return ( - - {item.name} - - - {item.quantity + ' ' + i18n.t('proximoScreen.inStock')} - - {item.price}€ - - - - - - - {item.description} - - - ); - } - - getModalSortMenu() { - return ( - - {i18n.t('proximoScreen.sortOrder')} - this.setSortMode(value)} - value={this.state.currentSortMode} - > - - - {i18n.t('proximoScreen.sortPrice')} - - - - {i18n.t('proximoScreen.sortPriceReverse')} - - - - {i18n.t('proximoScreen.sortName')} - - - - {i18n.t('proximoScreen.sortNameReverse')} - - - - ); - } - - onListItemPress(item: Object) { - this.setState({ - modalCurrentDisplayItem: this.getModalItemContent(item) - }); - if (this.modalRef) { - this.modalRef.open(); - } - } - - onSortMenuPress() { - this.setState({ - modalCurrentDisplayItem: this.getModalSortMenu() - }); - if (this.modalRef) { - this.modalRef.open(); - } - } - - getSortMenu() { - return ( - - ); - } - - renderItem({item}: Object) { - const onPress = this.onListItemPress.bind(this, item); - return ( - } - right={() => - - {item.price}€ - } - /> - ); - } - - keyExtractor(item: Object) { - return item.name + item.code; - } - - onModalRef(ref: Object) { - this.modalRef = ref; - } - - render() { - return ( - - - {this.state.modalCurrentDisplayItem} - - - - ); - } -} - -export default withTheme(ProximoListScreen); diff --git a/screens/Proxiwash/ProxiwashScreen.js b/screens/Proxiwash/ProxiwashScreen.js deleted file mode 100644 index 16430d7..0000000 --- a/screens/Proxiwash/ProxiwashScreen.js +++ /dev/null @@ -1,475 +0,0 @@ -// @flow - -import * as React from 'react'; -import {Alert, Platform, View} from 'react-native'; -import i18n from "i18n-js"; -import WebSectionList from "../../components/WebSectionList"; -import NotificationsManager from "../../utils/NotificationsManager"; -import AsyncStorageManager from "../../utils/AsyncStorageManager"; -import * as Expo from "expo"; -import {Avatar, Banner, Button, Card, Text, withTheme} from 'react-native-paper'; -import HeaderButton from "../../components/HeaderButton"; -import ProxiwashListItem from "../../components/ProxiwashListItem"; -import ProxiwashConstants from "../../constants/ProxiwashConstants"; -import CustomModal from "../../components/CustomModal"; -import AprilFoolsManager from "../../utils/AprilFoolsManager"; - -const DATA_URL = "https://etud.insa-toulouse.fr/~amicale_app/washinsa/washinsa.json"; - -let stateStrings = {}; -let modalStateStrings = {}; -let stateIcons = {}; - -const REFRESH_TIME = 1000 * 10; // Refresh every 10 seconds - -type Props = { - navigation: Object, - theme: Object, -} - -type State = { - refreshing: boolean, - firstLoading: boolean, - modalCurrentDisplayItem: React.Node, - machinesWatched: Array, - bannerVisible: boolean, -}; - - -/** - * Class defining the app's proxiwash screen. This screen shows information about washing machines and - * dryers, taken from a scrapper reading proxiwash website - */ -class ProxiwashScreen extends React.Component { - - modalRef: Object; - - onAboutPress: Function; - getRenderItem: Function; - getRenderSectionHeader: Function; - createDataset: Function; - onHideBanner: Function; - onModalRef: Function; - - fetchedData: Object; - colors: Object; - - state = { - refreshing: false, - firstLoading: true, - fetchedData: {}, - // machinesWatched: JSON.parse(dataString), - machinesWatched: [], - modalCurrentDisplayItem: null, - bannerVisible: AsyncStorageManager.getInstance().preferences.proxiwashShowBanner.current === '1', - }; - - /** - * Creates machine state parameters using current theme and translations - */ - constructor(props) { - super(props); - stateStrings[ProxiwashConstants.machineStates.TERMINE] = i18n.t('proxiwashScreen.states.finished'); - stateStrings[ProxiwashConstants.machineStates.DISPONIBLE] = i18n.t('proxiwashScreen.states.ready'); - stateStrings[ProxiwashConstants.machineStates["EN COURS"]] = i18n.t('proxiwashScreen.states.running'); - stateStrings[ProxiwashConstants.machineStates.HS] = i18n.t('proxiwashScreen.states.broken'); - stateStrings[ProxiwashConstants.machineStates.ERREUR] = i18n.t('proxiwashScreen.states.error'); - - modalStateStrings[ProxiwashConstants.machineStates.TERMINE] = i18n.t('proxiwashScreen.modal.finished'); - modalStateStrings[ProxiwashConstants.machineStates.DISPONIBLE] = i18n.t('proxiwashScreen.modal.ready'); - modalStateStrings[ProxiwashConstants.machineStates["EN COURS"]] = i18n.t('proxiwashScreen.modal.running'); - modalStateStrings[ProxiwashConstants.machineStates.HS] = i18n.t('proxiwashScreen.modal.broken'); - modalStateStrings[ProxiwashConstants.machineStates.ERREUR] = i18n.t('proxiwashScreen.modal.error'); - - stateIcons[ProxiwashConstants.machineStates.TERMINE] = 'check-circle'; - stateIcons[ProxiwashConstants.machineStates.DISPONIBLE] = 'radiobox-blank'; - stateIcons[ProxiwashConstants.machineStates["EN COURS"]] = 'progress-check'; - stateIcons[ProxiwashConstants.machineStates.HS] = 'alert-octagram-outline'; - stateIcons[ProxiwashConstants.machineStates.ERREUR] = 'alert'; - - // let dataString = AsyncStorageManager.getInstance().preferences.proxiwashWatchedMachines.current; - this.onAboutPress = this.onAboutPress.bind(this); - this.getRenderItem = this.getRenderItem.bind(this); - this.getRenderSectionHeader = this.getRenderSectionHeader.bind(this); - this.createDataset = this.createDataset.bind(this); - this.onHideBanner = this.onHideBanner.bind(this); - this.onModalRef = this.onModalRef.bind(this); - this.colors = props.theme.colors; - } - - onHideBanner() { - this.setState({bannerVisible: false}); - AsyncStorageManager.getInstance().savePref( - AsyncStorageManager.getInstance().preferences.proxiwashShowBanner.key, - '0' - ); - } - - /** - * Setup notification channel for android and add listeners to detect notifications fired - */ - componentDidMount() { - const rightButton = this.getRightButton.bind(this); - this.props.navigation.setOptions({ - headerRight: rightButton, - }); - if (AsyncStorageManager.getInstance().preferences.expoToken.current !== '') { - // Get latest watchlist from server - NotificationsManager.getMachineNotificationWatchlist((fetchedList) => { - this.setState({machinesWatched: fetchedList}) - }); - // Get updated watchlist after received notification - Expo.Notifications.addListener(() => { - NotificationsManager.getMachineNotificationWatchlist((fetchedList) => { - this.setState({machinesWatched: fetchedList}) - }); - }); - if (Platform.OS === 'android') { - Expo.Notifications.createChannelAndroidAsync('reminders', { - name: 'Reminders', - priority: 'max', - vibrate: [0, 250, 250, 250], - }); - } - } - } - - getDryersKeyExtractor(item: Object) { - return item !== undefined ? "dryer" + item.number : undefined; - } - - getWashersKeyExtractor(item: Object) { - return item !== undefined ? "washer" + item.number : undefined; - } - - /** - * Setup notifications for the machine with the given ID. - * One notification will be sent at the end of the program. - * Another will be send a few minutes before the end, based on the value of reminderNotifTime - * - * @param machineId The machine's ID - * @returns {Promise} - */ - setupNotifications(machineId: string) { - if (AsyncStorageManager.getInstance().preferences.expoToken.current !== '') { - if (!this.isMachineWatched(machineId)) { - NotificationsManager.setupMachineNotification(machineId, true); - this.saveNotificationToState(machineId); - } else - this.disableNotification(machineId); - } else { - this.showNotificationsDisabledWarning(); - } - } - - showNotificationsDisabledWarning() { - Alert.alert( - i18n.t("proxiwashScreen.modal.notificationErrorTitle"), - i18n.t("proxiwashScreen.modal.notificationErrorDescription"), - ); - } - - /** - * Stop scheduled notifications for the machine of the given ID. - * This will also remove the notification if it was already shown. - * - * @param machineId The machine's ID - */ - disableNotification(machineId: string) { - let data = this.state.machinesWatched; - if (data.length > 0) { - let arrayIndex = data.indexOf(machineId); - if (arrayIndex !== -1) { - NotificationsManager.setupMachineNotification(machineId, false); - this.removeNotificationFroState(arrayIndex); - } - } - } - - /** - * Add the given notifications associated to a machine ID to the watchlist, and save the array to the preferences - * - * @param machineId - */ - saveNotificationToState(machineId: string) { - let data = this.state.machinesWatched; - data.push(machineId); - this.updateNotificationState(data); - } - - /** - * remove the given index from the watchlist array and save it to preferences - * - * @param index - */ - removeNotificationFroState(index: number) { - let data = this.state.machinesWatched; - data.splice(index, 1); - this.updateNotificationState(data); - } - - /** - * Set the given data as the watchlist and save it to preferences - * - * @param data - */ - updateNotificationState(data: Array) { - this.setState({machinesWatched: data}); - // let prefKey = AsyncStorageManager.getInstance().preferences.proxiwashWatchedMachines.key; - // AsyncStorageManager.getInstance().savePref(prefKey, JSON.stringify(data)); - } - - /** - * Checks whether the machine of the given ID has scheduled notifications - * - * @param machineID The machine's ID - * @returns {boolean} - */ - isMachineWatched(machineID: string) { - return this.state.machinesWatched.indexOf(machineID) !== -1; - } - - createDataset(fetchedData: Object) { - let data = fetchedData; - if (AprilFoolsManager.getInstance().isAprilFoolsEnabled()) { - data = JSON.parse(JSON.stringify(fetchedData)); // Deep copy - AprilFoolsManager.getNewProxiwashDryerOrderedList(data.dryers); - AprilFoolsManager.getNewProxiwashWasherOrderedList(data.washers); - } - this.fetchedData = fetchedData; - - return [ - { - title: i18n.t('proxiwashScreen.dryers'), - icon: 'tumble-dryer', - data: data.dryers === undefined ? [] : data.dryers, - extraData: this.state, - keyExtractor: this.getDryersKeyExtractor - }, - { - title: i18n.t('proxiwashScreen.washers'), - icon: 'washing-machine', - data: data.washers === undefined ? [] : data.washers, - extraData: this.state, - keyExtractor: this.getWashersKeyExtractor - }, - ]; - } - - showModal(title: string, item: Object, isDryer: boolean) { - this.setState({ - modalCurrentDisplayItem: this.getModalContent(title, item, isDryer) - }); - if (this.modalRef) { - this.modalRef.open(); - } - } - - onSetupNotificationsPress(machineId: string) { - if (this.modalRef) { - this.modalRef.close(); - } - this.setupNotifications(machineId) - } - - getModalContent(title: string, item: Object, isDryer: boolean) { - let button = { - text: i18n.t("proxiwashScreen.modal.ok"), - icon: '', - onPress: undefined - }; - let message = modalStateStrings[ProxiwashConstants.machineStates[item.state]]; - const onPress = this.onSetupNotificationsPress.bind(this, item.number); - if (ProxiwashConstants.machineStates[item.state] === ProxiwashConstants.machineStates["EN COURS"]) { - button = - { - text: this.isMachineWatched(item.number) ? - i18n.t("proxiwashScreen.modal.disableNotifications") : - i18n.t("proxiwashScreen.modal.enableNotifications"), - icon: '', - onPress: onPress - } - ; - message = i18n.t('proxiwashScreen.modal.running', - { - start: item.startTime, - end: item.endTime, - remaining: item.remainingTime - }); - } else if (ProxiwashConstants.machineStates[item.state] === ProxiwashConstants.machineStates.DISPONIBLE) { - if (isDryer) - message += '\n' + i18n.t('proxiwashScreen.dryersTariff'); - else - message += '\n' + i18n.t('proxiwashScreen.washersTariff'); - } - return ( - - } - - /> - - {message} - - - {button.onPress !== undefined ? - - - : null} - - ); - } - - onAboutPress() { - this.props.navigation.navigate('ProxiwashAboutScreen'); - } - - getRightButton() { - return ( - - ); - } - - onModalRef(ref: Object) { - this.modalRef = ref; - } - - getMachineAvailableNumber(isDryer: boolean) { - let data; - if (isDryer) - data = this.fetchedData.dryers; - else - data = this.fetchedData.washers; - let count = 0; - for (let i = 0; i < data.length; i++) { - if (ProxiwashConstants.machineStates[data[i].state] === ProxiwashConstants.machineStates["DISPONIBLE"]) - count += 1; - } - return count; - } - - getRenderSectionHeader({section}: Object) { - const isDryer = section.title === i18n.t('proxiwashScreen.dryers'); - const nbAvailable = this.getMachineAvailableNumber(isDryer); - const subtitle = nbAvailable + ' ' + ((nbAvailable <= 1) ? i18n.t('proxiwashScreen.numAvailable') - : i18n.t('proxiwashScreen.numAvailablePlural')); - return ( - - - - - {section.title} - - - - {subtitle} - - - - ); - } - - /** - * Get list item to be rendered - * - * @param item The object containing the item's FetchedData - * @param section The object describing the current SectionList section - * @returns {React.Node} - */ - getRenderItem({item, section}: Object) { - const isMachineRunning = ProxiwashConstants.machineStates[item.state] === ProxiwashConstants.machineStates["EN COURS"]; - let displayNumber = item.number; - if (AprilFoolsManager.getInstance().isAprilFoolsEnabled()) - displayNumber = AprilFoolsManager.getProxiwashMachineDisplayNumber(parseInt(item.number)); - const machineName = (section.title === i18n.t('proxiwashScreen.dryers') ? - i18n.t('proxiwashScreen.dryer') : - i18n.t('proxiwashScreen.washer')) + ' n°' + displayNumber; - const isDryer = section.title === i18n.t('proxiwashScreen.dryers'); - const onPress = this.showModal.bind(this, machineName, item, isDryer); - let width = item.donePercent !== '' ? (parseInt(item.donePercent)).toString() + '%' : 0; - if (ProxiwashConstants.machineStates[item.state] === '0') - width = '100%'; - return ( - - ); - } - - render() { - const nav = this.props.navigation; - return ( - - } - > - {i18n.t('proxiwashScreen.enableNotificationsTip')} - - - {this.state.modalCurrentDisplayItem} - - - - - ); - } -} - -export default withTheme(ProxiwashScreen); diff --git a/screens/Websites/AvailableRoomScreen.js b/screens/Websites/AvailableRoomScreen.js deleted file mode 100644 index 4ca15d4..0000000 --- a/screens/Websites/AvailableRoomScreen.js +++ /dev/null @@ -1,62 +0,0 @@ -// @flow - -import * as React from 'react'; -import WebViewScreen from "../../components/WebViewScreen"; -import i18n from "i18n-js"; - -type Props = { - navigation: Object, -} - - -const ROOM_URL = 'http://planex.insa-toulouse.fr/salles.php'; -const PC_URL = 'http://planex.insa-toulouse.fr/sallesInfo.php'; -const CUSTOM_CSS_GENERAL = 'https://etud.insa-toulouse.fr/~amicale_app/custom_css/rooms/customMobile.css'; - -/** - * Class defining the app's planex screen. - * This screen uses a webview to render the planex page - */ -export default class AvailableRoomScreen extends React.Component { - - customInjectedJS: string; - customBibInjectedJS: string; - - constructor() { - super(); - this.customInjectedJS = - 'document.querySelector(\'head\').innerHTML += \'\';' + - 'document.querySelector(\'head\').innerHTML += \'\';' + - 'let header = $(".table tbody tr:first");' + - '$("table").prepend("");true;' + // Fix for crash on ios - '$("thead").append(header);true;'; - } - - render() { - const nav = this.props.navigation; - return ( - - ); - } -} - diff --git a/screens/Websites/PlanexScreen.js b/screens/Websites/PlanexScreen.js deleted file mode 100644 index 2228659..0000000 --- a/screens/Websites/PlanexScreen.js +++ /dev/null @@ -1,173 +0,0 @@ -// @flow - -import * as React from 'react'; -import ThemeManager from "../../utils/ThemeManager"; -import WebViewScreen from "../../components/WebViewScreen"; -import {Avatar, Banner} from "react-native-paper"; -import i18n from "i18n-js"; -import {View} from "react-native"; -import AsyncStorageManager from "../../utils/AsyncStorageManager"; - -type Props = { - navigation: Object, -} - -type State = { - bannerVisible: boolean, -} - - -const PLANEX_URL = 'http://planex.insa-toulouse.fr/'; - -const CUSTOM_CSS_GENERAL = 'https://etud.insa-toulouse.fr/~amicale_app/custom_css/planex/customMobile3.css'; -const CUSTOM_CSS_NIGHTMODE = 'https://etud.insa-toulouse.fr/~amicale_app/custom_css/planex/customDark2.css'; - -// // JS + JQuery functions used to remove alpha from events. Copy paste in browser console for quick testing -// // Remove alpha from given Jquery node -// function removeAlpha(node) { -// let bg = node.css("background-color"); -// if (bg.match("^rgba")) { -// let a = bg.slice(5).split(','); -// // Fix for tooltips with broken background -// if (parseInt(a[0]) === parseInt(a[1]) && parseInt(a[1]) === parseInt(a[2]) && parseInt(a[2]) === 0) { -// a[0] = a[1] = a[2] = '255'; -// } -// let newBg ='rgb(' + a[0] + ',' + a[1] + ',' + a[2] + ')'; -// node.css("background-color", newBg); -// } -// } -// // Observe for planning DOM changes -// let observer = new MutationObserver(function(mutations) { -// for (let i = 0; i < mutations.length; i++) { -// if (mutations[i]['addedNodes'].length > 0 && -// ($(mutations[i]['addedNodes'][0]).hasClass("fc-event") || $(mutations[i]['addedNodes'][0]).hasClass("tooltiptopicevent"))) -// removeAlpha($(mutations[i]['addedNodes'][0])) -// } -// }); -// // observer.observe(document.querySelector(".fc-body"), {attributes: false, childList: true, characterData: false, subtree:true}); -// observer.observe(document.querySelector("body"), {attributes: false, childList: true, characterData: false, subtree:true}); -// // Run remove alpha a first time on whole planning. Useful when code injected after planning fully loaded. -// $(".fc-event-container .fc-event").each(function(index) { -// removeAlpha($(this)); -// }); - -// Watch for changes in the calendar and call the remove alpha function -const OBSERVE_MUTATIONS_INJECTED = - 'function removeAlpha(node) {\n' + - ' let bg = node.css("background-color");\n' + - ' if (bg.match("^rgba")) {\n' + - ' let a = bg.slice(5).split(\',\');\n' + - ' // Fix for tooltips with broken background\n' + - ' if (parseInt(a[0]) === parseInt(a[1]) && parseInt(a[1]) === parseInt(a[2]) && parseInt(a[2]) === 0) {\n' + - ' a[0] = a[1] = a[2] = \'255\';\n' + - ' }\n' + - ' let newBg =\'rgb(\' + a[0] + \',\' + a[1] + \',\' + a[2] + \')\';\n' + - ' node.css("background-color", newBg);\n' + - ' }\n' + - '}\n' + - '// Observe for planning DOM changes\n' + - 'let observer = new MutationObserver(function(mutations) {\n' + - ' for (let i = 0; i < mutations.length; i++) {\n' + - ' if (mutations[i][\'addedNodes\'].length > 0 &&\n' + - ' ($(mutations[i][\'addedNodes\'][0]).hasClass("fc-event") || $(mutations[i][\'addedNodes\'][0]).hasClass("tooltiptopicevent")))\n' + - ' removeAlpha($(mutations[i][\'addedNodes\'][0]))\n' + - ' }\n' + - '});\n' + - '// observer.observe(document.querySelector(".fc-body"), {attributes: false, childList: true, characterData: false, subtree:true});\n' + - 'observer.observe(document.querySelector("body"), {attributes: false, childList: true, characterData: false, subtree:true});\n' + - '// Run remove alpha a first time on whole planning. Useful when code injected after planning fully loaded.\n' + - '$(".fc-event-container .fc-event").each(function(index) {\n' + - ' removeAlpha($(this));\n' + - '});'; -/** - * Class defining the app's planex screen. - * This screen uses a webview to render the planex page - */ -export default class PlanexScreen extends React.Component { - - customInjectedJS: string; - onHideBanner: Function; - onGoToSettings: Function; - - constructor() { - super(); - this.customInjectedJS = - '$(document).ready(function() {' + - OBSERVE_MUTATIONS_INJECTED + - '$("head").append(\'\');' + - '$("head").append(\'\');'; - - if (ThemeManager.getNightMode()) - this.customInjectedJS += '$("head").append(\'\');'; - - this.customInjectedJS += - 'removeAlpha();' + - '});true;'; // Prevent crash on ios - this.onHideBanner = this.onHideBanner.bind(this); - this.onGoToSettings = this.onGoToSettings.bind(this); - } - - state = { - bannerVisible: - AsyncStorageManager.getInstance().preferences.planexShowBanner.current === '1' && - AsyncStorageManager.getInstance().preferences.defaultStartScreen.current !== 'Planex', - }; - - onHideBanner() { - this.setState({bannerVisible: false}); - AsyncStorageManager.getInstance().savePref( - AsyncStorageManager.getInstance().preferences.planexShowBanner.key, - '0' - ); - } - - onGoToSettings() { - this.onHideBanner(); - this.props.navigation.navigate('SettingsScreen'); - } - - render() { - const nav = this.props.navigation; - return ( - - } - > - {i18n.t('planexScreen.enableStartScreen')} - - - - ); - } -} - diff --git a/src/components/Amicale/AuthenticatedScreen.js b/src/components/Amicale/AuthenticatedScreen.js new file mode 100644 index 0000000..ac7ddc9 --- /dev/null +++ b/src/components/Amicale/AuthenticatedScreen.js @@ -0,0 +1,219 @@ +// @flow + +import * as React from 'react'; +import ConnectionManager from "../../managers/ConnectionManager"; +import {ERROR_TYPE} from "../../utils/WebData"; +import ErrorView from "../Screens/ErrorView"; +import BasicLoadingScreen from "../Screens/BasicLoadingScreen"; +import {StackNavigationProp} from "@react-navigation/stack"; + +type Props = { + navigation: StackNavigationProp, + requests: Array<{ + link: string, + params: Object, + mandatory: boolean + }>, + renderFunction: (Array<{ [key: string]: any } | null>) => React.Node, + errorViewOverride?: Array<{ + errorCode: number, + message: string, + icon: string, + showRetryButton: boolean + }>, +} + +type State = { + loading: boolean, +} + +class AuthenticatedScreen extends React.Component { + + state = { + loading: true, + }; + + currentUserToken: string | null; + connectionManager: ConnectionManager; + errors: Array; + fetchedData: Array<{ [key: string]: any } | null>; + + constructor(props: Object) { + super(props); + this.connectionManager = ConnectionManager.getInstance(); + this.props.navigation.addListener('focus', this.onScreenFocus); + this.fetchedData = new Array(this.props.requests.length); + this.errors = new Array(this.props.requests.length); + } + + /** + * Refreshes screen if user changed + */ + onScreenFocus = () => { + if (this.currentUserToken !== this.connectionManager.getToken()) { + this.currentUserToken = this.connectionManager.getToken(); + this.fetchData(); + } + }; + + /** + * Fetches the data from the server. + * + * If the user is not logged in errorCode is set to BAD_TOKEN and all requests fail. + * + * If the user is logged in, send all requests. + */ + fetchData = () => { + if (!this.state.loading) + this.setState({loading: true}); + if (this.connectionManager.isLoggedIn()) { + for (let i = 0; i < this.props.requests.length; i++) { + this.connectionManager.authenticatedRequest( + this.props.requests[i].link, + this.props.requests[i].params) + .then((data) => { + this.onRequestFinished(data, i, -1); + }) + .catch((error) => { + this.onRequestFinished(null, i, error); + }); + } + } else { + for (let i = 0; i < this.props.requests.length; i++) { + this.onRequestFinished(null, i, ERROR_TYPE.BAD_TOKEN); + } + } + }; + + /** + * Callback used when a request finishes, successfully or not. + * Saves data and error code. + * If the token is invalid, logout the user and open the login screen. + * If the last request was received, stop the loading screen. + * + * @param data The data fetched from the server + * @param index The index for the data + * @param error The error code received + */ + onRequestFinished(data: { [key: string]: any } | null, index: number, error: number) { + if (index >= 0 && index < this.props.requests.length) { + this.fetchedData[index] = data; + this.errors[index] = error; + } + + if (error === ERROR_TYPE.BAD_TOKEN) // Token expired, logout user + this.connectionManager.disconnect(); + + if (this.allRequestsFinished()) + this.setState({loading: false}); + } + + /** + * Checks if all requests finished processing + * + * @return {boolean} True if all finished + */ + allRequestsFinished() { + let finished = true; + for (let i = 0; i < this.fetchedData.length; i++) { + if (this.fetchedData[i] === undefined) { + finished = false; + break; + } + } + return finished; + } + + /** + * Checks if all requests have finished successfully. + * This will return false only if a mandatory request failed. + * All non-mandatory requests can fail without impacting the return value. + * + * @return {boolean} True if all finished successfully + */ + allRequestsValid() { + let valid = true; + for (let i = 0; i < this.fetchedData.length; i++) { + if (this.fetchedData[i] === null && this.props.requests[i].mandatory) { + valid = false; + break; + } + } + return valid; + } + + /** + * Gets the error to render. + * Non-mandatory requests are ignored. + * + * + * @return {number} The error code or ERROR_TYPE.SUCCESS if no error was found + */ + getError() { + for (let i = 0; i < this.errors.length; i++) { + if (this.errors[i] !== 0 && this.props.requests[i].mandatory) { + return this.errors[i]; + } + } + return ERROR_TYPE.SUCCESS; + } + + /** + * Gets the error view to display in case of error + * + * @return {*} + */ + getErrorRender() { + const errorCode = this.getError(); + let shouldOverride = false; + let override = null; + const overrideList = this.props.errorViewOverride; + if (overrideList != null) { + for (let i = 0; i < overrideList.length; i++) { + if (overrideList[i].errorCode === errorCode) { + shouldOverride = true; + override = overrideList[i]; + break; + } + } + } + if (shouldOverride && override != null) { + return ( + + ); + } else { + return ( + + ); + } + + } + + /** + * Reloads the data, to be called using ref by parent components + */ + reload() { + this.fetchData(); + } + + render() { + return ( + this.state.loading + ? + : (this.allRequestsValid() + ? this.props.renderFunction(this.fetchedData) + : this.getErrorRender()) + ); + } +} + +export default AuthenticatedScreen; diff --git a/src/components/Amicale/LogoutDialog.js b/src/components/Amicale/LogoutDialog.js new file mode 100644 index 0000000..71e208b --- /dev/null +++ b/src/components/Amicale/LogoutDialog.js @@ -0,0 +1,46 @@ +// @flow + +import * as React from 'react'; +import i18n from 'i18n-js'; +import LoadingConfirmDialog from "../Dialogs/LoadingConfirmDialog"; +import ConnectionManager from "../../managers/ConnectionManager"; +import {StackNavigationProp} from "@react-navigation/stack"; + +type Props = { + navigation: StackNavigationProp, + visible: boolean, + onDismiss: () => void, +} + +class LogoutDialog extends React.PureComponent { + + onClickAccept = async () => { + return new Promise((resolve) => { + ConnectionManager.getInstance().disconnect() + .then(() => { + this.props.navigation.reset({ + index: 0, + routes: [{name: 'main'}], + }); + this.props.onDismiss(); + resolve(); + }); + }); + }; + + render() { + return ( + + ); + } +} + +export default LogoutDialog; diff --git a/src/components/Amicale/Vote/VoteResults.js b/src/components/Amicale/Vote/VoteResults.js new file mode 100644 index 0000000..fbe57b5 --- /dev/null +++ b/src/components/Amicale/Vote/VoteResults.js @@ -0,0 +1,116 @@ +// @flow + +import * as React from 'react'; +import {Avatar, Card, List, ProgressBar, Subheading, withTheme} from "react-native-paper"; +import {FlatList, StyleSheet} from "react-native"; +import i18n from 'i18n-js'; +import type {team} from "../../../screens/Amicale/VoteScreen"; +import type {CustomTheme} from "../../../managers/ThemeManager"; + + +type Props = { + teams: Array, + dateEnd: string, + theme: CustomTheme, +} + +class VoteResults extends React.Component { + + totalVotes: number; + winnerIds: Array; + + constructor(props) { + super(); + props.teams.sort(this.sortByVotes); + this.getTotalVotes(props.teams); + this.getWinnerIds(props.teams); + } + + shouldComponentUpdate() { + return false; + } + + sortByVotes = (a: team, b: team) => b.votes - a.votes; + + getTotalVotes(teams: Array) { + this.totalVotes = 0; + for (let i = 0; i < teams.length; i++) { + this.totalVotes += teams[i].votes; + } + } + + getWinnerIds(teams: Array) { + let max = teams[0].votes; + this.winnerIds = []; + for (let i = 0; i < teams.length; i++) { + if (teams[i].votes === max) + this.winnerIds.push(teams[i].id); + else + break; + } + } + + voteKeyExtractor = (item: team) => item.id.toString(); + + resultRenderItem = ({item}: { item: team }) => { + const isWinner = this.winnerIds.indexOf(item.id) !== -1; + const isDraw = this.winnerIds.length > 1; + const colors = this.props.theme.colors; + return ( + + isWinner + ? + : null} + titleStyle={{ + color: isWinner + ? colors.primary + : colors.text + }} + style={{padding: 0}} + /> + + + ); + }; + + render() { + return ( + + } + /> + + {i18n.t('voteScreen.results.totalVotes') + ' ' + this.totalVotes} + {/*$FlowFixMe*/} + + + + ); + } +} + +const styles = StyleSheet.create({ + card: { + margin: 10, + }, + icon: { + backgroundColor: 'transparent' + }, +}); + +export default withTheme(VoteResults); diff --git a/src/components/Amicale/Vote/VoteSelect.js b/src/components/Amicale/Vote/VoteSelect.js new file mode 100644 index 0000000..a3f249e --- /dev/null +++ b/src/components/Amicale/Vote/VoteSelect.js @@ -0,0 +1,137 @@ +// @flow + +import * as React from 'react'; +import {Avatar, Button, Card, RadioButton} from "react-native-paper"; +import {FlatList, StyleSheet, View} from "react-native"; +import ConnectionManager from "../../../managers/ConnectionManager"; +import LoadingConfirmDialog from "../../Dialogs/LoadingConfirmDialog"; +import ErrorDialog from "../../Dialogs/ErrorDialog"; +import i18n from 'i18n-js'; +import type {team} from "../../../screens/Amicale/VoteScreen"; + +type Props = { + teams: Array, + onVoteSuccess: () => void, + onVoteError: () => void, +} + +type State = { + selectedTeam: string, + voteDialogVisible: boolean, + errorDialogVisible: boolean, + currentError: number, +} + + +export default class VoteSelect extends React.PureComponent { + + state = { + selectedTeam: "none", + voteDialogVisible: false, + errorDialogVisible: false, + currentError: 0, + }; + + onVoteSelectionChange = (team: string) => this.setState({selectedTeam: team}); + + voteKeyExtractor = (item: team) => item.id.toString(); + + voteRenderItem = ({item}: { item: team }) => ; + + showVoteDialog = () => this.setState({voteDialogVisible: true}); + + onVoteDialogDismiss = () => this.setState({voteDialogVisible: false,}); + + onVoteDialogAccept = async () => { + return new Promise((resolve) => { + ConnectionManager.getInstance().authenticatedRequest( + "elections/vote", + {"team": parseInt(this.state.selectedTeam)}) + .then(() => { + this.onVoteDialogDismiss(); + this.props.onVoteSuccess(); + resolve(); + }) + .catch((error: number) => { + this.onVoteDialogDismiss(); + this.showErrorDialog(error); + resolve(); + }); + }); + }; + + showErrorDialog = (error: number) => this.setState({ + errorDialogVisible: true, + currentError: error, + }); + + onErrorDialogDismiss = () => { + this.setState({errorDialogVisible: false}); + this.props.onVoteError(); + }; + + render() { + return ( + + + + } + /> + + + {/*$FlowFixMe*/} + + + + + + + + + + + ); + } +} + +const styles = StyleSheet.create({ + card: { + margin: 10, + }, + icon: { + backgroundColor: 'transparent' + }, +}); diff --git a/src/components/Amicale/Vote/VoteTease.js b/src/components/Amicale/Vote/VoteTease.js new file mode 100644 index 0000000..0e19e7f --- /dev/null +++ b/src/components/Amicale/Vote/VoteTease.js @@ -0,0 +1,45 @@ +// @flow + +import * as React from 'react'; +import {Avatar, Card, Paragraph} from "react-native-paper"; +import {StyleSheet} from "react-native"; +import i18n from 'i18n-js'; + +type Props = { + startDate: string, +} + +export default class VoteTease extends React.Component { + + shouldComponentUpdate() { + return false; + } + + render() { + return ( + + } + /> + + + {i18n.t('voteScreen.tease.message') + ' ' + this.props.startDate} + + + + ); + } +} + +const styles = StyleSheet.create({ + card: { + margin: 10, + }, + icon: { + backgroundColor: 'transparent' + }, +}); diff --git a/src/components/Amicale/Vote/VoteTitle.js b/src/components/Amicale/Vote/VoteTitle.js new file mode 100644 index 0000000..e70fded --- /dev/null +++ b/src/components/Amicale/Vote/VoteTitle.js @@ -0,0 +1,48 @@ +// @flow + +import * as React from 'react'; +import {Avatar, Card, Paragraph} from "react-native-paper"; +import {StyleSheet} from "react-native"; +import i18n from 'i18n-js'; + +const ICON_AMICALE = require('../../../../assets/amicale.png'); + +export default class VoteTitle extends React.Component<{}> { + + shouldComponentUpdate() { + return false; + } + + render() { + return ( + + } + /> + + + {i18n.t('voteScreen.title.paragraph1')} + + + {i18n.t('voteScreen.title.paragraph2')} + + + + ); + } +} + +const styles = StyleSheet.create({ + card: { + margin: 10, + }, + icon: { + backgroundColor: 'transparent' + }, +}); diff --git a/src/components/Amicale/Vote/VoteWait.js b/src/components/Amicale/Vote/VoteWait.js new file mode 100644 index 0000000..c275f2f --- /dev/null +++ b/src/components/Amicale/Vote/VoteWait.js @@ -0,0 +1,72 @@ +// @flow + +import * as React from 'react'; +import {ActivityIndicator, Card, Paragraph, withTheme} from "react-native-paper"; +import {StyleSheet} from "react-native"; +import i18n from 'i18n-js'; +import type {CustomTheme} from "../../../managers/ThemeManager"; + +type Props = { + startDate: string | null, + justVoted: boolean, + hasVoted: boolean, + isVoteRunning: boolean, + theme: CustomTheme, +} + +class VoteWait extends React.Component { + + shouldComponentUpdate() { + return false; + } + + render() { + const colors = this.props.theme.colors; + const startDate = this.props.startDate; + return ( + + } + /> + + { + this.props.justVoted + ? + {i18n.t('voteScreen.wait.messageSubmitted')} + + : null + } + { + this.props.hasVoted + ? + {i18n.t('voteScreen.wait.messageVoted')} + + : null + } + { + startDate != null + ? + {i18n.t('voteScreen.wait.messageDate') + ' ' + startDate} + + : {i18n.t('voteScreen.wait.messageDateUndefined')} + } + + + ); + } +} + +const styles = StyleSheet.create({ + card: { + margin: 10, + }, + icon: { + backgroundColor: 'transparent' + }, +}); + +export default withTheme(VoteWait); diff --git a/src/components/Animations/AnimatedAccordion.js b/src/components/Animations/AnimatedAccordion.js new file mode 100644 index 0000000..9cc7dba --- /dev/null +++ b/src/components/Animations/AnimatedAccordion.js @@ -0,0 +1,101 @@ +// @flow + +import * as React from 'react'; +import {View} from "react-native"; +import {List, withTheme} from 'react-native-paper'; +import Collapsible from "react-native-collapsible"; +import * as Animatable from "react-native-animatable"; +import type {CustomTheme} from "../../managers/ThemeManager"; + +type Props = { + theme: CustomTheme, + title: string, + subtitle?: string, + left?: (props: { [keys: string]: any }) => React.Node, + opened?: boolean, + unmountWhenCollapsed: boolean, + children?: React.Node, +} + +type State = { + expanded: boolean, +} + +const AnimatedListIcon = Animatable.createAnimatableComponent(List.Icon); + +class AnimatedAccordion extends React.Component { + + static defaultProps = { + unmountWhenCollapsed: false, + } + chevronRef: { current: null | AnimatedListIcon }; + chevronIcon: string; + animStart: string; + animEnd: string; + + state = { + expanded: this.props.opened != null ? this.props.opened : false, + } + + constructor(props) { + super(props); + this.chevronRef = React.createRef(); + this.setupChevron(); + } + + setupChevron() { + if (this.state.expanded) { + this.chevronIcon = "chevron-up"; + this.animStart = "180deg"; + this.animEnd = "0deg"; + } else { + this.chevronIcon = "chevron-down"; + this.animStart = "0deg"; + this.animEnd = "180deg"; + } + } + + toggleAccordion = () => { + if (this.chevronRef.current != null) { + this.chevronRef.current.transitionTo({rotate: this.state.expanded ? this.animStart : this.animEnd}); + this.setState({expanded: !this.state.expanded}) + } + }; + + shouldComponentUpdate(nextProps: Props, nextState: State): boolean { + if (nextProps.opened != null && nextProps.opened !== this.props.opened) + this.state.expanded = nextProps.opened; + return true; + } + + render() { + const colors = this.props.theme.colors; + return ( + + } + left={this.props.left} + /> + + {!this.props.unmountWhenCollapsed || (this.props.unmountWhenCollapsed && this.state.expanded) + ? this.props.children + : null} + + + ); + } + +} + +export default withTheme(AnimatedAccordion); \ No newline at end of file diff --git a/src/components/Animations/AnimatedBottomBar.js b/src/components/Animations/AnimatedBottomBar.js new file mode 100644 index 0000000..6f37815 --- /dev/null +++ b/src/components/Animations/AnimatedBottomBar.js @@ -0,0 +1,170 @@ +// @flow + +import * as React from 'react'; +import {StyleSheet, View} from "react-native"; +import {FAB, IconButton, Surface, withTheme} from "react-native-paper"; +import AutoHideHandler from "../../utils/AutoHideHandler"; +import * as Animatable from 'react-native-animatable'; +import CustomTabBar from "../Tabbar/CustomTabBar"; +import {StackNavigationProp} from "@react-navigation/stack"; +import type {CustomTheme} from "../../managers/ThemeManager"; + +const AnimatedFAB = Animatable.createAnimatableComponent(FAB); + +type Props = { + navigation: StackNavigationProp, + theme: CustomTheme, + onPress: (action: string, data: any) => void, + seekAttention: boolean, +} + +type State = { + currentMode: string, +} + +const DISPLAY_MODES = { + DAY: "agendaDay", + WEEK: "agendaWeek", + MONTH: "month", +} + +class AnimatedBottomBar extends React.Component { + + ref: { current: null | Animatable.View }; + hideHandler: AutoHideHandler; + + displayModeIcons: { [key: string]: string }; + + state = { + currentMode: DISPLAY_MODES.WEEK, + } + + constructor() { + super(); + this.ref = React.createRef(); + this.hideHandler = new AutoHideHandler(false); + this.hideHandler.addListener(this.onHideChange); + + this.displayModeIcons = {}; + this.displayModeIcons[DISPLAY_MODES.DAY] = "calendar-text"; + this.displayModeIcons[DISPLAY_MODES.WEEK] = "calendar-week"; + this.displayModeIcons[DISPLAY_MODES.MONTH] = "calendar-range"; + } + + shouldComponentUpdate(nextProps: Props, nextState: State) { + return (nextProps.seekAttention !== this.props.seekAttention) + || (nextState.currentMode !== this.state.currentMode); + } + + onHideChange = (shouldHide: boolean) => { + if (this.ref.current != null) { + if (shouldHide) + this.ref.current.fadeOutDown(500); + else + this.ref.current.fadeInUp(500); + } + } + + onScroll = (event: SyntheticEvent) => { + this.hideHandler.onScroll(event); + }; + + changeDisplayMode = () => { + let newMode; + switch (this.state.currentMode) { + case DISPLAY_MODES.DAY: + newMode = DISPLAY_MODES.WEEK; + break; + case DISPLAY_MODES.WEEK: + newMode = DISPLAY_MODES.MONTH; + + break; + case DISPLAY_MODES.MONTH: + newMode = DISPLAY_MODES.DAY; + break; + } + this.setState({currentMode: newMode}); + this.props.onPress("changeView", newMode); + }; + + render() { + const buttonColor = this.props.theme.colors.primary; + return ( + + + + this.props.navigation.navigate('group-select')} + /> + + + + this.props.onPress('today', undefined)}/> + + + this.props.onPress('prev', undefined)}/> + this.props.onPress('next', undefined)}/> + + + + ); + } +} + +const styles = StyleSheet.create({ + container: { + position: 'absolute', + left: '5%', + width: '90%', + }, + surface: { + position: 'relative', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + borderRadius: 50, + elevation: 2, + }, + fabContainer: { + position: "absolute", + left: 0, + right: 0, + alignItems: "center", + width: '100%', + height: '100%' + }, + fab: { + position: 'absolute', + alignSelf: 'center', + top: '-25%', + } +}); + +export default withTheme(AnimatedBottomBar); diff --git a/src/components/Animations/AnimatedFAB.js b/src/components/Animations/AnimatedFAB.js new file mode 100644 index 0000000..24ff02f --- /dev/null +++ b/src/components/Animations/AnimatedFAB.js @@ -0,0 +1,66 @@ +// @flow + +import * as React from 'react'; +import {StyleSheet} from "react-native"; +import {FAB} from "react-native-paper"; +import AutoHideHandler from "../../utils/AutoHideHandler"; +import * as Animatable from 'react-native-animatable'; +import CustomTabBar from "../Tabbar/CustomTabBar"; +import {StackNavigationProp} from "@react-navigation/stack"; + +type Props = { + navigation: StackNavigationProp, + icon: string, + onPress: () => void, +} + +const AnimatedFab = Animatable.createAnimatableComponent(FAB); + +export default class AnimatedFAB extends React.Component { + + ref: { current: null | Animatable.View }; + hideHandler: AutoHideHandler; + + constructor() { + super(); + this.ref = React.createRef(); + this.hideHandler = new AutoHideHandler(false); + this.hideHandler.addListener(this.onHideChange); + } + + onScroll = (event: SyntheticEvent) => { + this.hideHandler.onScroll(event); + }; + + onHideChange = (shouldHide: boolean) => { + if (this.ref.current != null) { + if (shouldHide) + this.ref.current.bounceOutDown(1000); + else + this.ref.current.bounceInUp(1000); + } + } + + render() { + return ( + + ); + } +} + +const styles = StyleSheet.create({ + fab: { + position: 'absolute', + margin: 16, + right: 0, + }, +}); diff --git a/src/components/Dialogs/AlertDialog.js b/src/components/Dialogs/AlertDialog.js new file mode 100644 index 0000000..a79efc7 --- /dev/null +++ b/src/components/Dialogs/AlertDialog.js @@ -0,0 +1,34 @@ +// @flow + +import * as React from 'react'; +import {Button, Dialog, Paragraph, Portal} from 'react-native-paper'; + +type Props = { + visible: boolean, + onDismiss: () => void, + title: string, + message: string, +} + +class AlertDialog extends React.PureComponent { + + render() { + return ( + + + {this.props.title} + + {this.props.message} + + + + + + + ); + } +} + +export default AlertDialog; diff --git a/src/components/Dialogs/ErrorDialog.js b/src/components/Dialogs/ErrorDialog.js new file mode 100644 index 0000000..f357a5f --- /dev/null +++ b/src/components/Dialogs/ErrorDialog.js @@ -0,0 +1,57 @@ +// @flow + +import * as React from 'react'; +import i18n from "i18n-js"; +import {ERROR_TYPE} from "../../utils/WebData"; +import AlertDialog from "./AlertDialog"; + +type Props = { + visible: boolean, + onDismiss: () => void, + errorCode: number, +} + +class ErrorDialog extends React.PureComponent { + + title: string; + message: string; + + generateMessage() { + this.title = i18n.t("errors.title"); + switch (this.props.errorCode) { + case ERROR_TYPE.BAD_CREDENTIALS: + this.message = i18n.t("errors.badCredentials"); + break; + case ERROR_TYPE.BAD_TOKEN: + this.message = i18n.t("errors.badToken"); + break; + case ERROR_TYPE.NO_CONSENT: + this.message = i18n.t("errors.noConsent"); + break; + case ERROR_TYPE.BAD_INPUT: + this.message = i18n.t("errors.badInput"); + break; + case ERROR_TYPE.FORBIDDEN: + this.message = i18n.t("errors.forbidden"); + break; + case ERROR_TYPE.CONNECTION_ERROR: + this.message = i18n.t("errors.connectionError"); + break; + case ERROR_TYPE.SERVER_ERROR: + this.message = i18n.t("errors.serverError"); + break; + default: + this.message = i18n.t("errors.unknown"); + break; + } + } + + render() { + this.generateMessage(); + return ( + + ); + } +} + +export default ErrorDialog; diff --git a/src/components/Dialogs/LoadingConfirmDialog.js b/src/components/Dialogs/LoadingConfirmDialog.js new file mode 100644 index 0000000..922bc7c --- /dev/null +++ b/src/components/Dialogs/LoadingConfirmDialog.js @@ -0,0 +1,92 @@ +// @flow + +import * as React from 'react'; +import {ActivityIndicator, Button, Dialog, Paragraph, Portal} from 'react-native-paper'; +import i18n from "i18n-js"; + +type Props = { + visible: boolean, + onDismiss: () => void, + onAccept: () => Promise, // async function to be executed + title: string, + titleLoading: string, + message: string, + startLoading: boolean, +} + +type State = { + loading: boolean, +} + +class LoadingConfirmDialog extends React.PureComponent { + + static defaultProps = { + title: '', + message: '', + onDismiss: () => {}, + onAccept: () => {return Promise.resolve()}, + startLoading: false, + } + + state = { + loading: this.props.startLoading, + }; + + /** + * Set the dialog into loading state and closes it when operation finishes + */ + onClickAccept = () => { + this.setState({loading: true}); + this.props.onAccept().then(this.hideLoading); + }; + + /** + * Waits for fade out animations to finish before hiding loading + * @returns {TimeoutID} + */ + hideLoading = () => setTimeout(() => { + this.setState({loading: false}) + }, 200); + + /** + * Hide the dialog if it is not loading + */ + onDismiss = () => { + if (!this.state.loading) + this.props.onDismiss(); + }; + + render() { + return ( + + + + {this.state.loading + ? this.props.titleLoading + : this.props.title} + + + {this.state.loading + ? + : {this.props.message} + } + + {this.state.loading + ? null + : + + + + } + + + ); + } +} + +export default LoadingConfirmDialog; diff --git a/src/components/Home/ActionsDashboardItem.js b/src/components/Home/ActionsDashboardItem.js new file mode 100644 index 0000000..ad501d6 --- /dev/null +++ b/src/components/Home/ActionsDashboardItem.js @@ -0,0 +1,86 @@ +// @flow + +import * as React from 'react'; +import {Avatar, Card, List, withTheme} from 'react-native-paper'; +import {StyleSheet, View} from "react-native"; +import type {CustomTheme} from "../../managers/ThemeManager"; +import i18n from 'i18n-js'; +import {StackNavigationProp} from "@react-navigation/stack"; + +const ICON_AMICALE = require("../../../assets/amicale.png"); + +type Props = { + navigation: StackNavigationProp, + theme: CustomTheme, + isLoggedIn: boolean, +} + +class ActionsDashBoardItem extends React.Component { + + shouldComponentUpdate(nextProps: Props): boolean { + return (nextProps.theme.dark !== this.props.theme.dark) + || (nextProps.isLoggedIn !== this.props.isLoggedIn); + } + + render() { + const isLoggedIn = this.props.isLoggedIn; + return ( + + + } + right={props => } + onPress={isLoggedIn + ? () => this.props.navigation.navigate("services", { + screen: 'index' + }) + : () => this.props.navigation.navigate("login")} + style={styles.list} + /> + + } + right={props => } + onPress={() => this.props.navigation.navigate("feedback")} + style={{...styles.list, marginLeft: 10, marginRight: 10}} + /> + + + ); + } +} + +const styles = StyleSheet.create({ + card: { + width: 'auto', + margin: 10, + borderWidth: 1, + }, + avatar: { + backgroundColor: 'transparent', + marginTop: 'auto', + marginBottom: 'auto', + }, + list: { + // height: 50, + paddingTop: 0, + paddingBottom: 0, + } +}); + +export default withTheme(ActionsDashBoardItem); diff --git a/src/components/Home/EventDashboardItem.js b/src/components/Home/EventDashboardItem.js new file mode 100644 index 0000000..e902e29 --- /dev/null +++ b/src/components/Home/EventDashboardItem.js @@ -0,0 +1,87 @@ +// @flow + +import * as React from 'react'; +import {Avatar, Card, Text, withTheme} from 'react-native-paper'; +import {StyleSheet} from "react-native"; +import i18n from "i18n-js"; +import type {CustomTheme} from "../../managers/ThemeManager"; + +type Props = { + eventNumber: number; + clickAction: () => void, + theme: CustomTheme, + children?: React.Node +} + +/** + * Component used to display a dashboard item containing a preview event + */ +class EventDashBoardItem extends React.Component { + + shouldComponentUpdate(nextProps: Props) { + return (nextProps.theme.dark !== this.props.theme.dark) + || (nextProps.eventNumber !== this.props.eventNumber); + } + + render() { + const props = this.props; + const colors = props.theme.colors; + const isAvailable = props.eventNumber > 0; + const iconColor = isAvailable ? + colors.planningColor : + colors.textDisabled; + const textColor = isAvailable ? + colors.text : + colors.textDisabled; + let subtitle; + if (isAvailable) { + subtitle = + + {props.eventNumber} + + {props.eventNumber > 1 + ? i18n.t('homeScreen.dashboard.todayEventsSubtitlePlural') + : i18n.t('homeScreen.dashboard.todayEventsSubtitle')} + + ; + } else + subtitle = i18n.t('homeScreen.dashboard.todayEventsSubtitleNA'); + return ( + + + } + /> + + {props.children} + + + ); + } + +} + +const styles = StyleSheet.create({ + card: { + width: 'auto', + marginLeft: 10, + marginRight: 10, + marginTop: 10, + overflow: 'hidden', + }, + avatar: { + backgroundColor: 'transparent' + } +}); + +export default withTheme(EventDashBoardItem); diff --git a/src/components/Home/FeedItem.js b/src/components/Home/FeedItem.js new file mode 100644 index 0000000..b69bf8c --- /dev/null +++ b/src/components/Home/FeedItem.js @@ -0,0 +1,118 @@ +// @flow + +import * as React from 'react'; +import {Avatar, Button, Card, Text} from 'react-native-paper'; +import {View} from "react-native"; +import Autolink from "react-native-autolink"; +import i18n from "i18n-js"; +import ImageModal from 'react-native-image-modal'; +import {StackNavigationProp} from "@react-navigation/stack"; +import type {CustomTheme} from "../../managers/ThemeManager"; +import type {feedItem} from "../../screens/Home/HomeScreen"; + +const ICON_AMICALE = require('../../../assets/amicale.png'); + +type Props = { + navigation: StackNavigationProp, + theme: CustomTheme, + item: feedItem, + title: string, + subtitle: string, + height: number, +} + + +/** + * Component used to display a feed item + */ +class FeedItem extends React.Component { + + shouldComponentUpdate() { + return false; + } + + /** + * Gets the amicale INSAT logo + * + * @return {*} + */ + getAvatar() { + return ( + + ); + } + + onPress = () => { + this.props.navigation.navigate( + 'feed-information', + { + data: this.props.item, + date: this.props.subtitle + }); + }; + + render() { + const item = this.props.item; + const hasImage = item.full_picture !== '' && item.full_picture !== undefined; + + const cardMargin = 10; + const cardHeight = this.props.height - 2 * cardMargin; + const imageSize = 250; + const titleHeight = 80; + const actionsHeight = 60; + const textHeight = hasImage + ? cardHeight - titleHeight - actionsHeight - imageSize + : cardHeight - titleHeight - actionsHeight; + return ( + + + {hasImage ? + + : null} + + {item.message !== undefined ? + : null + } + + + + + + ); + } +} + +export default FeedItem; diff --git a/src/components/Home/PreviewEventDashboardItem.js b/src/components/Home/PreviewEventDashboardItem.js new file mode 100644 index 0000000..62c5227 --- /dev/null +++ b/src/components/Home/PreviewEventDashboardItem.js @@ -0,0 +1,89 @@ +// @flow + +import * as React from 'react'; +import {StyleSheet} from "react-native"; +import i18n from "i18n-js"; +import {Avatar, Button, Card} from 'react-native-paper'; +import {getFormattedEventTime, isDescriptionEmpty} from "../../utils/Planning"; +import CustomHTML from "../Overrides/CustomHTML"; +import type {CustomTheme} from "../../managers/ThemeManager"; +import type {event} from "../../screens/Home/HomeScreen"; + +type Props = { + event?: event, + clickAction: () => void, + theme?: CustomTheme, +} + +/** + * Component used to display an event preview if an event is available + */ +class PreviewEventDashboardItem extends React.Component { + + render() { + const props = this.props; + const isEmpty = props.event == null + ? true + : isDescriptionEmpty(props.event.description); + + if (props.event != null) { + const event = props.event; + const hasImage = event.logo !== '' && event.logo != null; + const getImage = () => ; + return ( + + {hasImage ? + : + } + {!isEmpty ? + + + : null} + + + + + + ); + } else + return null; + } +} + +const styles = StyleSheet.create({ + card: { + marginBottom: 10 + }, + content: { + maxHeight: 150, + overflow: 'hidden', + }, + actions: { + marginLeft: 'auto', + marginTop: 'auto', + flexDirection: 'row' + }, + avatar: { + backgroundColor: 'transparent' + } +}); + +export default PreviewEventDashboardItem; diff --git a/src/components/Home/SmallDashboardItem.js b/src/components/Home/SmallDashboardItem.js new file mode 100644 index 0000000..0c27295 --- /dev/null +++ b/src/components/Home/SmallDashboardItem.js @@ -0,0 +1,66 @@ +// @flow + +import * as React from 'react'; +import {Badge, IconButton, withTheme} from 'react-native-paper'; +import {View} from "react-native"; +import type {CustomTheme} from "../../managers/ThemeManager"; +import * as Animatable from "react-native-animatable"; + +type Props = { + color: string, + icon: string, + clickAction: () => void, + isAvailable: boolean, + badgeNumber: number, + theme: CustomTheme, +}; + +const AnimatableBadge = Animatable.createAnimatableComponent(Badge); + +/** + * Component used to render a small dashboard item + */ +class SmallDashboardItem extends React.Component { + + shouldComponentUpdate(nextProps: Props) { + return (nextProps.theme.dark !== this.props.theme.dark) + || (nextProps.isAvailable !== this.props.isAvailable) + || (nextProps.badgeNumber !== this.props.badgeNumber); + } + + render() { + const props = this.props; + const colors = props.theme.colors; + return ( + + + { + props.badgeNumber > 0 ? + + {props.badgeNumber} + : null + } + + ); + } + +} + +export default withTheme(SmallDashboardItem); diff --git a/src/components/Lists/CardList/CardList.js b/src/components/Lists/CardList/CardList.js new file mode 100644 index 0000000..338ecc0 --- /dev/null +++ b/src/components/Lists/CardList/CardList.js @@ -0,0 +1,63 @@ +// @flow + +import * as React from 'react'; +import {Animated} from "react-native"; +import ImageListItem from "./ImageListItem"; +import CardListItem from "./CardListItem"; + +type Props = { + dataset: Array, + isHorizontal: boolean, +} + +export type cardItem = { + title: string, + subtitle: string, + image: string | number, + onPress: () => void, +}; + +export type cardList = Array; + + +export default class CardList extends React.Component { + + static defaultProps = { + isHorizontal: false, + } + + renderItem = ({item}: { item: cardItem }) => { + if (this.props.isHorizontal) + return ; + else + return ; + }; + + keyExtractor = (item: cardItem) => item.title; + + render() { + let containerStyle; + if (this.props.isHorizontal) { + containerStyle = { + ...this.props.contentContainerStyle, + height: 150, + justifyContent: 'space-around', + }; + } else { + containerStyle = { + ...this.props.contentContainerStyle, + } + } + return ( + + ); + } +} \ No newline at end of file diff --git a/src/components/Lists/CardList/CardListItem.js b/src/components/Lists/CardList/CardListItem.js new file mode 100644 index 0000000..4d44c7b --- /dev/null +++ b/src/components/Lists/CardList/CardListItem.js @@ -0,0 +1,44 @@ +// @flow + +import * as React from 'react'; +import {Caption, Card, Paragraph} from 'react-native-paper'; +import type {cardItem} from "./CardList"; + +type Props = { + item: cardItem, +} + +export default class CardListItem extends React.Component { + + shouldComponentUpdate() { + return false; + } + + render() { + const props = this.props; + const item = props.item; + const source = typeof item.image === "number" + ? item.image + : {uri: item.image}; + return ( + + + + {item.title} + {item.subtitle} + + + ); + } +} \ No newline at end of file diff --git a/src/components/Lists/CardList/ImageListItem.js b/src/components/Lists/CardList/ImageListItem.js new file mode 100644 index 0000000..bedde23 --- /dev/null +++ b/src/components/Lists/CardList/ImageListItem.js @@ -0,0 +1,53 @@ +// @flow + +import * as React from 'react'; +import {Text, TouchableRipple} from 'react-native-paper'; +import {Image, View} from 'react-native'; +import type {cardItem} from "./CardList"; + +type Props = { + item: cardItem, +} + +export default class ImageListItem extends React.Component { + + shouldComponentUpdate() { + return false; + } + + render() { + const props = this.props; + const item = props.item; + const source = typeof item.image === "number" + ? item.image + : {uri: item.image}; + return ( + + + + {item.title} + + + ); + } +} \ No newline at end of file diff --git a/src/components/Lists/Clubs/ClubListHeader.js b/src/components/Lists/Clubs/ClubListHeader.js new file mode 100644 index 0000000..dbe4f62 --- /dev/null +++ b/src/components/Lists/Clubs/ClubListHeader.js @@ -0,0 +1,83 @@ +// @flow + +import * as React from 'react'; +import {Card, Chip, List, Text} from 'react-native-paper'; +import {StyleSheet, View} from "react-native"; +import i18n from 'i18n-js'; +import AnimatedAccordion from "../../Animations/AnimatedAccordion"; +import {isItemInCategoryFilter} from "../../../utils/Search"; +import type {category} from "../../../screens/Amicale/Clubs/ClubListScreen"; + +type Props = { + categories: Array, + onChipSelect: (id: number) => void, + selectedCategories: Array, +} + +class ClubListHeader extends React.Component { + + shouldComponentUpdate(nextProps: Props) { + return nextProps.selectedCategories.length !== this.props.selectedCategories.length; + } + + getChipRender = (category: category, key: string) => { + const onPress = () => this.props.onChipSelect(category.id); + return + {category.name} + ; + }; + + + getCategoriesRender() { + let final = []; + for (let i = 0; i < this.props.categories.length; i++) { + final.push(this.getChipRender(this.props.categories[i], this.props.categories[i].id.toString())); + } + return final; + } + + render() { + return ( + + } + opened={true} + > + {i18n.t("clubs.categoriesFilterMessage")} + + {this.getCategoriesRender()} + + + + ); + } +} + +const styles = StyleSheet.create({ + card: { + margin: 5 + }, + text: { + paddingLeft: 0, + marginTop: 5, + marginBottom: 10, + marginLeft: 'auto', + marginRight: 'auto', + }, + chipContainer: { + justifyContent: 'space-around', + flexDirection: 'row', + flexWrap: 'wrap', + paddingLeft: 0, + marginBottom: 5, + }, +}); + +export default ClubListHeader; diff --git a/src/components/Lists/Clubs/ClubListItem.js b/src/components/Lists/Clubs/ClubListItem.js new file mode 100644 index 0000000..2231e77 --- /dev/null +++ b/src/components/Lists/Clubs/ClubListItem.js @@ -0,0 +1,81 @@ +// @flow + +import * as React from 'react'; +import {Avatar, Chip, List, withTheme} from 'react-native-paper'; +import {View} from "react-native"; +import type {category, club} from "../../../screens/Amicale/Clubs/ClubListScreen"; +import type {CustomTheme} from "../../../managers/ThemeManager"; + +type Props = { + onPress: () => void, + categoryTranslator: (id: number) => category, + item: club, + height: number, + theme: CustomTheme, +} + +class ClubListItem extends React.Component { + + hasManagers: boolean; + + constructor(props) { + super(props); + this.hasManagers = props.item.responsibles.length > 0; + } + + shouldComponentUpdate() { + return false; + } + + getCategoriesRender(categories: Array) { + let final = []; + for (let i = 0; i < categories.length; i++) { + if (categories[i] !== null) { + const category: category = this.props.categoryTranslator(categories[i]); + final.push( + + {category.name} + + ); + } + } + return {final}; + } + + render() { + const categoriesRender = this.getCategoriesRender.bind(this, this.props.item.category); + const colors = this.props.theme.colors; + return ( + } + right={(props) => } + style={{ + height: this.props.height, + justifyContent: 'center', + }} + /> + ); + } +} + +export default withTheme(ClubListItem); diff --git a/src/components/Lists/PlanexGroups/GroupListAccordion.js b/src/components/Lists/PlanexGroups/GroupListAccordion.js new file mode 100644 index 0000000..dcf4377 --- /dev/null +++ b/src/components/Lists/PlanexGroups/GroupListAccordion.js @@ -0,0 +1,97 @@ +// @flow + +import * as React from 'react'; +import {List, withTheme} from 'react-native-paper'; +import {FlatList, View} from "react-native"; +import {stringMatchQuery} from "../../../utils/Search"; +import GroupListItem from "./GroupListItem"; +import AnimatedAccordion from "../../Animations/AnimatedAccordion"; +import type {group, groupCategory} from "../../../screens/Planex/GroupSelectionScreen"; +import type {CustomTheme} from "../../../managers/ThemeManager"; + +type Props = { + item: groupCategory, + onGroupPress: (group) => void, + onFavoritePress: (group) => void, + currentSearchString: string, + favoriteNumber: number, + height: number, + theme: CustomTheme, +} + +const LIST_ITEM_HEIGHT = 64; + +class GroupListAccordion extends React.Component { + + shouldComponentUpdate(nextProps: Props) { + return (nextProps.currentSearchString !== this.props.currentSearchString) + || (nextProps.favoriteNumber !== this.props.favoriteNumber) + || (nextProps.item.content.length !== this.props.item.content.length); + } + + keyExtractor = (item: group) => item.id.toString(); + + renderItem = ({item}: { item: group }) => { + const onPress = () => this.props.onGroupPress(item); + const onStarPress = () => this.props.onFavoritePress(item); + return ( + + ); + } + + getData() { + const originalData = this.props.item.content; + let displayData = []; + for (let i = 0; i < originalData.length; i++) { + if (stringMatchQuery(originalData[i].name, this.props.currentSearchString)) + displayData.push(originalData[i]); + } + return displayData; + } + + itemLayout = (data, index) => ({length: LIST_ITEM_HEIGHT, offset: LIST_ITEM_HEIGHT * index, index}); + + + render() { + const item = this.props.item; + return ( + + + item.id === 0 + ? + : null} + unmountWhenCollapsed={true}// Only render list if expanded for increased performance + opened={this.props.item.id === 0 || this.props.currentSearchString.length > 0} + > + {/*$FlowFixMe*/} + + + + ); + } +} + +export default withTheme(GroupListAccordion) \ No newline at end of file diff --git a/src/components/Lists/PlanexGroups/GroupListItem.js b/src/components/Lists/PlanexGroups/GroupListItem.js new file mode 100644 index 0000000..4cb9eec --- /dev/null +++ b/src/components/Lists/PlanexGroups/GroupListItem.js @@ -0,0 +1,66 @@ +// @flow + +import * as React from 'react'; +import {IconButton, List, withTheme} from 'react-native-paper'; +import type {CustomTheme} from "../../../managers/ThemeManager"; +import type {group} from "../../../screens/Planex/GroupSelectionScreen"; + +type Props = { + theme: CustomTheme, + onPress: () => void, + onStarPress: () => void, + item: group, + height: number, +} + +type State = { + isFav: boolean, +} + +class GroupListItem extends React.Component { + + constructor(props) { + super(props); + this.state = { + isFav: (props.item.isFav !== undefined && props.item.isFav), + } + } + + shouldComponentUpdate(prevProps: Props, prevState: State) { + return (prevState.isFav !== this.state.isFav); + } + + onStarPress = () => { + this.setState({isFav: !this.state.isFav}); + this.props.onStarPress(); + } + + render() { + const colors = this.props.theme.colors; + return ( + + } + right={props => + } + style={{ + height: this.props.height, + justifyContent: 'center', + }} + /> + ); + } +} + +export default withTheme(GroupListItem); diff --git a/src/components/Lists/Proximo/ProximoListItem.js b/src/components/Lists/Proximo/ProximoListItem.js new file mode 100644 index 0000000..230e63f --- /dev/null +++ b/src/components/Lists/Proximo/ProximoListItem.js @@ -0,0 +1,49 @@ +// @flow + +import * as React from 'react'; +import {Avatar, List, Text, withTheme} from 'react-native-paper'; +import i18n from "i18n-js"; + +type Props = { + onPress: Function, + color: string, + item: Object, + height: number, +} + +class ProximoListItem extends React.Component { + + colors: Object; + + constructor(props) { + super(props); + this.colors = props.theme.colors; + } + + shouldComponentUpdate() { + return false; + } + + render() { + return ( + } + right={() => + + {this.props.item.price}€ + } + style={{ + height: this.props.height, + justifyContent: 'center', + }} + /> + ); + } +} + +export default withTheme(ProximoListItem); diff --git a/src/components/Lists/Proxiwash/ProxiwashListItem.js b/src/components/Lists/Proxiwash/ProxiwashListItem.js new file mode 100644 index 0000000..7740ed9 --- /dev/null +++ b/src/components/Lists/Proxiwash/ProxiwashListItem.js @@ -0,0 +1,180 @@ +import * as React from 'react'; +import {Avatar, List, ProgressBar, Surface, Text, withTheme} from 'react-native-paper'; +import {StyleSheet, View} from "react-native"; +import ProxiwashConstants from "../../../constants/ProxiwashConstants"; +import i18n from "i18n-js"; +import AprilFoolsManager from "../../../managers/AprilFoolsManager"; +import * as Animatable from "react-native-animatable"; + +type Props = { + item: Object, + onPress: Function, + isWatched: boolean, + isDryer: boolean, + height: number, +} + +const AnimatedIcon = Animatable.createAnimatableComponent(Avatar.Icon); + + +/** + * Component used to display a proxiwash item, showing machine progression and state + */ +class ProxiwashListItem extends React.Component { + + stateColors: Object; + stateStrings: Object; + + title: string; + + constructor(props) { + super(props); + this.stateColors = {}; + this.stateStrings = {}; + + this.updateStateStrings(); + + let displayNumber = props.item.number; + if (AprilFoolsManager.getInstance().isAprilFoolsEnabled()) + displayNumber = AprilFoolsManager.getProxiwashMachineDisplayNumber(parseInt(props.item.number)); + + this.title = props.isDryer + ? i18n.t('proxiwashScreen.dryer') + : i18n.t('proxiwashScreen.washer'); + this.title += ' n°' + displayNumber; + } + + shouldComponentUpdate(nextProps: Props): boolean { + const props = this.props; + return (nextProps.theme.dark !== props.theme.dark) + || (nextProps.item.state !== props.item.state) + || (nextProps.item.donePercent !== props.item.donePercent) + || (nextProps.isWatched !== props.isWatched); + } + + updateStateStrings() { + this.stateStrings[ProxiwashConstants.machineStates.TERMINE] = i18n.t('proxiwashScreen.states.finished'); + this.stateStrings[ProxiwashConstants.machineStates.DISPONIBLE] = i18n.t('proxiwashScreen.states.ready'); + this.stateStrings[ProxiwashConstants.machineStates["EN COURS"]] = i18n.t('proxiwashScreen.states.running'); + this.stateStrings[ProxiwashConstants.machineStates.HS] = i18n.t('proxiwashScreen.states.broken'); + this.stateStrings[ProxiwashConstants.machineStates.ERREUR] = i18n.t('proxiwashScreen.states.error'); + } + + updateStateColors() { + const colors = this.props.theme.colors; + this.stateColors[ProxiwashConstants.machineStates.TERMINE] = colors.proxiwashFinishedColor; + this.stateColors[ProxiwashConstants.machineStates.DISPONIBLE] = colors.proxiwashReadyColor; + this.stateColors[ProxiwashConstants.machineStates["EN COURS"]] = colors.proxiwashRunningColor; + this.stateColors[ProxiwashConstants.machineStates.HS] = colors.proxiwashBrokenColor; + this.stateColors[ProxiwashConstants.machineStates.ERREUR] = colors.proxiwashErrorColor; + } + + onListItemPress = () => this.props.onPress(this.title, this.props.item, this.props.isDryer); + + render() { + const props = this.props; + const colors = props.theme.colors; + const machineState = props.item.state; + const isRunning = ProxiwashConstants.machineStates[machineState] === ProxiwashConstants.machineStates["EN COURS"]; + const isReady = ProxiwashConstants.machineStates[machineState] === ProxiwashConstants.machineStates.DISPONIBLE; + const description = isRunning ? props.item.startTime + '/' + props.item.endTime : ''; + const stateIcon = ProxiwashConstants.stateIcons[machineState]; + const stateString = this.stateStrings[ProxiwashConstants.machineStates[machineState]]; + const progress = isRunning + ? props.item.donePercent !== '' + ? parseInt(props.item.donePercent) / 100 + : 0 + : 1; + + const icon = props.isWatched + ? + : ; + this.updateStateColors(); + return ( + + { + !isReady + ? + : null + } + icon} + right={() => ( + + + + {stateString} + + + + + + )} + /> + + ); + } +} + +const styles = StyleSheet.create({ + container: { + margin: 5, + justifyContent: 'center', + elevation: 1 + }, + icon: { + backgroundColor: 'transparent' + }, + progressBar: { + position: 'absolute', + left: 0, + borderRadius: 4, + }, +}); + +export default withTheme(ProxiwashListItem); diff --git a/src/components/Lists/Proxiwash/ProxiwashSectionHeader.js b/src/components/Lists/Proxiwash/ProxiwashSectionHeader.js new file mode 100644 index 0000000..48d1e0e --- /dev/null +++ b/src/components/Lists/Proxiwash/ProxiwashSectionHeader.js @@ -0,0 +1,72 @@ +import * as React from 'react'; +import {Avatar, Text, withTheme} from 'react-native-paper'; +import {StyleSheet, View} from "react-native"; +import i18n from "i18n-js"; + +type Props = { + title: string, + isDryer: boolean, + nbAvailable: number, +} + +/** + * Component used to display a proxiwash item, showing machine progression and state + */ +class ProxiwashListItem extends React.Component { + + constructor(props) { + super(props); + } + + shouldComponentUpdate(nextProps: Props) { + return (nextProps.theme.dark !== this.props.theme.dark) + || (nextProps.nbAvailable !== this.props.nbAvailable) + } + + render() { + const props = this.props; + const subtitle = props.nbAvailable + ' ' + ( + (props.nbAvailable <= 1) + ? i18n.t('proxiwashScreen.numAvailable') + : i18n.t('proxiwashScreen.numAvailablePlural')); + const iconColor = props.nbAvailable > 0 + ? this.props.theme.colors.success + : this.props.theme.colors.primary; + return ( + + + + + {props.title} + + + {subtitle} + + + + ); + } +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + marginLeft: 5, + marginRight: 5, + marginBottom: 10, + marginTop: 20, + }, + icon: { + backgroundColor: 'transparent' + }, + text: { + fontSize: 20, + fontWeight: 'bold', + } +}); + +export default withTheme(ProxiwashListItem); diff --git a/src/components/Overrides/CustomAgenda.js b/src/components/Overrides/CustomAgenda.js new file mode 100644 index 0000000..b490fc7 --- /dev/null +++ b/src/components/Overrides/CustomAgenda.js @@ -0,0 +1,60 @@ +import * as React from 'react'; +import {View} from "react-native"; +import {withTheme} from 'react-native-paper'; +import {Agenda} from "react-native-calendars"; + +type Props = { + theme: Object, +} + +/** + * Abstraction layer for Agenda component, using custom configuration + */ +class CustomAgenda extends React.Component { + + getAgenda() { + return ; + } + + render() { + // Completely recreate the component on theme change to force theme reload + if (this.props.theme.dark) + return ( + + {this.getAgenda()} + + ); + else + return this.getAgenda(); + } +} + +export default withTheme(CustomAgenda); diff --git a/src/components/Overrides/CustomHTML.js b/src/components/Overrides/CustomHTML.js new file mode 100644 index 0000000..340717b --- /dev/null +++ b/src/components/Overrides/CustomHTML.js @@ -0,0 +1,37 @@ +import * as React from 'react'; +import {Text, withTheme} from 'react-native-paper'; +import HTML from "react-native-render-html"; +import {Linking} from "react-native"; + +type Props = { + theme: Object, + html: string, +} + +/** + * Abstraction layer for Agenda component, using custom configuration + */ +class CustomHTML extends React.Component { + + openWebLink = (event, link) => { + Linking.openURL(link).catch((err) => console.error('Error opening link', err)); + }; + + getBasicText = (htmlAttribs, children, convertedCSSStyles, passProps) => { + return {children}; + }; + + render() { + // Surround description with p to allow text styling if the description is not html + return " + this.props.html + "

"} + renderers={{ + p: this.getBasicText, + }} + ignoredStyles={['color', 'background-color']} + + onLinkPress={this.openWebLink}/>; + } +} + +export default withTheme(CustomHTML); diff --git a/src/components/Overrides/CustomHeaderButton.js b/src/components/Overrides/CustomHeaderButton.js new file mode 100644 index 0000000..ec6754e --- /dev/null +++ b/src/components/Overrides/CustomHeaderButton.js @@ -0,0 +1,29 @@ +// @flow + +import * as React from 'react'; +import MaterialCommunityIcons from "react-native-vector-icons/MaterialCommunityIcons"; +import {HeaderButton, HeaderButtons} from 'react-navigation-header-buttons'; +import {withTheme} from "react-native-paper"; +import * as Touchable from "react-native/Libraries/Components/Touchable/TouchableNativeFeedback.android"; + +const MaterialHeaderButton = (props: Object) => + ; + +const MaterialHeaderButtons = (props: Object) => { + return ( + + ); +}; + +export default withTheme(MaterialHeaderButtons); + +export {Item} from 'react-navigation-header-buttons'; diff --git a/components/CustomIntroSlider.js b/src/components/Overrides/CustomIntroSlider.js similarity index 58% rename from components/CustomIntroSlider.js rename to src/components/Overrides/CustomIntroSlider.js index 8c19ba3..3633246 100644 --- a/components/CustomIntroSlider.js +++ b/src/components/Overrides/CustomIntroSlider.js @@ -1,15 +1,198 @@ // @flow import * as React from 'react'; -import {LinearGradient} from "expo-linear-gradient"; -import {Image, StyleSheet, View} from "react-native"; -import {MaterialCommunityIcons} from "@expo/vector-icons"; +import {Image, Platform, StatusBar, StyleSheet, View} from "react-native"; +import MaterialCommunityIcons from "react-native-vector-icons/MaterialCommunityIcons"; import {Text} from "react-native-paper"; import i18n from 'i18n-js'; import AppIntroSlider from "react-native-app-intro-slider"; -import Update from "../constants/Update"; +import Update from "../../constants/Update"; +import ThemeManager from "../../managers/ThemeManager"; +import LinearGradient from 'react-native-linear-gradient'; + +type Props = { + onDone: Function, + isUpdate: boolean, + isAprilFools: boolean, +}; + +/** + * Class used to create intro slides + */ +export default class CustomIntroSlider extends React.Component { + + sliderRef: {current: null | AppIntroSlider}; + + introSlides: Array; + updateSlides: Array; + aprilFoolsSlides: Array; + currentSlides: Array; + + /** + * Generates intro slides + */ + constructor() { + super(); + this.sliderRef = React.createRef(); + this.introSlides = [ + { + key: '1', + title: i18n.t('intro.slide1.title'), + text: i18n.t('intro.slide1.text'), + image: require('../../../assets/splash.png'), + colors: ['#dc2634', '#be1522'], + }, + { + key: '2', + title: i18n.t('intro.slide2.title'), + text: i18n.t('intro.slide2.text'), + icon: 'calendar-range', + colors: ['#d99e09', '#9e7205'], + }, + { + key: '3', + title: i18n.t('intro.slide3.title'), + text: i18n.t('intro.slide3.text'), + icon: 'washing-machine', + colors: ['#1fa5ee', '#0976b1'], + }, + { + key: '4', + title: i18n.t('intro.slide4.title'), + text: i18n.t('intro.slide4.text'), + icon: 'shopping', + colors: ['#ec5904', '#b64300'], + }, + { + key: '5', + title: i18n.t('intro.slide5.title'), + text: i18n.t('intro.slide5.text'), + icon: 'timetable', + colors: ['#7c33ec', '#5e11d1'], + }, + { + key: '6', + title: i18n.t('intro.slide6.title'), + text: i18n.t('intro.slide6.text'), + icon: 'silverware-fork-knife', + colors: ['#ec1213', '#970902'], + }, + { + key: '7', + title: i18n.t('intro.slide7.title'), + text: i18n.t('intro.slide7.text'), + icon: 'cogs', + colors: ['#37c13e', '#1a5a1d'], + }, + ]; + this.updateSlides = []; + for (let i = 0; i < Update.slidesNumber; i++) { + this.updateSlides.push( + { + key: i.toString(), + title: Update.getInstance().titleList[i], + text: Update.getInstance().descriptionList[i], + icon: Update.iconList[i], + colors: Update.colorsList[i], + }, + ); + } + + this.aprilFoolsSlides = [ + { + key: '1', + title: i18n.t('intro.aprilFoolsSlide.title'), + text: i18n.t('intro.aprilFoolsSlide.text'), + icon: 'fish', + colors: ['#e01928', '#be1522'], + }, + ]; + } + + + /** + * Render item to be used for the intro introSlides + * + * @param item The item to be displayed + * @param dimensions Dimensions of the item + */ + static getIntroRenderItem({item, dimensions}: Object) { + return ( + + {item.image !== undefined ? + + + + : } + + {item.title} + {item.text} + + + ); + } + + setStatusBarColor(color: string) { + if (Platform.OS === 'android') + StatusBar.setBackgroundColor(color, true); + } + + onSlideChange = (index: number, lastIndex: number) => { + this.setStatusBarColor(this.currentSlides[index].colors[0]); + }; + + onSkip = () => { + this.setStatusBarColor(this.currentSlides[this.currentSlides.length-1].colors[0]); + if (this.sliderRef.current != null) + this.sliderRef.current.goToSlide(this.currentSlides.length-1); + } + + onDone = () => { + this.setStatusBarColor(ThemeManager.getCurrentTheme().colors.surface); + this.props.onDone(); + } + + render() { + this.currentSlides = this.introSlides; + if (this.props.isUpdate) + this.currentSlides = this.updateSlides; + else if (this.props.isAprilFools) + this.currentSlides = this.aprilFoolsSlides; + this.setStatusBarColor(this.currentSlides[0].colors[0]); + return ( + + ); + } + +} -// Content to be used int the intro slides const styles = StyleSheet.create({ mainContent: { @@ -37,144 +220,3 @@ const styles = StyleSheet.create({ marginBottom: 16, }, }); - -type Props = { - onDone: Function, - isUpdate: boolean, - isAprilFools: boolean, -}; - -export default class CustomIntroSlider extends React.Component { - - introSlides: Array; - updateSlides: Array; - aprilFoolsSlides: Array; - - constructor() { - super(); - this.introSlides = [ - { - key: '1', - title: i18n.t('intro.slide1.title'), - text: i18n.t('intro.slide1.text'), - image: require('../assets/splash.png'), - colors: ['#e01928', '#be1522'], - }, - { - key: '2', - title: i18n.t('intro.slide2.title'), - text: i18n.t('intro.slide2.text'), - icon: 'calendar-range', - colors: ['#d99e09', '#c28d08'], - }, - { - key: '3', - title: i18n.t('intro.slide3.title'), - text: i18n.t('intro.slide3.text'), - icon: 'washing-machine', - colors: ['#1fa5ee', '#1c97da'], - }, - { - key: '4', - title: i18n.t('intro.slide4.title'), - text: i18n.t('intro.slide4.text'), - icon: 'shopping', - colors: ['#ec5904', '#da5204'], - }, - { - key: '5', - title: i18n.t('intro.slide5.title'), - text: i18n.t('intro.slide5.text'), - icon: 'timetable', - colors: ['#7c33ec', '#732fda'], - }, - { - key: '6', - title: i18n.t('intro.slide6.title'), - text: i18n.t('intro.slide6.text'), - icon: 'silverware-fork-knife', - colors: ['#ec1213', '#ff372f'], - }, - { - key: '7', - title: i18n.t('intro.slide7.title'), - text: i18n.t('intro.slide7.text'), - icon: 'cogs', - colors: ['#37c13e', '#26852b'], - }, - ]; - this.updateSlides = [ - { - key: '1', - title: Update.getInstance().title, - text: Update.getInstance().description, - icon: Update.icon, - colors: ['#e01928', '#be1522'], - }, - ]; - this.aprilFoolsSlides = [ - { - key: '1', - title: i18n.t('intro.aprilFoolsSlide.title'), - text: i18n.t('intro.aprilFoolsSlide.text'), - icon: 'fish', - colors: ['#e01928', '#be1522'], - }, - ]; - } - - - /** - * Render item to be used for the intro introSlides - * @param item - * @param dimensions - */ - static getIntroRenderItem({item, dimensions}: Object) { - - return ( - - {item.image !== undefined ? - - : } - - {item.title} - {item.text} - - - ); - } - - render() { - let slides = this.introSlides; - if (this.props.isUpdate) - slides = this.updateSlides; - else if (this.props.isAprilFools) - slides = this.aprilFoolsSlides; - return ( - - ); - } - -} - - diff --git a/components/CustomModal.js b/src/components/Overrides/CustomModal.js similarity index 50% rename from components/CustomModal.js rename to src/components/Overrides/CustomModal.js index 99b38d6..598acc4 100644 --- a/components/CustomModal.js +++ b/src/components/Overrides/CustomModal.js @@ -3,9 +3,17 @@ import * as React from 'react'; import {withTheme} from 'react-native-paper'; import {Modalize} from "react-native-modalize"; +import {View} from "react-native-animatable"; +import CustomTabBar from "../Tabbar/CustomTabBar"; +/** + * Abstraction layer for Modalize component, using custom configuration + * + * @param props Props to pass to the element. Must specify an onRef prop to get an Modalize ref. + * @return {*} + */ function CustomModal(props) { - const { colors } = props.theme; + const {colors} = props.theme; return ( - {props.children} + + {props.children} + + ); } diff --git a/src/components/Overrides/CustomSlider.js b/src/components/Overrides/CustomSlider.js new file mode 100644 index 0000000..122ea4a --- /dev/null +++ b/src/components/Overrides/CustomSlider.js @@ -0,0 +1,58 @@ +// @flow + +import * as React from 'react'; +import {Text, withTheme} from 'react-native-paper'; +import {View} from "react-native-animatable"; +import type {CustomTheme} from "../../managers/ThemeManager"; +import Slider, {SliderProps} from "@react-native-community/slider"; + +type Props = { + theme: CustomTheme, + valueSuffix: string, + ...SliderProps +} + +type State = { + currentValue: number, +} + +/** + * Abstraction layer for Modalize component, using custom configuration + * + * @param props Props to pass to the element. Must specify an onRef prop to get an Modalize ref. + * @return {*} + */ +class CustomSlider extends React.Component { + + static defaultProps = { + valueSuffix: "", + } + + state = { + currentValue: this.props.value, + } + + onValueChange = (value: number) => { + this.setState({currentValue: value}); + if (this.props.onValueChange != null) + this.props.onValueChange(value); + } + + render() { + return ( + + + {this.state.currentValue}min + + + + ); + } + +} + +export default withTheme(CustomSlider); + diff --git a/src/components/Screens/BasicLoadingScreen.js b/src/components/Screens/BasicLoadingScreen.js new file mode 100644 index 0000000..01ad378 --- /dev/null +++ b/src/components/Screens/BasicLoadingScreen.js @@ -0,0 +1,37 @@ +// @flow + +import * as React from 'react'; +import {View} from 'react-native'; +import {ActivityIndicator, withTheme} from 'react-native-paper'; + +/** + * Component used to display a header button + * + * @param props Props to pass to the component + * @return {*} + */ +function BasicLoadingScreen(props) { + const {colors} = props.theme; + let position = undefined; + if (props.isAbsolute !== undefined && props.isAbsolute) + position = 'absolute'; + + return ( + + + + ); +} + +export default withTheme(BasicLoadingScreen); diff --git a/src/components/Screens/ErrorView.js b/src/components/Screens/ErrorView.js new file mode 100644 index 0000000..19d4704 --- /dev/null +++ b/src/components/Screens/ErrorView.js @@ -0,0 +1,186 @@ +// @flow + +import * as React from 'react'; +import {Button, Subheading, withTheme} from 'react-native-paper'; +import {StyleSheet, View} from "react-native"; +import MaterialCommunityIcons from "react-native-vector-icons/MaterialCommunityIcons"; +import i18n from 'i18n-js'; +import {ERROR_TYPE} from "../../utils/WebData"; +import * as Animatable from 'react-native-animatable'; + +type Props = { + navigation: Object, + route: Object, + errorCode: number, + onRefresh: Function, + icon: string, + message: string, + showRetryButton: boolean, +} + +type State = { + refreshing: boolean, +} + +class ErrorView extends React.PureComponent { + + colors: Object; + + message: string; + icon: string; + + showLoginButton: boolean; + + static defaultProps = { + errorCode: 0, + icon: '', + message: '', + showRetryButton: true, + } + + state = { + refreshing: false, + }; + + constructor(props) { + super(props); + this.colors = props.theme.colors; + } + + generateMessage() { + this.showLoginButton = false; + if (this.props.errorCode !== 0) { + switch (this.props.errorCode) { + case ERROR_TYPE.BAD_CREDENTIALS: + this.message = i18n.t("errors.badCredentials"); + this.icon = "account-alert-outline"; + break; + case ERROR_TYPE.BAD_TOKEN: + this.message = i18n.t("errors.badToken"); + this.icon = "account-alert-outline"; + this.showLoginButton = true; + break; + case ERROR_TYPE.NO_CONSENT: + this.message = i18n.t("errors.noConsent"); + this.icon = "account-remove-outline"; + break; + case ERROR_TYPE.BAD_INPUT: + this.message = i18n.t("errors.badInput"); + this.icon = "alert-circle-outline"; + break; + case ERROR_TYPE.FORBIDDEN: + this.message = i18n.t("errors.forbidden"); + this.icon = "lock"; + break; + case ERROR_TYPE.CONNECTION_ERROR: + this.message = i18n.t("errors.connectionError"); + this.icon = "access-point-network-off"; + break; + case ERROR_TYPE.SERVER_ERROR: + this.message = i18n.t("errors.serverError"); + this.icon = "server-network-off"; + break; + default: + this.message = i18n.t("errors.unknown"); + this.icon = "alert-circle-outline"; + break; + } + } else { + this.message = this.props.message; + this.icon = this.props.icon; + } + + } + + getRetryButton() { + return ; + } + + goToLogin = () => { + this.props.navigation.navigate("login", + { + screen: 'login', + params: {nextScreen: this.props.route.name} + }) + }; + + getLoginButton() { + return ; + } + + render() { + this.generateMessage(); + return ( + + + + + + + {this.message} + + {this.props.showRetryButton + ? (this.showLoginButton + ? this.getLoginButton() + : this.getRetryButton()) + : null} + + + ); + } +} + +const styles = StyleSheet.create({ + outer: { + height: '100%', + }, + inner: { + marginTop: 'auto', + marginBottom: 'auto', + }, + iconContainer: { + marginLeft: 'auto', + marginRight: 'auto', + marginBottom: 20 + }, + subheading: { + textAlign: 'center', + paddingHorizontal: 20 + }, + button: { + marginTop: 10, + marginLeft: 'auto', + marginRight: 'auto', + } +}); + + +export default withTheme(ErrorView); diff --git a/src/components/Screens/WebSectionList.js b/src/components/Screens/WebSectionList.js new file mode 100644 index 0000000..a12b34b --- /dev/null +++ b/src/components/Screens/WebSectionList.js @@ -0,0 +1,268 @@ +// @flow + +import * as React from 'react'; +import {ERROR_TYPE, readData} from "../../utils/WebData"; +import i18n from "i18n-js"; +import {Snackbar} from 'react-native-paper'; +import {Animated, RefreshControl, View} from "react-native"; +import ErrorView from "./ErrorView"; +import BasicLoadingScreen from "./BasicLoadingScreen"; +import {withCollapsible} from "../../utils/withCollapsible"; +import * as Animatable from 'react-native-animatable'; +import CustomTabBar from "../Tabbar/CustomTabBar"; +import {Collapsible} from "react-navigation-collapsible"; + +type Props = { + navigation: { [key: string]: any }, + fetchUrl: string, + autoRefreshTime: number, + refreshOnFocus: boolean, + renderItem: (data: { [key: string]: any }) => React.Node, + createDataset: (data: { [key: string]: any }) => Array, + onScroll: (event: SyntheticEvent) => void, + collapsibleStack: Collapsible, + + showError: boolean, + itemHeight?: number, + updateData?: number, + renderSectionHeader?: (data: { [key: string]: any }) => React.Node, + stickyHeader?: boolean, +} + +type State = { + refreshing: boolean, + firstLoading: boolean, + fetchedData: { [key: string]: any } | null, + snackbarVisible: boolean +}; + + +const MIN_REFRESH_TIME = 5 * 1000; + +/** + * Component used to render a SectionList with data fetched from the web + * + * This is a pure component, meaning it will only update if a shallow comparison of state and props is different. + * To force the component to update, change the value of updateData. + */ +class WebSectionList extends React.PureComponent { + + static defaultProps = { + stickyHeader: false, + updateData: 0, + showError: true, + }; + + scrollRef: { current: null | Animated.SectionList }; + refreshInterval: IntervalID; + lastRefresh: Date | null; + + state = { + refreshing: false, + firstLoading: true, + fetchedData: null, + snackbarVisible: false + }; + + /** + * Registers react navigation events on first screen load. + * Allows to detect when the screen is focused + */ + componentDidMount() { + const onScreenFocus = this.onScreenFocus.bind(this); + const onScreenBlur = this.onScreenBlur.bind(this); + this.props.navigation.addListener('focus', onScreenFocus); + this.props.navigation.addListener('blur', onScreenBlur); + this.scrollRef = React.createRef(); + this.onRefresh(); + this.lastRefresh = null; + } + + /** + * Refreshes data when focusing the screen and setup a refresh interval if asked to + */ + onScreenFocus() { + if (this.props.refreshOnFocus && this.lastRefresh) + this.onRefresh(); + if (this.props.autoRefreshTime > 0) + this.refreshInterval = setInterval(this.onRefresh, this.props.autoRefreshTime) + // if (this.scrollRef.current) // Reset scroll to top + // this.scrollRef.current.getNode().scrollToLocation({animated:false, itemIndex:0, sectionIndex:0}); + } + + /** + * Removes any interval on un-focus + */ + onScreenBlur() { + clearInterval(this.refreshInterval); + } + + + /** + * Callback used when fetch is successful. + * It will update the displayed data and stop the refresh animation + * + * @param fetchedData The newly fetched data + */ + onFetchSuccess = (fetchedData: { [key: string]: any }) => { + this.setState({ + fetchedData: fetchedData, + refreshing: false, + firstLoading: false + }); + this.lastRefresh = new Date(); + }; + + /** + * Callback used when fetch encountered an error. + * It will reset the displayed data and show an error. + */ + onFetchError = () => { + this.setState({ + fetchedData: null, + refreshing: false, + firstLoading: false + }); + this.showSnackBar(); + }; + + /** + * Refreshes data and shows an animations while doing it + */ + onRefresh = () => { + let canRefresh; + if (this.lastRefresh != null) { + const last = this.lastRefresh; + canRefresh = (new Date().getTime() - last.getTime()) > MIN_REFRESH_TIME; + } else + canRefresh = true; + if (canRefresh) { + this.setState({refreshing: true}); + readData(this.props.fetchUrl) + .then(this.onFetchSuccess) + .catch(this.onFetchError); + } + }; + + /** + * Shows the error popup + */ + showSnackBar = () => this.setState({snackbarVisible: true}); + + /** + * Hides the error popup + */ + hideSnackBar = () => this.setState({snackbarVisible: false}); + + itemLayout = (data: { [key: string]: any }, index: number) => { + const height = this.props.itemHeight; + if (height == null) + return undefined; + return { + length: height, + offset: height * index, + index + } + }; + + renderSectionHeader = (data: { section: { [key: string]: any } }) => { + if (this.props.renderSectionHeader != null) { + return ( + + {this.props.renderSectionHeader(data)} + + ); + } else + return null; + } + + renderItem = (data: { + item: { [key: string]: any }, + index: number, + section: { [key: string]: any }, + separators: { [key: string]: any }, + }) => { + return ( + + {this.props.renderItem(data)} + + ); + } + + onScroll = (event: SyntheticEvent) => { + if (this.props.onScroll) + this.props.onScroll(event); + } + + render() { + let dataset = []; + if (this.state.fetchedData != null || (this.state.fetchedData == null && !this.props.showError)) { + if (this.state.fetchedData == null) + dataset = this.props.createDataset({}); + else + dataset = this.props.createDataset(this.state.fetchedData); + } + const {containerPaddingTop, scrollIndicatorInsetTop, onScrollWithListener} = this.props.collapsibleStack; + return ( + + + } + renderSectionHeader={this.renderSectionHeader} + renderItem={this.renderItem} + stickySectionHeadersEnabled={this.props.stickyHeader} + style={{minHeight: '100%'}} + ListEmptyComponent={this.state.refreshing + ? + : + } + getItemLayout={this.props.itemHeight != null ? this.itemLayout : undefined} + // Animations + onScroll={onScrollWithListener(this.onScroll)} + contentContainerStyle={{ + paddingTop: containerPaddingTop, + paddingBottom: CustomTabBar.TAB_BAR_HEIGHT, + minHeight: '100%' + }} + scrollIndicatorInsets={{top: scrollIndicatorInsetTop}} + /> + { + }, + }} + duration={4000} + style={{ + bottom: CustomTabBar.TAB_BAR_HEIGHT + }} + > + {i18n.t("homeScreen.listUpdateFail")} + + + ); + } +} + +export default withCollapsible(WebSectionList); diff --git a/src/components/Screens/WebViewScreen.js b/src/components/Screens/WebViewScreen.js new file mode 100644 index 0000000..17fe118 --- /dev/null +++ b/src/components/Screens/WebViewScreen.js @@ -0,0 +1,197 @@ +// @flow + +import * as React from 'react'; +import WebView from "react-native-webview"; +import BasicLoadingScreen from "./BasicLoadingScreen"; +import ErrorView from "./ErrorView"; +import {ERROR_TYPE} from "../../utils/WebData"; +import MaterialHeaderButtons, {Item} from '../Overrides/CustomHeaderButton'; +import {Divider, HiddenItem, OverflowMenu} from "react-navigation-header-buttons"; +import i18n from 'i18n-js'; +import {Animated, BackHandler, Linking} from "react-native"; +import {withCollapsible} from "../../utils/withCollapsible"; +import MaterialCommunityIcons from "react-native-vector-icons/MaterialCommunityIcons"; +import {withTheme} from "react-native-paper"; +import type {CustomTheme} from "../../managers/ThemeManager"; +import {StackNavigationProp} from "@react-navigation/stack"; +import {Collapsible} from "react-navigation-collapsible"; + +type Props = { + navigation: StackNavigationProp, + theme: CustomTheme, + url: string, + customJS: string, + collapsibleStack: Collapsible, + onMessage: Function, + onScroll: Function, + showAdvancedControls: boolean, +} + +const AnimatedWebView = Animated.createAnimatedComponent(WebView); + +/** + * Class defining a webview screen. + */ +class WebViewScreen extends React.PureComponent { + + static defaultProps = { + customJS: '', + showAdvancedControls: true, + }; + + webviewRef: Object; + + canGoBack: boolean; + + constructor() { + super(); + this.webviewRef = React.createRef(); + this.canGoBack = false; + } + + /** + * Creates refresh button after mounting + */ + componentDidMount() { + this.props.navigation.setOptions({ + headerRight: this.props.showAdvancedControls + ? this.getAdvancedButtons + : this.getBasicButton, + }); + this.props.navigation.addListener( + 'focus', + () => + BackHandler.addEventListener( + 'hardwareBackPress', + this.onBackButtonPressAndroid + ) + ); + this.props.navigation.addListener( + 'blur', + () => + BackHandler.removeEventListener( + 'hardwareBackPress', + this.onBackButtonPressAndroid + ) + ); + } + + onBackButtonPressAndroid = () => { + if (this.canGoBack) { + this.onGoBackClicked(); + return true; + } + return false; + }; + + /** + * Gets a header refresh button + * + * @return {*} + */ + getBasicButton = () => { + return ( + + + + + ); + }; + + getAdvancedButtons = () => { + return ( + + + } + > + + + + + + + ); + } + + /** + * Callback to use when refresh button is clicked. Reloads the webview. + */ + onRefreshClicked = () => this.webviewRef.current.getNode().reload(); // Need to call getNode() as we are working with animated components + onGoBackClicked = () => this.webviewRef.current.getNode().goBack(); + onGoForwardClicked = () => this.webviewRef.current.getNode().goForward(); + + onOpenClicked = () => Linking.openURL(this.props.url); + + injectJavaScript = (script: string) => { + this.webviewRef.current.getNode().injectJavaScript(script); + } + + /** + * Gets the loading indicator + * + * @return {*} + */ + getRenderLoading = () => ; + + getJavascriptPadding(padding: number) { + return ( + "document.getElementsByTagName('body')[0].style.paddingTop = '" + padding + "px';" + + "true;" + ); + } + + onScroll = (event: Object) => { + if (this.props.onScroll) + this.props.onScroll(event); + } + + render() { + const {containerPaddingTop, onScrollWithListener} = this.props.collapsibleStack; + return ( + } + onNavigationStateChange={navState => { + this.canGoBack = navState.canGoBack; + }} + onMessage={this.props.onMessage} + onLoad={() => this.injectJavaScript(this.getJavascriptPadding(containerPaddingTop))} + // Animations + onScroll={onScrollWithListener(this.onScroll)} + /> + ); + } +} + +export default withCollapsible(withTheme(WebViewScreen)); diff --git a/src/components/Tabbar/CustomTabBar.js b/src/components/Tabbar/CustomTabBar.js new file mode 100644 index 0000000..c83c175 --- /dev/null +++ b/src/components/Tabbar/CustomTabBar.js @@ -0,0 +1,159 @@ +import * as React from 'react'; +import {withTheme} from 'react-native-paper'; +import TabIcon from "./TabIcon"; +import TabHomeIcon from "./TabHomeIcon"; +import {Animated} from 'react-native'; + +type Props = { + state: Object, + descriptors: Object, + navigation: Object, + theme: Object, + collapsibleStack: Object, +} + +type State = { + translateY: AnimatedValue, + barSynced: boolean, +} + +const TAB_ICONS = { + proxiwash: 'tshirt-crew', + services: 'account-circle', + planning: 'calendar-range', + planex: 'clock', +}; + +/** + * Abstraction layer for Agenda component, using custom configuration + */ +class CustomTabBar extends React.Component { + + static TAB_BAR_HEIGHT = 48; + + state = { + translateY: new Animated.Value(0), + barSynced: false,// Is the bar synced with the header for animations? + } + + tabRef: Object; + + constructor(props) { + super(props); + this.tabRef = React.createRef(); + } + + onItemPress(route: Object, currentIndex: number, destIndex: number) { + const event = this.props.navigation.emit({ + type: 'tabPress', + target: route.key, + canPreventDefault: true, + }); + if (currentIndex !== destIndex && !event.defaultPrevented) { + this.state.translateY = new Animated.Value(0); + this.props.navigation.navigate(route.name); + } + } + + onItemLongPress(route: Object) { + const event = this.props.navigation.emit({ + type: 'tabLongPress', + target: route.key, + canPreventDefault: true, + }); + if (route.name === "home" && !event.defaultPrevented) { + this.props.navigation.navigate('tetris'); + } + } + + tabBarIcon = (route, focused) => { + let icon = TAB_ICONS[route.name]; + icon = focused ? icon : icon + ('-outline'); + if (route.name !== "home") + return icon; + else + return null; +}; + + + onRouteChange = () => { + this.setState({barSynced: false}); + } + + renderIcon = (route, index) => { + const state = this.props.state; + const {options} = this.props.descriptors[route.key]; + const label = + options.tabBarLabel !== undefined + ? options.tabBarLabel + : options.title !== undefined + ? options.title + : route.name; + + const isFocused = state.index === index; + + const onPress = () => this.onItemPress(route, state.index, index); + + const onLongPress = () => this.onItemLongPress(route); + + if (isFocused) { + const stackState = route.state; + const stackRoute = route.state ? stackState.routes[stackState.index] : undefined; + const params = stackRoute ? stackRoute.params : undefined; + const collapsible = params ? params.collapsible : undefined; + if (collapsible && !this.state.barSynced) { + this.setState({ + translateY: Animated.multiply(-1.5, collapsible.translateY), + barSynced: true, + }); + } + } + + const color = isFocused ? this.props.theme.colors.primary : this.props.theme.colors.tabIcon; + if (route.name !== "home") { + return index} + key={route.key} + /> + } else + return + }; + + render() { + this.props.navigation.addListener('state', this.onRouteChange); + return ( + + {this.props.state.routes.map(this.renderIcon)} + + ); + } +} + +export default withTheme(CustomTabBar); diff --git a/src/components/Tabbar/TabHomeIcon.js b/src/components/Tabbar/TabHomeIcon.js new file mode 100644 index 0000000..608a66e --- /dev/null +++ b/src/components/Tabbar/TabHomeIcon.js @@ -0,0 +1,106 @@ +// @flow + +import * as React from 'react'; +import {Image, Platform, View} from "react-native"; +import {FAB, TouchableRipple, withTheme} from 'react-native-paper'; +import * as Animatable from "react-native-animatable"; + +type Props = { + focused: boolean, + onPress: Function, + onLongPress: Function, + theme: Object, + tabBarHeight: number, +} + +const AnimatedFAB = Animatable.createAnimatableComponent(FAB); + +/** + * Abstraction layer for Agenda component, using custom configuration + */ +class TabHomeIcon extends React.Component { + + focusedIcon = require('../../../assets/tab-icon.png'); + unFocusedIcon = require('../../../assets/tab-icon-outline.png'); + + constructor(props) { + super(props); + Animatable.initializeRegistryWithDefinitions({ + fabFocusIn: { + "0": { + scale: 1, translateY: 0 + }, + "0.9": { + scale: 1.2, translateY: -9 + }, + "1": { + scale: 1.1, translateY: -7 + }, + }, + fabFocusOut: { + "0": { + scale: 1.1, translateY: -6 + }, + "1": { + scale: 1, translateY: 0 + }, + } + }); + } + + iconRender = ({size, color}) => + this.props.focused + ? + : ; + + shouldComponentUpdate(nextProps: Props): boolean { + return (nextProps.focused !== this.props.focused); + } + + render(): React$Node { + const props = this.props; + return ( + + + + + + + ); + } + +} + +export default withTheme(TabHomeIcon); \ No newline at end of file diff --git a/src/components/Tabbar/TabIcon.js b/src/components/Tabbar/TabIcon.js new file mode 100644 index 0000000..8e4c0f6 --- /dev/null +++ b/src/components/Tabbar/TabIcon.js @@ -0,0 +1,115 @@ +// @flow + +import * as React from 'react'; +import {View} from "react-native"; +import {TouchableRipple, withTheme} from 'react-native-paper'; +import MaterialCommunityIcons from "react-native-vector-icons/MaterialCommunityIcons"; +import * as Animatable from "react-native-animatable"; + +type Props = { + focused: boolean, + color: string, + label: string, + icon: string, + onPress: Function, + onLongPress: Function, + theme: Object, + extraData: any, +} + +const AnimatedIcon = Animatable.createAnimatableComponent(MaterialCommunityIcons); + + +/** + * Abstraction layer for Agenda component, using custom configuration + */ +class TabIcon extends React.Component { + + firstRender: boolean; + + constructor(props) { + super(props); + Animatable.initializeRegistryWithDefinitions({ + focusIn: { + "0": { + scale: 1, translateY: 0 + }, + "0.9": { + scale: 1.3, translateY: 7 + }, + "1": { + scale: 1.2, translateY: 6 + }, + }, + focusOut: { + "0": { + scale: 1.2, translateY: 6 + }, + "1": { + scale: 1, translateY: 0 + }, + } + }); + this.firstRender = true; + } + + componentDidMount() { + this.firstRender = false; + } + + shouldComponentUpdate(nextProps: Props): boolean { + return (nextProps.focused !== this.props.focused) + || (nextProps.theme.dark !== this.props.theme.dark) + || (nextProps.extraData !== this.props.extraData); + } + + render(): React$Node { + const props = this.props; + return ( + + + + + + + {props.label} + + + + ); + } +} + +export default withTheme(TabIcon); \ No newline at end of file diff --git a/src/constants/ProxiwashConstants.js b/src/constants/ProxiwashConstants.js new file mode 100644 index 0000000..799e956 --- /dev/null +++ b/src/constants/ProxiwashConstants.js @@ -0,0 +1,16 @@ +export default { + machineStates: { + "TERMINE": "0", + "DISPONIBLE": "1", + "EN COURS": "2", + "HS": "3", + "ERREUR": "4" + }, + stateIcons: { + "TERMINE": 'check-circle', + "DISPONIBLE": 'radiobox-blank', + "EN COURS": 'progress-check', + "HS": 'alert-octagram-outline', + "ERREUR": 'alert' + } +}; diff --git a/src/constants/Update.js b/src/constants/Update.js new file mode 100644 index 0000000..3ad9733 --- /dev/null +++ b/src/constants/Update.js @@ -0,0 +1,62 @@ +import i18n from "i18n-js"; + +/** + * Singleton used to manage update slides. + * Must be a singleton because it uses translations. + * + * Change values in this class to change the update slide. + * You will also need to update those translations: + *
    + *
  • intro.updateSlide.title
  • + *
  • intro.updateSlide.text
  • + *
+ */ +export default class Update { + + // Increment the number to show the update slide + static number = 6; + // Change the number of slides to display + static slidesNumber = 4; + // Change the icons to be displayed on the update slide + static iconList = [ + 'star', + 'clock', + 'qrcode-scan', + 'account', + ]; + static colorsList = [ + ['#e01928', '#be1522'], + ['#7c33ec', '#5e11d1'], + ['#337aec', '#114ed1'], + ['#e01928', '#be1522'], + ] + + static instance: Update | null = null; + + titleList: Array; + descriptionList: Array; + + /** + * Init translations + */ + constructor() { + this.titleList = []; + this.descriptionList = []; + for (let i = 0; i < Update.slidesNumber; i++) { + this.titleList.push(i18n.t('intro.updateSlide'+ i + '.title')) + this.descriptionList.push(i18n.t('intro.updateSlide'+ i + '.text')) + } + } + + /** + * Get this class instance or create one if none is found + * + * @returns {Update} + */ + static getInstance(): Update { + return Update.instance === null ? + Update.instance = new Update() : + Update.instance; + } + +}; diff --git a/utils/AprilFoolsManager.js b/src/managers/AprilFoolsManager.js similarity index 100% rename from utils/AprilFoolsManager.js rename to src/managers/AprilFoolsManager.js diff --git a/utils/AsyncStorageManager.js b/src/managers/AsyncStorageManager.js similarity index 85% rename from utils/AsyncStorageManager.js rename to src/managers/AsyncStorageManager.js index 46461ed..f0c96fb 100644 --- a/utils/AsyncStorageManager.js +++ b/src/managers/AsyncStorageManager.js @@ -1,9 +1,9 @@ // @flow -import {AsyncStorage} from "react-native"; +import AsyncStorage from '@react-native-community/async-storage'; /** - * Static class used to manage preferences. + * Singleton used to manage preferences. * Preferences are fetched at the start of the app and saved in an instance object. * This allows for a synchronous access to saved data. */ @@ -14,7 +14,7 @@ export default class AsyncStorageManager { /** * Get this class instance or create one if none is found - * @returns {ThemeManager} + * @returns {AsyncStorageManager} */ static getInstance(): AsyncStorageManager { return AsyncStorageManager.instance === null ? @@ -39,11 +39,6 @@ export default class AsyncStorageManager { default: '5', current: '', }, - proxiwashWatchedMachines: { - key: 'proxiwashWatchedMachines', - default: '[]', - current: '', - }, nightModeFollowSystem: { key: 'nightModeFollowSystem', default: '1', @@ -66,7 +61,7 @@ export default class AsyncStorageManager { }, defaultStartScreen: { key: 'defaultStartScreen', - default: 'Home', + default: 'home', current: '', }, proxiwashShowBanner: { @@ -74,6 +69,11 @@ export default class AsyncStorageManager { default: '1', current: '', }, + proxiwashWatchedMachines: { + key: 'proxiwashWatchedMachines', + default: '[]', + current: '', + }, planexShowBanner: { key: 'planexShowBanner', default: '1', @@ -84,6 +84,16 @@ export default class AsyncStorageManager { default: '1', current: '', }, + planexCurrentGroup: { + key: 'planexCurrentGroup', + default: '', + current: '', + }, + planexFavoriteGroups: { + key: 'planexFavoriteGroups', + default: '[]', + current: '', + }, }; /** @@ -96,6 +106,7 @@ export default class AsyncStorageManager { let prefKeys = []; // Get all available keys for (let [key, value] of Object.entries(this.preferences)) { + //$FlowFixMe prefKeys.push(value.key); } // Get corresponding values @@ -112,7 +123,7 @@ export default class AsyncStorageManager { /** * Save the value associated to the given key to preferences. - * This updates the preferences object and saves it to AsynStorage. + * This updates the preferences object and saves it to AsyncStorage. * * @param key * @param val diff --git a/src/managers/ConnectionManager.js b/src/managers/ConnectionManager.js new file mode 100644 index 0000000..0151f45 --- /dev/null +++ b/src/managers/ConnectionManager.js @@ -0,0 +1,157 @@ +// @flow + +import * as Keychain from 'react-native-keychain'; +import {apiRequest, ERROR_TYPE, isResponseValid} from "../utils/WebData"; + +/** + * champ: error + * + * 0 : SUCCESS -> pas d'erreurs + * 1 : BAD_CREDENTIALS -> email ou mdp invalide + * 2 : BAD_TOKEN -> session expirée + * 3 : NO_CONSENT + * 403 : FORBIDDEN -> accès a la ressource interdit + * 500 : SERVER_ERROR -> pb coté serveur + */ + +const SERVER_NAME = "amicale-insat.fr"; +const AUTH_PATH = "password"; + +export default class ConnectionManager { + static instance: ConnectionManager | null = null; + + #email: string; + #token: string | null; + + listeners: Array; + + constructor() { + this.#token = null; + this.listeners = []; + } + + /** + * Get this class instance or create one if none is found + * @returns {ConnectionManager} + */ + static getInstance(): ConnectionManager { + return ConnectionManager.instance === null ? + ConnectionManager.instance = new ConnectionManager() : + ConnectionManager.instance; + } + + getToken() { + return this.#token; + } + + onLoginStateChange(newState: boolean) { + for (let i = 0; i < this.listeners.length; i++) { + if (this.listeners[i] !== undefined) + this.listeners[i](newState); + } + } + + addLoginStateListener(listener: Function) { + this.listeners.push(listener); + } + + async recoverLogin() { + return new Promise((resolve, reject) => { + if (this.getToken() !== null) + resolve(this.getToken()); + else { + Keychain.getInternetCredentials(SERVER_NAME) + .then((data) => { + if (data) { + this.#token = data.password; + this.onLoginStateChange(true); + resolve(this.#token); + } else + reject(false); + }) + .catch(() => { + reject(false); + }); + } + }); + } + + isLoggedIn() { + return this.getToken() !== null; + } + + async saveLogin(email: string, token: string) { + return new Promise((resolve, reject) => { + Keychain.setInternetCredentials(SERVER_NAME, 'token', token) + .then(() => { + this.#token = token; + this.#email = email; + this.onLoginStateChange(true); + resolve(true); + }) + .catch(() => { + reject(false); + }); + }); + } + + async disconnect() { + return new Promise((resolve, reject) => { + Keychain.resetInternetCredentials(SERVER_NAME) + .then(() => { + this.#token = null; + this.onLoginStateChange(false); + resolve(true); + }) + .catch(() => { + reject(false); + }); + }); + } + + async connect(email: string, password: string) { + return new Promise((resolve, reject) => { + const data = { + email: email, + password: password, + }; + apiRequest(AUTH_PATH, 'POST', data) + .then((response) => { + this.saveLogin(email, response.token) + .then(() => { + resolve(true); + }) + .catch(() => { + reject(ERROR_TYPE.UNKNOWN); + }); + }) + .catch((error) => reject(error)); + }); + } + + isConnectionResponseValid(response: Object) { + let valid = isResponseValid(response); + + if (valid && response.error === ERROR_TYPE.SUCCESS) + valid = valid + && response.data.token !== undefined + && response.data.token !== '' + && typeof response.data.token === "string"; + return valid; + } + + async authenticatedRequest(path: string, params: Object) { + return new Promise((resolve, reject) => { + if (this.getToken() !== null) { + let data = { + token: this.getToken(), + ...params + }; + apiRequest(path, 'POST', data) + .then((response) => resolve(response)) + .catch((error) => reject(error)); + } else + reject(ERROR_TYPE.UNKNOWN); + }); + } +} diff --git a/src/managers/DateManager.js b/src/managers/DateManager.js new file mode 100644 index 0000000..a6cc82b --- /dev/null +++ b/src/managers/DateManager.js @@ -0,0 +1,65 @@ +// @flow + +import i18n from 'i18n-js'; + +/** + * Singleton used to manage date translations. + * Translations are hardcoded as toLocaleDateString does not work on current android JS engine + */ +export default class DateManager { + static instance: DateManager | null = null; + + daysOfWeek = []; + monthsOfYear = []; + + constructor() { + this.daysOfWeek.push(i18n.t("date.daysOfWeek.sunday")); // 0 represents sunday + this.daysOfWeek.push(i18n.t("date.daysOfWeek.monday")); + this.daysOfWeek.push(i18n.t("date.daysOfWeek.tuesday")); + this.daysOfWeek.push(i18n.t("date.daysOfWeek.wednesday")); + this.daysOfWeek.push(i18n.t("date.daysOfWeek.thursday")); + this.daysOfWeek.push(i18n.t("date.daysOfWeek.friday")); + this.daysOfWeek.push(i18n.t("date.daysOfWeek.saturday")); + + this.monthsOfYear.push(i18n.t("date.monthsOfYear.january")); + this.monthsOfYear.push(i18n.t("date.monthsOfYear.february")); + this.monthsOfYear.push(i18n.t("date.monthsOfYear.march")); + this.monthsOfYear.push(i18n.t("date.monthsOfYear.april")); + this.monthsOfYear.push(i18n.t("date.monthsOfYear.may")); + this.monthsOfYear.push(i18n.t("date.monthsOfYear.june")); + this.monthsOfYear.push(i18n.t("date.monthsOfYear.july")); + this.monthsOfYear.push(i18n.t("date.monthsOfYear.august")); + this.monthsOfYear.push(i18n.t("date.monthsOfYear.september")); + this.monthsOfYear.push(i18n.t("date.monthsOfYear.october")); + this.monthsOfYear.push(i18n.t("date.monthsOfYear.november")); + this.monthsOfYear.push(i18n.t("date.monthsOfYear.december")); + } + + /** + * Get this class instance or create one if none is found + * @returns {DateManager} + */ + static getInstance(): DateManager { + return DateManager.instance === null ? + DateManager.instance = new DateManager() : + DateManager.instance; + } + + /** + * Gets a translated string representing the given date. + * + * @param dateString The date with the format YYYY-MM-DD + * @return {string} The translated string + */ + getTranslatedDate(dateString: string) { + let dateArray = dateString.split('-'); + let date = new Date(); + date.setFullYear(parseInt(dateArray[0]), parseInt(dateArray[1]) - 1, parseInt(dateArray[2])); + return this.daysOfWeek[date.getDay()] + " " + date.getDate() + " " + this.monthsOfYear[date.getMonth()] + " " + date.getFullYear(); + } + + static isWeekend(date: Date) { + return date.getDay() === 6 || date.getDay() === 0; + } + +} diff --git a/utils/LocaleManager.js b/src/managers/LocaleManager.js similarity index 53% rename from utils/LocaleManager.js rename to src/managers/LocaleManager.js index 5b7b28d..ca45c40 100644 --- a/utils/LocaleManager.js +++ b/src/managers/LocaleManager.js @@ -1,10 +1,10 @@ // @flow import i18n from 'i18n-js'; -import * as Localization from 'expo-localization'; +import * as RNLocalize from "react-native-localize"; -import en from '../translations/en'; -import fr from '../translations/fr'; +import en from '../../translations/en'; +import fr from '../../translations/fr'; /** * Static class used to manage locales @@ -17,10 +17,10 @@ export default class LocaleManager { static initTranslations() { i18n.fallbacks = true; i18n.translations = {fr, en}; - i18n.locale = Localization.locale; + i18n.locale = RNLocalize.findBestAvailableLanguage(["en", "fr"]).languageTag; } static getCurrentLocale() { - return Localization.locale; + return RNLocalize.findBestAvailableLanguage(["en", "fr"]).languageTag; } } diff --git a/utils/ThemeManager.js b/src/managers/ThemeManager.js similarity index 58% rename from utils/ThemeManager.js rename to src/managers/ThemeManager.js index 86ae540..9c087c4 100644 --- a/utils/ThemeManager.js +++ b/src/managers/ThemeManager.js @@ -1,12 +1,61 @@ // @flow import AsyncStorageManager from "./AsyncStorageManager"; -import {DarkTheme, DefaultTheme} from 'react-native-paper'; +import {DarkTheme, DefaultTheme, Theme} from 'react-native-paper'; import AprilFoolsManager from "./AprilFoolsManager"; import {Appearance} from 'react-native-appearance'; const colorScheme = Appearance.getColorScheme(); +export type CustomTheme = { + ...Theme, + colors: { + primary: string, + accent: string, + tabIcon: string, + card: string, + dividerBackground: string, + ripple: string, + textDisabled: string, + icon: string, + subtitle: string, + success: string, + warning: string, + danger: string, + + // Calendar/Agenda + agendaBackgroundColor: string, + agendaDayTextColor: string, + + // PROXIWASH + proxiwashFinishedColor: string, + proxiwashReadyColor: string, + proxiwashRunningColor: string, + proxiwashRunningBgColor: string, + proxiwashBrokenColor: string, + proxiwashErrorColor: string, + + // Screens + planningColor: string, + proximoColor: string, + proxiwashColor: string, + menuColor: string, + tutorinsaColor: string, + + // Tetris + tetrisBackground: string, + tetrisBorder:string, + tetrisScore:string, + tetrisI : string, + tetrisO : string, + tetrisT : string, + tetrisS : string, + tetrisZ : string, + tetrisJ : string, + tetrisL : string, + }, +} + /** * Singleton class used to manage themes */ @@ -19,7 +68,12 @@ export default class ThemeManager { this.updateThemeCallback = null; } - static getWhiteTheme() { + /** + * Gets the light theme + * + * @return {CustomTheme} Object containing theme variables + * */ + static getWhiteTheme(): CustomTheme { return { ...DefaultTheme, colors: { @@ -29,12 +83,14 @@ export default class ThemeManager { tabIcon: "#929292", card: "rgb(255, 255, 255)", dividerBackground: '#e2e2e2', + ripple: "rgba(0,0,0,0.2)", textDisabled: '#c1c1c1', icon: '#5d5d5d', subtitle: '#707070', success: "#5cb85c", warning: "#f0ad4e", danger: "#d9534f", + cc: 'dst', // Calendar/Agenda agendaBackgroundColor: '#f3f3f4', @@ -54,11 +110,28 @@ export default class ThemeManager { proxiwashColor: '#1fa5ee', menuColor: '#e91314', tutorinsaColor: '#f93943', + + // Tetris + tetrisBackground:'#e6e6e6', + tetrisBorder:'#2f2f2f', + tetrisScore:'#e2bd33', + tetrisI : '#3cd9e6', + tetrisO : '#ffdd00', + tetrisT : '#a716e5', + tetrisS : '#09c528', + tetrisZ : '#ff0009', + tetrisJ : '#2a67e3', + tetrisL : '#da742d', }, }; } - static getDarkTheme() { + /** + * Gets the dark theme + * + * @return {CustomTheme} Object containing theme variables + * */ + static getDarkTheme(): CustomTheme { return { ...DarkTheme, colors: { @@ -69,6 +142,7 @@ export default class ThemeManager { tabIcon: "#6d6d6d", card: "rgb(18, 18, 18)", dividerBackground: '#222222', + ripple: "rgba(255,255,255,0.2)", textDisabled: '#5b5b5b', icon: '#b3b3b3', subtitle: '#aaaaaa', @@ -94,12 +168,25 @@ export default class ThemeManager { proxiwashColor: '#1fa5ee', menuColor: '#b81213', tutorinsaColor: '#f93943', + + // Tetris + tetrisBackground:'#2c2c2c', + tetrisBorder:'#1b1b1b', + tetrisScore:'#e2d707', + tetrisI : '#30b3be', + tetrisO : '#c1a700', + tetrisT : '#9114c7', + tetrisS : '#08a121', + tetrisZ : '#b50008', + tetrisJ : '#0f37b9', + tetrisL : '#b96226', }, }; } /** * Get this class instance or create one if none is found + * * @returns {ThemeManager} */ static getInstance(): ThemeManager { @@ -109,6 +196,10 @@ export default class ThemeManager { } /** + * Gets night mode status. + * If Follow System Preferences is enabled, will first use system theme. + * If disabled or not available, will use value stored din preferences + * * @returns {boolean} Night mode state */ static getNightMode(): boolean { @@ -119,17 +210,23 @@ export default class ThemeManager { } /** - * Get the current theme based on night mode - * @returns {Object} + * Get the current theme based on night mode and events + * + * @returns {CustomTheme} The current theme */ - static getCurrentTheme(): Object { + static getCurrentTheme(): CustomTheme { if (AprilFoolsManager.getInstance().isAprilFoolsEnabled()) return AprilFoolsManager.getAprilFoolsTheme(ThemeManager.getWhiteTheme()); else return ThemeManager.getBaseTheme() } - static getBaseTheme() { + /** + * Get the theme based on night mode + * + * @return {CustomTheme} The theme + */ + static getBaseTheme(): CustomTheme { if (ThemeManager.getNightMode()) return ThemeManager.getDarkTheme(); else @@ -137,22 +234,23 @@ export default class ThemeManager { } /** - * Set the function to be called when the theme is changed (allows for general reload of the app) + * Sets the function to be called when the theme is changed (allows for general reload of the app) + * * @param callback Function to call after theme change */ - setUpdateThemeCallback(callback: ?Function) { + setUpdateThemeCallback(callback: () => void) { this.updateThemeCallback = callback; } /** * Set night mode and save it to preferences * - * @param isNightMode Whether to enable night mode + * @param isNightMode True to enable night mode, false to disable */ setNightMode(isNightMode: boolean) { let nightModeKey = AsyncStorageManager.getInstance().preferences.nightMode.key; AsyncStorageManager.getInstance().savePref(nightModeKey, isNightMode ? '1' : '0'); - if (this.updateThemeCallback !== null) + if (this.updateThemeCallback != null) this.updateThemeCallback(); } diff --git a/src/navigation/MainNavigator.js b/src/navigation/MainNavigator.js new file mode 100644 index 0000000..79aca49 --- /dev/null +++ b/src/navigation/MainNavigator.js @@ -0,0 +1,193 @@ +// @flow + +import * as React from 'react'; +import SettingsScreen from '../screens/Other/SettingsScreen'; +import AboutScreen from '../screens/About/AboutScreen'; +import AboutDependenciesScreen from '../screens/About/AboutDependenciesScreen'; +import DebugScreen from '../screens/About/DebugScreen'; +import {createStackNavigator, TransitionPresets} from "@react-navigation/stack"; +import i18n from "i18n-js"; +import TabNavigator from "./TabNavigator"; +import TetrisScreen from "../screens/Tetris/TetrisScreen"; +import VoteScreen from "../screens/Amicale/VoteScreen"; +import LoginScreen from "../screens/Amicale/LoginScreen"; +import {Platform} from "react-native"; +import AvailableRoomScreen from "../screens/Services/Websites/AvailableRoomScreen"; +import BibScreen from "../screens/Services/Websites/BibScreen"; +import SelfMenuScreen from "../screens/Services/SelfMenuScreen"; +import ProximoMainScreen from "../screens/Services/Proximo/ProximoMainScreen"; +import ProximoListScreen from "../screens/Services/Proximo/ProximoListScreen"; +import ProximoAboutScreen from "../screens/Services/Proximo/ProximoAboutScreen"; +import {AmicaleWebsiteScreen} from "../screens/Services/Websites/AmicaleWebsiteScreen"; +import {ElusEtudiantsWebsiteScreen} from "../screens/Services/Websites/ElusEtudiantsWebsiteScreen"; +import {WiketudWebsiteScreen} from "../screens/Services/Websites/WiketudWebsiteScreen"; +import {TutorInsaWebsiteScreen} from "../screens/Services/Websites/TutorInsaWebsiteScreen"; +import {ENTWebsiteScreen} from "../screens/Services/Websites/ENTWebsiteScreen"; +import {BlueMindWebsiteScreen} from "../screens/Services/Websites/BlueMindWebsiteScreen"; +import ProfileScreen from "../screens/Amicale/ProfileScreen"; +import ClubListScreen from "../screens/Amicale/Clubs/ClubListScreen"; +import ClubAboutScreen from "../screens/Amicale/Clubs/ClubAboutScreen"; +import ClubDisplayScreen from "../screens/Amicale/Clubs/ClubDisplayScreen"; +import {createScreenCollapsibleStack, getWebsiteStack} from "../utils/CollapsibleUtils"; +import BugReportScreen from "../screens/Other/FeedbackScreen"; + +const modalTransition = Platform.OS === 'ios' ? TransitionPresets.ModalPresentationIOS : TransitionPresets.ModalSlideFromBottomIOS; + +const screenTransition = TransitionPresets.SlideFromRightIOS; + +const defaultScreenOptions = { + gestureEnabled: true, + cardOverlayEnabled: true, + ...screenTransition, +}; + + +const MainStack = createStackNavigator(); + +function MainStackComponent(props: { createTabNavigator: () => React.Node }) { + return ( + + + + + + + + + + {/* INSA */} + {getWebsiteStack("available-rooms", MainStack, AvailableRoomScreen, i18n.t('screens.availableRooms'))} + {getWebsiteStack("bib", MainStack, BibScreen, i18n.t('screens.bib'))} + {createScreenCollapsibleStack("self-menu", MainStack, SelfMenuScreen, i18n.t('screens.menuSelf'))} + + {/* STUDENTS */} + {createScreenCollapsibleStack("proximo", MainStack, ProximoMainScreen, i18n.t('screens.proximo'))} + {createScreenCollapsibleStack( + "proximo-list", + MainStack, + ProximoListScreen, + i18n.t('screens.proximoArticles'), + true, + {...screenTransition}, + )} + + {getWebsiteStack("amicale-website", MainStack, AmicaleWebsiteScreen, i18n.t('screens.amicaleWebsite'))} + {getWebsiteStack("elus-etudiants", MainStack, ElusEtudiantsWebsiteScreen, "Élus Étudiants")} + {getWebsiteStack("wiketud", MainStack, WiketudWebsiteScreen, "Wiketud")} + {getWebsiteStack("tutorinsa", MainStack, TutorInsaWebsiteScreen, "Tutor'INSA")} + {getWebsiteStack("ent", MainStack, ENTWebsiteScreen, i18n.t('screens.ent'))} + {getWebsiteStack("bluemind", MainStack, BlueMindWebsiteScreen, i18n.t('screens.bluemind'))} + + + {/* AMICALE */} + {createScreenCollapsibleStack("profile", MainStack, ProfileScreen, i18n.t('screens.profile'))} + {createScreenCollapsibleStack("club-list", MainStack, ClubListScreen, i18n.t('clubs.clubList'))} + + + + + + + ); +} + +type Props = { + defaultHomeRoute: string | null, + defaultHomeData: { [key: string]: any } +} + +export default class MainNavigator extends React.Component { + + createTabNavigator: () => React.Node; + + constructor(props: Props) { + super(props); + this.createTabNavigator = () => + } + + render() { + return ( + + ); + } +} diff --git a/src/navigation/TabNavigator.js b/src/navigation/TabNavigator.js new file mode 100644 index 0000000..8012086 --- /dev/null +++ b/src/navigation/TabNavigator.js @@ -0,0 +1,220 @@ +import * as React from 'react'; +import {createStackNavigator, TransitionPresets} from '@react-navigation/stack'; +import {createBottomTabNavigator} from "@react-navigation/bottom-tabs"; + +import HomeScreen from '../screens/Home/HomeScreen'; +import PlanningScreen from '../screens/Planning/PlanningScreen'; +import PlanningDisplayScreen from '../screens/Planning/PlanningDisplayScreen'; +import ProxiwashScreen from '../screens/Proxiwash/ProxiwashScreen'; +import ProxiwashAboutScreen from '../screens/Proxiwash/ProxiwashAboutScreen'; +import PlanexScreen from '../screens/Planex/PlanexScreen'; +import AsyncStorageManager from "../managers/AsyncStorageManager"; +import {useTheme} from 'react-native-paper'; +import {Platform} from 'react-native'; +import i18n from "i18n-js"; +import ClubDisplayScreen from "../screens/Amicale/Clubs/ClubDisplayScreen"; +import ScannerScreen from "../screens/Home/ScannerScreen"; +import FeedItemScreen from "../screens/Home/FeedItemScreen"; +import {createCollapsibleStack} from "react-navigation-collapsible"; +import GroupSelectionScreen from "../screens/Planex/GroupSelectionScreen"; +import CustomTabBar from "../components/Tabbar/CustomTabBar"; +import WebsitesHomeScreen from "../screens/Services/ServicesScreen"; +import ServicesSectionScreen from "../screens/Services/ServicesSectionScreen"; +import AmicaleContactScreen from "../screens/Amicale/AmicaleContactScreen"; +import {createScreenCollapsibleStack, getWebsiteStack} from "../utils/CollapsibleUtils"; + +const modalTransition = Platform.OS === 'ios' ? TransitionPresets.ModalPresentationIOS : TransitionPresets.ModalSlideFromBottomIOS; + + +const defaultScreenOptions = { + gestureEnabled: true, + cardOverlayEnabled: true, + ...modalTransition, +}; + + +const ServicesStack = createStackNavigator(); + +function ServicesStackComponent() { + return ( + + {createScreenCollapsibleStack("index", ServicesStack, WebsitesHomeScreen, i18n.t('screens.services'))} + {createScreenCollapsibleStack("services-section", ServicesStack, ServicesSectionScreen, "SECTION")} + {createScreenCollapsibleStack("amicale-contact", ServicesStack, AmicaleContactScreen, i18n.t('screens.amicaleAbout'))} + + ); +} + +const ProxiwashStack = createStackNavigator(); + +function ProxiwashStackComponent() { + return ( + + {createScreenCollapsibleStack("index", ProxiwashStack, ProxiwashScreen, i18n.t('screens.proxiwash'))} + + + ); +} + +const PlanningStack = createStackNavigator(); + +function PlanningStackComponent() { + return ( + + + + + ); +} + +const HomeStack = createStackNavigator(); + +function HomeStackComponent(initialRoute: string | null, defaultData: { [key: string]: any }) { + let params = undefined; + if (initialRoute != null) + params = {data: defaultData, nextScreen: initialRoute, shouldOpen: true}; + const {colors} = useTheme(); + return ( + + {createCollapsibleStack( + , + { + collapsedColor: colors.surface, + useNativeDriver: true, + } + )} + + + + + + ); +} + +const PlanexStack = createStackNavigator(); + +function PlanexStackComponent() { + return ( + + {getWebsiteStack("index", PlanexStack, PlanexScreen, "Planex")} + {createScreenCollapsibleStack("group-select", PlanexStack, GroupSelectionScreen, "GROUP SELECT")} + + ); +} + +const Tab = createBottomTabNavigator(); + +type Props = { + defaultHomeRoute: string | null, + defaultHomeData: { [key: string]: any } +} + +export default class TabNavigator extends React.Component { + + createHomeStackComponent: () => HomeStackComponent; + defaultRoute: string; + + constructor(props) { + super(props); + if (props.defaultHomeRoute != null) + this.defaultRoute = 'home'; + else + this.defaultRoute = AsyncStorageManager.getInstance().preferences.defaultStartScreen.current.toLowerCase(); + this.createHomeStackComponent = () => HomeStackComponent(props.defaultHomeRoute, props.defaultHomeData); + } + + render() { + return ( + } + > + + + + + + + + ); + } +} diff --git a/src/screens/About/AboutDependenciesScreen.js b/src/screens/About/AboutDependenciesScreen.js new file mode 100644 index 0000000..8e77afa --- /dev/null +++ b/src/screens/About/AboutDependenciesScreen.js @@ -0,0 +1,72 @@ +// @flow + +import * as React from 'react'; +import {FlatList} from "react-native"; +import packageJson from '../../../package'; +import {List} from 'react-native-paper'; + +type listItem = { + name: string, + version: string +}; + +/** + * Generates the dependencies list from the raw json + * + * @param object The raw json + * @return {Array} + */ +function generateListFromObject(object: { [string]: string }): Array { + let list = []; + let keys = Object.keys(object); + let values = Object.values(object); + for (let i = 0; i < keys.length; i++) { + list.push({name: keys[i], version: values[i]}); + } + //$FlowFixMe + return list; +} + +type Props = { + navigation: Object, + route: Object +} + +const LIST_ITEM_HEIGHT = 64; + +/** + * Class defining a screen showing the list of libraries used by the app, taken from package.json + */ +export default class AboutDependenciesScreen extends React.Component { + + data: Array; + + constructor() { + super(); + this.data = generateListFromObject(packageJson.dependencies); + } + + keyExtractor = (item: Object) => item.name; + + renderItem = ({item}: Object) => + ; + + itemLayout = (data, index) => ({length: LIST_ITEM_HEIGHT, offset: LIST_ITEM_HEIGHT * index, index}); + + render() { + return ( + + ); + } +} diff --git a/screens/About/AboutScreen.js b/src/screens/About/AboutScreen.js similarity index 68% rename from screens/About/AboutScreen.js rename to src/screens/About/AboutScreen.js index ac890e8..4054f64 100644 --- a/screens/About/AboutScreen.js +++ b/src/screens/About/AboutScreen.js @@ -3,25 +3,14 @@ import * as React from 'react'; import {FlatList, Linking, Platform, View} from 'react-native'; import i18n from "i18n-js"; -import appJson from '../../app'; -import AsyncStorageManager from "../../utils/AsyncStorageManager"; -import CustomModal from "../../components/CustomModal"; -import {Avatar, Button, Card, List, Text, Title, withTheme} from 'react-native-paper'; +import AsyncStorageManager from "../../managers/AsyncStorageManager"; +import {Avatar, Card, List, Title, withTheme} from 'react-native-paper'; +import packageJson from "../../../package.json"; const links = { appstore: 'https://apps.apple.com/us/app/campus-amicale-insat/id1477722148', playstore: 'https://play.google.com/store/apps/details?id=fr.amicaleinsat.application', git: 'https://git.etud.insa-toulouse.fr/vergnet/application-amicale/src/branch/master/README.md', - bugsMail: 'mailto:vergnet@etud.insa-toulouse.fr?' + - 'subject=' + - '[BUG] Application Amicale INSA Toulouse' + - '&body=' + - 'Coucou Arnaud ça bug c\'est nul,\n\n' + - 'Informations sur ton système si tu sais (iOS ou Android, modèle du tel, version):\n\n\n' + - 'Nature du problème :\n\n\n' + - 'Étapes pour reproduire ce pb :\n\n\n\n' + - 'Stp corrige le pb, bien cordialement.', - bugsGit: 'https://git.etud.insa-toulouse.fr/vergnet/application-amicale/issues', changelog: 'https://git.etud.insa-toulouse.fr/vergnet/application-amicale/src/branch/master/Changelog.md', license: 'https://git.etud.insa-toulouse.fr/vergnet/application-amicale/src/branch/master/LICENSE', authorMail: "mailto:vergnet@etud.insa-toulouse.fr?" + @@ -77,9 +66,9 @@ class AboutScreen extends React.Component { showChevron: true }, { - onPressCallback: () => this.openBugReportModal(), + onPressCallback: () => this.props.navigation.navigate("feedback"), icon: 'bug', - text: i18n.t('aboutScreen.bugs'), + text: i18n.t("feedbackScreen.homeButtonTitle"), showChevron: true }, { @@ -101,11 +90,11 @@ class AboutScreen extends React.Component { showChevron: true }, { - onPressCallback: () => this.props.navigation.navigate('DebugScreen'), + onPressCallback: () => this.props.navigation.navigate('debug'), icon: 'bug-check', text: i18n.t('aboutScreen.debug'), showChevron: true, - showOnlyDebug: true + showOnlyInDebug: true }, ]; /** @@ -165,12 +154,15 @@ class AboutScreen extends React.Component { showChevron: true }, { - onPressCallback: () => this.props.navigation.navigate('AboutDependenciesScreen'), + onPressCallback: () => this.props.navigation.navigate('dependencies'), icon: 'developer-board', text: i18n.t('aboutScreen.libs'), showChevron: true }, ]; + /** + * Order of information cards + */ dataOrder: Array = [ { id: 'app', @@ -183,51 +175,56 @@ class AboutScreen extends React.Component { }, ]; - getCardItem: Function; - getMainCard: Function; - onModalRef: Function; - onPressMail: Function; - onPressGit: Function; colors: Object; constructor(props) { super(props); - this.getCardItem = this.getCardItem.bind(this); - this.getMainCard = this.getMainCard.bind(this); - this.onModalRef = this.onModalRef.bind(this); - this.onPressMail = openWebLink.bind(this, links.bugsMail); - this.onPressGit = openWebLink.bind(this, links.bugsGit); this.colors = props.theme.colors; } + /** + * Gets the app icon + * @param props + * @return {*} + */ getAppIcon(props) { return ( ); } - keyExtractor(item: Object) { + /** + * Extracts a key from the given item + * + * @param item The item to extract the key from + * @return {string} The extracted key + */ + keyExtractor(item: Object): string { return item.icon; } + /** + * Gets the app card showing information and links about the app. + * + * @return {*} + */ getAppCard() { return ( @@ -235,6 +232,11 @@ class AboutScreen extends React.Component { ); } + /** + * Gets the team card showing information and links about the team + * + * @return {*} + */ getTeamCard() { return ( @@ -245,17 +247,15 @@ class AboutScreen extends React.Component { {i18n.t('aboutScreen.author')} {i18n.t('aboutScreen.additionalDev')} @@ -263,6 +263,11 @@ class AboutScreen extends React.Component { ); } + /** + * Gets the techno card showing information and links about the technologies used in the app + * + * @return {*} + */ getTechnoCard() { return ( @@ -270,9 +275,7 @@ class AboutScreen extends React.Component { {i18n.t('aboutScreen.technologies')} @@ -280,12 +283,25 @@ class AboutScreen extends React.Component { ); } + /** + * Gets a chevron icon + * + * @param props + * @return {*} + */ getChevronIcon(props: Object) { return ( ); } + /** + * Gets a custom list item icon + * + * @param item The item to show the icon for + * @param props + * @return {*} + */ getItemIcon(item: Object, props: Object) { return ( @@ -295,10 +311,12 @@ class AboutScreen extends React.Component { /** * Get a clickable card item to be rendered inside a card. * - * @returns {React.Node} + * @returns {*} */ - getCardItem({item}: Object) { - let shouldShow = !item.showOnlyInDebug || (item.showOnlyInDebug && this.state.isDebugUnlocked); + getCardItem = ({item}: Object) => { + let shouldShow = item === undefined + || !item.showOnlyInDebug + || (item.showOnlyInDebug && this.state.isDebugUnlocked); const getItemIcon = this.getItemIcon.bind(this, item); if (shouldShow) { if (item.showChevron) { @@ -321,8 +339,11 @@ class AboutScreen extends React.Component { } } else return null; - } + }; + /** + * Tries to unlock debug mode + */ tryUnlockDebugMode() { this.debugTapCounter = this.debugTapCounter + 1; if (this.debugTapCounter >= 4) { @@ -330,59 +351,22 @@ class AboutScreen extends React.Component { } } + /** + * Unlocks debug mode + */ unlockDebugMode() { this.setState({isDebugUnlocked: true}); let key = AsyncStorageManager.getInstance().preferences.debugUnlocked.key; AsyncStorageManager.getInstance().savePref(key, '1'); } - getBugReportModal() { - return ( - - {i18n.t('aboutScreen.bugs')} - - {i18n.t('aboutScreen.bugsDescription')} - - - - - ); - } - - openBugReportModal() { - if (this.modalRef) { - this.modalRef.open(); - } - } - - getMainCard({item}: Object) { + /** + * Gets a card, depending on the given item's id + * + * @param item The item to show + * @return {*} + */ + getMainCard = ({item}: Object) => { switch (item.id) { case 'app': return this.getAppCard(); @@ -392,26 +376,15 @@ class AboutScreen extends React.Component { return this.getTechnoCard(); } return ; - } - - onModalRef(ref: Object) { - this.modalRef = ref; - } + }; render() { return ( - - - {this.getBugReportModal()} - - item.id} - renderItem={this.getMainCard} - /> - + ); } } diff --git a/src/screens/About/DebugScreen.js b/src/screens/About/DebugScreen.js new file mode 100644 index 0000000..e9f8e8d --- /dev/null +++ b/src/screens/About/DebugScreen.js @@ -0,0 +1,164 @@ +// @flow + +import * as React from 'react'; +import {FlatList, View} from "react-native"; +import AsyncStorageManager from "../../managers/AsyncStorageManager"; +import CustomModal from "../../components/Overrides/CustomModal"; +import {Button, List, Subheading, TextInput, Title, withTheme} from 'react-native-paper'; + +type Props = { + navigation: Object, +}; + +type State = { + modalCurrentDisplayItem: Object, + currentPreferences: Array, +} + +/** + * Class defining the Debug screen. + * This screen allows the user to get and modify information on the app/device. + */ +class DebugScreen extends React.Component { + + modalRef: Object; + modalInputValue = ''; + + onModalRef: Function; + + colors: Object; + + constructor(props) { + super(props); + this.onModalRef = this.onModalRef.bind(this); + this.colors = props.theme.colors; + let copy = {...AsyncStorageManager.getInstance().preferences}; + let currentPreferences = []; + Object.values(copy).map((object) => { + currentPreferences.push(object); + }); + this.state = { + modalCurrentDisplayItem: {}, + currentPreferences: currentPreferences + }; + } + + /** + * Show the edit modal + * @param item + */ + showEditModal(item: Object) { + this.setState({ + modalCurrentDisplayItem: item + }); + if (this.modalRef) { + this.modalRef.open(); + } + } + + /** + * Gets the edit modal content + * + * @return {*} + */ + getModalContent() { + return ( + + {this.state.modalCurrentDisplayItem.key} + Default: {this.state.modalCurrentDisplayItem.default} + Current: {this.state.modalCurrentDisplayItem.current} + this.modalInputValue = text} + /> + + + + + + + ); + } + + findIndexOfKey(key: string) { + let index = -1; + for (let i = 0; i < this.state.currentPreferences.length; i++) { + if (this.state.currentPreferences[i].key === key) { + index = i; + break; + } + } + return index; + } + + /** + * Saves the new value of the given preference + * + * @param key The pref key + * @param value The pref value + */ + saveNewPrefs(key: string, value: string) { + this.setState((prevState) => { + let currentPreferences = [...prevState.currentPreferences]; + currentPreferences[this.findIndexOfKey(key)].current = value; + return {currentPreferences}; + }); + AsyncStorageManager.getInstance().savePref(key, value); + this.modalRef.close(); + } + + /** + * Callback used when receiving the modal ref + * + * @param ref + */ + onModalRef(ref: Object) { + this.modalRef = ref; + } + + renderItem = ({item}: Object) => { + return ( + this.showEditModal(item)} + /> + ); + }; + + render() { + return ( + + + {this.getModalContent()} + + {/*$FlowFixMe*/} + + + ); + } +} + +export default withTheme(DebugScreen); diff --git a/src/screens/Amicale/AmicaleContactScreen.js b/src/screens/Amicale/AmicaleContactScreen.js new file mode 100644 index 0000000..10c817b --- /dev/null +++ b/src/screens/Amicale/AmicaleContactScreen.js @@ -0,0 +1,148 @@ +// @flow + +import * as React from 'react'; +import {Animated, FlatList, Image, Linking, View} from 'react-native'; +import {Card, List, Text, withTheme} from 'react-native-paper'; +import i18n from 'i18n-js'; +import {Collapsible} from "react-navigation-collapsible"; +import CustomTabBar from "../../components/Tabbar/CustomTabBar"; +import {withCollapsible} from "../../utils/withCollapsible"; + +type Props = { + collapsibleStack: Collapsible +}; + +type State = {}; + +/** + * Class defining a planning event information page. + */ +class AmicaleContactScreen extends React.Component { + + + CONTACT_DATASET = [ + { + name: i18n.t("amicaleAbout.roles.interSchools"), + email: "inter.ecoles@amicale-insat.fr", + icon: "share-variant" + }, + { + name: i18n.t("amicaleAbout.roles.culture"), + email: "culture@amicale-insat.fr", + icon: "book" + }, + { + name: i18n.t("amicaleAbout.roles.animation"), + email: "animation@amicale-insat.fr", + icon: "emoticon" + }, + { + name: i18n.t("amicaleAbout.roles.clubs"), + email: "clubs@amicale-insat.fr", + icon: "account-group" + }, + { + name: i18n.t("amicaleAbout.roles.event"), + email: "evenements@amicale-insat.fr", + icon: "calendar-range" + }, + { + name: i18n.t("amicaleAbout.roles.tech"), + email: "technique@amicale-insat.fr", + icon: "settings" + }, + { + name: i18n.t("amicaleAbout.roles.communication"), + email: "amicale@amicale-insat.fr", + icon: "comment-account" + }, + { + name: i18n.t("amicaleAbout.roles.intraSchools"), + email: "intra.ecoles@amicale-insat.fr", + icon: "school" + }, + { + name: i18n.t("amicaleAbout.roles.publicRelations"), + email: "rp@amicale-insat.fr", + icon: "account-tie" + }, + ]; + + colors: Object; + + constructor(props) { + super(props); + this.colors = props.theme.colors; + } + + keyExtractor = (item: Object) => item.email; + + getChevronIcon = (props: Object) => ; + + renderItem = ({item}: Object) => { + const onPress = () => Linking.openURL('mailto:' + item.email); + return } + right={this.getChevronIcon} + onPress={onPress} + /> + }; + + getScreen = () => { + return ( + + + + + + } + /> + + {i18n.t("amicaleAbout.message")} + {/*$FlowFixMe*/} + + + + + ); + }; + + render() { + const {containerPaddingTop, scrollIndicatorInsetTop, onScroll} = this.props.collapsibleStack; + return ( + + ); + } +} + +export default withCollapsible(withTheme(AmicaleContactScreen)); diff --git a/src/screens/Amicale/AmicaleHomeScreen.js b/src/screens/Amicale/AmicaleHomeScreen.js new file mode 100644 index 0000000..8ed88ad --- /dev/null +++ b/src/screens/Amicale/AmicaleHomeScreen.js @@ -0,0 +1,85 @@ +// @flow + +import * as React from 'react'; +import {ScrollView, StyleSheet} from "react-native"; +import {Button, withTheme} from 'react-native-paper'; + +type Props = { + navigation: Object, + route: Object, +} + +type State = {} + +class AmicaleHomeScreen extends React.Component { + + state = {}; + + colors: Object; + + constructor(props) { + super(props); + + this.colors = props.theme.colors; + } + + render() { + const nav = this.props.navigation; + return ( + + + + + + + + ); + } +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + flexDirection: 'column', + justifyContent: 'center', + }, + card: { + margin: 10, + }, + header: { + fontSize: 36, + marginBottom: 48 + }, + textInput: {}, + btnContainer: { + marginTop: 5, + marginBottom: 10, + } +}); + +export default withTheme(AmicaleHomeScreen); diff --git a/src/screens/Amicale/Clubs/ClubAboutScreen.js b/src/screens/Amicale/Clubs/ClubAboutScreen.js new file mode 100644 index 0000000..b0f4aa5 --- /dev/null +++ b/src/screens/Amicale/Clubs/ClubAboutScreen.js @@ -0,0 +1,65 @@ +// @flow + +import * as React from 'react'; +import {Image, ScrollView, View} from 'react-native'; +import {Card, List, Text, withTheme} from 'react-native-paper'; +import i18n from 'i18n-js'; +import Autolink from "react-native-autolink"; + +type Props = { +}; + +type State = { +}; + +const CONTACT_LINK = 'clubs@amicale-insat.fr'; + +/** + * Class defining a planning event information page. + */ +class ClubAboutScreen extends React.Component { + + colors: Object; + + constructor(props) { + super(props); + this.colors = props.theme.colors; + } + + render() { + return ( + + + + + {i18n.t("clubs.about.text")} + + } + /> + + {i18n.t("clubs.about.message")} + + + + + ); + } +} + +export default withTheme(ClubAboutScreen); diff --git a/src/screens/Amicale/Clubs/ClubDisplayScreen.js b/src/screens/Amicale/Clubs/ClubDisplayScreen.js new file mode 100644 index 0000000..e1b8221 --- /dev/null +++ b/src/screens/Amicale/Clubs/ClubDisplayScreen.js @@ -0,0 +1,216 @@ +// @flow + +import * as React from 'react'; +import {Linking, ScrollView, View} from 'react-native'; +import {Avatar, Button, Card, Chip, Paragraph, withTheme} from 'react-native-paper'; +import ImageModal from 'react-native-image-modal'; +import i18n from "i18n-js"; +import AuthenticatedScreen from "../../../components/Amicale/AuthenticatedScreen"; +import CustomHTML from "../../../components/Overrides/CustomHTML"; +import CustomTabBar from "../../../components/Tabbar/CustomTabBar"; +import type {category, club} from "./ClubListScreen"; +import type {CustomTheme} from "../../../managers/ThemeManager"; +import {StackNavigationProp} from "@react-navigation/stack"; +import {ERROR_TYPE} from "../../../utils/WebData"; + +type Props = { + navigation: StackNavigationProp, + route: { + params?: { + data?: club, + categories?: Array, + clubId?: number, + }, ... + }, + theme: CustomTheme +}; + +type State = { + imageModalVisible: boolean, +}; + +const AMICALE_MAIL = "clubs@amicale-insat.fr"; + +/** + * Class defining a club event information page. + * If called with data and categories navigation parameters, will use those to display the data. + * If called with clubId parameter, will fetch the information on the server + */ +class ClubDisplayScreen extends React.Component { + + displayData: club | null; + categories: Array | null; + clubId: number; + + shouldFetchData: boolean; + + state = { + imageModalVisible: false, + }; + + constructor(props) { + super(props); + if (this.props.route.params != null) { + if (this.props.route.params.data != null && this.props.route.params.categories != null) { + this.displayData = this.props.route.params.data; + this.categories = this.props.route.params.categories; + this.clubId = this.props.route.params.data.id; + this.shouldFetchData = false; + } else if (this.props.route.params.clubId != null) { + this.displayData = null; + this.categories = null; + this.clubId = this.props.route.params.clubId; + this.shouldFetchData = true; + } + } + } + + getCategoryName(id: number) { + if (this.categories !== null) { + for (let i = 0; i < this.categories.length; i++) { + if (id === this.categories[i].id) + return this.categories[i].name; + } + } + return ""; + } + + getCategoriesRender(categories: [number, number]) { + if (this.categories === null) + return null; + + let final = []; + for (let i = 0; i < categories.length; i++) { + let cat = categories[i]; + if (cat !== null) { + final.push( + + {this.getCategoryName(cat)} + + ); + } + } + return {final}; + } + + getManagersRender(resp: Array, email: string | null) { + let final = []; + for (let i = 0; i < resp.length; i++) { + final.push({resp[i]}) + } + const hasManagers = resp.length > 0; + return ( + + } + /> + + {final} + {this.getEmailButton(email, hasManagers)} + + + ); + } + + getEmailButton(email: string | null, hasManagers: boolean) { + const destinationEmail = email != null && hasManagers + ? email + : AMICALE_MAIL; + const text = email != null && hasManagers + ? i18n.t("clubs.clubContact") + : i18n.t("clubs.amicaleContact"); + return ( + + + + ); + } + + updateHeaderTitle(data: Object) { + this.props.navigation.setOptions({title: data.name}) + } + + getScreen = (response: Array) => { + let data: club = response[0]; + this.updateHeaderTitle(data); + if (data != null) { + return ( + + {this.getCategoriesRender(data.category)} + {data.logo !== null ? + + + + : } + + {data.description !== null ? + // Surround description with div to allow text styling if the description is not html + + + + : } + {this.getManagersRender(data.responsibles, data.email)} + + ); + } else + return null; + + }; + + render() { + if (this.shouldFetchData) + return ; + else + return this.getScreen([this.displayData]); + } +} + +export default withTheme(ClubDisplayScreen); diff --git a/src/screens/Amicale/Clubs/ClubListScreen.js b/src/screens/Amicale/Clubs/ClubListScreen.js new file mode 100644 index 0000000..0418197 --- /dev/null +++ b/src/screens/Amicale/Clubs/ClubListScreen.js @@ -0,0 +1,218 @@ +// @flow + +import * as React from 'react'; +import {Animated, Platform} from "react-native"; +import {Searchbar} from 'react-native-paper'; +import AuthenticatedScreen from "../../../components/Amicale/AuthenticatedScreen"; +import i18n from "i18n-js"; +import ClubListItem from "../../../components/Lists/Clubs/ClubListItem"; +import {isItemInCategoryFilter, stringMatchQuery} from "../../../utils/Search"; +import ClubListHeader from "../../../components/Lists/Clubs/ClubListHeader"; +import MaterialHeaderButtons, {Item} from "../../../components/Overrides/CustomHeaderButton"; +import {withCollapsible} from "../../../utils/withCollapsible"; +import {StackNavigationProp} from "@react-navigation/stack"; +import type {CustomTheme} from "../../../managers/ThemeManager"; +import {Collapsible} from "react-navigation-collapsible"; + +export type category = { + id: number, + name: string, +}; + +export type club = { + id: number, + name: string, + description: string, + logo: string, + email: string | null, + category: [number, number], + responsibles: Array, +}; + +type Props = { + navigation: StackNavigationProp, + theme: CustomTheme, + collapsibleStack: Collapsible, +} + +type State = { + currentlySelectedCategories: Array, + currentSearchString: string, +} + +const LIST_ITEM_HEIGHT = 96; + +class ClubListScreen extends React.Component { + + state = { + currentlySelectedCategories: [], + currentSearchString: '', + }; + + categories: Array; + + /** + * Creates the header content + */ + componentDidMount() { + this.props.navigation.setOptions({ + headerTitle: this.getSearchBar, + headerRight: this.getHeaderButtons, + headerBackTitleVisible: false, + headerTitleContainerStyle: Platform.OS === 'ios' ? + {marginHorizontal: 0, width: '70%'} : + {marginHorizontal: 0, right: 50, left: 50}, + }); + } + + /** + * Gets the header search bar + * + * @return {*} + */ + getSearchBar = () => { + return ( + + ); + }; + + /** + * Gets the header button + * @return {*} + */ + getHeaderButtons = () => { + const onPress = () => this.props.navigation.navigate("club-about"); + return + + ; + }; + + /** + * Callback used when the search changes + * + * @param str The new search string + */ + onSearchStringChange = (str: string) => { + this.updateFilteredData(str, null); + }; + + keyExtractor = (item: club) => item.id.toString(); + + itemLayout = (data, index) => ({length: LIST_ITEM_HEIGHT, offset: LIST_ITEM_HEIGHT * index, index}); + + getScreen = (data: Array<{ categories: Array, clubs: Array } | null>) => { + let categoryList = []; + let clubList = []; + if (data[0] != null) { + categoryList = data[0].categories; + clubList = data[0].clubs; + } + this.categories = categoryList; + const {containerPaddingTop, scrollIndicatorInsetTop, onScroll} = this.props.collapsibleStack; + return ( + + ) + }; + + onChipSelect = (id: number) => this.updateFilteredData(null, id); + + updateFilteredData(filterStr: string | null, categoryId: number | null) { + let newCategoriesState = [...this.state.currentlySelectedCategories]; + let newStrState = this.state.currentSearchString; + if (filterStr !== null) + newStrState = filterStr; + if (categoryId !== null) { + let index = newCategoriesState.indexOf(categoryId); + if (index === -1) + newCategoriesState.push(categoryId); + else + newCategoriesState.splice(index, 1); + } + if (filterStr !== null || categoryId !== null) + this.setState({ + currentSearchString: newStrState, + currentlySelectedCategories: newCategoriesState, + }) + } + + getListHeader() { + return ; + } + + getCategoryOfId = (id: number) => { + for (let i = 0; i < this.categories.length; i++) { + if (id === this.categories[i].id) + return this.categories[i]; + } + }; + + shouldRenderItem(item: club) { + let shouldRender = this.state.currentlySelectedCategories.length === 0 + || isItemInCategoryFilter(this.state.currentlySelectedCategories, item.category); + if (shouldRender) + shouldRender = stringMatchQuery(item.name, this.state.currentSearchString); + return shouldRender; + } + + getRenderItem = ({item}: { item: club }) => { + const onPress = this.onListItemPress.bind(this, item); + if (this.shouldRenderItem(item)) { + return ( + + ); + } else + return null; + }; + + /** + * Callback used when clicking an article in the list. + * It opens the modal to show detailed information about the article + * + * @param item The article pressed + */ + onListItemPress(item: club) { + this.props.navigation.navigate("club-information", {data: item, categories: this.categories}); + } + + render() { + return ( + + ); + } +} + +export default withCollapsible(ClubListScreen); diff --git a/src/screens/Amicale/LoginScreen.js b/src/screens/Amicale/LoginScreen.js new file mode 100644 index 0000000..fcc2404 --- /dev/null +++ b/src/screens/Amicale/LoginScreen.js @@ -0,0 +1,290 @@ +// @flow + +import * as React from 'react'; +import {Animated, KeyboardAvoidingView, Linking, StyleSheet, View} from "react-native"; +import {Avatar, Button, Card, HelperText, Paragraph, TextInput, withTheme} from 'react-native-paper'; +import ConnectionManager from "../../managers/ConnectionManager"; +import i18n from 'i18n-js'; +import ErrorDialog from "../../components/Dialogs/ErrorDialog"; +import {withCollapsible} from "../../utils/withCollapsible"; +import {Collapsible} from "react-navigation-collapsible"; +import CustomTabBar from "../../components/Tabbar/CustomTabBar"; +import type {CustomTheme} from "../../managers/ThemeManager"; + +type Props = { + navigation: Object, + route: Object, + collapsibleStack: Collapsible, + theme: CustomTheme +} + +type State = { + email: string, + password: string, + isEmailValidated: boolean, + isPasswordValidated: boolean, + loading: boolean, + dialogVisible: boolean, + dialogError: number, +} + +const ICON_AMICALE = require('../../../assets/amicale.png'); + +const RESET_PASSWORD_PATH = "https://www.amicale-insat.fr//password/reset"; + +const emailRegex = /^.+@.+\..+$/; + +class LoginScreen extends React.Component { + + state = { + email: '', + password: '', + isEmailValidated: false, + isPasswordValidated: false, + loading: false, + dialogVisible: false, + dialogError: 0, + }; + + onEmailChange: Function; + onPasswordChange: Function; + passwordInputRef: Object; + + + constructor(props) { + super(props); + this.onEmailChange = this.onInputChange.bind(this, true); + this.onPasswordChange = this.onInputChange.bind(this, false); + } + + showErrorDialog = (error: number) => + this.setState({ + dialogVisible: true, + dialogError: error, + }); + + hideErrorDialog = () => this.setState({dialogVisible: false}); + + handleSuccess = () => this.props.navigation.goBack(); + + onResetPasswordClick = () => Linking.openURL(RESET_PASSWORD_PATH); + + validateEmail = () => this.setState({isEmailValidated: true}); + + isEmailValid() { + return emailRegex.test(this.state.email); + } + + shouldShowEmailError() { + return this.state.isEmailValidated && !this.isEmailValid(); + } + + validatePassword = () => this.setState({isPasswordValidated: true}); + + isPasswordValid() { + return this.state.password !== ''; + } + + shouldShowPasswordError() { + return this.state.isPasswordValidated && !this.isPasswordValid(); + } + + shouldEnableLogin() { + return this.isEmailValid() && this.isPasswordValid() && !this.state.loading; + } + + onInputChange(isEmail: boolean, value: string) { + if (isEmail) { + this.setState({ + email: value, + isEmailValidated: false, + }); + } else { + this.setState({ + password: value, + isPasswordValidated: false, + }); + } + } + + onEmailSubmit = () => this.passwordInputRef.focus(); + + onSubmit = () => { + if (this.shouldEnableLogin()) { + this.setState({loading: true}); + ConnectionManager.getInstance().connect(this.state.email, this.state.password) + .then(this.handleSuccess) + .catch(this.showErrorDialog) + .finally(() => { + this.setState({loading: false}); + }); + } + }; + + getFormInput() { + return ( + + + + {i18n.t("loginScreen.emailError")} + + { + this.passwordInputRef = ref; + }} + label={i18n.t("loginScreen.password")} + mode='outlined' + value={this.state.password} + onChangeText={this.onPasswordChange} + onBlur={this.validatePassword} + onSubmitEditing={this.onSubmit} + error={this.shouldShowPasswordError()} + textContentType={'password'} + autoCapitalize={'none'} + autoCompleteType={'password'} + autoCorrect={false} + keyboardType={'default'} + returnKeyType={'done'} + secureTextEntry={true} + /> + + {i18n.t("loginScreen.passwordError")} + + + ); + } + + getMainCard() { + return ( + + } + /> + + {this.getFormInput()} + + + + + + + + + ); + } + + getSecondaryCard() { + return ( + + } + /> + + {i18n.t("loginScreen.whyAccountParagraph")} + {i18n.t("loginScreen.whyAccountParagraph2")} + {i18n.t("loginScreen.noAccount")} + + + ); + } + + render() { + const {containerPaddingTop, scrollIndicatorInsetTop, onScroll} = this.props.collapsibleStack; + return ( + + + + {this.getMainCard()} + {this.getSecondaryCard()} + + + + + ); + } +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + flexDirection: 'column', + justifyContent: 'center', + }, + card: { + margin: 10, + }, + header: { + fontSize: 36, + marginBottom: 48 + }, + textInput: {}, + btnContainer: { + marginTop: 5, + marginBottom: 10, + } +}); + +export default withCollapsible(withTheme(LoginScreen)); diff --git a/src/screens/Amicale/ProfileScreen.js b/src/screens/Amicale/ProfileScreen.js new file mode 100644 index 0000000..d0dd71b --- /dev/null +++ b/src/screens/Amicale/ProfileScreen.js @@ -0,0 +1,338 @@ +// @flow + +import * as React from 'react'; +import {Animated, FlatList, StyleSheet, View} from "react-native"; +import {Avatar, Button, Card, Divider, List, withTheme} from 'react-native-paper'; +import AuthenticatedScreen from "../../components/Amicale/AuthenticatedScreen"; +import i18n from 'i18n-js'; +import LogoutDialog from "../../components/Amicale/LogoutDialog"; +import MaterialHeaderButtons, {Item} from "../../components/Overrides/CustomHeaderButton"; +import CustomTabBar from "../../components/Tabbar/CustomTabBar"; +import {Collapsible} from "react-navigation-collapsible"; +import {withCollapsible} from "../../utils/withCollapsible"; + +type Props = { + navigation: Object, + theme: Object, + collapsibleStack: Collapsible, +} + +type State = { + dialogVisible: boolean, +} + +class ProfileScreen extends React.Component { + + state = { + dialogVisible: false, + }; + + data: Object; + + flatListData: Array; + + constructor() { + super(); + this.flatListData = [ + {id: '0'}, + {id: '1'}, + {id: '2'}, + ] + } + + componentDidMount() { + this.props.navigation.setOptions({ + headerRight: this.getHeaderButton, + }); + } + + showDisconnectDialog = () => this.setState({dialogVisible: true}); + + hideDisconnectDialog = () => this.setState({dialogVisible: false}); + + getHeaderButton = () => + + ; + + getScreen = (data: Object) => { + this.data = data[0]; + const {containerPaddingTop, scrollIndicatorInsetTop, onScroll} = this.props.collapsibleStack; + return ( + + + + + ) + }; + + getRenderItem = ({item}: Object) => { + switch (item.id) { + case '0': + return this.getPersonalCard(); + case '1': + return this.getClubCard(); + default: + return this.getMembershipCar(); + } + }; + + /** + * Checks if the given field is available + * + * @param field The field to check + * @return {boolean} + */ + isFieldAvailable(field: ?string) { + return field !== null; + } + + /** + * Gets the given field value. + * If the field does not have a value, returns a placeholder text + * + * @param field The field to get the value from + * @return {*} + */ + getFieldValue(field: ?string) { + return this.isFieldAvailable(field) + ? field + : i18n.t("profileScreen.noData"); + } + + /** + * Gets a list item showing personal information + * + * @param field The field to display + * @param icon The icon to use + * @return {*} + */ + getPersonalListItem(field: ?string, icon: string) { + let title = this.isFieldAvailable(field) ? this.getFieldValue(field) : ':('; + let subtitle = this.isFieldAvailable(field) ? '' : this.getFieldValue(field); + return ( + } + /> + ); + } + + /** + * Gets a card containing user personal information + * + * @return {*} + */ + getPersonalCard() { + return ( + + } + /> + + + + {i18n.t("profileScreen.personalInformation")} + {this.getPersonalListItem(this.data.birthday, "cake-variant")} + {this.getPersonalListItem(this.data.phone, "phone")} + {this.getPersonalListItem(this.data.email, "email")} + {this.getPersonalListItem(this.data.branch, "school")} + + + + + + + + ); + } + + /** + * Gets a cars containing clubs the user is part of + * + * @return {*} + */ + getClubCard() { + return ( + + } + /> + + + {this.getClubList(this.data.clubs)} + + + ); + } + + /** + * Gets a card showing if the user has payed his membership + * + * @return {*} + */ + getMembershipCar() { + return ( + + } + /> + + + {this.getMembershipItem(this.data.validity)} + + + + ); + } + + /** + * Gets the item showing if the user has payed his membership + * + * @return {*} + */ + getMembershipItem(state: boolean) { + return ( + } + /> + ); + } + + /** + * Opens the club details screen for the club of given ID + * @param id The club's id to open + */ + openClubDetailsScreen(id: number) { + this.props.navigation.navigate("club-information", {clubId: id}); + } + + /** + * Gets a list item for the club list + * + * @param item The club to render + * @return {*} + */ + clubListItem = ({item}: Object) => { + const onPress = () => this.openClubDetailsScreen(item.id); + let description = i18n.t("profileScreen.isMember"); + let icon = (props) => ; + if (item.is_manager) { + description = i18n.t("profileScreen.isManager"); + icon = (props) => ; + } + return ; + }; + + clubKeyExtractor = (item: Object) => item.name; + + sortClubList = (a: Object, b: Object) => a.is_manager ? -1 : 1; + + /** + * Renders the list of clubs the user is part of + * + * @param list The club list + * @return {*} + */ + getClubList(list: Array) { + list.sort(this.sortClubList); + return ( + //$FlowFixMe + + ); + } + + render() { + return ( + + ); + } +} + +const styles = StyleSheet.create({ + card: { + margin: 10, + }, + icon: { + backgroundColor: 'transparent' + }, + editButton: { + marginLeft: 'auto' + } + +}); + +export default withCollapsible(withTheme(ProfileScreen)); diff --git a/src/screens/Amicale/VoteScreen.js b/src/screens/Amicale/VoteScreen.js new file mode 100644 index 0000000..39defc8 --- /dev/null +++ b/src/screens/Amicale/VoteScreen.js @@ -0,0 +1,306 @@ +// @flow + +import * as React from 'react'; +import {FlatList, RefreshControl, View} from "react-native"; +import AuthenticatedScreen from "../../components/Amicale/AuthenticatedScreen"; +import {getTimeOnlyString, stringToDate} from "../../utils/Planning"; +import VoteTitle from "../../components/Amicale/Vote/VoteTitle"; +import VoteTease from "../../components/Amicale/Vote/VoteTease"; +import VoteSelect from "../../components/Amicale/Vote/VoteSelect"; +import VoteResults from "../../components/Amicale/Vote/VoteResults"; +import VoteWait from "../../components/Amicale/Vote/VoteWait"; + +export type team = { + id: number, + name: string, + votes: number, +} + +type teamResponse = { + has_voted: boolean, + teams: Array, +}; + +type stringVoteDates = { + date_begin: string, + date_end: string, + date_result_begin: string, + date_result_end: string, +} + +type objectVoteDates = { + date_begin: Date, + date_end: Date, + date_result_begin: Date, + date_result_end: Date, +} + +// const FAKE_DATE = { +// "date_begin": "2020-04-19 15:50", +// "date_end": "2020-04-19 15:50", +// "date_result_begin": "2020-04-19 19:50", +// "date_result_end": "2020-04-19 22:50", +// }; +// +// const FAKE_DATE2 = { +// "date_begin": null, +// "date_end": null, +// "date_result_begin": null, +// "date_result_end": null, +// }; +// +// const FAKE_TEAMS = { +// has_voted: false, +// teams: [ +// { +// id: 1, +// name: "TEST TEAM1", +// }, +// { +// id: 2, +// name: "TEST TEAM2", +// }, +// ], +// }; +// const FAKE_TEAMS2 = { +// has_voted: false, +// teams: [ +// { +// id: 1, +// name: "TEST TEAM1", +// votes: 9, +// }, +// { +// id: 2, +// name: "TEST TEAM2", +// votes: 9, +// }, +// { +// id: 3, +// name: "TEST TEAM3", +// votes: 5, +// }, +// ], +// }; + +const MIN_REFRESH_TIME = 5 * 1000; + +type Props = { + navigation: Object +} + +type State = { + hasVoted: boolean, +} + +export default class VoteScreen extends React.Component { + + state = { + hasVoted: false, + }; + + teams: Array; + hasVoted: boolean; + datesString: null | stringVoteDates; + dates: null | objectVoteDates; + + today: Date; + + mainFlatListData: Array<{ key: string }>; + lastRefresh: Date; + + authRef: { current: null | AuthenticatedScreen }; + + constructor() { + super(); + this.hasVoted = false; + this.today = new Date(); + this.authRef = React.createRef(); + this.mainFlatListData = [ + {key: 'main'}, + {key: 'info'}, + ] + } + + reloadData = () => { + let canRefresh; + if (this.lastRefresh !== undefined) + canRefresh = (new Date().getTime() - this.lastRefresh.getTime()) > MIN_REFRESH_TIME; + else + canRefresh = true; + if (canRefresh && this.authRef.current != null) + this.authRef.current.reload() + }; + + generateDateObject() { + const strings = this.datesString; + if (strings != null) { + const dateBegin = stringToDate(strings.date_begin); + const dateEnd = stringToDate(strings.date_end); + const dateResultBegin = stringToDate(strings.date_result_begin); + const dateResultEnd = stringToDate(strings.date_result_end); + if (dateBegin != null && dateEnd != null && dateResultBegin != null && dateResultEnd != null) { + this.dates = { + date_begin: dateBegin, + date_end: dateEnd, + date_result_begin: dateResultBegin, + date_result_end: dateResultEnd, + }; + } else + this.dates = null; + } else + this.dates = null; + } + + getDateString(date: Date, dateString: string): string { + if (this.today.getDate() === date.getDate()) { + const str = getTimeOnlyString(dateString); + return str != null ? str : ""; + } else + return dateString; + } + + isVoteRunning() { + return this.dates != null && this.today > this.dates.date_begin && this.today < this.dates.date_end; + } + + isVoteStarted() { + return this.dates != null && this.today > this.dates.date_begin; + } + + isResultRunning() { + return this.dates != null && this.today > this.dates.date_result_begin && this.today < this.dates.date_result_end; + } + + isResultStarted() { + return this.dates != null && this.today > this.dates.date_result_begin; + } + + mainRenderItem = ({item}: Object) => { + if (item.key === 'info') + return ; + else if (item.key === 'main' && this.dates != null) + return this.getContent(); + else + return null; + }; + + getScreen = (data: Array<{ [key: string]: any } | null>) => { + // data[0] = FAKE_TEAMS2; + // data[1] = FAKE_DATE; + this.lastRefresh = new Date(); + + const teams : teamResponse | null = data[0]; + const dateStrings : stringVoteDates | null = data[1]; + + if (dateStrings != null && dateStrings.date_begin == null) + this.datesString = null; + else + this.datesString = dateStrings; + + if (teams != null) { + this.teams = teams.teams; + this.hasVoted = teams.has_voted; + } + + this.generateDateObject(); + return ( + + {/*$FlowFixMe*/} + + } + extraData={this.state.hasVoted.toString()} + renderItem={this.mainRenderItem} + /> + + ); + }; + + getContent() { + if (!this.isVoteStarted()) + return this.getTeaseVoteCard(); + else if (this.isVoteRunning() && (!this.hasVoted && !this.state.hasVoted)) + return this.getVoteCard(); + else if (!this.isResultStarted()) + return this.getWaitVoteCard(); + else if (this.isResultRunning()) + return this.getVoteResultCard(); + else + return null; + } + + onVoteSuccess = () => this.setState({hasVoted: true}); + + /** + * The user has not voted yet, and the votes are open + */ + getVoteCard() { + return ; + } + + /** + * Votes have ended, results can be displayed + */ + getVoteResultCard() { + if (this.dates != null && this.datesString != null) + return ; + else + return null; + } + + /** + * Vote will open shortly + */ + getTeaseVoteCard() { + if (this.dates != null && this.datesString != null) + return ; + else + return null; + } + + /** + * Votes have ended, or user has voted waiting for results + */ + getWaitVoteCard() { + let startDate = null; + if (this.dates != null && this.datesString != null && this.dates.date_result_begin != null) + startDate = this.getDateString(this.dates.date_result_begin, this.datesString.date_result_begin); + return ; + } + + render() { + return ( + + ); + } +} diff --git a/src/screens/Home/FeedItemScreen.js b/src/screens/Home/FeedItemScreen.js new file mode 100644 index 0000000..7373ba5 --- /dev/null +++ b/src/screens/Home/FeedItemScreen.js @@ -0,0 +1,98 @@ +// @flow + +import * as React from 'react'; +import {Linking, ScrollView, View} from 'react-native'; +import {Avatar, Card, Text, withTheme} from 'react-native-paper'; +import ImageModal from 'react-native-image-modal'; +import Autolink from "react-native-autolink"; +import MaterialHeaderButtons, {Item} from "../../components/Overrides/CustomHeaderButton"; +import CustomTabBar from "../../components/Tabbar/CustomTabBar"; + +type Props = { + navigation: Object, + route: Object +}; + +const ICON_AMICALE = require('../../../assets/amicale.png'); +const NAME_AMICALE = 'Amicale INSA Toulouse'; +/** + * Class defining a planning event information page. + */ +class FeedItemScreen extends React.Component { + + displayData: Object; + date: string; + + colors: Object; + + constructor(props) { + super(props); + this.colors = props.theme.colors; + this.displayData = this.props.route.params.data; + this.date = this.props.route.params.date; + } + + componentDidMount() { + this.props.navigation.setOptions({ + headerRight: this.getHeaderButton, + }); + } + + onOutLinkPress = () => { + Linking.openURL(this.displayData.permalink_url); + }; + + getHeaderButton = () => { + return + + ; + }; + + getAvatar() { + return ( + + ); + } + + getContent() { + const hasImage = this.displayData.full_picture !== '' && this.displayData.full_picture !== undefined; + return ( + + + {hasImage ? + + : null} + + {this.displayData.message !== undefined ? + : null + } + + + ); + } + + render() { + return this.getContent(); + } +} + +export default withTheme(FeedItemScreen); diff --git a/src/screens/Home/HomeScreen.js b/src/screens/Home/HomeScreen.js new file mode 100644 index 0000000..e6cbb13 --- /dev/null +++ b/src/screens/Home/HomeScreen.js @@ -0,0 +1,572 @@ +// @flow + +import * as React from 'react'; +import {FlatList} from 'react-native'; +import i18n from "i18n-js"; +import DashboardItem from "../../components/Home/EventDashboardItem"; +import WebSectionList from "../../components/Screens/WebSectionList"; +import {withTheme} from 'react-native-paper'; +import FeedItem from "../../components/Home/FeedItem"; +import SquareDashboardItem from "../../components/Home/SmallDashboardItem"; +import PreviewEventDashboardItem from "../../components/Home/PreviewEventDashboardItem"; +import {stringToDate} from "../../utils/Planning"; +import ActionsDashBoardItem from "../../components/Home/ActionsDashboardItem"; +import {CommonActions} from '@react-navigation/native'; +import MaterialHeaderButtons, {Item} from "../../components/Overrides/CustomHeaderButton"; +import AnimatedFAB from "../../components/Animations/AnimatedFAB"; +import {StackNavigationProp} from "@react-navigation/stack"; +import type {CustomTheme} from "../../managers/ThemeManager"; +import {View} from "react-native-animatable"; +import ConnectionManager from "../../managers/ConnectionManager"; +import LogoutDialog from "../../components/Amicale/LogoutDialog"; +// import DATA from "../dashboard_data.json"; + + +const NAME_AMICALE = 'Amicale INSA Toulouse'; +const DATA_URL = "https://etud.insa-toulouse.fr/~amicale_app/dashboard/dashboard_data.json"; +const FEED_ITEM_HEIGHT = 500; + +const SECTIONS_ID = [ + 'dashboard', + 'news_feed' +]; + +const REFRESH_TIME = 1000 * 20; // Refresh every 20 seconds + +type rawDashboard = { + news_feed: { + data: Array, + }, + dashboard: fullDashboard, +} + +export type feedItem = { + full_picture: string, + message: string, + permalink_url: string, + created_time: number, + id: string, +}; + +type fullDashboard = { + today_menu: Array<{ [key: string]: any }>, + proximo_articles: number, + available_machines: { + dryers: number, + washers: number, + }, + today_events: Array<{ [key: string]: any }>, + available_tutorials: number, +} + +type dashboardItem = { + id: string, + content: Array<{ [key: string]: any }> +}; + +type dashboardSmallItem = { + id: string, + data: number, + icon: string, + color: string, + onPress: () => void, + isAvailable: boolean +}; + +export type event = { + id: number, + title: string, + logo: string | null, + date_begin: string, + date_end: string, + description: string, + club: string, + category_id: number, + url: string, +} + +type listSection = { + title: string, + data: Array | Array, + id: string +}; + +type Props = { + navigation: StackNavigationProp, + route: { params: any, ... }, + theme: CustomTheme, +} + +type State = { + dialogVisible: boolean, +} + +/** + * Class defining the app's home screen + */ +class HomeScreen extends React.Component { + + colors: Object; + + isLoggedIn: boolean | null; + + fabRef: { current: null | AnimatedFAB }; + currentNewFeed: Array; + + state = { + dialogVisible: false, + } + + constructor(props) { + super(props); + this.colors = props.theme.colors; + this.fabRef = React.createRef(); + this.currentNewFeed = []; + this.isLoggedIn = null; + } + + /** + * Converts a dateString using Unix Timestamp to a formatted date + * + * @param dateString {string} The Unix Timestamp representation of a date + * @return {string} The formatted output date + */ + static getFormattedDate(dateString: number) { + let date = new Date(dateString * 1000); + return date.toLocaleString(); + } + + componentDidMount() { + this.props.navigation.addListener('focus', this.onScreenFocus); + // Handle link open when home is focused + this.props.navigation.addListener('state', this.handleNavigationParams); + } + + onScreenFocus = () => { + if (ConnectionManager.getInstance().isLoggedIn() !== this.isLoggedIn) { + this.isLoggedIn = ConnectionManager.getInstance().isLoggedIn(); + this.props.navigation.setOptions({ + headerRight: this.getHeaderButton, + }); + } + // handle link open when home is not focused or created + this.handleNavigationParams(); + }; + + handleNavigationParams = () => { + if (this.props.route.params != null) { + if (this.props.route.params.nextScreen != null) { + this.props.navigation.navigate(this.props.route.params.nextScreen, this.props.route.params.data); + // reset params to prevent infinite loop + this.props.navigation.dispatch(CommonActions.setParams({nextScreen: null})); + } + } + }; + + getHeaderButton = () => { + let onPressLog = () => this.props.navigation.navigate("login"); + let logIcon = "login"; + let logColor = this.props.theme.colors.primary; + if (this.isLoggedIn) { + onPressLog = () => this.showDisconnectDialog(); + logIcon = "logout"; + logColor = this.props.theme.colors.text; + } + + const onPressSettings = () => this.props.navigation.navigate("settings"); + const onPressAbout = () => this.props.navigation.navigate("about"); + return + + + + ; + }; + + showDisconnectDialog = () => this.setState({dialogVisible: true}); + + hideDisconnectDialog = () => this.setState({dialogVisible: false}); + + onProxiwashClick = () => { + this.props.navigation.navigate("proxiwash"); + }; + + onProximoClick = () => { + this.props.navigation.navigate("proximo"); + }; + + onTutorInsaClick = () => { + this.props.navigation.navigate("tutorinsa"); + }; + + onMenuClick = () => { + this.props.navigation.navigate('self-menu'); + }; + + /** + * Creates the dataset to be used in the FlatList + * + * @param fetchedData + * @return {*} + */ + createDataset = (fetchedData: rawDashboard) => { + // fetchedData = DATA; + let dashboardData; + if (fetchedData.news_feed != null) { + this.currentNewFeed = fetchedData.news_feed.data; + } + if (fetchedData.dashboard != null) + dashboardData = this.generateDashboardDataset(fetchedData.dashboard); + else + dashboardData = this.generateDashboardDataset(null); + return [ + { + title: '', + data: dashboardData, + id: SECTIONS_ID[0] + }, + { + title: i18n.t('homeScreen.newsFeed'), + data: this.currentNewFeed, + id: SECTIONS_ID[1] + } + ]; + }; + + /** + * Generates the dataset associated to the dashboard to be displayed in the FlatList as a section + * + * @param dashboardData + * @return {Array} + */ + generateDashboardDataset(dashboardData: fullDashboard | null): Array { + return [ + {id: 'actions', content: []}, + { + id: 'top', + content: [ + { + id: 'washers', + data: dashboardData == null ? 0 : dashboardData.available_machines.washers, + icon: 'washing-machine', + color: this.colors.proxiwashColor, + onPress: this.onProxiwashClick, + isAvailable: dashboardData == null ? false : dashboardData.available_machines.washers > 0 + }, + { + id: 'dryers', + data: dashboardData == null ? 0 : dashboardData.available_machines.dryers, + icon: 'tumble-dryer', + color: this.colors.proxiwashColor, + onPress: this.onProxiwashClick, + isAvailable: dashboardData == null ? false : dashboardData.available_machines.dryers > 0 + }, + { + id: 'available_tutorials', + data: dashboardData == null ? 0 : dashboardData.available_tutorials, + icon: 'school', + color: this.colors.tutorinsaColor, + onPress: this.onTutorInsaClick, + isAvailable: dashboardData == null ? false : dashboardData.available_tutorials > 0 + }, + { + id: 'proximo_articles', + data: dashboardData == null ? 0 : dashboardData.proximo_articles, + icon: 'shopping', + color: this.colors.proximoColor, + onPress: this.onProximoClick, + isAvailable: dashboardData == null ? false : dashboardData.proximo_articles > 0 + }, + { + id: 'today_menu', + data: dashboardData == null ? [] : dashboardData.today_menu, + icon: 'silverware-fork-knife', + color: this.colors.menuColor, + onPress: this.onMenuClick, + isAvailable: dashboardData == null ? false : dashboardData.today_menu.length > 0 + }, + ] + }, + { + id: 'event', + content: dashboardData == null ? [] : dashboardData.today_events + }, + + ]; + } + + /** + * Gets a dashboard item + * + * @param item The item to display + * @return {*} + */ + getDashboardItem(item: dashboardItem) { + let content = item.content; + if (item.id === 'event') + return this.getDashboardEvent(content); + else if (item.id === 'top') + return this.getDashboardRow(content); + else + return this.getDashboardActions(); + } + + getDashboardActions() { + return ; + } + + /** + * Gets the time limit depending on the current day: + * 17:30 for every day of the week except for thursday 11:30 + * 00:00 on weekends + */ + getTodayEventTimeLimit() { + let now = new Date(); + if (now.getDay() === 4) // Thursday + now.setHours(11, 30, 0); + else if (now.getDay() === 6 || now.getDay() === 0) // Weekend + now.setHours(0, 0, 0); + else + now.setHours(17, 30, 0); + return now; + } + + /** + * Gets the duration (in milliseconds) of an event + * + * @param event {event} + * @return {number} The number of milliseconds + */ + getEventDuration(event: event): number { + let start = stringToDate(event.date_begin); + let end = stringToDate(event.date_end); + let duration = 0; + if (start != null && end != null) + duration = end - start; + return duration; + } + + /** + * Gets events starting after the limit + * + * @param events + * @param limit + * @return {Array} + */ + getEventsAfterLimit(events: Array, limit: Date): Array { + let validEvents = []; + for (let event of events) { + let startDate = stringToDate(event.date_begin); + if (startDate != null && startDate >= limit) { + validEvents.push(event); + } + } + return validEvents; + } + + /** + * Gets the event with the longest duration in the given array. + * If all events have the same duration, return the first in the array. + * + * @param events + */ + getLongestEvent(events: Array): event { + let longestEvent = events[0]; + let longestTime = 0; + for (let event of events) { + let time = this.getEventDuration(event); + if (time > longestTime) { + longestTime = time; + longestEvent = event; + } + } + return longestEvent; + } + + /** + * Gets events that have not yet ended/started + * + * @param events + */ + getFutureEvents(events: Array): Array { + let validEvents = []; + let now = new Date(); + for (let event of events) { + let startDate = stringToDate(event.date_begin); + let endDate = stringToDate(event.date_end); + if (startDate != null) { + if (startDate > now) + validEvents.push(event); + else if (endDate != null) { + if (endDate > now || endDate < startDate) // Display event if it ends the following day + validEvents.push(event); + } + } + } + return validEvents; + } + + /** + * Gets the event to display in the preview + * + * @param events + * @return {Object} + */ + getDisplayEvent(events: Array): event | null { + let displayEvent = null; + if (events.length > 1) { + let eventsAfterLimit = this.getEventsAfterLimit(events, this.getTodayEventTimeLimit()); + if (eventsAfterLimit.length > 0) { + if (eventsAfterLimit.length === 1) + displayEvent = eventsAfterLimit[0]; + else + displayEvent = this.getLongestEvent(events); + } else { + displayEvent = this.getLongestEvent(events); + } + } else if (events.length === 1) { + displayEvent = events[0]; + } + return displayEvent; + } + + onEventContainerClick = () => this.props.navigation.navigate('planning'); + + /** + * Gets the event render item. + * If a preview is available, it will be rendered inside + * + * @param content + * @return {*} + */ + getDashboardEvent(content: Array) { + let futureEvents = this.getFutureEvents(content); + let displayEvent = this.getDisplayEvent(futureEvents); + // const clickPreviewAction = () => + // this.props.navigation.navigate('students', { + // screen: 'planning-information', + // params: {data: displayEvent} + // }); + return ( + + + + ); + } + + dashboardRowRenderItem = ({item}: { item: dashboardSmallItem }) => { + return ( + + ); + }; + + /** + * Gets a classic dashboard item. + * + * @param content + * @return {*} + */ + getDashboardRow(content: Array) { + return ( + //$FlowFixMe + ); + } + + /** + * Gets a render item for the given feed object + * + * @param item The feed item to display + * @return {*} + */ + getFeedItem(item: feedItem) { + return ( + + ); + } + + /** + * Gets a FlatList render item + * + * @param item The item to display + * @param section The current section + * @return {*} + */ + getRenderItem = ({item, section}: { + item: { [key: string]: any }, + section: listSection + }) => { + if (section.id === SECTIONS_ID[0]) { + const data: dashboardItem = item; + return this.getDashboardItem(data); + } else { + const data: feedItem = item; + return this.getFeedItem(data); + } + }; + + openScanner = () => this.props.navigation.navigate("scanner"); + + onScroll = (event: SyntheticEvent) => { + if (this.fabRef.current != null) + this.fabRef.current.onScroll(event); + }; + + render() { + return ( + + + + + + ); + } +} + +export default withTheme(HomeScreen); diff --git a/src/screens/Home/ScannerScreen.js b/src/screens/Home/ScannerScreen.js new file mode 100644 index 0000000..de1e8ae --- /dev/null +++ b/src/screens/Home/ScannerScreen.js @@ -0,0 +1,178 @@ +// @flow + +import * as React from 'react'; +import {Linking, Platform, StyleSheet, View} from "react-native"; +import {Button, Text, withTheme} from 'react-native-paper'; +import {RNCamera} from 'react-native-camera'; +import {BarcodeMask} from '@nartc/react-native-barcode-mask'; +import URLHandler from "../../utils/URLHandler"; +import AlertDialog from "../../components/Dialogs/AlertDialog"; +import i18n from 'i18n-js'; +import CustomTabBar from "../../components/Tabbar/CustomTabBar"; +import LoadingConfirmDialog from "../../components/Dialogs/LoadingConfirmDialog"; +import {PERMISSIONS, request, RESULTS} from 'react-native-permissions'; + +type Props = {}; +type State = { + hasPermission: boolean, + scanned: boolean, + dialogVisible: boolean, + dialogTitle: string, + dialogMessage: string, + loading: boolean, +}; + +class ScannerScreen extends React.Component { + + state = { + hasPermission: false, + scanned: false, + dialogVisible: false, + dialogTitle: "", + dialogMessage: "", + loading: false, + }; + + constructor() { + super(); + } + + componentDidMount() { + this.requestPermissions(); + } + + requestPermissions = () => { + if (Platform.OS === 'android') + request(PERMISSIONS.ANDROID.CAMERA).then(this.updatePermissionStatus) + else + request(PERMISSIONS.IOS.CAMERA).then(this.updatePermissionStatus) + }; + + updatePermissionStatus = (result) => this.setState({hasPermission: result === RESULTS.GRANTED}); + + handleCodeScanned = ({type, data}) => { + if (!URLHandler.isUrlValid(data)) + this.showErrorDialog(); + else { + this.showOpeningDialog(); + Linking.openURL(data); + } + }; + + getPermissionScreen() { + return + {i18n.t("scannerScreen.errorPermission")} + + + } + + showHelpDialog = () => { + this.setState({ + dialogVisible: true, + scanned: true, + dialogTitle: i18n.t("scannerScreen.helpTitle"), + dialogMessage: i18n.t("scannerScreen.helpMessage"), + }); + }; + + showOpeningDialog = () => { + this.setState({ + loading: true, + scanned: true, + }); + }; + + showErrorDialog() { + this.setState({ + dialogVisible: true, + scanned: true, + dialogTitle: i18n.t("scannerScreen.errorTitle"), + dialogMessage: i18n.t("scannerScreen.errorMessage"), + }); + } + + onDialogDismiss = () => this.setState({ + dialogVisible: false, + scanned: false, + }); + + getScanner() { + return ( + + + + ); + } + + render() { + return ( + + {this.state.hasPermission + ? this.getScanner() + : this.getPermissionScreen() + } + + + + + ); + } +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + }, + button: { + position: 'absolute', + bottom: 20, + width: '80%', + left: '10%' + }, +}); + +export default withTheme(ScannerScreen); diff --git a/src/screens/Other/FeedbackScreen.js b/src/screens/Other/FeedbackScreen.js new file mode 100644 index 0000000..0a63f3b --- /dev/null +++ b/src/screens/Other/FeedbackScreen.js @@ -0,0 +1,111 @@ +// @flow + +import * as React from 'react'; +import {Avatar, Button, Card, Paragraph, withTheme} from "react-native-paper"; +import i18n from "i18n-js"; +import {Linking, ScrollView} from "react-native"; +import type {CustomTheme} from "../../managers/ThemeManager"; + +type Props = { + theme: CustomTheme +}; + +const links = { + bugsMail: `mailto:app@amicale-insat.fr +?subject=[BUG] Application CAMPUS +&body=Coucou Arnaud ça bug c'est nul,\n\n +Informations sur ton système si tu sais (iOS ou Android, modèle du tel, version):\n\n\n +Nature du problème :\n\n\n +Étapes pour reproduire ce pb :\n\n\n\n +Stp corrige le pb, bien cordialement.`, + bugsGit: 'https://git.etud.insa-toulouse.fr/vergnet/application-amicale/issues/new', + facebook: "https://www.facebook.com/campus.insat", + feedbackMail: `mailto:app@amicale-insat.fr +?subject=[FEEDBACK] Application CAMPUS +&body=Coucou Arnaud j'ai du feedback\n\n\n\nBien cordialement.`, + feedbackGit: "https://git.etud.insa-toulouse.fr/vergnet/application-amicale/issues/new", +} + +class FeedbackScreen extends React.Component { + + getButtons(isBug: boolean) { + return ( + + + + + + ); + } + + render() { + return ( + + + } + /> + + + {i18n.t('feedbackScreen.bugsDescription')} + + + {i18n.t('feedbackScreen.contactMeans')} + + + {this.getButtons(true)} + + + + } + /> + + + {i18n.t('feedbackScreen.feedbackDescription')} + + + {this.getButtons(false)} + + + ); + } +} + +export default withTheme(FeedbackScreen); \ No newline at end of file diff --git a/screens/SettingsScreen.js b/src/screens/Other/SettingsScreen.js similarity index 55% rename from screens/SettingsScreen.js rename to src/screens/Other/SettingsScreen.js index 9d2c328..7362d4d 100644 --- a/screens/SettingsScreen.js +++ b/src/screens/Other/SettingsScreen.js @@ -1,75 +1,66 @@ // @flow import * as React from 'react'; -import {ScrollView} from "react-native"; -import ThemeManager from '../utils/ThemeManager'; +import {ScrollView, View} from "react-native"; +import type {CustomTheme} from "../../managers/ThemeManager"; +import ThemeManager from '../../managers/ThemeManager'; import i18n from "i18n-js"; -import AsyncStorageManager from "../utils/AsyncStorageManager"; -import NotificationsManager from "../utils/NotificationsManager"; -import {Card, List, Switch, ToggleButton} from 'react-native-paper'; +import AsyncStorageManager from "../../managers/AsyncStorageManager"; +import {Card, List, Switch, ToggleButton, withTheme} from 'react-native-paper'; import {Appearance} from "react-native-appearance"; +import CustomSlider from "../../components/Overrides/CustomSlider"; type Props = { - navigation: Object, + theme: CustomTheme, }; type State = { nightMode: boolean, nightModeFollowSystem: boolean, - proxiwashNotifPickerSelected: string, + notificationReminderSelected: number, startScreenPickerSelected: string, }; /** * Class defining the Settings screen. This screen shows controls to modify app preferences. */ -export default class SettingsScreen extends React.Component { - state = { - nightMode: ThemeManager.getNightMode(), - nightModeFollowSystem: AsyncStorageManager.getInstance().preferences.nightModeFollowSystem.current === '1' && - Appearance.getColorScheme() !== 'no-preference', - proxiwashNotifPickerSelected: AsyncStorageManager.getInstance().preferences.proxiwashNotifications.current, - startScreenPickerSelected: AsyncStorageManager.getInstance().preferences.defaultStartScreen.current, - }; +class SettingsScreen extends React.Component { - onProxiwashNotifPickerValueChange: Function; - onStartScreenPickerValueChange: Function; - onToggleNightMode: Function; - onToggleNightModeFollowSystem: Function; + savedNotificationReminder: number; constructor() { super(); - this.onProxiwashNotifPickerValueChange = this.onProxiwashNotifPickerValueChange.bind(this); - this.onStartScreenPickerValueChange = this.onStartScreenPickerValueChange.bind(this); - this.onToggleNightMode = this.onToggleNightMode.bind(this); - this.onToggleNightModeFollowSystem = this.onToggleNightModeFollowSystem.bind(this); + let notifReminder = AsyncStorageManager.getInstance().preferences.proxiwashNotifications.current; + this.savedNotificationReminder = parseInt(notifReminder); + if (isNaN(this.savedNotificationReminder)) + this.savedNotificationReminder = 0; + + this.state = { + nightMode: ThemeManager.getNightMode(), + nightModeFollowSystem: AsyncStorageManager.getInstance().preferences.nightModeFollowSystem.current === '1' && + Appearance.getColorScheme() !== 'no-preference', + notificationReminderSelected: this.savedNotificationReminder, + startScreenPickerSelected: AsyncStorageManager.getInstance().preferences.defaultStartScreen.current, + }; } /** - * Save the value for the proxiwash reminder notification time + * Saves the value for the proxiwash reminder notification time * * @param value The value to store */ - onProxiwashNotifPickerValueChange(value: string) { - if (value != null) { - let key = AsyncStorageManager.getInstance().preferences.proxiwashNotifications.key; - AsyncStorageManager.getInstance().savePref(key, value); - this.setState({ - proxiwashNotifPickerSelected: value - }); - let intVal = 0; - if (value !== 'never') - intVal = parseInt(value); - NotificationsManager.setMachineReminderNotificationTime(intVal); - } - } + onProxiwashNotifPickerValueChange = (value: number) => { + let key = AsyncStorageManager.getInstance().preferences.proxiwashNotifications.key; + AsyncStorageManager.getInstance().savePref(key, value.toString()); + this.setState({notificationReminderSelected: value}) + }; /** - * Save the value for the proxiwash reminder notification time + * Saves the value for the proxiwash reminder notification time * * @param value The value to store */ - onStartScreenPickerValueChange(value: string) { + onStartScreenPickerValueChange = (value: string) => { if (value != null) { let key = AsyncStorageManager.getInstance().preferences.defaultStartScreen.key; AsyncStorageManager.getInstance().savePref(key, value); @@ -77,7 +68,7 @@ export default class SettingsScreen extends React.Component { startScreenPickerSelected: value }); } - } + }; /** * Returns a picker allowing the user to select the proxiwash reminder notification time @@ -86,14 +77,16 @@ export default class SettingsScreen extends React.Component { */ getProxiwashNotifPicker() { return ( - - - - - + thumbTintColor={this.props.theme.colors.primary} + minimumTrackTintColor={this.props.theme.colors.primary} + /> ); } @@ -107,25 +100,26 @@ export default class SettingsScreen extends React.Component { - - - - - + + + + + ); } /** - * Toggle night mode and save it to preferences + * Toggles night mode and saves it to preferences */ - onToggleNightMode() { + onToggleNightMode = () => { ThemeManager.getInstance().setNightMode(!this.state.nightMode); this.setState({nightMode: !this.state.nightMode}); - } + }; - onToggleNightModeFollowSystem() { + onToggleNightModeFollowSystem = () => { const value = !this.state.nightModeFollowSystem; this.setState({nightModeFollowSystem: value}); let key = AsyncStorageManager.getInstance().preferences.nightModeFollowSystem.key; @@ -135,15 +129,16 @@ export default class SettingsScreen extends React.Component { ThemeManager.getInstance().setNightMode(nightMode); this.setState({nightMode: nightMode}); } - } + }; /** - * Get a list item using a checkbox control + * Gets a list item using a checkbox control * * @param onPressCallback The callback when the checkbox state changes * @param icon The icon name to display on the list item * @param title The text to display as this list item title * @param subtitle The text to display as this list item subtitle + * @param state The current state of the switch * @returns {React.Node} */ getToggleItem(onPressCallback: Function, icon: string, title: string, subtitle: string, state: boolean) { @@ -152,7 +147,7 @@ export default class SettingsScreen extends React.Component { title={title} description={subtitle} left={props => } - right={props => + right={() => { ) : null} { Appearance.getColorScheme() === 'no-preference' || !this.state.nightModeFollowSystem ? - this.getToggleItem( - this.onToggleNightMode, - 'theme-light-dark', - i18n.t('settingsScreen.nightMode'), - this.state.nightMode ? - i18n.t('settingsScreen.nightModeSubOn') : - i18n.t('settingsScreen.nightModeSubOff'), - this.state.nightMode - ) : null + this.getToggleItem( + this.onToggleNightMode, + 'theme-light-dark', + i18n.t('settingsScreen.nightMode'), + this.state.nightMode ? + i18n.t('settingsScreen.nightModeSubOn') : + i18n.t('settingsScreen.nightModeSubOff'), + this.state.nightMode + ) : null } - } - > - {this.getStartScreenPicker()} - + /> + {this.getStartScreenPicker()} - } - > + opened={true} + /> + {this.getProxiwashNotifPicker()} - + - ); } } + +export default withTheme(SettingsScreen); diff --git a/src/screens/Planex/GroupSelectionScreen.js b/src/screens/Planex/GroupSelectionScreen.js new file mode 100644 index 0000000..c020b12 --- /dev/null +++ b/src/screens/Planex/GroupSelectionScreen.js @@ -0,0 +1,233 @@ +// @flow + +import * as React from 'react'; +import {Platform} from "react-native"; +import i18n from "i18n-js"; +import {Searchbar} from "react-native-paper"; +import {stringMatchQuery} from "../../utils/Search"; +import WebSectionList from "../../components/Screens/WebSectionList"; +import GroupListAccordion from "../../components/Lists/PlanexGroups/GroupListAccordion"; +import AsyncStorageManager from "../../managers/AsyncStorageManager"; +import {StackNavigationProp} from "@react-navigation/stack"; + +const LIST_ITEM_HEIGHT = 70; + +export type group = { + name: string, + id: number, + isFav: boolean, +}; + +export type groupCategory = { + name: string, + id: number, + content: Array, +}; + +type Props = { + navigation: StackNavigationProp, +} + +type State = { + currentSearchString: string, + favoriteGroups: Array, +}; + +function sortName(a: group | groupCategory, b: group | groupCategory) { + if (a.name.toLowerCase() < b.name.toLowerCase()) + return -1; + if (a.name.toLowerCase() > b.name.toLowerCase()) + return 1; + return 0; +} + +const GROUPS_URL = 'http://planex.insa-toulouse.fr/wsAdeGrp.php?projectId=1'; +const REPLACE_REGEX = /_/g; + +/** + * Class defining proximo's article list of a certain category. + */ +class GroupSelectionScreen extends React.Component { + + constructor(props: Props) { + super(props); + this.state = { + currentSearchString: '', + favoriteGroups: JSON.parse(AsyncStorageManager.getInstance().preferences.planexFavoriteGroups.current), + }; + } + + /** + * Creates the header content + */ + componentDidMount() { + this.props.navigation.setOptions({ + headerTitle: this.getSearchBar, + headerBackTitleVisible: false, + headerTitleContainerStyle: Platform.OS === 'ios' ? + {marginHorizontal: 0, width: '70%'} : + {marginHorizontal: 0, right: 50, left: 50}, + }); + } + + /** + * Gets the header search bar + * + * @return {*} + */ + getSearchBar = () => { + return ( + + ); + }; + + /** + * Callback used when the search changes + * + * @param str The new search string + */ + onSearchStringChange = (str: string) => { + this.setState({currentSearchString: str}) + }; + + /** + * Callback used when clicking an article in the list. + * It opens the modal to show detailed information about the article + * + * @param item The article pressed + */ + onListItemPress = (item: group) => { + this.props.navigation.navigate("planex", { + screen: "index", + params: {group: item} + }); + }; + + onListFavoritePress = (item: group) => { + this.updateGroupFavorites(item); + }; + + isGroupInFavorites(group: group) { + let isFav = false; + for (let i = 0; i < this.state.favoriteGroups.length; i++) { + if (group.id === this.state.favoriteGroups[i].id) { + isFav = true; + break; + } + } + return isFav; + } + + removeGroupFromFavorites(favorites: Array, group: group) { + for (let i = 0; i < favorites.length; i++) { + if (group.id === favorites[i].id) { + favorites.splice(i, 1); + break; + } + } + } + + addGroupToFavorites(favorites: Array, group: group) { + group.isFav = true; + favorites.push(group); + favorites.sort(sortName); + } + + updateGroupFavorites(group: group) { + let newFavorites = [...this.state.favoriteGroups] + if (this.isGroupInFavorites(group)) + this.removeGroupFromFavorites(newFavorites, group); + else + this.addGroupToFavorites(newFavorites, group); + this.setState({favoriteGroups: newFavorites}) + AsyncStorageManager.getInstance().savePref( + AsyncStorageManager.getInstance().preferences.planexFavoriteGroups.key, + JSON.stringify(newFavorites)); + } + + shouldDisplayAccordion(item: groupCategory) { + let shouldDisplay = false; + for (let i = 0; i < item.content.length; i++) { + if (stringMatchQuery(item.content[i].name, this.state.currentSearchString)) { + shouldDisplay = true; + break; + } + } + return shouldDisplay; + } + + /** + * Gets a render item for the given article + * + * @param item The article to render + * @return {*} + */ + renderItem = ({item}: { item: groupCategory }) => { + if (this.shouldDisplayAccordion(item)) { + return ( + + ); + } else + return null; + }; + + generateData(fetchedData: { [key: string]: groupCategory }) { + let data = []; + for (let key in fetchedData) { + this.formatGroups(fetchedData[key]); + data.push(fetchedData[key]); + } + data.sort(sortName); + data.unshift({name: i18n.t("planexScreen.favorites"), id: 0, content: this.state.favoriteGroups}); + return data; + } + + formatGroups(item: groupCategory) { + for (let i = 0; i < item.content.length; i++) { + item.content[i].name = item.content[i].name.replace(REPLACE_REGEX, " ") + item.content[i].isFav = this.isGroupInFavorites(item.content[i]); + } + } + + /** + * Creates the dataset to be used in the FlatList + * + * @param fetchedData + * @return {*} + * */ + createDataset = (fetchedData: { [key: string]: groupCategory }) => { + return [ + { + title: '', + data: this.generateData(fetchedData) + } + ]; + } + + render() { + return ( + + ); + } +} + +export default GroupSelectionScreen; diff --git a/src/screens/Planex/PlanexScreen.js b/src/screens/Planex/PlanexScreen.js new file mode 100644 index 0000000..51b34e6 --- /dev/null +++ b/src/screens/Planex/PlanexScreen.js @@ -0,0 +1,354 @@ +// @flow + +import * as React from 'react'; +import ThemeManager from "../../managers/ThemeManager"; +import WebViewScreen from "../../components/Screens/WebViewScreen"; +import {Avatar, Banner, withTheme} from "react-native-paper"; +import i18n from "i18n-js"; +import {View} from "react-native"; +import AsyncStorageManager from "../../managers/AsyncStorageManager"; +import AlertDialog from "../../components/Dialogs/AlertDialog"; +import {withCollapsible} from "../../utils/withCollapsible"; +import {dateToString, getTimeOnlyString} from "../../utils/Planning"; +import DateManager from "../../managers/DateManager"; +import AnimatedBottomBar from "../../components/Animations/AnimatedBottomBar"; +import {CommonActions} from "@react-navigation/native"; +import ErrorView from "../../components/Screens/ErrorView"; + +type Props = { + navigation: Object, + route: Object, + theme: Object, + collapsibleStack: Object, +} + +type State = { + bannerVisible: boolean, + dialogVisible: boolean, + dialogTitle: string, + dialogMessage: string, + currentGroup: Object, +} + + +const PLANEX_URL = 'http://planex.insa-toulouse.fr/'; + +// // JS + JQuery functions used to remove alpha from events. Copy paste in browser console for quick testing +// // Remove alpha from given Jquery node +// function removeAlpha(node) { +// let bg = node.css("background-color"); +// if (bg.match("^rgba")) { +// let a = bg.slice(5).split(','); +// // Fix for tooltips with broken background +// if (parseInt(a[0]) === parseInt(a[1]) && parseInt(a[1]) === parseInt(a[2]) && parseInt(a[2]) === 0) { +// a[0] = a[1] = a[2] = '255'; +// } +// let newBg ='rgb(' + a[0] + ',' + a[1] + ',' + a[2] + ')'; +// node.css("background-color", newBg); +// } +// } +// // Observe for planning DOM changes +// let observer = new MutationObserver(function(mutations) { +// for (let i = 0; i < mutations.length; i++) { +// if (mutations[i]['addedNodes'].length > 0 && +// ($(mutations[i]['addedNodes'][0]).hasClass("fc-event") || $(mutations[i]['addedNodes'][0]).hasClass("tooltiptopicevent"))) +// removeAlpha($(mutations[i]['addedNodes'][0])) +// } +// }); +// // observer.observe(document.querySelector(".fc-body"), {attributes: false, childList: true, characterData: false, subtree:true}); +// observer.observe(document.querySelector("body"), {attributes: false, childList: true, characterData: false, subtree:true}); +// // Run remove alpha a first time on whole planning. Useful when code injected after planning fully loaded. +// $(".fc-event-container .fc-event").each(function(index) { +// removeAlpha($(this)); +// }); + +// Watch for changes in the calendar and call the remove alpha function +const OBSERVE_MUTATIONS_INJECTED = + 'function removeAlpha(node) {\n' + + ' let bg = node.css("background-color");\n' + + ' if (bg.match("^rgba")) {\n' + + ' let a = bg.slice(5).split(\',\');\n' + + ' // Fix for tooltips with broken background\n' + + ' if (parseInt(a[0]) === parseInt(a[1]) && parseInt(a[1]) === parseInt(a[2]) && parseInt(a[2]) === 0) {\n' + + ' a[0] = a[1] = a[2] = \'255\';\n' + + ' }\n' + + ' let newBg =\'rgb(\' + a[0] + \',\' + a[1] + \',\' + a[2] + \')\';\n' + + ' node.css("background-color", newBg);\n' + + ' }\n' + + '}\n' + + '// Observe for planning DOM changes\n' + + 'let observer = new MutationObserver(function(mutations) {\n' + + ' for (let i = 0; i < mutations.length; i++) {\n' + + ' if (mutations[i][\'addedNodes\'].length > 0 &&\n' + + ' ($(mutations[i][\'addedNodes\'][0]).hasClass("fc-event") || $(mutations[i][\'addedNodes\'][0]).hasClass("tooltiptopicevent")))\n' + + ' removeAlpha($(mutations[i][\'addedNodes\'][0]))\n' + + ' }\n' + + '});\n' + + '// observer.observe(document.querySelector(".fc-body"), {attributes: false, childList: true, characterData: false, subtree:true});\n' + + 'observer.observe(document.querySelector("body"), {attributes: false, childList: true, characterData: false, subtree:true});\n' + + '// Run remove alpha a first time on whole planning. Useful when code injected after planning fully loaded.\n' + + '$(".fc-event-container .fc-event").each(function(index) {\n' + + ' removeAlpha($(this));\n' + + '});'; + +const FULL_CALENDAR_SETTINGS = ` +var calendar = $('#calendar').fullCalendar('getCalendar'); +calendar.option({ + eventClick: function (data, event, view) { + var message = { + title: data.title, + color: data.color, + start: data.start._d, + end: data.end._d, + }; + window.ReactNativeWebView.postMessage(JSON.stringify(message)); + } +});`; + +const EXEC_COMMAND = ` +function execCommand(event) { + alert(JSON.stringify(event)); + var data = JSON.parse(event.data); + if (data.action === "setGroup") + displayAde(data.data); + else + $('#calendar').fullCalendar(data.action, data.data); +};` + +const CUSTOM_CSS = "body>.container{padding-top:20px; padding-bottom: 50px}header,#entite,#groupe_visibility,#calendar .fc-left,#calendar .fc-right{display:none}.fc-toolbar .fc-center{width:100%}.fc-toolbar .fc-center>*{float:none;width:100%;margin:0}#entite{margin-bottom:5px!important}#entite,#groupe{width:calc(100% - 20px);margin:0 10px}#groupe_visibility{width:100%}#calendar .fc-agendaWeek-view .fc-content-skeleton .fc-title{font-size:.6rem}#calendar .fc-agendaWeek-view .fc-content-skeleton .fc-time{font-size:.5rem}#calendar .fc-month-view .fc-content-skeleton .fc-title{font-size:.6rem}#calendar .fc-month-view .fc-content-skeleton .fc-time{font-size:.7rem}.fc-axis{font-size:.8rem;width:15px!important}.fc-day-header{font-size:.8rem}.fc-unthemed td.fc-today{background:#be1522; opacity:0.4}"; +const CUSTOM_CSS_DARK = "body{background-color:#121212}.fc-unthemed .fc-content,.fc-unthemed .fc-divider,.fc-unthemed .fc-list-heading td,.fc-unthemed .fc-list-view,.fc-unthemed .fc-popover,.fc-unthemed .fc-row,.fc-unthemed tbody,.fc-unthemed td,.fc-unthemed th,.fc-unthemed thead{border-color:#222}.fc-toolbar .fc-center>*,h2,table{color:#fff}.fc-event-container{color:#121212}.fc-event-container .fc-bg{opacity:0.2;background-color:#000}.fc-unthemed td.fc-today{background:#be1522; opacity:0.4}"; + +const INJECT_STYLE = ` +$('head').append(''); +$('head').append(''); +`; + +/** + * Class defining the app's Planex screen. + * This screen uses a webview to render the page + */ +class PlanexScreen extends React.Component { + + webScreenRef: Object; + barRef: Object; + + customInjectedJS: string; + + /** + * Defines custom injected JavaScript to improve the page display on mobile + */ + constructor(props) { + super(props); + this.webScreenRef = React.createRef(); + this.barRef = React.createRef(); + + let currentGroup = AsyncStorageManager.getInstance().preferences.planexCurrentGroup.current; + if (currentGroup === '') + currentGroup = {name: "SELECT GROUP", id: -1}; + else { + currentGroup = JSON.parse(currentGroup); + props.navigation.setOptions({title: currentGroup.name}) + } + this.state = { + bannerVisible: + AsyncStorageManager.getInstance().preferences.planexShowBanner.current === '1' && + AsyncStorageManager.getInstance().preferences.defaultStartScreen.current !== 'Planex', + dialogVisible: false, + dialogTitle: "", + dialogMessage: "", + currentGroup: currentGroup, + }; + this.generateInjectedJS(currentGroup.id); + } + + componentDidMount() { + this.props.navigation.addListener('focus', this.onScreenFocus); + } + + onScreenFocus = () => { + this.handleNavigationParams(); + }; + + handleNavigationParams = () => { + if (this.props.route.params !== undefined) { + if (this.props.route.params.group !== undefined && this.props.route.params.group !== null) { + // reset params to prevent infinite loop + this.selectNewGroup(this.props.route.params.group); + this.props.navigation.dispatch(CommonActions.setParams({group: null})); + } + } + }; + + selectNewGroup(group: Object) { + this.sendMessage('setGroup', group.id); + this.setState({currentGroup: group}); + AsyncStorageManager.getInstance().savePref( + AsyncStorageManager.getInstance().preferences.planexCurrentGroup.key, + JSON.stringify(group)); + this.props.navigation.setOptions({title: group.name}) + this.generateInjectedJS(group.id); + } + + generateInjectedJS(groupID: number) { + this.customInjectedJS = "$(document).ready(function() {" + + OBSERVE_MUTATIONS_INJECTED + + FULL_CALENDAR_SETTINGS + + "displayAde(" + groupID + ");" // Reset Ade + + (DateManager.isWeekend(new Date()) ? "calendar.next()" : "") + + INJECT_STYLE; + + if (ThemeManager.getNightMode()) + this.customInjectedJS += "$('head').append('');"; + + this.customInjectedJS += + 'removeAlpha();' + + '});' + + EXEC_COMMAND + + 'true;'; // Prevents crash on ios + } + + shouldComponentUpdate(nextProps: Props): boolean { + if (nextProps.theme.dark !== this.props.theme.dark) + this.generateInjectedJS(this.state.currentGroup.id); + return true; + } + + /** + * Callback used when closing the banner. + * This hides the banner and saves to preferences to prevent it from reopening + */ + onHideBanner = () => { + this.setState({bannerVisible: false}); + AsyncStorageManager.getInstance().savePref( + AsyncStorageManager.getInstance().preferences.planexShowBanner.key, + '0' + ); + }; + + /** + * Callback used when the used click on the navigate to settings button. + * This will hide the banner and open the SettingsScreen + * + */ + onGoToSettings = () => { + this.onHideBanner(); + this.props.navigation.navigate('settings'); + }; + + sendMessage = (action: string, data: any) => { + let command; + if (action === "setGroup") + command = "displayAde(" + data + ")"; + else + command = "$('#calendar').fullCalendar('" + action + "', '" + data + "')"; + this.webScreenRef.current.injectJavaScript(command + ';true;'); + } + + onMessage = (event: Object) => { + let data = JSON.parse(event.nativeEvent.data); + let startDate = dateToString(new Date(data.start), true); + let endDate = dateToString(new Date(data.end), true); + let msg = DateManager.getInstance().getTranslatedDate(startDate) + "\n"; + msg += getTimeOnlyString(startDate) + ' - ' + getTimeOnlyString(endDate); + this.showDialog(data.title, msg) + }; + + showDialog = (title: string, message: string) => { + this.setState({ + dialogVisible: true, + dialogTitle: title, + dialogMessage: message, + }); + }; + + hideDialog = () => { + this.setState({ + dialogVisible: false, + }); + }; + + onScroll = (event: Object) => { + this.barRef.current.onScroll(event); + }; + + getWebView() { + const showWebview = this.state.currentGroup.id !== -1; + + return ( + + {!showWebview + ? + : null} + + + + ); + } + + render() { + const {containerPaddingTop} = this.props.collapsibleStack; + return ( + + {/*Allow to draw webview bellow banner*/} + + {this.props.theme.dark // Force component theme update + ? this.getWebView() + : {this.getWebView()}} + + } + > + {i18n.t('planexScreen.enableStartScreen')} + + + + + ); + } +} + +export default withCollapsible(withTheme(PlanexScreen)); \ No newline at end of file diff --git a/src/screens/Planning/PlanningDisplayScreen.js b/src/screens/Planning/PlanningDisplayScreen.js new file mode 100644 index 0000000..904557c --- /dev/null +++ b/src/screens/Planning/PlanningDisplayScreen.js @@ -0,0 +1,134 @@ +// @flow + +import * as React from 'react'; +import {ScrollView, View} from 'react-native'; +import {getDateOnlyString, getFormattedEventTime} from '../../utils/Planning'; +import {Card, withTheme} from 'react-native-paper'; +import DateManager from "../../managers/DateManager"; +import ImageModal from 'react-native-image-modal'; +import BasicLoadingScreen from "../../components/Screens/BasicLoadingScreen"; +import {apiRequest, ERROR_TYPE} from "../../utils/WebData"; +import ErrorView from "../../components/Screens/ErrorView"; +import CustomHTML from "../../components/Overrides/CustomHTML"; +import CustomTabBar from "../../components/Tabbar/CustomTabBar"; +import i18n from 'i18n-js'; + +type Props = { + navigation: Object, + route: Object +}; + +type State = { + loading: boolean +}; + +const CLUB_INFO_PATH = "event/info"; + +/** + * Class defining a planning event information page. + */ +class PlanningDisplayScreen extends React.Component { + + displayData: Object; + shouldFetchData: boolean; + eventId: number; + errorCode: number; + + colors: Object; + + constructor(props) { + super(props); + this.colors = props.theme.colors; + + if (this.props.route.params.data !== undefined) { + this.displayData = this.props.route.params.data; + this.eventId = this.displayData.id; + this.shouldFetchData = false; + this.errorCode = 0; + this.state = { + loading: false, + }; + } else { + this.displayData = null; + this.eventId = this.props.route.params.eventId; + this.shouldFetchData = true; + this.errorCode = 0; + this.state = { + loading: true, + }; + this.fetchData(); + + } + } + + fetchData = () => { + this.setState({loading: true}); + apiRequest(CLUB_INFO_PATH, 'POST', {id: this.eventId}) + .then(this.onFetchSuccess) + .catch(this.onFetchError); + }; + + onFetchSuccess = (data: Object) => { + this.displayData = data; + this.setState({loading: false}); + }; + + onFetchError = (error: number) => { + this.errorCode = error; + this.setState({loading: false}); + }; + + getContent() { + let subtitle = getFormattedEventTime( + this.displayData["date_begin"], this.displayData["date_end"]); + let dateString = getDateOnlyString(this.displayData["date_begin"]); + if (dateString !== null) + subtitle += ' | ' + DateManager.getInstance().getTranslatedDate(dateString); + return ( + + + {this.displayData.logo !== null ? + + + : } + + {this.displayData.description !== null ? + + + + : } + + ); + } + + getErrorView() { + if (this.errorCode === ERROR_TYPE.BAD_INPUT) + return ; + else + return ; + } + + render() { + if (this.state.loading) + return ; + else if (this.errorCode === 0) + return this.getContent(); + else + return this.getErrorView(); + } +} + +export default withTheme(PlanningDisplayScreen); diff --git a/screens/Planning/PlanningScreen.js b/src/screens/Planning/PlanningScreen.js similarity index 69% rename from screens/Planning/PlanningScreen.js rename to src/screens/Planning/PlanningScreen.js index 829862b..438d9d6 100644 --- a/screens/Planning/PlanningScreen.js +++ b/src/screens/Planning/PlanningScreen.js @@ -4,10 +4,16 @@ import * as React from 'react'; import {BackHandler, View} from 'react-native'; import i18n from "i18n-js"; import {LocaleConfig} from 'react-native-calendars'; -import WebDataManager from "../../utils/WebDataManager"; -import PlanningEventManager from '../../utils/PlanningEventManager'; +import {readData} from "../../utils/WebData"; +import type {eventObject} from "../../utils/Planning"; +import { + generateEventAgenda, + getCurrentDateString, + getDateOnlyString, + getFormattedEventTime, +} from '../../utils/Planning'; import {Avatar, Divider, List} from 'react-native-paper'; -import CustomAgenda from "../../components/CustomAgenda"; +import CustomAgenda from "../../components/Overrides/CustomAgenda"; LocaleConfig.locales['fr'] = { monthNames: ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'], @@ -20,6 +26,7 @@ LocaleConfig.locales['fr'] = { type Props = { navigation: Object, + route: Object, } type State = { @@ -28,17 +35,15 @@ type State = { calendarShowing: boolean, }; -const FETCH_URL = "https://amicale-insat.fr/event/json/list"; - +const FETCH_URL = "https://www.amicale-insat.fr/api/event/list"; const AGENDA_MONTH_SPAN = 3; /** * Class defining the app's planning screen */ -export default class PlanningScreen extends React.Component { +class PlanningScreen extends React.Component { - agendaRef: Agenda; - webDataManager: WebDataManager; + agendaRef: Object; lastRefresh: Date; minTimeBetweenRefresh = 60; @@ -59,11 +64,10 @@ export default class PlanningScreen extends React.Component { onAgendaRef: Function; onCalendarToggled: Function; onBackButtonPressAndroid: Function; - currentDate = this.getCurrentDate(); + currentDate = getDateOnlyString(getCurrentDateString()); constructor(props: any) { super(props); - this.webDataManager = new WebDataManager(FETCH_URL); if (i18n.currentLocale().startsWith("fr")) { LocaleConfig.defaultLocale = 'fr'; } @@ -78,6 +82,9 @@ export default class PlanningScreen extends React.Component { this.onBackButtonPressAndroid = this.onBackButtonPressAndroid.bind(this); } + /** + * Captures focus and blur events to hook on android back button + */ componentDidMount() { this.onRefresh(); this.didFocusSubscription = this.props.navigation.addListener( @@ -98,6 +105,11 @@ export default class PlanningScreen extends React.Component { ); } + /** + * Overrides default android back button behaviour to close the calendar if it was open. + * + * @return {boolean} + */ onBackButtonPressAndroid() { if (this.state.calendarShowing) { this.agendaRef.chooseDay(this.agendaRef.state.selectedDay); @@ -107,37 +119,82 @@ export default class PlanningScreen extends React.Component { } }; - getCurrentDate() { - let today = new Date(); - return this.getFormattedDate(today); + /** + * Function used to check if a row has changed + * + * @param r1 + * @param r2 + * @return {boolean} + */ + rowHasChanged(r1: Object, r2: Object) { + return false; + // if (r1 !== undefined && r2 !== undefined) + // return r1.title !== r2.title; + // else return !(r1 === undefined && r2 === undefined); } - getFormattedDate(date: Date) { - let dd = String(date.getDate()).padStart(2, '0'); - let mm = String(date.getMonth() + 1).padStart(2, '0'); //January is 0! - let yyyy = date.getFullYear(); - return yyyy + '-' + mm + '-' + dd; - } + /** + * Refreshes data and shows an animation while doing it + */ + onRefresh = () => { + let canRefresh; + if (this.lastRefresh !== undefined) + canRefresh = (new Date().getTime() - this.lastRefresh.getTime()) / 1000 > this.minTimeBetweenRefresh; + else + canRefresh = true; - generateEmptyCalendar() { - let end = new Date(new Date().setMonth(new Date().getMonth() + AGENDA_MONTH_SPAN + 1)); - let daysOfYear = {}; - for (let d = new Date(2019, 8, 1); d <= end; d.setDate(d.getDate() + 1)) { - daysOfYear[this.getFormattedDate(new Date(d))] = [] + if (canRefresh) { + this.setState({refreshing: true}); + readData(FETCH_URL) + .then((fetchedData) => { + this.setState({ + refreshing: false, + agendaItems: generateEventAgenda(fetchedData, AGENDA_MONTH_SPAN) + }); + this.lastRefresh = new Date(); + }) + .catch(() => { + this.setState({ + refreshing: false, + }); + }); } - return daysOfYear; + }; + + /** + * Callback used when receiving the agenda ref + * + * @param ref + */ + onAgendaRef(ref: Object) { + this.agendaRef = ref; } - getRenderItem(item: Object) { - const onPress = this.props.navigation.navigate.bind(this, 'PlanningDisplayScreen', {data: item}); + /** + * Callback used when a button is pressed to toggle the calendar + * + * @param isCalendarOpened True is the calendar is already open, false otherwise + */ + onCalendarToggled(isCalendarOpened: boolean) { + this.setState({calendarShowing: isCalendarOpened}); + } + + /** + * Gets an event render item + * + * @param item The current event to render + * @return {*} + */ + getRenderItem(item: eventObject) { + const onPress = this.props.navigation.navigate.bind(this, 'planning-information', {data: item}); if (item.logo !== null) { return ( } @@ -151,7 +208,7 @@ export default class PlanningScreen extends React.Component { @@ -159,87 +216,21 @@ export default class PlanningScreen extends React.Component { } } + /** + * Gets an empty render item for an empty date + * + * @return {*} + */ getRenderEmptyDate() { return ( ); } - rowHasChanged(r1: Object, r2: Object) { - return false; - // if (r1 !== undefined && r2 !== undefined) - // return r1.title !== r2.title; - // else return !(r1 === undefined && r2 === undefined); - } - - /** - * Refresh data and show a toast if any error occurred - * @private - */ - onRefresh = () => { - let canRefresh; - if (this.lastRefresh !== undefined) - canRefresh = (new Date().getTime() - this.lastRefresh.getTime()) / 1000 > this.minTimeBetweenRefresh; - else - canRefresh = true; - - if (canRefresh) { - this.setState({refreshing: true}); - this.webDataManager.readData() - .then((fetchedData) => { - this.setState({ - refreshing: false, - }); - this.generateEventAgenda(fetchedData); - this.lastRefresh = new Date(); - }) - .catch((err) => { - this.setState({ - refreshing: false, - }); - // console.log(err); - }); - } - }; - - generateEventAgenda(eventList: Array) { - let agendaItems = this.generateEmptyCalendar(); - for (let i = 0; i < eventList.length; i++) { - if (agendaItems[PlanningEventManager.getEventStartDate(eventList[i])] !== undefined) { - this.pushEventInOrder(agendaItems, eventList[i], PlanningEventManager.getEventStartDate(eventList[i])); - } - } - this.setState({agendaItems: agendaItems}) - } - - pushEventInOrder(agendaItems: Object, event: Object, startDate: string) { - if (agendaItems[startDate].length === 0) - agendaItems[startDate].push(event); - else { - for (let i = 0; i < agendaItems[startDate].length; i++) { - if (PlanningEventManager.isEventBefore(event, agendaItems[startDate][i])) { - agendaItems[startDate].splice(i, 0, event); - break; - } else if (i === agendaItems[startDate].length - 1) { - agendaItems[startDate].push(event); - break; - } - } - } - } - - onAgendaRef(ref: Agenda) { - this.agendaRef = ref; - } - - onCalendarToggled(isCalendarOpened: boolean) { - this.setState({calendarShowing: isCalendarOpened}); - } - render() { - // console.log("rendering PlanningScreen"); return ( { ); } } + +export default PlanningScreen; diff --git a/screens/Proxiwash/ProxiwashAboutScreen.js b/src/screens/Proxiwash/ProxiwashAboutScreen.js similarity index 88% rename from screens/Proxiwash/ProxiwashAboutScreen.js rename to src/screens/Proxiwash/ProxiwashAboutScreen.js index dd4cd21..2f4a040 100644 --- a/screens/Proxiwash/ProxiwashAboutScreen.js +++ b/src/screens/Proxiwash/ProxiwashAboutScreen.js @@ -4,13 +4,16 @@ import * as React from 'react'; import {Image, ScrollView, View} from 'react-native'; import i18n from "i18n-js"; import {Card, List, Paragraph, Text, Title} from 'react-native-paper'; +import CustomTabBar from "../../components/Tabbar/CustomTabBar"; type Props = { navigation: Object, }; +const LOGO = "https://etud.insa-toulouse.fr/~amicale_app/images/Proxiwash.png"; + /** - * Class defining an about screen. This screen shows the user information about the app and it's author. + * Class defining the proxiwash about screen. */ export default class ProxiwashAboutScreen extends React.Component { @@ -26,9 +29,8 @@ export default class ProxiwashAboutScreen extends React.Component { alignItems: 'center' }}> + source={{uri: LOGO}} + style={{height: '100%', width: '100%', resizeMode: "contain"}}/> {i18n.t('proxiwashScreen.description')} @@ -67,7 +69,7 @@ export default class ProxiwashAboutScreen extends React.Component { {i18n.t('proxiwashScreen.dryersTariff')} - + } diff --git a/src/screens/Proxiwash/ProxiwashScreen.js b/src/screens/Proxiwash/ProxiwashScreen.js new file mode 100644 index 0000000..1f2f3f3 --- /dev/null +++ b/src/screens/Proxiwash/ProxiwashScreen.js @@ -0,0 +1,439 @@ +// @flow + +import * as React from 'react'; +import {Alert, View} from 'react-native'; +import i18n from "i18n-js"; +import WebSectionList from "../../components/Screens/WebSectionList"; +import * as Notifications from "../../utils/Notifications"; +import AsyncStorageManager from "../../managers/AsyncStorageManager"; +import {Avatar, Banner, Button, Card, Text, withTheme} from 'react-native-paper'; +import ProxiwashListItem from "../../components/Lists/Proxiwash/ProxiwashListItem"; +import ProxiwashConstants from "../../constants/ProxiwashConstants"; +import CustomModal from "../../components/Overrides/CustomModal"; +import AprilFoolsManager from "../../managers/AprilFoolsManager"; +import MaterialHeaderButtons, {Item} from "../../components/Overrides/CustomHeaderButton"; +import ProxiwashSectionHeader from "../../components/Lists/Proxiwash/ProxiwashSectionHeader"; +import {withCollapsible} from "../../utils/withCollapsible"; +import type {CustomTheme} from "../../managers/ThemeManager"; +import {Collapsible} from "react-navigation-collapsible"; +import {StackNavigationProp} from "@react-navigation/stack"; +import {getCleanedMachineWatched, getMachineEndDate, isMachineWatched} from "../../utils/Proxiwash"; +import {Modalize} from "react-native-modalize"; + +const DATA_URL = "https://etud.insa-toulouse.fr/~amicale_app/washinsa/washinsa.json"; + +let modalStateStrings = {}; + +const REFRESH_TIME = 1000 * 10; // Refresh every 10 seconds +const LIST_ITEM_HEIGHT = 64; + +export type Machine = { + number: string, + state: string, + startTime: string, + endTime: string, + donePercent: string, + remainingTime: string, +} + +type Props = { + navigation: StackNavigationProp, + theme: CustomTheme, + collapsibleStack: Collapsible, +} + +type State = { + refreshing: boolean, + modalCurrentDisplayItem: React.Node, + machinesWatched: Array, + bannerVisible: boolean, +}; + + +/** + * Class defining the app's proxiwash screen. This screen shows information about washing machines and + * dryers, taken from a scrapper reading proxiwash website + */ +class ProxiwashScreen extends React.Component { + + modalRef: null | Modalize; + + fetchedData: { + dryers: Array, + washers: Array, + }; + + state = { + refreshing: false, + modalCurrentDisplayItem: null, + machinesWatched: JSON.parse(AsyncStorageManager.getInstance().preferences.proxiwashWatchedMachines.current), + bannerVisible: AsyncStorageManager.getInstance().preferences.proxiwashShowBanner.current === '1', + }; + + /** + * Creates machine state parameters using current theme and translations + */ + constructor(props) { + super(props); + modalStateStrings[ProxiwashConstants.machineStates.TERMINE] = i18n.t('proxiwashScreen.modal.finished'); + modalStateStrings[ProxiwashConstants.machineStates.DISPONIBLE] = i18n.t('proxiwashScreen.modal.ready'); + modalStateStrings[ProxiwashConstants.machineStates["EN COURS"]] = i18n.t('proxiwashScreen.modal.running'); + modalStateStrings[ProxiwashConstants.machineStates.HS] = i18n.t('proxiwashScreen.modal.broken'); + modalStateStrings[ProxiwashConstants.machineStates.ERREUR] = i18n.t('proxiwashScreen.modal.error'); + } + + /** + * Callback used when closing the banner. + * This hides the banner and saves to preferences to prevent it from reopening + */ + onHideBanner = () => { + this.setState({bannerVisible: false}); + AsyncStorageManager.getInstance().savePref( + AsyncStorageManager.getInstance().preferences.proxiwashShowBanner.key, + '0' + ); + }; + + /** + * Setup notification channel for android and add listeners to detect notifications fired + */ + componentDidMount() { + this.props.navigation.setOptions({ + headerRight: () => + + + , + }); + } + + /** + * Callback used when pressing the about button. + * This will open the ProxiwashAboutScreen. + */ + onAboutPress = () => this.props.navigation.navigate('proxiwash-about'); + + /** + * Extracts the key for the given item + * + * @param item The item to extract the key from + * @return {*} The extracted key + */ + getKeyExtractor = (item: Machine) => item.number; + + /** + * Setups notifications for the machine with the given ID. + * One notification will be sent at the end of the program. + * Another will be send a few minutes before the end, based on the value of reminderNotifTime + * + * @param machine The machine to watch + */ + setupNotifications(machine: Machine) { + if (!isMachineWatched(machine, this.state.machinesWatched)) { + Notifications.setupMachineNotification(machine.number, true, getMachineEndDate(machine)) + .then(() => { + this.saveNotificationToState(machine); + }) + .catch(() => { + this.showNotificationsDisabledWarning(); + }); + } else { + Notifications.setupMachineNotification(machine.number, false, null) + .then(() => { + this.removeNotificationFromState(machine); + }); + } + } + + /** + * Shows a warning telling the user notifications are disabled for the app + */ + showNotificationsDisabledWarning() { + Alert.alert( + i18n.t("proxiwashScreen.modal.notificationErrorTitle"), + i18n.t("proxiwashScreen.modal.notificationErrorDescription"), + ); + } + + /** + * Adds the given notifications associated to a machine ID to the watchlist, and saves the array to the preferences + * + * @param machine + */ + saveNotificationToState(machine: Machine) { + let data = this.state.machinesWatched; + data.push(machine); + this.saveNewWatchedList(data); + } + + /** + * Removes the given index from the watchlist array and saves it to preferences + * + * @param machine + */ + removeNotificationFromState(machine: Machine) { + let data = this.state.machinesWatched; + for (let i = 0; i < data.length; i++) { + if (data[i].number === machine.number && data[i].endTime === machine.endTime) { + data.splice(i, 1); + break; + } + } + this.saveNewWatchedList(data); + } + + saveNewWatchedList(list: Array) { + this.setState({machinesWatched: list}); + AsyncStorageManager.getInstance().savePref( + AsyncStorageManager.getInstance().preferences.proxiwashWatchedMachines.key, + JSON.stringify(list), + ); + } + + /** + * Creates the dataset to be used by the flatlist + * + * @param fetchedData + * @return {*} + */ + createDataset = (fetchedData: Object) => { + let data = fetchedData; + if (AprilFoolsManager.getInstance().isAprilFoolsEnabled()) { + data = JSON.parse(JSON.stringify(fetchedData)); // Deep copy + AprilFoolsManager.getNewProxiwashDryerOrderedList(data.dryers); + AprilFoolsManager.getNewProxiwashWasherOrderedList(data.washers); + } + this.fetchedData = data; + this.state.machinesWatched = + getCleanedMachineWatched(this.state.machinesWatched, [...data.dryers, ...data.washers]); + return [ + { + title: i18n.t('proxiwashScreen.dryers'), + icon: 'tumble-dryer', + data: data.dryers === undefined ? [] : data.dryers, + keyExtractor: this.getKeyExtractor + }, + { + title: i18n.t('proxiwashScreen.washers'), + icon: 'washing-machine', + data: data.washers === undefined ? [] : data.washers, + keyExtractor: this.getKeyExtractor + }, + ]; + }; + + /** + * Shows a modal for the given item + * + * @param title The title to use + * @param item The item to display information for in the modal + * @param isDryer True if the given item is a dryer + */ + showModal = (title: string, item: Object, isDryer: boolean) => { + this.setState({ + modalCurrentDisplayItem: this.getModalContent(title, item, isDryer) + }); + if (this.modalRef) { + this.modalRef.open(); + } + }; + + /** + * Callback used when the user clicks on enable notifications for a machine + * + * @param machine The machine to set notifications for + */ + onSetupNotificationsPress(machine: Machine) { + if (this.modalRef) { + this.modalRef.close(); + } + this.setupNotifications(machine); + } + + /** + * Generates the modal content. + * This shows information for the given machine. + * + * @param title The title to use + * @param item The item to display information for in the modal + * @param isDryer True if the given item is a dryer + * @return {*} + */ + getModalContent(title: string, item: Object, isDryer: boolean) { + let button = { + text: i18n.t("proxiwashScreen.modal.ok"), + icon: '', + onPress: undefined + }; + let message = modalStateStrings[ProxiwashConstants.machineStates[item.state]]; + const onPress = this.onSetupNotificationsPress.bind(this, item); + if (ProxiwashConstants.machineStates[item.state] === ProxiwashConstants.machineStates["EN COURS"]) { + let remainingTime = parseInt(item.remainingTime) + if (remainingTime < 0) + remainingTime = 0; + + button = + { + text: isMachineWatched(item, this.state.machinesWatched) ? + i18n.t("proxiwashScreen.modal.disableNotifications") : + i18n.t("proxiwashScreen.modal.enableNotifications"), + icon: '', + onPress: onPress + } + ; + message = i18n.t('proxiwashScreen.modal.running', + { + start: item.startTime, + end: item.endTime, + remaining: remainingTime + }); + } else if (ProxiwashConstants.machineStates[item.state] === ProxiwashConstants.machineStates.DISPONIBLE) { + if (isDryer) + message += '\n' + i18n.t('proxiwashScreen.dryersTariff'); + else + message += '\n' + i18n.t('proxiwashScreen.washersTariff'); + } + return ( + + } + + /> + + {message} + + + {button.onPress !== undefined ? + + + : null} + + ); + } + + /** + * Callback used when receiving modal ref + * + * @param ref + */ + onModalRef = (ref: Object) => { + this.modalRef = ref; + }; + + /** + * Gets the number of machines available + * + * @param isDryer True if we are only checking for dryer, false for washers + * @return {number} The number of machines available + */ + getMachineAvailableNumber(isDryer: boolean) { + let data; + if (isDryer) + data = this.fetchedData.dryers; + else + data = this.fetchedData.washers; + let count = 0; + for (let i = 0; i < data.length; i++) { + if (ProxiwashConstants.machineStates[data[i].state] === ProxiwashConstants.machineStates["DISPONIBLE"]) + count += 1; + } + return count; + } + + /** + * Gets the section render item + * + * @param section The section to render + * @return {*} + */ + getRenderSectionHeader = ({section}: Object) => { + const isDryer = section.title === i18n.t('proxiwashScreen.dryers'); + const nbAvailable = this.getMachineAvailableNumber(isDryer); + return ( + + ); + }; + + /** + * Gets the list item to be rendered + * + * @param item The object containing the item's FetchedData + * @param section The object describing the current SectionList section + * @returns {React.Node} + */ + getRenderItem = ({item, section}: Object) => { + const isDryer = section.title === i18n.t('proxiwashScreen.dryers'); + return ( + + ); + }; + + render() { + const nav = this.props.navigation; + const {containerPaddingTop} = this.props.collapsibleStack; + return ( + + + + + } + > + {i18n.t('proxiwashScreen.enableNotificationsTip')} + + + {this.state.modalCurrentDisplayItem} + + + ); + } +} + +export default withCollapsible(withTheme(ProxiwashScreen)); diff --git a/screens/Proximo/ProximoAboutScreen.js b/src/screens/Services/Proximo/ProximoAboutScreen.js similarity index 75% rename from screens/Proximo/ProximoAboutScreen.js rename to src/screens/Services/Proximo/ProximoAboutScreen.js index b7e0118..93a5a98 100644 --- a/screens/Proximo/ProximoAboutScreen.js +++ b/src/screens/Services/Proximo/ProximoAboutScreen.js @@ -4,13 +4,16 @@ import * as React from 'react'; import {Image, ScrollView, View} from 'react-native'; import i18n from "i18n-js"; import {Card, List, Paragraph, Text} from 'react-native-paper'; +import CustomTabBar from "../../../components/Tabbar/CustomTabBar"; type Props = { navigation: Object, }; +const LOGO = "https://etud.insa-toulouse.fr/~amicale_app/images/Proximo.png"; + /** - * Class defining an about screen. This screen shows the user information about the app and it's author. + * Class defining the proximo about screen. */ export default class ProximoAboutScreen extends React.Component { @@ -26,9 +29,8 @@ export default class ProximoAboutScreen extends React.Component { alignItems: 'center' }}> + source={{uri: LOGO}} + style={{height: '100%', width: '100%', resizeMode: "contain"}}/> {i18n.t('proximoScreen.description')} @@ -40,13 +42,13 @@ export default class ProximoAboutScreen extends React.Component { 18h30 - 19h30 - + } /> - 18{i18n.t('proximoScreen.paymentMethodsDescription')} + {i18n.t('proximoScreen.paymentMethodsDescription')} diff --git a/src/screens/Services/Proximo/ProximoListScreen.js b/src/screens/Services/Proximo/ProximoListScreen.js new file mode 100644 index 0000000..c8d6735 --- /dev/null +++ b/src/screens/Services/Proximo/ProximoListScreen.js @@ -0,0 +1,329 @@ +// @flow + +import * as React from 'react'; +import {Animated, Image, Platform, ScrollView, View} from "react-native"; +import i18n from "i18n-js"; +import CustomModal from "../../../components/Overrides/CustomModal"; +import {RadioButton, Searchbar, Subheading, Text, Title, withTheme} from "react-native-paper"; +import {stringMatchQuery} from "../../../utils/Search"; +import ProximoListItem from "../../../components/Lists/Proximo/ProximoListItem"; +import MaterialHeaderButtons, {Item} from "../../../components/Overrides/CustomHeaderButton"; +import {withCollapsible} from "../../../utils/withCollapsible"; + +function sortPrice(a, b) { + return a.price - b.price; +} + +function sortPriceReverse(a, b) { + return b.price - a.price; +} + +function sortName(a, b) { + if (a.name.toLowerCase() < b.name.toLowerCase()) + return -1; + if (a.name.toLowerCase() > b.name.toLowerCase()) + return 1; + return 0; +} + +function sortNameReverse(a, b) { + if (a.name.toLowerCase() < b.name.toLowerCase()) + return 1; + if (a.name.toLowerCase() > b.name.toLowerCase()) + return -1; + return 0; +} + +const LIST_ITEM_HEIGHT = 84; + +type Props = { + navigation: Object, + route: Object, + theme: Object, + collapsibleStack: Object, +} + +type State = { + currentSortMode: number, + modalCurrentDisplayItem: React.Node, + currentSearchString: string, +}; + +/** + * Class defining proximo's article list of a certain category. + */ +class ProximoListScreen extends React.Component { + + modalRef: Object; + listData: Array; + shouldFocusSearchBar: boolean; + + constructor(props) { + super(props); + this.listData = this.props.route.params['data']['data'].sort(sortName); + this.shouldFocusSearchBar = this.props.route.params['shouldFocusSearchBar']; + this.state = { + currentSearchString: '', + currentSortMode: 3, + modalCurrentDisplayItem: null, + }; + } + + + /** + * Creates the header content + */ + componentDidMount() { + this.props.navigation.setOptions({ + headerRight: this.getSortMenuButton, + headerTitle: this.getSearchBar, + headerBackTitleVisible: false, + headerTitleContainerStyle: Platform.OS === 'ios' ? + {marginHorizontal: 0, width: '70%'} : + {marginHorizontal: 0, right: 50, left: 50}, + }); + } + + /** + * Gets the header search bar + * + * @return {*} + */ + getSearchBar = () => { + return ( + + ); + }; + + /** + * Gets the sort menu header button + * + * @return {*} + */ + getSortMenuButton = () => { + return + + ; + }; + + /** + * Callback used when clicking on the sort menu button. + * It will open the modal to show a sort selection + */ + onSortMenuPress = () => { + this.setState({ + modalCurrentDisplayItem: this.getModalSortMenu() + }); + if (this.modalRef) { + this.modalRef.open(); + } + }; + + /** + * Sets the current sort mode. + * + * @param mode The number representing the mode + */ + setSortMode(mode: number) { + this.setState({ + currentSortMode: mode, + }); + switch (mode) { + case 1: + this.listData.sort(sortPrice); + break; + case 2: + this.listData.sort(sortPriceReverse); + break; + case 3: + this.listData.sort(sortName); + break; + case 4: + this.listData.sort(sortNameReverse); + break; + } + if (this.modalRef && mode !== this.state.currentSortMode) { + this.modalRef.close(); + } + } + + /** + * Gets a color depending on the quantity available + * + * @param availableStock The quantity available + * @return + */ + getStockColor(availableStock: number) { + let color: string; + if (availableStock > 3) + color = this.props.theme.colors.success; + else if (availableStock > 0) + color = this.props.theme.colors.warning; + else + color = this.props.theme.colors.danger; + return color; + } + + /** + * Callback used when the search changes + * + * @param str The new search string + */ + onSearchStringChange = (str: string) => { + this.setState({currentSearchString: str}) + }; + + /** + * Gets the modal content depending on the given article + * + * @param item The article to display + * @return {*} + */ + getModalItemContent(item: Object) { + return ( + + {item.name} + + + {item.quantity + ' ' + i18n.t('proximoScreen.inStock')} + + {item.price}€ + + + + + + + {item.description} + + + ); + } + + /** + * Gets the modal content to display a sort menu + * + * @return {*} + */ + getModalSortMenu() { + return ( + + {i18n.t('proximoScreen.sortOrder')} + this.setSortMode(value)} + value={this.state.currentSortMode} + > + + + + + + + ); + } + + /** + * Callback used when clicking an article in the list. + * It opens the modal to show detailed information about the article + * + * @param item The article pressed + */ + onListItemPress(item: Object) { + this.setState({ + modalCurrentDisplayItem: this.getModalItemContent(item) + }); + if (this.modalRef) { + this.modalRef.open(); + } + } + + /** + * Gets a render item for the given article + * + * @param item The article to render + * @return {*} + */ + renderItem = ({item}: Object) => { + if (stringMatchQuery(item.name, this.state.currentSearchString)) { + const onPress = this.onListItemPress.bind(this, item); + const color = this.getStockColor(parseInt(item.quantity)); + return ( + + ); + } else + return null; + }; + + /** + * Extracts a key for the given article + * + * @param item The article to extract the key from + * @return {*} The extracted key + */ + keyExtractor(item: Object) { + return item.name + item.code; + } + + /** + * Callback used when receiving the modal ref + * + * @param ref + */ + onModalRef = (ref: Object) => { + this.modalRef = ref; + }; + + itemLayout = (data, index) => ({length: LIST_ITEM_HEIGHT, offset: LIST_ITEM_HEIGHT * index, index}); + + render() { + const {containerPaddingTop, scrollIndicatorInsetTop, onScroll} = this.props.collapsibleStack; + return ( + + + {this.state.modalCurrentDisplayItem} + + + + ); + } +} + +export default withCollapsible(withTheme(ProximoListScreen)); diff --git a/screens/Proximo/ProximoMainScreen.js b/src/screens/Services/Proximo/ProximoMainScreen.js similarity index 73% rename from screens/Proximo/ProximoMainScreen.js rename to src/screens/Services/Proximo/ProximoMainScreen.js index c278608..423deb8 100644 --- a/screens/Proximo/ProximoMainScreen.js +++ b/src/screens/Services/Proximo/ProximoMainScreen.js @@ -3,14 +3,16 @@ import * as React from 'react'; import {View} from 'react-native' import i18n from "i18n-js"; -import WebSectionList from "../../components/WebSectionList"; +import WebSectionList from "../../../components/Screens/WebSectionList"; import {List, withTheme} from 'react-native-paper'; -import HeaderButton from "../../components/HeaderButton"; +import MaterialHeaderButtons, {Item} from "../../../components/Overrides/CustomHeaderButton"; const DATA_URL = "https://etud.insa-toulouse.fr/~proximo/data/stock-v2.json"; +const LIST_ITEM_HEIGHT = 84; type Props = { navigation: Object, + route: Object, } type State = { @@ -18,8 +20,8 @@ type State = { } /** - * Class defining the main proximo screen. This screen shows the different categories of articles - * offered by proximo. + * Class defining the main proximo screen. + * This screen shows the different categories of articles offered by proximo. */ class ProximoMainScreen extends React.Component { @@ -41,6 +43,14 @@ class ProximoMainScreen extends React.Component { this.colors = props.theme.colors; } + /** + * Function used to sort items in the list. + * Makes the All category stick to the top and sorts the others by name ascending + * + * @param a + * @param b + * @return {number} + */ static sortFinalData(a: Object, b: Object) { let str1 = a.type.name.toLowerCase(); let str2 = b.type.name.toLowerCase(); @@ -59,23 +69,76 @@ class ProximoMainScreen extends React.Component { return 0; } + /** + * Creates header button + */ componentDidMount() { - const rightButton = this.getRightButton.bind(this); + const rightButton = this.getHeaderButtons.bind(this); this.props.navigation.setOptions({ headerRight: rightButton, }); } + /** + * Callback used when the search button is pressed. + * This will open a new ProximoListScreen with all items displayed + */ + onPressSearchBtn() { + let searchScreenData = { + shouldFocusSearchBar: true, + data: { + type: { + id: "0", + name: i18n.t('proximoScreen.all'), + icon: 'star' + }, + data: this.articles !== undefined ? + this.getAvailableArticles(this.articles, undefined) : [] + }, + }; + this.props.navigation.navigate('proximo-list', searchScreenData); + } + + /** + * Callback used when the about button is pressed. + * This will open the ProximoAboutScreen + */ + onPressAboutBtn() { + this.props.navigation.navigate('proximo-about'); + } + + /** + * Gets the header buttons + * @return {*} + */ + getHeaderButtons() { + return + + + ; + } + + /** + * Extracts a key for the given category + * + * @param item The category to extract the key from + * @return {*} The extracted key + */ getKeyExtractor(item: Object) { return item !== undefined ? item.type['id'] : undefined; } + /** + * Creates the dataset to be used in the FlatList + * + * @param fetchedData + * @return {*} + * */ createDataset(fetchedData: Object) { return [ { title: '', data: this.generateData(fetchedData), - extraData: this.state, keyExtractor: this.getKeyExtractor } ]; @@ -133,46 +196,19 @@ class ProximoMainScreen extends React.Component { return availableArticles; } - onPressSearchBtn() { - let searchScreenData = { - shouldFocusSearchBar: true, - data: { - type: { - id: "0", - name: i18n.t('proximoScreen.all'), - icon: 'star' - }, - data: this.articles !== undefined ? - this.getAvailableArticles(this.articles, undefined) : [] - }, - }; - this.props.navigation.navigate('ProximoListScreen', searchScreenData); - } - - onPressAboutBtn() { - this.props.navigation.navigate('ProximoAboutScreen'); - } - - getRightButton() { - return ( - - - - - ); - } - - + /** + * Gets the given category render item + * + * @param item The category to render + * @return {*} + */ getRenderItem({item}: Object) { let dataToSend = { shouldFocusSearchBar: false, data: item, }; const subtitle = item.data.length + " " + (item.data.length > 1 ? i18n.t('proximoScreen.articles') : i18n.t('proximoScreen.article')); - const onPress = this.props.navigation.navigate.bind(this, 'ProximoListScreen', dataToSend); + const onPress = this.props.navigation.navigate.bind(this, 'proximo-list', dataToSend); if (item.data.length > 0) { return ( { icon={item.type.icon} color={this.colors.primary}/>} right={props => } + style={{ + height: LIST_ITEM_HEIGHT, + justifyContent: 'center', + }} /> ); } else diff --git a/screens/SelfMenuScreen.js b/src/screens/Services/SelfMenuScreen.js similarity index 67% rename from screens/SelfMenuScreen.js rename to src/screens/Services/SelfMenuScreen.js index 45369cf..2c958c2 100644 --- a/screens/SelfMenuScreen.js +++ b/src/screens/Services/SelfMenuScreen.js @@ -2,10 +2,10 @@ import * as React from 'react'; import {View} from 'react-native'; -import i18n from "i18n-js"; -import WebSectionList from "../components/WebSectionList"; +import DateManager from "../../managers/DateManager"; +import WebSectionList from "../../components/Screens/WebSectionList"; import {Card, Text, withTheme} from 'react-native-paper'; -import AprilFoolsManager from "../utils/AprilFoolsManager"; +import AprilFoolsManager from "../../managers/AprilFoolsManager"; const DATA_URL = "https://etud.insa-toulouse.fr/~amicale_app/menu/menu_data.json"; @@ -15,14 +15,9 @@ type Props = { /** * Class defining the app's menu screen. - * This screen fetches data from etud to render the RU menu */ class SelfMenuScreen extends React.Component { - // Hard code strings as toLocaleDateString does not work on current android JS engine - daysOfWeek = []; - monthsOfYear = []; - getRenderItem: Function; getRenderSectionHeader: Function; createDataset: Function; @@ -30,26 +25,6 @@ class SelfMenuScreen extends React.Component { constructor(props) { super(props); - this.daysOfWeek.push(i18n.t("date.daysOfWeek.monday")); - this.daysOfWeek.push(i18n.t("date.daysOfWeek.tuesday")); - this.daysOfWeek.push(i18n.t("date.daysOfWeek.wednesday")); - this.daysOfWeek.push(i18n.t("date.daysOfWeek.thursday")); - this.daysOfWeek.push(i18n.t("date.daysOfWeek.friday")); - this.daysOfWeek.push(i18n.t("date.daysOfWeek.saturday")); - this.daysOfWeek.push(i18n.t("date.daysOfWeek.sunday")); - - this.monthsOfYear.push(i18n.t("date.monthsOfYear.january")); - this.monthsOfYear.push(i18n.t("date.monthsOfYear.february")); - this.monthsOfYear.push(i18n.t("date.monthsOfYear.march")); - this.monthsOfYear.push(i18n.t("date.monthsOfYear.april")); - this.monthsOfYear.push(i18n.t("date.monthsOfYear.may")); - this.monthsOfYear.push(i18n.t("date.monthsOfYear.june")); - this.monthsOfYear.push(i18n.t("date.monthsOfYear.july")); - this.monthsOfYear.push(i18n.t("date.monthsOfYear.august")); - this.monthsOfYear.push(i18n.t("date.monthsOfYear.september")); - this.monthsOfYear.push(i18n.t("date.monthsOfYear.october")); - this.monthsOfYear.push(i18n.t("date.monthsOfYear.november")); - this.monthsOfYear.push(i18n.t("date.monthsOfYear.december")); this.getRenderItem = this.getRenderItem.bind(this); this.getRenderSectionHeader = this.getRenderSectionHeader.bind(this); @@ -57,10 +32,22 @@ class SelfMenuScreen extends React.Component { this.colors = props.theme.colors; } + /** + * Extract a key for the given item + * + * @param item The item to extract the key from + * @return {*} The extracted key + */ getKeyExtractor(item: Object) { return item !== undefined ? item['name'] : undefined; } + /** + * Creates the dataset to be used in the FlatList + * + * @param fetchedData + * @return {[]} + */ createDataset(fetchedData: Object) { let result = []; // Prevent crash by giving a default value when fetchedData is empty (not yet available) @@ -69,7 +56,6 @@ class SelfMenuScreen extends React.Component { { title: '', data: [], - extraData: super.state, keyExtractor: this.getKeyExtractor } ]; @@ -80,9 +66,8 @@ class SelfMenuScreen extends React.Component { for (let i = 0; i < fetchedData.length; i++) { result.push( { - title: this.getFormattedDate(fetchedData[i].date), + title: DateManager.getInstance().getTranslatedDate(fetchedData[i].date), data: fetchedData[i].meal[0].foodcategory, - extraData: super.state, keyExtractor: this.getKeyExtractor, } ); @@ -90,13 +75,12 @@ class SelfMenuScreen extends React.Component { return result } - getFormattedDate(dateString: string) { - let dateArray = dateString.split('-'); - let date = new Date(); - date.setFullYear(parseInt(dateArray[0]), parseInt(dateArray[1]) - 1, parseInt(dateArray[2])); - return this.daysOfWeek[date.getDay() - 1] + " " + date.getDate() + " " + this.monthsOfYear[date.getMonth()] + " " + date.getFullYear(); - } - + /** + * Gets the render section header + * + * @param section The section to render the header from + * @return {*} + */ getRenderSectionHeader({section}: Object) { return ( { ); } + /** + * Gets a FlatList render item + * + * @param item The item to render + * @return {*} + */ getRenderItem({item}: Object) { return ( { ); } + /** + * Formats the given string to make sure it starts with a capital letter + * + * @param name The string to format + * @return {string} The formatted string + */ formatName(name: String) { return name.charAt(0) + name.substr(1).toLowerCase(); } diff --git a/src/screens/Services/ServicesScreen.js b/src/screens/Services/ServicesScreen.js new file mode 100644 index 0000000..77ff748 --- /dev/null +++ b/src/screens/Services/ServicesScreen.js @@ -0,0 +1,300 @@ +// @flow + +import * as React from 'react'; +import type {cardList} from "../../components/Lists/CardList/CardList"; +import CardList from "../../components/Lists/CardList/CardList"; +import CustomTabBar from "../../components/Tabbar/CustomTabBar"; +import {withCollapsible} from "../../utils/withCollapsible"; +import {Collapsible} from "react-navigation-collapsible"; +import {CommonActions} from "@react-navigation/native"; +import {Animated, View} from "react-native"; +import {Avatar, Button, Card, Divider, List, Title, TouchableRipple, withTheme} from "react-native-paper"; +import type {CustomTheme} from "../../managers/ThemeManager"; +import ConnectionManager from "../../managers/ConnectionManager"; +import i18n from 'i18n-js'; +import MaterialHeaderButtons, {Item} from "../../components/Overrides/CustomHeaderButton"; + +type Props = { + navigation: Object, + route: Object, + collapsibleStack: Collapsible, + theme: CustomTheme, +} + +const CLUBS_IMAGE = "https://etud.insa-toulouse.fr/~amicale_app/images/Clubs.png"; +const PROFILE_IMAGE = "https://etud.insa-toulouse.fr/~amicale_app/images/ProfilAmicaliste.png"; +const VOTE_IMAGE = "https://etud.insa-toulouse.fr/~amicale_app/images/Vote.png"; +const AMICALE_IMAGE = require("../../../assets/amicale.png"); + +const PROXIMO_IMAGE = "https://etud.insa-toulouse.fr/~amicale_app/images/Proximo.png" +const WIKETUD_IMAGE = "https://etud.insa-toulouse.fr/~amicale_app/images/Wiketud.png"; +const EE_IMAGE = "https://etud.insa-toulouse.fr/~amicale_app/images/EEC.png"; +const TUTORINSA_IMAGE = "https://etud.insa-toulouse.fr/~amicale_app/images/TutorINSA.png"; + +const BIB_IMAGE = "https://etud.insa-toulouse.fr/~amicale_app/images/Bib.png"; +const RU_IMAGE = "https://etud.insa-toulouse.fr/~amicale_app/images/RU.png"; +const ROOM_IMAGE = "https://etud.insa-toulouse.fr/~amicale_app/images/Salles.png"; +const EMAIL_IMAGE = "https://etud.insa-toulouse.fr/~amicale_app/images/Bluemind.png"; +const ENT_IMAGE = "https://etud.insa-toulouse.fr/~amicale_app/images/ENT.png"; + +export type listItem = { + title: string, + description: string, + image: string | number, + shouldLogin: boolean, + content: cardList, +} + +type State = { + isLoggedIn: boolean, +} + +class ServicesScreen extends React.Component { + + amicaleDataset: cardList; + studentsDataset: cardList; + insaDataset: cardList; + + finalDataset: Array + + constructor(props) { + super(props); + const nav = props.navigation; + this.amicaleDataset = [ + { + title: i18n.t('screens.clubsAbout'), + subtitle: "CLUB LIST", + image: CLUBS_IMAGE, + onPress: () => nav.navigate("club-list"), + }, + { + title: i18n.t('screens.profile'), + subtitle: "PROFIL", + image: PROFILE_IMAGE, + onPress: () => nav.navigate("profile"), + }, + { + title: i18n.t('screens.amicaleWebsite'), + subtitle: "AMICALE", + image: AMICALE_IMAGE, + onPress: () => nav.navigate("amicale-website"), + }, + { + title: i18n.t('screens.vote'), + subtitle: "ELECTIONS", + image: VOTE_IMAGE, + onPress: () => nav.navigate("vote"), + }, + ]; + this.studentsDataset = [ + { + title: i18n.t('screens.proximo'), + subtitle: "proximo", + image: PROXIMO_IMAGE, + onPress: () => nav.navigate("proximo"), + }, + { + title: "Wiketud", + subtitle: "wiketud", + image: WIKETUD_IMAGE, + onPress: () => nav.navigate("wiketud"), + }, + { + title: "Élus Étudiants", + subtitle: "ELUS ETUDIANTS", + image: EE_IMAGE, + onPress: () => nav.navigate("elus-etudiants"), + }, + { + title: "Tutor'INSA", + subtitle: "TUTOR INSA", + image: TUTORINSA_IMAGE, + onPress: () => nav.navigate("tutorinsa"), + }, + ]; + this.insaDataset = [ + { + title: i18n.t('screens.menuSelf'), + subtitle: "the ru", + image: RU_IMAGE, + onPress: () => nav.navigate("self-menu"), + }, + { + title: i18n.t('screens.availableRooms'), + subtitle: "ROOMS", + image: ROOM_IMAGE, + onPress: () => nav.navigate("available-rooms"), + }, + { + title: i18n.t('screens.bib'), + subtitle: "BIB", + image: BIB_IMAGE, + onPress: () => nav.navigate("bib"), + }, + { + title: i18n.t('screens.bluemind'), + subtitle: "EMAIL", + image: EMAIL_IMAGE, + onPress: () => nav.navigate("bluemind"), + }, + { + title: i18n.t('screens.ent'), + subtitle: "ENT", + image: ENT_IMAGE, + onPress: () => nav.navigate("ent"), + }, + ]; + this.finalDataset = [ + { + title: i18n.t("servicesScreen.amicale"), + description: "LOGIN", + image: AMICALE_IMAGE, + shouldLogin: true, + content: this.amicaleDataset + }, + { + title: i18n.t("servicesScreen.students"), + description: "SERVICES OFFERED BY STUDENTS", + image: 'account-group', + shouldLogin: false, + content: this.studentsDataset + }, + { + title: i18n.t("servicesScreen.insa"), + description: "SERVICES OFFERED BY INSA", + image: 'school', + shouldLogin: false, + content: this.insaDataset + }, + ]; + this.state = { + isLoggedIn: ConnectionManager.getInstance().isLoggedIn() + } + } + + componentDidMount() { + this.props.navigation.addListener('focus', this.onFocus); + this.props.navigation.setOptions({ + headerRight: this.getAboutButton, + }); + } + + getAboutButton = () => + + + ; + + onAboutPress = () => this.props.navigation.navigate('amicale-contact'); + + onFocus = () => { + this.handleNavigationParams(); + this.setState({isLoggedIn: ConnectionManager.getInstance().isLoggedIn()}) + } + + handleNavigationParams() { + if (this.props.route.params != null) { + if (this.props.route.params.nextScreen != null) { + this.props.navigation.navigate(this.props.route.params.nextScreen); + // reset params to prevent infinite loop + this.props.navigation.dispatch(CommonActions.setParams({nextScreen: null})); + } + } + }; + + getAvatar(props, source: string | number) { + if (typeof source === "number") + return + else + return + } + + getLoginMessage() { + return ( + + + {i18n.t("servicesScreen.notLoggedIn")} + + + + ) + } + + renderItem = ({item}: { item: listItem }) => { + const shouldShowLogin = !this.state.isLoggedIn && item.shouldLogin; + return ( + this.props.navigation.navigate("services-section", {data: item})} + > + + this.getAvatar(props, item.image)} + right={shouldShowLogin + ? undefined + : (props) => } + /> + { + shouldShowLogin + ? this.getLoginMessage() + : + } + + + + ); + }; + + keyExtractor = (item: listItem) => { + return item.title; + } + + render() { + const {containerPaddingTop, scrollIndicatorInsetTop, onScroll} = this.props.collapsibleStack; + return } + /> + } +} + +export default withCollapsible(withTheme(ServicesScreen)); diff --git a/src/screens/Services/ServicesSectionScreen.js b/src/screens/Services/ServicesSectionScreen.js new file mode 100644 index 0000000..78bad25 --- /dev/null +++ b/src/screens/Services/ServicesSectionScreen.js @@ -0,0 +1,76 @@ +// @flow + +import * as React from 'react'; +import CardList from "../../components/Lists/CardList/CardList"; +import CustomTabBar from "../../components/Tabbar/CustomTabBar"; +import {withCollapsible} from "../../utils/withCollapsible"; +import {Collapsible} from "react-navigation-collapsible"; +import {CommonActions} from "@react-navigation/native"; +import ConnectionManager from "../../managers/ConnectionManager"; +import type {listItem} from "./ServicesScreen"; +import ErrorView from "../../components/Screens/ErrorView"; +import {ERROR_TYPE} from "../../utils/WebData"; + +type Props = { + navigation: Object, + route: Object, + collapsibleStack: Collapsible, +} + +type State = { + isLoggedIn: boolean, +} + +class ServicesSectionScreen extends React.Component { + + finalDataset: listItem; + + constructor(props) { + super(props); + this.handleNavigationParams(); + this.state = { + isLoggedIn: ConnectionManager.getInstance().isLoggedIn(), + } + } + + componentDidMount() { + this.props.navigation.addListener('focus', this.onFocus); + + } + + onFocus = () => { + this.setState({isLoggedIn: ConnectionManager.getInstance().isLoggedIn()}) + } + + handleNavigationParams() { + if (this.props.route.params != null) { + if (this.props.route.params.data != null) { + this.finalDataset = this.props.route.params.data; + // reset params to prevent infinite loop + this.props.navigation.dispatch(CommonActions.setParams({data: null})); + this.props.navigation.setOptions({ + headerTitle: this.finalDataset.title, + }); + } + } + } + + render() { + const {containerPaddingTop, scrollIndicatorInsetTop, onScroll} = this.props.collapsibleStack; + if (!this.state.isLoggedIn && this.finalDataset.shouldLogin) + return ; + else + return + } +} + +export default withCollapsible(ServicesSectionScreen); diff --git a/src/screens/Services/Websites/AmicaleWebsiteScreen.js b/src/screens/Services/Websites/AmicaleWebsiteScreen.js new file mode 100644 index 0000000..9d63576 --- /dev/null +++ b/src/screens/Services/Websites/AmicaleWebsiteScreen.js @@ -0,0 +1,28 @@ +// @flow + +import * as React from 'react'; +import WebViewScreen from "../../../components/Screens/WebViewScreen"; +import {CommonActions} from "@react-navigation/native"; + +const URL = 'https://www.amicale-insat.fr/'; +/** + * Class defining the app's available rooms screen. + * This screen uses a webview to render the page + */ +export const AmicaleWebsiteScreen = (props: Object) => { + let path = ''; + if (props.route.params !== undefined) { + if (props.route.params.path !== undefined && props.route.params.path !== null) { + path = props.route.params.path; + path = path.replace(URL, ''); + // reset params to prevent infinite loop + props.navigation.dispatch(CommonActions.setParams({path: null})); + } + } + return ( + + ); +}; + diff --git a/src/screens/Services/Websites/AvailableRoomScreen.js b/src/screens/Services/Websites/AvailableRoomScreen.js new file mode 100644 index 0000000..425afd1 --- /dev/null +++ b/src/screens/Services/Websites/AvailableRoomScreen.js @@ -0,0 +1,45 @@ +// @flow + +import * as React from 'react'; +import WebViewScreen from "../../../components/Screens/WebViewScreen"; + +type Props = { + navigation: Object, +} + + +const ROOM_URL = 'http://planex.insa-toulouse.fr/salles.php'; +const CUSTOM_CSS_GENERAL = 'https://etud.insa-toulouse.fr/~amicale_app/custom_css/rooms/customMobile2.css'; + +/** + * Class defining the app's available rooms screen. + * This screen uses a webview to render the page + */ +export default class AvailableRoomScreen extends React.Component { + + customInjectedJS: string; + + /** + * Defines custom injected JavaScript to improve the page display on mobile + */ + constructor() { + super(); + this.customInjectedJS = + 'document.querySelector(\'head\').innerHTML += \'\';' + + 'document.querySelector(\'head\').innerHTML += \'\';' + + 'let header = $(".table tbody tr:first");' + + '$("table").prepend("");true;' + // Fix for crash on ios + '$("thead").append(header);true;'; + } + + render() { + const nav = this.props.navigation; + return ( + + ); + } +} + diff --git a/screens/Websites/BibScreen.js b/src/screens/Services/Websites/BibScreen.js similarity index 72% rename from screens/Websites/BibScreen.js rename to src/screens/Services/Websites/BibScreen.js index b3a6642..9544a8f 100644 --- a/screens/Websites/BibScreen.js +++ b/src/screens/Services/Websites/BibScreen.js @@ -1,8 +1,7 @@ // @flow import * as React from 'react'; -import WebViewScreen from "../../components/WebViewScreen"; -import i18n from "i18n-js"; +import WebViewScreen from "../../../components/Screens/WebViewScreen"; type Props = { navigation: Object, @@ -13,14 +12,17 @@ const CUSTOM_CSS_GENERAL = 'https://etud.insa-toulouse.fr/~amicale_app/custom_cs const CUSTOM_CSS_Bib = 'https://etud.insa-toulouse.fr/~amicale_app/custom_css/rooms/customBibMobile.css'; /** - * Class defining the app's planex screen. - * This screen uses a webview to render the planex page + * Class defining the app's Bib screen. + * This screen uses a webview to render the page */ export default class AvailableRoomScreen extends React.Component { customInjectedJS: string; customBibInjectedJS: string; + /** + * Defines custom injected JavaScript to improve the page display on mobile + */ constructor() { super(); this.customInjectedJS = @@ -47,19 +49,8 @@ export default class AvailableRoomScreen extends React.Component { return ( + url={BIB_URL} + customJS={this.customBibInjectedJS}/> ); } } diff --git a/src/screens/Services/Websites/BlueMindWebsiteScreen.js b/src/screens/Services/Websites/BlueMindWebsiteScreen.js new file mode 100644 index 0000000..c4dabf5 --- /dev/null +++ b/src/screens/Services/Websites/BlueMindWebsiteScreen.js @@ -0,0 +1,18 @@ +// @flow + +import * as React from 'react'; +import WebViewScreen from "../../../components/Screens/WebViewScreen"; + +const URL = 'https://etud-mel.insa-toulouse.fr/webmail/'; +/** + * Class defining the app's available rooms screen. + * This screen uses a webview to render the page + */ +export const BlueMindWebsiteScreen = (props: Object) => { + return ( + + ); +}; + diff --git a/src/screens/Services/Websites/ENTWebsiteScreen.js b/src/screens/Services/Websites/ENTWebsiteScreen.js new file mode 100644 index 0000000..8d79211 --- /dev/null +++ b/src/screens/Services/Websites/ENTWebsiteScreen.js @@ -0,0 +1,18 @@ +// @flow + +import * as React from 'react'; +import WebViewScreen from "../../../components/Screens/WebViewScreen"; + +const URL = 'https://ent.insa-toulouse.fr/'; +/** + * Class defining the app's available rooms screen. + * This screen uses a webview to render the page + */ +export const ENTWebsiteScreen = (props: Object) => { + return ( + + ); +}; + diff --git a/src/screens/Services/Websites/ElusEtudiantsWebsiteScreen.js b/src/screens/Services/Websites/ElusEtudiantsWebsiteScreen.js new file mode 100644 index 0000000..367580a --- /dev/null +++ b/src/screens/Services/Websites/ElusEtudiantsWebsiteScreen.js @@ -0,0 +1,18 @@ +// @flow + +import * as React from 'react'; +import WebViewScreen from "../../../components/Screens/WebViewScreen"; + +const URL = 'https://etud.insa-toulouse.fr/~eeinsat/'; +/** + * Class defining the app's available rooms screen. + * This screen uses a webview to render the page + */ +export const ElusEtudiantsWebsiteScreen = (props: Object) => { + return ( + + ); +}; + diff --git a/src/screens/Services/Websites/TutorInsaWebsiteScreen.js b/src/screens/Services/Websites/TutorInsaWebsiteScreen.js new file mode 100644 index 0000000..d78a4df --- /dev/null +++ b/src/screens/Services/Websites/TutorInsaWebsiteScreen.js @@ -0,0 +1,18 @@ +// @flow + +import * as React from 'react'; +import WebViewScreen from "../../../components/Screens/WebViewScreen"; + +const URL = 'https://www.etud.insa-toulouse.fr/~tutorinsa/'; +/** + * Class defining the app's available rooms screen. + * This screen uses a webview to render the page + */ +export const TutorInsaWebsiteScreen = (props: Object) => { + return ( + + ); +}; + diff --git a/src/screens/Services/Websites/WiketudWebsiteScreen.js b/src/screens/Services/Websites/WiketudWebsiteScreen.js new file mode 100644 index 0000000..d4eda67 --- /dev/null +++ b/src/screens/Services/Websites/WiketudWebsiteScreen.js @@ -0,0 +1,18 @@ +// @flow + +import * as React from 'react'; +import WebViewScreen from "../../../components/Screens/WebViewScreen"; + +const URL = 'https://wiki.etud.insa-toulouse.fr/'; +/** + * Class defining the app's available rooms screen. + * This screen uses a webview to render the page + */ +export const WiketudWebsiteScreen = (props: Object) => { + return ( + + ); +}; + diff --git a/src/screens/Tetris/GameLogic.js b/src/screens/Tetris/GameLogic.js new file mode 100644 index 0000000..98c1257 --- /dev/null +++ b/src/screens/Tetris/GameLogic.js @@ -0,0 +1,237 @@ +// @flow + +import Piece from "./Piece"; +import ScoreManager from "./ScoreManager"; +import GridManager from "./GridManager"; + +export default class GameLogic { + + static levelTicks = [ + 1000, + 800, + 600, + 400, + 300, + 200, + 150, + 100, + ]; + + #scoreManager: ScoreManager; + #gridManager: GridManager; + + #height: number; + #width: number; + + #gameRunning: boolean; + #gamePaused: boolean; + #gameTime: number; + + #currentObject: Piece; + + #gameTick: number; + #gameTickInterval: IntervalID; + #gameTimeInterval: IntervalID; + + #pressInInterval: TimeoutID; + #isPressedIn: boolean; + #autoRepeatActivationDelay: number; + #autoRepeatDelay: number; + + #nextPieces: Array; + #nextPiecesCount: number; + + #onTick: Function; + #onClock: Function; + endCallback: Function; + + #colors: Object; + + constructor(height: number, width: number, colors: Object) { + this.#height = height; + this.#width = width; + this.#gameRunning = false; + this.#gamePaused = false; + this.#colors = colors; + this.#autoRepeatActivationDelay = 300; + this.#autoRepeatDelay = 50; + this.#nextPieces = []; + this.#nextPiecesCount = 3; + this.#scoreManager = new ScoreManager(); + this.#gridManager = new GridManager(this.getWidth(), this.getHeight(), this.#colors); + } + + getHeight(): number { + return this.#height; + } + + getWidth(): number { + return this.#width; + } + + getCurrentGrid() { + return this.#gridManager.getCurrentGrid(); + } + + isGameRunning(): boolean { + return this.#gameRunning; + } + + isGamePaused(): boolean { + return this.#gamePaused; + } + + onFreeze() { + this.#gridManager.freezeTetromino(this.#currentObject, this.#scoreManager); + this.createTetromino(); + } + + setNewGameTick(level: number) { + if (level >= GameLogic.levelTicks.length) + return; + this.#gameTick = GameLogic.levelTicks[level]; + clearInterval(this.#gameTickInterval); + this.#gameTickInterval = setInterval(this.#onTick, this.#gameTick); + } + + onTick(callback: Function) { + this.#currentObject.tryMove(0, 1, + this.#gridManager.getCurrentGrid(), this.getWidth(), this.getHeight(), + () => this.onFreeze()); + callback( + this.#scoreManager.getScore(), + this.#scoreManager.getLevel(), + this.#gridManager.getCurrentGrid()); + if (this.#scoreManager.canLevelUp()) + this.setNewGameTick(this.#scoreManager.getLevel()); + } + + onClock(callback: Function) { + this.#gameTime++; + callback(this.#gameTime); + } + + canUseInput() { + return this.#gameRunning && !this.#gamePaused + } + + rightPressed(callback: Function) { + this.#isPressedIn = true; + this.movePressedRepeat(true, callback, 1, 0); + } + + leftPressedIn(callback: Function) { + this.#isPressedIn = true; + this.movePressedRepeat(true, callback, -1, 0); + } + + downPressedIn(callback: Function) { + this.#isPressedIn = true; + this.movePressedRepeat(true, callback, 0, 1); + } + + movePressedRepeat(isInitial: boolean, callback: Function, x: number, y: number) { + if (!this.canUseInput() || !this.#isPressedIn) + return; + const moved = this.#currentObject.tryMove(x, y, + this.#gridManager.getCurrentGrid(), this.getWidth(), this.getHeight(), + () => this.onFreeze()); + if (moved) { + if (y === 1) { + this.#scoreManager.incrementScore(); + callback(this.#gridManager.getCurrentGrid(), this.#scoreManager.getScore()); + } else + callback(this.#gridManager.getCurrentGrid()); + } + this.#pressInInterval = setTimeout(() => + this.movePressedRepeat(false, callback, x, y), + isInitial ? this.#autoRepeatActivationDelay : this.#autoRepeatDelay + ); + } + + pressedOut() { + this.#isPressedIn = false; + clearTimeout(this.#pressInInterval); + } + + rotatePressed(callback: Function) { + if (!this.canUseInput()) + return; + + if (this.#currentObject.tryRotate(this.#gridManager.getCurrentGrid(), this.getWidth(), this.getHeight())) + callback(this.#gridManager.getCurrentGrid()); + } + + getNextPiecesPreviews() { + let finalArray = []; + for (let i = 0; i < this.#nextPieces.length; i++) { + finalArray.push(this.#gridManager.getEmptyGrid(4, 4)); + this.#nextPieces[i].toGrid(finalArray[i], true); + } + + return finalArray; + } + + recoverNextPiece() { + this.#currentObject = this.#nextPieces.shift(); + this.generateNextPieces(); + } + + generateNextPieces() { + while (this.#nextPieces.length < this.#nextPiecesCount) { + this.#nextPieces.push(new Piece(this.#colors)); + } + } + + createTetromino() { + this.pressedOut(); + this.recoverNextPiece(); + if (!this.#currentObject.isPositionValid(this.#gridManager.getCurrentGrid(), this.getWidth(), this.getHeight())) + this.endGame(false); + } + + togglePause() { + if (!this.#gameRunning) + return; + this.#gamePaused = !this.#gamePaused; + if (this.#gamePaused) { + clearInterval(this.#gameTickInterval); + clearInterval(this.#gameTimeInterval); + } else { + this.#gameTickInterval = setInterval(this.#onTick, this.#gameTick); + this.#gameTimeInterval = setInterval(this.#onClock, 1000); + } + } + + endGame(isRestart: boolean) { + this.#gameRunning = false; + this.#gamePaused = false; + clearInterval(this.#gameTickInterval); + clearInterval(this.#gameTimeInterval); + this.endCallback(this.#gameTime, this.#scoreManager.getScore(), isRestart); + } + + startGame(tickCallback: Function, clockCallback: Function, endCallback: Function) { + if (this.#gameRunning) + this.endGame(true); + this.#gameRunning = true; + this.#gamePaused = false; + this.#gameTime = 0; + this.#scoreManager = new ScoreManager(); + this.#gameTick = GameLogic.levelTicks[this.#scoreManager.getLevel()]; + this.#gridManager = new GridManager(this.getWidth(), this.getHeight(), this.#colors); + this.#nextPieces = []; + this.generateNextPieces(); + this.createTetromino(); + tickCallback( + this.#scoreManager.getScore(), + this.#scoreManager.getLevel(), + this.#gridManager.getCurrentGrid()); + clockCallback(this.#gameTime); + this.#onTick = this.onTick.bind(this, tickCallback); + this.#onClock = this.onClock.bind(this, clockCallback); + this.#gameTickInterval = setInterval(this.#onTick, this.#gameTick); + this.#gameTimeInterval = setInterval(this.#onClock, 1000); + this.endCallback = endCallback; + } +} diff --git a/src/screens/Tetris/GridManager.js b/src/screens/Tetris/GridManager.js new file mode 100644 index 0000000..f08ca26 --- /dev/null +++ b/src/screens/Tetris/GridManager.js @@ -0,0 +1,122 @@ +// @flow + +import Piece from "./Piece"; +import ScoreManager from "./ScoreManager"; +import type {coordinates} from './Shapes/BaseShape'; + + +export type cell = {color: string, isEmpty: boolean, key: string}; +export type grid = Array>; + +/** + * Class used to manage the game grid + * + */ +export default class GridManager { + + #currentGrid: grid; + #colors: Object; + + /** + * Initializes a grid of the given size + * + * @param width The grid width + * @param height The grid height + * @param colors Object containing current theme colors + */ + constructor(width: number, height: number, colors: Object) { + this.#colors = colors; + this.#currentGrid = this.getEmptyGrid(height, width); + } + + /** + * Get the current grid + * + * @return {grid} The current grid + */ + getCurrentGrid(): grid { + return this.#currentGrid; + } + + /** + * Get a new empty grid line of the given size + * + * @param width The line size + * @return {Array} + */ + getEmptyLine(width: number): Array { + let line = []; + for (let col = 0; col < width; col++) { + line.push({ + color: this.#colors.tetrisBackground, + isEmpty: true, + key: col.toString(), + }); + } + return line; + } + + /** + * Gets a new empty grid + * + * @param width The grid width + * @param height The grid height + * @return {grid} A new empty grid + */ + getEmptyGrid(height: number, width: number): grid { + let grid = []; + for (let row = 0; row < height; row++) { + grid.push(this.getEmptyLine(width)); + } + return grid; + } + + /** + * Removes the given lines from the grid, + * shifts down every line on top and adds new empty lines on top. + * + * @param lines An array of line numbers to remove + * @param scoreManager A reference to the score manager + */ + clearLines(lines: Array, scoreManager: ScoreManager) { + lines.sort(); + for (let i = 0; i < lines.length; i++) { + this.#currentGrid.splice(lines[i], 1); + this.#currentGrid.unshift(this.getEmptyLine(this.#currentGrid[0].length)); + } + scoreManager.addLinesRemovedPoints(lines.length); + } + + /** + * Gets the lines to clear around the given piece's coordinates. + * The piece's coordinates are used for optimization and to prevent checking the whole grid. + * + * @param coord The piece's coordinates to check lines at + * @return {Array} An array containing the line numbers to clear + */ + getLinesToClear(coord: Array): Array { + let rows = []; + for (let i = 0; i < coord.length; i++) { + let isLineFull = true; + for (let col = 0; col < this.#currentGrid[coord[i].y].length; col++) { + if (this.#currentGrid[coord[i].y][col].isEmpty) { + isLineFull = false; + break; + } + } + if (isLineFull && rows.indexOf(coord[i].y) === -1) + rows.push(coord[i].y); + } + return rows; + } + + /** + * Freezes the given piece to the grid + * + * @param currentObject The piece to freeze + * @param scoreManager A reference to the score manager + */ + freezeTetromino(currentObject: Piece, scoreManager: ScoreManager) { + this.clearLines(this.getLinesToClear(currentObject.getCoordinates()), scoreManager); + } +} diff --git a/src/screens/Tetris/Piece.js b/src/screens/Tetris/Piece.js new file mode 100644 index 0000000..5293de9 --- /dev/null +++ b/src/screens/Tetris/Piece.js @@ -0,0 +1,166 @@ +import ShapeL from "./Shapes/ShapeL"; +import ShapeI from "./Shapes/ShapeI"; +import ShapeJ from "./Shapes/ShapeJ"; +import ShapeO from "./Shapes/ShapeO"; +import ShapeS from "./Shapes/ShapeS"; +import ShapeT from "./Shapes/ShapeT"; +import ShapeZ from "./Shapes/ShapeZ"; +import type {coordinates} from './Shapes/BaseShape'; +import type {grid} from './GridManager'; + +/** + * Class used as an abstraction layer for shapes. + * Use this class to manipulate pieces rather than Shapes directly + * + */ +export default class Piece { + + #shapes = [ + ShapeL, + ShapeI, + ShapeJ, + ShapeO, + ShapeS, + ShapeT, + ShapeZ, + ]; + #currentShape: Object; + #colors: Object; + + /** + * Initializes this piece's color and shape + * + * @param colors Object containing current theme colors + */ + constructor(colors: Object) { + this.#currentShape = this.getRandomShape(colors); + this.#colors = colors; + } + + /** + * Gets a random shape object + * + * @param colors Object containing current theme colors + */ + getRandomShape(colors: Object) { + return new this.#shapes[Math.floor(Math.random() * 7)](colors); + } + + /** + * Removes the piece from the given grid + * + * @param grid The grid to remove the piece from + */ + removeFromGrid(grid: grid) { + const coord: Array = this.#currentShape.getCellsCoordinates(true); + for (let i = 0; i < coord.length; i++) { + grid[coord[i].y][coord[i].x] = { + color: this.#colors.tetrisBackground, + isEmpty: true, + key: grid[coord[i].y][coord[i].x].key + }; + } + } + + /** + * Adds this piece to the given grid + * + * @param grid The grid to add the piece to + * @param isPreview Should we use this piece's current position to determine the cells? + */ + toGrid(grid: grid, isPreview: boolean) { + const coord: Array = this.#currentShape.getCellsCoordinates(!isPreview); + for (let i = 0; i < coord.length; i++) { + grid[coord[i].y][coord[i].x] = { + color: this.#currentShape.getColor(), + isEmpty: false, + key: grid[coord[i].y][coord[i].x].key + }; + } + } + + /** + * Checks if the piece's current position is valid + * + * @param grid The current game grid + * @param width The grid's width + * @param height The grid's height + * @return {boolean} If the position is valid + */ + isPositionValid(grid: grid, width: number, height: number) { + let isValid = true; + const coord: Array = this.#currentShape.getCellsCoordinates(true); + for (let i = 0; i < coord.length; i++) { + if (coord[i].x >= width + || coord[i].x < 0 + || coord[i].y >= height + || coord[i].y < 0 + || !grid[coord[i].y][coord[i].x].isEmpty) { + isValid = false; + break; + } + } + return isValid; + } + + /** + * Tries to move the piece by the given offset on the given grid + * + * @param x Position X offset + * @param y Position Y offset + * @param grid The grid to move the piece on + * @param width The grid's width + * @param height The grid's height + * @param freezeCallback Callback to use if the piece should freeze itself + * @return {boolean} True if the move was valid, false otherwise + */ + tryMove(x: number, y: number, grid: grid, width: number, height: number, freezeCallback: Function) { + if (x > 1) x = 1; // Prevent moving from more than one tile + if (x < -1) x = -1; + if (y > 1) y = 1; + if (y < -1) y = -1; + if (x !== 0 && y !== 0) y = 0; // Prevent diagonal movement + + this.removeFromGrid(grid); + this.#currentShape.move(x, y); + let isValid = this.isPositionValid(grid, width, height); + + if (!isValid) + this.#currentShape.move(-x, -y); + + let shouldFreeze = !isValid && y !== 0; + this.toGrid(grid, false); + if (shouldFreeze) + freezeCallback(); + return isValid; + } + + /** + * Tries to rotate the piece + * + * @param grid The grid to rotate the piece on + * @param width The grid's width + * @param height The grid's height + * @return {boolean} True if the rotation was valid, false otherwise + */ + tryRotate(grid: grid, width: number, height: number) { + this.removeFromGrid(grid); + this.#currentShape.rotate(true); + if (!this.isPositionValid(grid, width, height)) { + this.#currentShape.rotate(false); + this.toGrid(grid, false); + return false; + } + this.toGrid(grid, false); + return true; + } + + /** + * Gets this piece used cells coordinates + * + * @return {Array} An array of coordinates + */ + getCoordinates(): Array { + return this.#currentShape.getCellsCoordinates(true); + } +} diff --git a/src/screens/Tetris/ScoreManager.js b/src/screens/Tetris/ScoreManager.js new file mode 100644 index 0000000..e000202 --- /dev/null +++ b/src/screens/Tetris/ScoreManager.js @@ -0,0 +1,101 @@ +// @flow + +/** + * Class used to manage game score + */ +export default class ScoreManager { + + #scoreLinesModifier = [40, 100, 300, 1200]; + + #score: number; + #level: number; + #levelProgression: number; + + /** + * Initializes score to 0 + */ + constructor() { + this.#score = 0; + this.#level = 0; + this.#levelProgression = 0; + } + + /** + * Gets the current score + * + * @return {number} The current score + */ + getScore(): number { + return this.#score; + } + + /** + * Gets the current level + * + * @return {number} The current level + */ + getLevel(): number { + return this.#level; + } + + /** + * Gets the current level progression + * + * @return {number} The current level progression + */ + getLevelProgression(): number { + return this.#levelProgression; + } + + /** + * Increments the score by one + */ + incrementScore() { + this.#score++; + } + + /** + * Add score corresponding to the number of lines removed at the same time. + * Also updates the level progression. + * + * The more lines cleared at the same time, the more points and level progression the player gets. + * + * @param numberRemoved The number of lines removed at the same time + */ + addLinesRemovedPoints(numberRemoved: number) { + if (numberRemoved < 1 || numberRemoved > 4) + return; + this.#score += this.#scoreLinesModifier[numberRemoved-1] * (this.#level + 1); + switch (numberRemoved) { + case 1: + this.#levelProgression += 1; + break; + case 2: + this.#levelProgression += 3; + break; + case 3: + this.#levelProgression += 5; + break; + case 4: // Did a tetris ! + this.#levelProgression += 8; + break; + } + } + + /** + * Checks if the player can go to the next level. + * + * If he can, change the level. + * + * @return {boolean} True if the current level has changed + */ + canLevelUp() { + let canLevel = this.#levelProgression > this.#level * 5; + if (canLevel){ + this.#levelProgression -= this.#level * 5; + this.#level++; + } + return canLevel; + } + +} diff --git a/src/screens/Tetris/Shapes/BaseShape.js b/src/screens/Tetris/Shapes/BaseShape.js new file mode 100644 index 0000000..e1890ac --- /dev/null +++ b/src/screens/Tetris/Shapes/BaseShape.js @@ -0,0 +1,106 @@ +// @flow + +export type coordinates = { + x: number, + y: number, +} + +/** + * Abstract class used to represent a BaseShape. + * Abstract classes do not exist by default in Javascript: we force it by throwing errors in the constructor + * and in methods to implement + */ +export default class BaseShape { + + #currentShape: Array>; + #rotation: number; + position: coordinates; + + /** + * Prevent instantiation if classname is BaseShape to force class to be abstract + */ + constructor() { + if (this.constructor === BaseShape) + throw new Error("Abstract class can't be instantiated"); + this.#rotation = 0; + this.position = {x: 0, y: 0}; + this.#currentShape = this.getShapes()[this.#rotation]; + } + + /** + * Gets this shape's color. + * Must be implemented by child class + */ + getColor(): string { + throw new Error("Method 'getColor()' must be implemented"); + } + + /** + * Gets this object's all possible shapes as an array. + * Must be implemented by child class. + * + * Used by tests to read private fields + */ + getShapes(): Array>> { + throw new Error("Method 'getShapes()' must be implemented"); + } + + /** + * Gets this object's current shape. + * + * Used by tests to read private fields + */ + getCurrentShape(): Array> { + return this.#currentShape; + } + + /** + * Gets this object's coordinates. + * This will return an array of coordinates representing the positions of the cells used by this object. + * + * @param isAbsolute Should we take into account the current position of the object? + * @return {Array} This object cells coordinates + */ + getCellsCoordinates(isAbsolute: boolean): Array { + let coordinates = []; + for (let row = 0; row < this.#currentShape.length; row++) { + for (let col = 0; col < this.#currentShape[row].length; col++) { + if (this.#currentShape[row][col] === 1) + if (isAbsolute) + coordinates.push({x: this.position.x + col, y: this.position.y + row}); + else + coordinates.push({x: col, y: row}); + } + } + return coordinates; + } + + /** + * Rotate this object + * + * @param isForward Should we rotate clockwise? + */ + rotate(isForward: boolean) { + if (isForward) + this.#rotation++; + else + this.#rotation--; + if (this.#rotation > 3) + this.#rotation = 0; + else if (this.#rotation < 0) + this.#rotation = 3; + this.#currentShape = this.getShapes()[this.#rotation]; + } + + /** + * Move this object + * + * @param x Position X offset to add + * @param y Position Y offset to add + */ + move(x: number, y: number) { + this.position.x += x; + this.position.y += y; + } + +} diff --git a/src/screens/Tetris/Shapes/ShapeI.js b/src/screens/Tetris/Shapes/ShapeI.js new file mode 100644 index 0000000..d3d2205 --- /dev/null +++ b/src/screens/Tetris/Shapes/ShapeI.js @@ -0,0 +1,47 @@ +// @flow + +import BaseShape from "./BaseShape"; + +export default class ShapeI extends BaseShape { + + #colors: Object; + + constructor(colors: Object) { + super(); + this.position.x = 3; + this.#colors = colors; + } + + getColor(): string { + return this.#colors.tetrisI; + } + + getShapes() { + return [ + [ + [0, 0, 0, 0], + [1, 1, 1, 1], + [0, 0, 0, 0], + [0, 0, 0, 0], + ], + [ + [0, 0, 1, 0], + [0, 0, 1, 0], + [0, 0, 1, 0], + [0, 0, 1, 0], + ], + [ + [0, 0, 0, 0], + [0, 0, 0, 0], + [1, 1, 1, 1], + [0, 0, 0, 0], + ], + [ + [0, 1, 0, 0], + [0, 1, 0, 0], + [0, 1, 0, 0], + [0, 1, 0, 0], + ], + ]; + } +} diff --git a/src/screens/Tetris/Shapes/ShapeJ.js b/src/screens/Tetris/Shapes/ShapeJ.js new file mode 100644 index 0000000..391e180 --- /dev/null +++ b/src/screens/Tetris/Shapes/ShapeJ.js @@ -0,0 +1,43 @@ +// @flow + +import BaseShape from "./BaseShape"; + +export default class ShapeJ extends BaseShape { + + #colors: Object; + + constructor(colors: Object) { + super(); + this.position.x = 3; + this.#colors = colors; + } + + getColor(): string { + return this.#colors.tetrisJ; + } + + getShapes() { + return [ + [ + [1, 0, 0], + [1, 1, 1], + [0, 0, 0], + ], + [ + [0, 1, 1], + [0, 1, 0], + [0, 1, 0], + ], + [ + [0, 0, 0], + [1, 1, 1], + [0, 0, 1], + ], + [ + [0, 1, 0], + [0, 1, 0], + [1, 1, 0], + ], + ]; + } +} diff --git a/src/screens/Tetris/Shapes/ShapeL.js b/src/screens/Tetris/Shapes/ShapeL.js new file mode 100644 index 0000000..77562cc --- /dev/null +++ b/src/screens/Tetris/Shapes/ShapeL.js @@ -0,0 +1,43 @@ +// @flow + +import BaseShape from "./BaseShape"; + +export default class ShapeL extends BaseShape { + + #colors: Object; + + constructor(colors: Object) { + super(); + this.position.x = 3; + this.#colors = colors; + } + + getColor(): string { + return this.#colors.tetrisL; + } + + getShapes() { + return [ + [ + [0, 0, 1], + [1, 1, 1], + [0, 0, 0], + ], + [ + [0, 1, 0], + [0, 1, 0], + [0, 1, 1], + ], + [ + [0, 0, 0], + [1, 1, 1], + [1, 0, 0], + ], + [ + [1, 1, 0], + [0, 1, 0], + [0, 1, 0], + ], + ]; + } +} diff --git a/src/screens/Tetris/Shapes/ShapeO.js b/src/screens/Tetris/Shapes/ShapeO.js new file mode 100644 index 0000000..e55b3aa --- /dev/null +++ b/src/screens/Tetris/Shapes/ShapeO.js @@ -0,0 +1,39 @@ +// @flow + +import BaseShape from "./BaseShape"; + +export default class ShapeO extends BaseShape { + + #colors: Object; + + constructor(colors: Object) { + super(); + this.position.x = 4; + this.#colors = colors; + } + + getColor(): string { + return this.#colors.tetrisO; + } + + getShapes() { + return [ + [ + [1, 1], + [1, 1], + ], + [ + [1, 1], + [1, 1], + ], + [ + [1, 1], + [1, 1], + ], + [ + [1, 1], + [1, 1], + ], + ]; + } +} diff --git a/src/screens/Tetris/Shapes/ShapeS.js b/src/screens/Tetris/Shapes/ShapeS.js new file mode 100644 index 0000000..2124a00 --- /dev/null +++ b/src/screens/Tetris/Shapes/ShapeS.js @@ -0,0 +1,43 @@ +// @flow + +import BaseShape from "./BaseShape"; + +export default class ShapeS extends BaseShape { + + #colors: Object; + + constructor(colors: Object) { + super(); + this.position.x = 3; + this.#colors = colors; + } + + getColor(): string { + return this.#colors.tetrisS; + } + + getShapes() { + return [ + [ + [0, 1, 1], + [1, 1, 0], + [0, 0, 0], + ], + [ + [0, 1, 0], + [0, 1, 1], + [0, 0, 1], + ], + [ + [0, 0, 0], + [0, 1, 1], + [1, 1, 0], + ], + [ + [1, 0, 0], + [1, 1, 0], + [0, 1, 0], + ], + ]; + } +} diff --git a/src/screens/Tetris/Shapes/ShapeT.js b/src/screens/Tetris/Shapes/ShapeT.js new file mode 100644 index 0000000..244bfae --- /dev/null +++ b/src/screens/Tetris/Shapes/ShapeT.js @@ -0,0 +1,43 @@ +// @flow + +import BaseShape from "./BaseShape"; + +export default class ShapeT extends BaseShape { + + #colors: Object; + + constructor(colors: Object) { + super(); + this.position.x = 3; + this.#colors = colors; + } + + getColor(): string { + return this.#colors.tetrisT; + } + + getShapes() { + return [ + [ + [0, 1, 0], + [1, 1, 1], + [0, 0, 0], + ], + [ + [0, 1, 0], + [0, 1, 1], + [0, 1, 0], + ], + [ + [0, 0, 0], + [1, 1, 1], + [0, 1, 0], + ], + [ + [0, 1, 0], + [1, 1, 0], + [0, 1, 0], + ], + ]; + } +} diff --git a/src/screens/Tetris/Shapes/ShapeZ.js b/src/screens/Tetris/Shapes/ShapeZ.js new file mode 100644 index 0000000..05a619f --- /dev/null +++ b/src/screens/Tetris/Shapes/ShapeZ.js @@ -0,0 +1,43 @@ +// @flow + +import BaseShape from "./BaseShape"; + +export default class ShapeZ extends BaseShape { + + #colors: Object; + + constructor(colors: Object) { + super(); + this.position.x = 3; + this.#colors = colors; + } + + getColor(): string { + return this.#colors.tetrisZ; + } + + getShapes() { + return [ + [ + [1, 1, 0], + [0, 1, 1], + [0, 0, 0], + ], + [ + [0, 0, 1], + [0, 1, 1], + [0, 1, 0], + ], + [ + [0, 0, 0], + [1, 1, 0], + [0, 1, 1], + ], + [ + [0, 1, 0], + [1, 1, 0], + [1, 0, 0], + ], + ]; + } +} diff --git a/src/screens/Tetris/TetrisScreen.js b/src/screens/Tetris/TetrisScreen.js new file mode 100644 index 0000000..ea05a27 --- /dev/null +++ b/src/screens/Tetris/TetrisScreen.js @@ -0,0 +1,299 @@ +// @flow + +import * as React from 'react'; +import {Alert, View} from 'react-native'; +import {IconButton, Text, withTheme} from 'react-native-paper'; +import MaterialCommunityIcons from "react-native-vector-icons/MaterialCommunityIcons"; +import GameLogic from "./GameLogic"; +import Grid from "./components/Grid"; +import Preview from "./components/Preview"; +import i18n from "i18n-js"; +import MaterialHeaderButtons, {Item} from "../../components/Overrides/CustomHeaderButton"; + +type Props = { + navigation: Object, +} + +type State = { + grid: Array>, + gameRunning: boolean, + gameTime: number, + gameScore: number, + gameLevel: number, +} + +class TetrisScreen extends React.Component { + + colors: Object; + + logic: GameLogic; + onTick: Function; + onClock: Function; + onGameEnd: Function; + updateGrid: Function; + updateGridScore: Function; + + constructor(props) { + super(props); + this.colors = props.theme.colors; + this.logic = new GameLogic(20, 10, this.colors); + this.state = { + grid: this.logic.getCurrentGrid(), + gameRunning: false, + gameTime: 0, + gameScore: 0, + gameLevel: 0, + }; + this.onTick = this.onTick.bind(this); + this.onClock = this.onClock.bind(this); + this.onGameEnd = this.onGameEnd.bind(this); + this.updateGrid = this.updateGrid.bind(this); + this.updateGridScore = this.updateGridScore.bind(this); + this.props.navigation.addListener('blur', this.onScreenBlur.bind(this)); + this.props.navigation.addListener('focus', this.onScreenFocus.bind(this)); + } + + componentDidMount() { + const rightButton = this.getRightButton.bind(this); + this.props.navigation.setOptions({ + headerRight: rightButton, + }); + this.startGame(); + } + + getRightButton() { + return + this.togglePause()}/> + ; + } + + /** + * Remove any interval on un-focus + */ + onScreenBlur() { + if (!this.logic.isGamePaused()) + this.logic.togglePause(); + } + + onScreenFocus() { + if (!this.logic.isGameRunning()) + this.startGame(); + else if (this.logic.isGamePaused()) + this.showPausePopup(); + } + + getFormattedTime(seconds: number) { + let date = new Date(); + date.setHours(0); + date.setMinutes(0); + date.setSeconds(seconds); + let format; + if (date.getHours()) + format = date.getHours() + ':' + date.getMinutes() + ':' + date.getSeconds(); + else if (date.getMinutes()) + format = date.getMinutes() + ':' + date.getSeconds(); + else + format = date.getSeconds(); + return format; + } + + onTick(score: number, level: number, newGrid: Array>) { + this.setState({ + gameScore: score, + gameLevel: level, + grid: newGrid, + }); + } + + onClock(time: number) { + this.setState({ + gameTime: time, + }); + } + + updateGrid(newGrid: Array>) { + this.setState({ + grid: newGrid, + }); + } + + updateGridScore(newGrid: Array>, score: number) { + this.setState({ + grid: newGrid, + gameScore: score, + }); + } + + togglePause() { + this.logic.togglePause(); + if (this.logic.isGamePaused()) + this.showPausePopup(); + } + + showPausePopup() { + Alert.alert( + i18n.t("game.pause"), + i18n.t("game.pauseMessage"), + [ + {text: i18n.t("game.restart.text"), onPress: () => this.showRestartConfirm()}, + {text: i18n.t("game.resume"), onPress: () => this.togglePause()}, + ], + {cancelable: false}, + ); + } + + showRestartConfirm() { + Alert.alert( + i18n.t("game.restart.confirm"), + i18n.t("game.restart.confirmMessage"), + [ + {text: i18n.t("game.restart.confirmNo"), onPress: () => this.showPausePopup()}, + {text: i18n.t("game.restart.confirmYes"), onPress: () => this.startGame()}, + ], + {cancelable: false}, + ); + } + + showGameOverConfirm() { + let message = i18n.t("game.gameOver.score") + this.state.gameScore + '\n'; + message += i18n.t("game.gameOver.level") + this.state.gameLevel + '\n'; + message += i18n.t("game.gameOver.time") + this.getFormattedTime(this.state.gameTime) + '\n'; + Alert.alert( + i18n.t("game.gameOver.text"), + message, + [ + {text: i18n.t("game.gameOver.exit"), onPress: () => this.props.navigation.goBack()}, + {text: i18n.t("game.restart.text"), onPress: () => this.startGame()}, + ], + {cancelable: false}, + ); + } + + startGame() { + this.logic.startGame(this.onTick, this.onClock, this.onGameEnd); + this.setState({ + gameRunning: true, + }); + } + + onGameEnd(time: number, score: number, isRestart: boolean) { + this.setState({ + gameTime: time, + gameScore: score, + gameRunning: false, + }); + if (!isRestart) + this.showGameOverConfirm(); + } + + render() { + return ( + + + + {this.getFormattedTime(this.state.gameTime)} + + + + {this.state.gameLevel} + + + + {this.state.gameScore} + + + + + + + this.logic.rotatePressed(this.updateGrid)} + style={{marginRight: 'auto'}} + /> + + this.logic.pressedOut()} + onPressIn={() => this.logic.leftPressedIn(this.updateGrid)} + + /> + this.logic.pressedOut()} + onPressIn={() => this.logic.rightPressed(this.updateGrid)} + /> + + this.logic.downPressedIn(this.updateGridScore)} + onPress={() => this.logic.pressedOut()} + style={{marginLeft: 'auto'}} + color={this.colors.tetrisScore} + /> + + + ); + } + +} + +export default withTheme(TetrisScreen); diff --git a/src/screens/Tetris/__tests__/GridManager.test.js b/src/screens/Tetris/__tests__/GridManager.test.js new file mode 100644 index 0000000..8808754 --- /dev/null +++ b/src/screens/Tetris/__tests__/GridManager.test.js @@ -0,0 +1,103 @@ +import React from 'react'; +import GridManager from "../GridManager"; +import ScoreManager from "../ScoreManager"; +import Piece from "../Piece"; + +let colors = { + tetrisBackground: "#000002" +}; + +jest.mock("../ScoreManager"); + +afterAll(() => { + jest.restoreAllMocks(); +}); + + +test('getEmptyLine', () => { + let g = new GridManager(2, 2, colors); + expect(g.getEmptyLine(2)).toStrictEqual([ + {color: colors.tetrisBackground, isEmpty: true}, + {color: colors.tetrisBackground, isEmpty: true}, + ]); + + expect(g.getEmptyLine(-1)).toStrictEqual([]); +}); + +test('getEmptyGrid', () => { + let g = new GridManager(2, 2, colors); + expect(g.getEmptyGrid(2, 2)).toStrictEqual([ + [ + {color: colors.tetrisBackground, isEmpty: true}, + {color: colors.tetrisBackground, isEmpty: true}, + ], + [ + {color: colors.tetrisBackground, isEmpty: true}, + {color: colors.tetrisBackground, isEmpty: true}, + ], + ]); + + expect(g.getEmptyGrid(-1, 2)).toStrictEqual([]); + expect(g.getEmptyGrid(2, -1)).toStrictEqual([[], []]); +}); + +test('getLinesToClear', () => { + let g = new GridManager(2, 2, colors); + g.getCurrentGrid()[0][0].isEmpty = false; + g.getCurrentGrid()[0][1].isEmpty = false; + let coord = [{x: 1, y: 0}]; + expect(g.getLinesToClear(coord)).toStrictEqual([0]); + + g.getCurrentGrid()[0][0].isEmpty = true; + g.getCurrentGrid()[0][1].isEmpty = true; + g.getCurrentGrid()[1][0].isEmpty = false; + g.getCurrentGrid()[1][1].isEmpty = false; + expect(g.getLinesToClear(coord)).toStrictEqual([]); + coord = [{x: 1, y: 1}]; + expect(g.getLinesToClear(coord)).toStrictEqual([1]); +}); + +test('clearLines', () => { + let g = new GridManager(2, 2, colors); + let grid = [ + [ + {color: colors.tetrisBackground, isEmpty: true}, + {color: colors.tetrisBackground, isEmpty: true}, + ], + [ + {color: '0', isEmpty: true}, + {color: '0', isEmpty: true}, + ], + ]; + g.getCurrentGrid()[1][0].color = '0'; + g.getCurrentGrid()[1][1].color = '0'; + expect(g.getCurrentGrid()).toStrictEqual(grid); + let scoreManager = new ScoreManager(); + g.clearLines([1], scoreManager); + grid = [ + [ + {color: colors.tetrisBackground, isEmpty: true}, + {color: colors.tetrisBackground, isEmpty: true}, + ], + [ + {color: colors.tetrisBackground, isEmpty: true}, + {color: colors.tetrisBackground, isEmpty: true}, + ], + ]; + expect(g.getCurrentGrid()).toStrictEqual(grid); +}); + +test('freezeTetromino', () => { + let g = new GridManager(2, 2, colors); + let spy1 = jest.spyOn(GridManager.prototype, 'getLinesToClear') + .mockImplementation(() => {}); + let spy2 = jest.spyOn(GridManager.prototype, 'clearLines') + .mockImplementation(() => {}); + g.freezeTetromino(new Piece({}), null); + + expect(spy1).toHaveBeenCalled(); + expect(spy2).toHaveBeenCalled(); + + spy1.mockRestore(); + spy2.mockRestore(); +}); diff --git a/src/screens/Tetris/__tests__/Piece.test.js b/src/screens/Tetris/__tests__/Piece.test.js new file mode 100644 index 0000000..da5d7b2 --- /dev/null +++ b/src/screens/Tetris/__tests__/Piece.test.js @@ -0,0 +1,155 @@ +import React from 'react'; +import Piece from "../Piece"; +import ShapeI from "../Shapes/ShapeI"; + +let colors = { + tetrisI: "#000001", + tetrisBackground: "#000002" +}; + +jest.mock("../Shapes/ShapeI"); + +beforeAll(() => { + jest.spyOn(Piece.prototype, 'getRandomShape') + .mockImplementation((colors: Object) => {return new ShapeI(colors);}); +}); + +afterAll(() => { + jest.restoreAllMocks(); +}); + +test('isPositionValid', () => { + let x = 0; + let y = 0; + let spy = jest.spyOn(ShapeI.prototype, 'getCellsCoordinates') + .mockImplementation(() => {return [{x: x, y: y}];}); + let grid = [ + [{isEmpty: true}, {isEmpty: true}], + [{isEmpty: true}, {isEmpty: false}], + ]; + let size = 2; + + let p = new Piece(colors); + expect(p.isPositionValid(grid, size, size)).toBeTrue(); + x = 1; y = 0; + expect(p.isPositionValid(grid, size, size)).toBeTrue(); + x = 0; y = 1; + expect(p.isPositionValid(grid, size, size)).toBeTrue(); + x = 1; y = 1; + expect(p.isPositionValid(grid, size, size)).toBeFalse(); + x = 2; y = 0; + expect(p.isPositionValid(grid, size, size)).toBeFalse(); + x = -1; y = 0; + expect(p.isPositionValid(grid, size, size)).toBeFalse(); + x = 0; y = 2; + expect(p.isPositionValid(grid, size, size)).toBeFalse(); + x = 0; y = -1; + expect(p.isPositionValid(grid, size, size)).toBeFalse(); + + spy.mockRestore(); +}); + +test('tryMove', () => { + let p = new Piece(colors); + const callbackMock = jest.fn(); + let isValid = true; + let spy1 = jest.spyOn(Piece.prototype, 'isPositionValid') + .mockImplementation(() => {return isValid;}); + let spy2 = jest.spyOn(Piece.prototype, 'removeFromGrid') + .mockImplementation(() => {}); + let spy3 = jest.spyOn(Piece.prototype, 'toGrid') + .mockImplementation(() => {}); + + expect(p.tryMove(-1, 0, null, null, null, callbackMock)).toBeTrue(); + isValid = false; + expect(p.tryMove(-1, 0, null, null, null, callbackMock)).toBeFalse(); + isValid = true; + expect(p.tryMove(0, 1, null, null, null, callbackMock)).toBeTrue(); + expect(callbackMock).toBeCalledTimes(0); + + isValid = false; + expect(p.tryMove(0, 1, null, null, null, callbackMock)).toBeFalse(); + expect(callbackMock).toBeCalledTimes(1); + + expect(spy2).toBeCalledTimes(4); + expect(spy3).toBeCalledTimes(4); + + spy1.mockRestore(); + spy2.mockRestore(); + spy3.mockRestore(); +}); + +test('tryRotate', () => { + let p = new Piece(colors); + let isValid = true; + let spy1 = jest.spyOn(Piece.prototype, 'isPositionValid') + .mockImplementation(() => {return isValid;}); + let spy2 = jest.spyOn(Piece.prototype, 'removeFromGrid') + .mockImplementation(() => {}); + let spy3 = jest.spyOn(Piece.prototype, 'toGrid') + .mockImplementation(() => {}); + + expect(p.tryRotate( null, null, null)).toBeTrue(); + isValid = false; + expect(p.tryRotate( null, null, null)).toBeFalse(); + + expect(spy2).toBeCalledTimes(2); + expect(spy3).toBeCalledTimes(2); + + spy1.mockRestore(); + spy2.mockRestore(); + spy3.mockRestore(); +}); + + +test('toGrid', () => { + let x = 0; + let y = 0; + let spy1 = jest.spyOn(ShapeI.prototype, 'getCellsCoordinates') + .mockImplementation(() => {return [{x: x, y: y}];}); + let spy2 = jest.spyOn(ShapeI.prototype, 'getColor') + .mockImplementation(() => {return colors.tetrisI;}); + let grid = [ + [{isEmpty: true}, {isEmpty: true}], + [{isEmpty: true}, {isEmpty: true}], + ]; + let expectedGrid = [ + [{color: colors.tetrisI, isEmpty: false}, {isEmpty: true}], + [{isEmpty: true}, {isEmpty: true}], + ]; + + let p = new Piece(colors); + p.toGrid(grid, true); + expect(grid).toStrictEqual(expectedGrid); + + spy1.mockRestore(); + spy2.mockRestore(); +}); + +test('removeFromGrid', () => { + let gridOld = [ + [ + {color: colors.tetrisI, isEmpty: false}, + {color: colors.tetrisI, isEmpty: false}, + {color: colors.tetrisBackground, isEmpty: true}, + ], + ]; + let gridNew = [ + [ + {color: colors.tetrisBackground, isEmpty: true}, + {color: colors.tetrisBackground, isEmpty: true}, + {color: colors.tetrisBackground, isEmpty: true}, + ], + ]; + let oldCoord = [{x: 0, y: 0}, {x: 1, y: 0}]; + let spy1 = jest.spyOn(ShapeI.prototype, 'getCellsCoordinates') + .mockImplementation(() => {return oldCoord;}); + let spy2 = jest.spyOn(ShapeI.prototype, 'getColor') + .mockImplementation(() => {return colors.tetrisI;}); + let p = new Piece(colors); + p.removeFromGrid(gridOld); + expect(gridOld).toStrictEqual(gridNew); + + spy1.mockRestore(); + spy2.mockRestore(); +}); diff --git a/src/screens/Tetris/__tests__/ScoreManager.test.js b/src/screens/Tetris/__tests__/ScoreManager.test.js new file mode 100644 index 0000000..e84654d --- /dev/null +++ b/src/screens/Tetris/__tests__/ScoreManager.test.js @@ -0,0 +1,71 @@ +import React from 'react'; +import ScoreManager from "../ScoreManager"; + + +test('incrementScore', () => { + let scoreManager = new ScoreManager(); + expect(scoreManager.getScore()).toBe(0); + scoreManager.incrementScore(); + expect(scoreManager.getScore()).toBe(1); +}); + +test('addLinesRemovedPoints', () => { + let scoreManager = new ScoreManager(); + scoreManager.addLinesRemovedPoints(0); + scoreManager.addLinesRemovedPoints(5); + expect(scoreManager.getScore()).toBe(0); + expect(scoreManager.getLevelProgression()).toBe(0); + + scoreManager.addLinesRemovedPoints(1); + expect(scoreManager.getScore()).toBe(40); + expect(scoreManager.getLevelProgression()).toBe(1); + + scoreManager.addLinesRemovedPoints(2); + expect(scoreManager.getScore()).toBe(140); + expect(scoreManager.getLevelProgression()).toBe(4); + + scoreManager.addLinesRemovedPoints(3); + expect(scoreManager.getScore()).toBe(440); + expect(scoreManager.getLevelProgression()).toBe(9); + + scoreManager.addLinesRemovedPoints(4); + expect(scoreManager.getScore()).toBe(1640); + expect(scoreManager.getLevelProgression()).toBe(17); +}); + +test('canLevelUp', () => { + let scoreManager = new ScoreManager(); + expect(scoreManager.canLevelUp()).toBeFalse(); + expect(scoreManager.getLevel()).toBe(0); + expect(scoreManager.getLevelProgression()).toBe(0); + + scoreManager.addLinesRemovedPoints(1); + expect(scoreManager.canLevelUp()).toBeTrue(); + expect(scoreManager.getLevel()).toBe(1); + expect(scoreManager.getLevelProgression()).toBe(1); + + scoreManager.addLinesRemovedPoints(1); + expect(scoreManager.canLevelUp()).toBeFalse(); + expect(scoreManager.getLevel()).toBe(1); + expect(scoreManager.getLevelProgression()).toBe(2); + + scoreManager.addLinesRemovedPoints(2); + expect(scoreManager.canLevelUp()).toBeFalse(); + expect(scoreManager.getLevel()).toBe(1); + expect(scoreManager.getLevelProgression()).toBe(5); + + scoreManager.addLinesRemovedPoints(1); + expect(scoreManager.canLevelUp()).toBeTrue(); + expect(scoreManager.getLevel()).toBe(2); + expect(scoreManager.getLevelProgression()).toBe(1); + + scoreManager.addLinesRemovedPoints(4); + expect(scoreManager.canLevelUp()).toBeFalse(); + expect(scoreManager.getLevel()).toBe(2); + expect(scoreManager.getLevelProgression()).toBe(9); + + scoreManager.addLinesRemovedPoints(2); + expect(scoreManager.canLevelUp()).toBeTrue(); + expect(scoreManager.getLevel()).toBe(3); + expect(scoreManager.getLevelProgression()).toBe(2); +}); diff --git a/src/screens/Tetris/__tests__/Shape.test.js b/src/screens/Tetris/__tests__/Shape.test.js new file mode 100644 index 0000000..3a4537e --- /dev/null +++ b/src/screens/Tetris/__tests__/Shape.test.js @@ -0,0 +1,104 @@ +import React from 'react'; +import BaseShape from "../Shapes/BaseShape"; +import ShapeI from "../Shapes/ShapeI"; + +const colors = { + tetrisI: '#000001', + tetrisO: '#000002', + tetrisT: '#000003', + tetrisS: '#000004', + tetrisZ: '#000005', + tetrisJ: '#000006', + tetrisL: '#000007', +}; + +test('constructor', () => { + expect(() => new BaseShape()).toThrow(Error); + + let T = new ShapeI(colors); + expect(T.position.y).toBe(0); + expect(T.position.x).toBe(3); + expect(T.getCurrentShape()).toStrictEqual(T.getShapes()[0]); + expect(T.getColor()).toBe(colors.tetrisI); +}); + +test("move", () => { + let T = new ShapeI(colors); + T.move(0, 1); + expect(T.position.x).toBe(3); + expect(T.position.y).toBe(1); + T.move(1, 0); + expect(T.position.x).toBe(4); + expect(T.position.y).toBe(1); + T.move(1, 1); + expect(T.position.x).toBe(5); + expect(T.position.y).toBe(2); + T.move(2, 2); + expect(T.position.x).toBe(7); + expect(T.position.y).toBe(4); + T.move(-1, -1); + expect(T.position.x).toBe(6); + expect(T.position.y).toBe(3); +}); + +test('rotate', () => { + let T = new ShapeI(colors); + T.rotate(true); + expect(T.getCurrentShape()).toStrictEqual(T.getShapes()[1]); + T.rotate(true); + expect(T.getCurrentShape()).toStrictEqual(T.getShapes()[2]); + T.rotate(true); + expect(T.getCurrentShape()).toStrictEqual(T.getShapes()[3]); + T.rotate(true); + expect(T.getCurrentShape()).toStrictEqual(T.getShapes()[0]); + T.rotate(false); + expect(T.getCurrentShape()).toStrictEqual(T.getShapes()[3]); + T.rotate(false); + expect(T.getCurrentShape()).toStrictEqual(T.getShapes()[2]); + T.rotate(false); + expect(T.getCurrentShape()).toStrictEqual(T.getShapes()[1]); + T.rotate(false); + expect(T.getCurrentShape()).toStrictEqual(T.getShapes()[0]); +}); + +test('getCellsCoordinates', () => { + let T = new ShapeI(colors); + expect(T.getCellsCoordinates(false)).toStrictEqual([ + {x: 0, y: 1}, + {x: 1, y: 1}, + {x: 2, y: 1}, + {x: 3, y: 1}, + ]); + expect(T.getCellsCoordinates(true)).toStrictEqual([ + {x: 3, y: 1}, + {x: 4, y: 1}, + {x: 5, y: 1}, + {x: 6, y: 1}, + ]); + T.move(1, 1); + expect(T.getCellsCoordinates(false)).toStrictEqual([ + {x: 0, y: 1}, + {x: 1, y: 1}, + {x: 2, y: 1}, + {x: 3, y: 1}, + ]); + expect(T.getCellsCoordinates(true)).toStrictEqual([ + {x: 4, y: 2}, + {x: 5, y: 2}, + {x: 6, y: 2}, + {x: 7, y: 2}, + ]); + T.rotate(true); + expect(T.getCellsCoordinates(false)).toStrictEqual([ + {x: 2, y: 0}, + {x: 2, y: 1}, + {x: 2, y: 2}, + {x: 2, y: 3}, + ]); + expect(T.getCellsCoordinates(true)).toStrictEqual([ + {x: 6, y: 1}, + {x: 6, y: 2}, + {x: 6, y: 3}, + {x: 6, y: 4}, + ]); +}); diff --git a/src/screens/Tetris/components/Cell.js b/src/screens/Tetris/components/Cell.js new file mode 100644 index 0000000..6167e2c --- /dev/null +++ b/src/screens/Tetris/components/Cell.js @@ -0,0 +1,41 @@ +// @flow + +import * as React from 'react'; +import {View} from 'react-native'; +import {withTheme} from 'react-native-paper'; + +type Props = { + item: Object +} + +class Cell extends React.PureComponent { + + colors: Object; + + constructor(props) { + super(props); + this.colors = props.theme.colors; + } + + render() { + const item = this.props.item; + return ( + + ); + } + + +} + +export default withTheme(Cell); diff --git a/src/screens/Tetris/components/Grid.js b/src/screens/Tetris/components/Grid.js new file mode 100644 index 0000000..d61af89 --- /dev/null +++ b/src/screens/Tetris/components/Grid.js @@ -0,0 +1,70 @@ +// @flow + +import * as React from 'react'; +import {View} from 'react-native'; +import {withTheme} from 'react-native-paper'; +import Cell from "./Cell"; + +type Props = { + navigation: Object, + grid: Array>, + backgroundColor: string, + height: number, + width: number, + containerMaxHeight: number | string, + containerMaxWidth: number | string, +} + +class Grid extends React.Component { + + colors: Object; + + constructor(props) { + super(props); + this.colors = props.theme.colors; + } + + getRow(rowNumber: number) { + let cells = this.props.grid[rowNumber].map(this.getCellRender); + return ( + + {cells} + + ); + } + + getCellRender = (item: Object) => { + return ; + }; + + getGrid() { + let rows = []; + for (let i = 0; i < this.props.height; i++) { + rows.push(this.getRow(i)); + } + return rows; + } + + render() { + return ( + + {this.getGrid()} + + ); + } +} + +export default withTheme(Grid); diff --git a/src/screens/Tetris/components/Preview.js b/src/screens/Tetris/components/Preview.js new file mode 100644 index 0000000..48c4442 --- /dev/null +++ b/src/screens/Tetris/components/Preview.js @@ -0,0 +1,57 @@ +// @flow + +import * as React from 'react'; +import {View} from 'react-native'; +import {withTheme} from 'react-native-paper'; +import Grid from "./Grid"; + +type Props = { + next: Object, +} + +class Preview extends React.PureComponent { + + colors: Object; + + constructor(props) { + super(props); + this.colors = props.theme.colors; + } + + getGrids() { + let grids = []; + for (let i = 0; i < this.props.next.length; i++) { + grids.push( + this.getGridRender(this.props.next[i], i) + ); + } + return grids; + } + + getGridRender(item: Object, index: number) { + return ; + }; + + render() { + if (this.props.next.length > 0) { + return ( + + {this.getGrids()} + + ); + } else + return null; + } + + +} + +export default withTheme(Preview); diff --git a/src/utils/AutoHideHandler.js b/src/utils/AutoHideHandler.js new file mode 100644 index 0000000..dbb7740 --- /dev/null +++ b/src/utils/AutoHideHandler.js @@ -0,0 +1,41 @@ +// @flow + +import * as React from 'react'; + +const speedOffset = 5; + +export default class AutoHideHandler { + + lastOffset: number; + isHidden: boolean; + + listeners: Array; + + constructor(startHidden: boolean) { + this.listeners = []; + this.isHidden = startHidden; + } + + addListener(listener: Function) { + this.listeners.push(listener); + } + + notifyListeners(shouldHide: boolean) { + for (let i = 0; i < this.listeners.length; i++) { + this.listeners[i](shouldHide); + } + } + + onScroll({nativeEvent}: Object) { + const speed = nativeEvent.contentOffset.y < 0 ? 0 : this.lastOffset - nativeEvent.contentOffset.y; + if (speed < -speedOffset && !this.isHidden) { // Go down + this.notifyListeners(true); + this.isHidden = true; + } else if (speed > speedOffset && this.isHidden) { // Go up + this.notifyListeners(false); + this.isHidden = false; + } + this.lastOffset = nativeEvent.contentOffset.y; + } + +} \ No newline at end of file diff --git a/src/utils/CollapsibleUtils.js b/src/utils/CollapsibleUtils.js new file mode 100644 index 0000000..ec461ac --- /dev/null +++ b/src/utils/CollapsibleUtils.js @@ -0,0 +1,38 @@ +// @flow + +import * as React from 'react'; +import {useTheme} from "react-native-paper"; +import {createCollapsibleStack} from "react-navigation-collapsible"; +import StackNavigator, {StackNavigationOptions} from "@react-navigation/stack"; + +export function createScreenCollapsibleStack( + name: string, + Stack: StackNavigator, + component: React.Node, + title: string, + useNativeDriver?: boolean, + options?: StackNavigationOptions) { + const {colors} = useTheme(); + const screenOptions = options != null ? options : {}; + return createCollapsibleStack( + , + { + collapsedColor: colors.surface, + useNativeDriver: useNativeDriver != null ? useNativeDriver : true, // native driver does not work with webview + } + ) +} + +export function getWebsiteStack(name: string, Stack: any, component: any, title: string) { + return createScreenCollapsibleStack(name, Stack, component, title, false); +} \ No newline at end of file diff --git a/src/utils/Notifications.js b/src/utils/Notifications.js new file mode 100644 index 0000000..56e19ea --- /dev/null +++ b/src/utils/Notifications.js @@ -0,0 +1,84 @@ +// @flow + +import {checkNotifications, requestNotifications, RESULTS} from 'react-native-permissions'; +import AsyncStorageManager from "../managers/AsyncStorageManager"; +import i18n from "i18n-js"; + +const PushNotification = require("react-native-push-notification"); + +const reminderIdMultiplicator = 100; + +/** + * Async function asking permission to send notifications to the user + * + * @returns {Promise} + */ +export async function askPermissions() { + return new Promise(((resolve, reject) => { + checkNotifications().then(({status}) => { + if (status === RESULTS.GRANTED) + resolve(); + else if (status === RESULTS.BLOCKED) + reject() + else { + requestNotifications().then(({status}) => { + if (status === RESULTS.GRANTED) + resolve(); + else + reject(); + }); + } + }); + })); +} + +function createNotifications(machineID: string, date: Date) { + let reminder = parseInt(AsyncStorageManager.getInstance().preferences.proxiwashNotifications.current); + if (!isNaN(reminder)) { + let id = reminderIdMultiplicator * parseInt(machineID); + let reminderDate = new Date(date); + reminderDate.setMinutes(reminderDate.getMinutes() - reminder); + PushNotification.localNotificationSchedule({ + title: i18n.t("proxiwashScreen.notifications.machineRunningTitle", {time: reminder}), + message: i18n.t("proxiwashScreen.notifications.machineRunningBody", {number: machineID}), + id: id.toString(), + date: reminderDate, + }); + console.log("Setting up notifications for ", date, " and reminder for ", reminderDate); + } else + console.log("Setting up notifications for ", date); + + PushNotification.localNotificationSchedule({ + title: i18n.t("proxiwashScreen.notifications.machineFinishedTitle"), + message: i18n.t("proxiwashScreen.notifications.machineFinishedBody", {number: machineID}), + id: machineID, + date: date, + }); +} + +/** + * Asks the server to enable/disable notifications for the specified machine + * + * @param machineID The machine ID + * @param isEnabled True to enable notifications, false to disable + * @param endDate + */ +export async function setupMachineNotification(machineID: string, isEnabled: boolean, endDate: Date | null) { + return new Promise((resolve, reject) => { + if (isEnabled && endDate != null) { + askPermissions() + .then(() => { + createNotifications(machineID, endDate); + resolve(); + }) + .catch(() => { + reject(); + }); + } else { + PushNotification.cancelLocalNotifications({id: machineID}); + let reminderId = reminderIdMultiplicator * parseInt(machineID); + PushNotification.cancelLocalNotifications({id: reminderId.toString()}); + resolve(); + } + }); +} \ No newline at end of file diff --git a/src/utils/Planning.js b/src/utils/Planning.js new file mode 100644 index 0000000..8a1b405 --- /dev/null +++ b/src/utils/Planning.js @@ -0,0 +1,255 @@ +// @flow + +export type eventObject = { + id: number, + title: string, + logo: string, + date_begin: string, + date_end: string, + description: string, + club: string, + category_id: number, + url: string, +}; + +// Regex used to check date string validity +const dateRegExp = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/; + +/** + * Gets the current day string representation in the format + * YYYY-MM-DD + * + * @return {string} The string representation + */ +export function getCurrentDateString(): string { + return dateToString(new Date(Date.now())); +} + +/** + * Checks if the given date is before the other. + * + * @param event1Date Event 1 date in format YYYY-MM-DD HH:MM + * @param event2Date Event 2 date in format YYYY-MM-DD HH:MM + * @return {boolean} + */ +export function isEventBefore(event1Date: string, event2Date: string): boolean { + let date1 = stringToDate(event1Date); + let date2 = stringToDate(event2Date); + if (date1 !== null && date2 !== null) + return date1 < date2; + else + return false; +} + +/** + * Gets only the date part of the given event date string in the format + * YYYY-MM-DD HH:MM + * + * @param dateString The string to get the date from + * @return {string|null} Date in format YYYY:MM:DD or null if given string is invalid + */ +export function getDateOnlyString(dateString: string): string | null { + if (isEventDateStringFormatValid(dateString)) + return dateString.split(" ")[0]; + else + return null; +} + +/** + * Gets only the time part of the given event date string in the format + * YYYY-MM-DD HH:MM + * + * @param dateString The string to get the date from + * @return {string|null} Time in format HH:MM or null if given string is invalid + */ +export function getTimeOnlyString(dateString: string): string | null { + if (isEventDateStringFormatValid(dateString)) + return dateString.split(" ")[1]; + else + return null; +} + +/** + * Checks if the given date string is in the format + * YYYY-MM-DD HH:MM + * + * @param dateString The string to check + * @return {boolean} + */ +export function isEventDateStringFormatValid(dateString: ?string): boolean { + return dateString !== undefined + && dateString !== null + && dateRegExp.test(dateString); +} + +/** + * Converts the given date string to a date object.
+ * Accepted format: YYYY-MM-DD HH:MM + * + * @param dateString The string to convert + * @return {Date|null} The date object or null if the given string is invalid + */ +export function stringToDate(dateString: string): Date | null { + let date = new Date(); + if (isEventDateStringFormatValid(dateString)) { + let stringArray = dateString.split(' '); + let dateArray = stringArray[0].split('-'); + let timeArray = stringArray[1].split(':'); + date.setFullYear( + parseInt(dateArray[0]), + parseInt(dateArray[1]) - 1, // Month range from 0 to 11 + parseInt(dateArray[2]) + ); + date.setHours( + parseInt(timeArray[0]), + parseInt(timeArray[1]), + 0, + 0, + ); + } else + date = null; + + return date; +} + +/** + * Converts a date object to a string in the format + * YYYY-MM-DD HH-MM-SS + * + * @param date The date object to convert + * @return {string} The converted string + */ +export function dateToString(date: Date, isUTC: boolean): string { + const day = String(date.getDate()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, '0'); //January is 0! + const year = date.getFullYear(); + const h = isUTC ? date.getUTCHours() : date.getHours(); + const hours = String(h).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return year + '-' + month + '-' + day + ' ' + hours + ':' + minutes; +} + +/** + * Returns a string corresponding to the event start and end times in the following format: + * + * HH:MM - HH:MM + * + * If the end date is not specified or is equal to start time, only start time will be shown. + * + * If the end date is not on the same day, 23:59 will be shown as end time + * + * @param start Start time in YYYY-MM-DD HH:MM:SS format + * @param end End time in YYYY-MM-DD HH:MM:SS format + * @return {string} Formatted string or "/ - /" on error + */ +export function getFormattedEventTime(start: string, end: string): string { + let formattedStr = '/ - /'; + let startDate = stringToDate(start); + let endDate = stringToDate(end); + + if (startDate !== null && endDate !== null && startDate.getTime() !== endDate.getTime()) { + formattedStr = String(startDate.getHours()).padStart(2, '0') + ':' + + String(startDate.getMinutes()).padStart(2, '0') + ' - '; + if (endDate.getFullYear() > startDate.getFullYear() + || endDate.getMonth() > startDate.getMonth() + || endDate.getDate() > startDate.getDate()) + formattedStr += '23:59'; + else + formattedStr += String(endDate.getHours()).padStart(2, '0') + ':' + + String(endDate.getMinutes()).padStart(2, '0'); + } else if (startDate !== null) + formattedStr = + String(startDate.getHours()).padStart(2, '0') + ':' + + String(startDate.getMinutes()).padStart(2, '0'); + + return formattedStr +} + +/** + * Checks if the given description can be considered empty. + *
+ * An empty description is composed only of whitespace, br or p tags + * + * + * @param description The text to check + * @return {boolean} + */ +export function isDescriptionEmpty(description: ?string): boolean { + if (description !== undefined && description !== null) { + return description + .split('

').join('') // Equivalent to a replace all + .split('

').join('') + .split('
').join('').trim() === ''; + } else + return true; +} + +/** + * Generates an object with an empty array for each key. + * Each key is a date string in the format + * YYYY-MM-DD + * + * @param numberOfMonths The number of months to create, starting from the current date + * @return {Object} + */ +export function generateEmptyCalendar(numberOfMonths: number): Object { + let end = new Date(Date.now()); + end.setMonth(end.getMonth() + numberOfMonths); + let daysOfYear = {}; + for (let d = new Date(Date.now()); d <= end; d.setDate(d.getDate() + 1)) { + const dateString = getDateOnlyString( + dateToString(new Date(d))); + if (dateString !== null) + daysOfYear[dateString] = [] + } + return daysOfYear; +} + +/** + * Generates an object with an array of eventObject at each key. + * Each key is a date string in the format + * YYYY-MM-DD. + * + * If no event is available at the given key, the array will be empty + * + * @param eventList The list of events to map to the agenda + * @param numberOfMonths The number of months to create the agenda for + * @return {Object} + */ +export function generateEventAgenda(eventList: Array, numberOfMonths: number): Object { + let agendaItems = generateEmptyCalendar(numberOfMonths); + for (let i = 0; i < eventList.length; i++) { + const dateString = getDateOnlyString(eventList[i].date_begin); + if (dateString !== null) { + const eventArray = agendaItems[dateString]; + if (eventArray !== undefined) + pushEventInOrder(eventArray, eventList[i]); + } + + } + return agendaItems; +} + +/** + * Adds events to the given array depending on their starting date. + * + * Events starting before are added at the front. + * + * @param eventArray The array to hold sorted events + * @param event The event to add to the array + */ +export function pushEventInOrder(eventArray: Array, event: eventObject): Object { + if (eventArray.length === 0) + eventArray.push(event); + else { + for (let i = 0; i < eventArray.length; i++) { + if (isEventBefore(event.date_begin, eventArray[i].date_begin)) { + eventArray.splice(i, 0, event); + break; + } else if (i === eventArray.length - 1) { + eventArray.push(event); + break; + } + } + } +} diff --git a/src/utils/Proxiwash.js b/src/utils/Proxiwash.js new file mode 100644 index 0000000..a891fd4 --- /dev/null +++ b/src/utils/Proxiwash.js @@ -0,0 +1,87 @@ +// @flow + +import type {Machine} from "../screens/Proxiwash/ProxiwashScreen"; + +/** + * Gets the machine end Date object. + * If the end time is at least 12 hours before the current time, + * it will be considered as happening the day after. + * If it is before but less than 12 hours, it will be considered invalid (to fix proxiwash delay) + * + * @param machine The machine to get the date from + * @returns {Date} The date object representing the end time. + */ +export function getMachineEndDate(machine: Machine): Date | null { + const array = machine.endTime.split(":"); + let endDate = new Date(Date.now()); + endDate.setHours(parseInt(array[0]), parseInt(array[1])); + + let limit = new Date(Date.now()); + if (endDate < limit) { + if (limit.getHours() > 12) { + limit.setHours(limit.getHours() - 12); + if (endDate < limit) + endDate.setDate(endDate.getDate() + 1); + else + endDate = null; + } else + endDate = null; + } + + return endDate; +} + +/** + * Checks whether the machine of the given ID has scheduled notifications + * + * @param machine The machine to check + * @param machineList The machine list + * @returns {boolean} + */ +export function isMachineWatched(machine: Machine, machineList: Array) { + let watched = false; + for (let i = 0; i < machineList.length; i++) { + if (machineList[i].number === machine.number && machineList[i].endTime === machine.endTime) { + watched = true; + break; + } + } + return watched; +} + +/** + * Gets the machine of the given id + * + * @param id The machine's ID + * @param allMachines The machine list + * @returns {null|Machine} The machine or null if not found + */ +export function getMachineOfId(id: string, allMachines: Array) { + for (let i = 0; i < allMachines.length; i++) { + if (allMachines[i].number === id) + return allMachines[i]; + } + return null; +} + +/** + * Gets a cleaned machine watched list by removing invalid entries. + * An entry is considered invalid if the end time in the watched list + * and in the full list does not match (a new machine cycle started) + * + * @param machineWatchedList The current machine watch list + * @param allMachines The current full machine list + * @returns {Array} + */ +export function getCleanedMachineWatched(machineWatchedList: Array, allMachines: Array) { + let newList = []; + for (let i = 0; i < machineWatchedList.length; i++) { + let machine = getMachineOfId(machineWatchedList[i].number, allMachines); + if (machine !== null + && machineWatchedList[i].number === machine.number + && machineWatchedList[i].endTime === machine.endTime) { + newList.push(machine); + } + } + return newList; +} \ No newline at end of file diff --git a/src/utils/Search.js b/src/utils/Search.js new file mode 100644 index 0000000..501cf58 --- /dev/null +++ b/src/utils/Search.js @@ -0,0 +1,28 @@ + + + +/** + * Sanitizes the given string to improve search performance + * + * @param str The string to sanitize + * @return {string} The sanitized string + */ +export function sanitizeString(str: string): string { + return str.toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/ /g, "") + .replace(/_/g, ""); +} + +export function stringMatchQuery(str: string, query: string) { + return sanitizeString(str).includes(sanitizeString(query)); +} + +export function isItemInCategoryFilter(filter: Array, categories: Array) { + for (const category of categories) { + if (filter.indexOf(category) !== -1) + return true; + } + return false; +} diff --git a/src/utils/URLHandler.js b/src/utils/URLHandler.js new file mode 100644 index 0000000..e9f0e89 --- /dev/null +++ b/src/utils/URLHandler.js @@ -0,0 +1,109 @@ +// @flow + +import {Linking} from 'react-native'; + +export default class URLHandler { + + static SCHEME = "campus-insat://"; + + static CLUB_INFO_URL_PATH = "club"; + static EVENT_INFO_URL_PATH = "event"; + + static CLUB_INFO_ROUTE = "club-information"; + static EVENT_INFO_ROUTE = "planning-information"; + + onInitialURLParsed: Function; + onDetectURL: Function; + + constructor(onInitialURLParsed: Function, onDetectURL: Function) { + this.onInitialURLParsed = onInitialURLParsed; + this.onDetectURL = onDetectURL; + } + + listen() { + Linking.addEventListener('url', this.onUrl); + Linking.getInitialURL().then(this.onInitialUrl); + } + + onUrl = ({url}: { url: string }) => { + if (url != null) { + let data = URLHandler.getUrlData(URLHandler.parseUrl(url)); + if (data !== null) + this.onDetectURL(data); + } + }; + + onInitialUrl = (url: ?string) => { + if (url != null) { + let data = URLHandler.getUrlData(URLHandler.parseUrl(url)); + if (data !== null) + this.onInitialURLParsed(data); + } + }; + + static parseUrl(url: string) { + let params = {}; + let path = ""; + let temp = url.replace(URLHandler.SCHEME, ""); + if (temp != null) { + let array = temp.split("?"); + if (array != null && array.length > 0) { + path = array[0]; + } + if (array != null && array.length > 1) { + let tempParams = array[1].split("&"); + for (let i = 0; i < tempParams.length; i++) { + let paramsArray = tempParams[i].split("="); + if (paramsArray.length > 1) { + params[paramsArray[0]] = paramsArray[1]; + } + } + } + } + return {path: path, queryParams: params}; + } + + static getUrlData({path, queryParams}: Object) { + let data = null; + if (path !== null) { + if (URLHandler.isClubInformationLink(path)) + data = URLHandler.generateClubInformationData(queryParams); + else if (URLHandler.isPlanningInformationLink(path)) + data = URLHandler.generatePlanningInformationData(queryParams); + } + return data; + } + + static isUrlValid(url: string) { + return this.getUrlData(URLHandler.parseUrl(url)) !== null; + } + + static isClubInformationLink(path: string) { + return path === URLHandler.CLUB_INFO_URL_PATH; + } + + static isPlanningInformationLink(path: string) { + return path === URLHandler.EVENT_INFO_URL_PATH; + } + + static generateClubInformationData(params: Object): Object | null { + if (params !== undefined && params.id !== undefined) { + let id = parseInt(params.id); + if (!isNaN(id)) { + return {route: URLHandler.CLUB_INFO_ROUTE, data: {clubId: id}}; + } + } + return null; + } + + static generatePlanningInformationData(params: Object): Object | null { + if (params !== undefined && params.id !== undefined) { + let id = parseInt(params.id); + if (!isNaN(id)) { + return {route: URLHandler.EVENT_INFO_ROUTE, data: {eventId: id}}; + } + } + return null; + } + +} diff --git a/src/utils/WebData.js b/src/utils/WebData.js new file mode 100644 index 0000000..3f354e3 --- /dev/null +++ b/src/utils/WebData.js @@ -0,0 +1,81 @@ +// @flow + +export const ERROR_TYPE = { + SUCCESS: 0, + BAD_CREDENTIALS: 1, + BAD_TOKEN: 2, + NO_CONSENT: 3, + BAD_INPUT: 400, + FORBIDDEN: 403, + CONNECTION_ERROR: 404, + SERVER_ERROR: 500, + UNKNOWN: 999, +}; + +type response_format = { + error: number, + data: Object, +} + +const API_ENDPOINT = "https://www.amicale-insat.fr/api/"; + +export async function apiRequest(path: string, method: string, params: ?Object) { + if (params === undefined || params === null) + params = {}; + + return new Promise((resolve, reject) => { + fetch(API_ENDPOINT + path, { + method: method, + headers: new Headers({ + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ + ...params + }) + }).then(async (response) => response.json()) + .then((response: response_format) => { + if (isResponseValid(response)) { + if (response.error === ERROR_TYPE.SUCCESS) + resolve(response.data); + else + reject(response.error); + } else + reject(ERROR_TYPE.CONNECTION_ERROR); + }) + .catch(() => { + reject(ERROR_TYPE.CONNECTION_ERROR); + }); + }); +} + +export function isResponseValid(response: response_format) { + let valid = response !== undefined + && response.error !== undefined + && typeof response.error === "number"; + + valid = valid + && response.data !== undefined + && typeof response.data === "object"; + return valid; +} + +/** + * Read data from FETCH_URL and return it. + * If no data was found, returns an empty object + * + * @param url The urls to fetch data from + * @return {Promise} + */ +export async function readData(url: string) { + return new Promise((resolve, reject) => { + fetch(url) + .then(async (response) => response.json()) + .then((data) => { + resolve(data); + }) + .catch(() => { + reject(); + }); + }); +} diff --git a/src/utils/withCollapsible.js b/src/utils/withCollapsible.js new file mode 100644 index 0000000..c773c2a --- /dev/null +++ b/src/utils/withCollapsible.js @@ -0,0 +1,32 @@ +import React from 'react'; +import {StatusBar} from 'react-native'; +import {useCollapsibleStack} from "react-navigation-collapsible"; + +export const withCollapsible = (Component: any) => { + return React.forwardRef((props: any, ref: any) => { + + const { + onScroll, + onScrollWithListener, + containerPaddingTop, + scrollIndicatorInsetTop, + translateY, + progress, + opacity, + } = useCollapsibleStack(); + const statusbarHeight = StatusBar.currentHeight != null ? StatusBar.currentHeight : 0; + return ; + }); +}; diff --git a/translations/en.json b/translations/en.json index 6bf72c7..27ac26a 100644 --- a/translations/en.json +++ b/translations/en.json @@ -1,10 +1,17 @@ { "screens": { "home": "Home", - "planning": "Planning", - "planningDisplayScreen": "Event Details", + "planning": "Events", + "planningDisplayScreen": "Event details", + "clubDisplayScreen": "Club details", + "feedDisplayScreen": "Details", + "clubsAbout": "Clubs", + "amicaleAbout": "Contact", + "amicaleWebsite": "Amicale's website", "proxiwash": "Proxiwash", + "services": "Services", "proximo": "Proximo", + "proximoArticles": "Articles", "menuSelf": "RU Menu", "settings": "Settings", "availableRooms": "Available rooms", @@ -12,12 +19,13 @@ "bluemind": "INSA Mails", "ent": "INSA ENT", "about": "About", - "debug": "Debug" - }, - "sidenav": { - "divider1": "Student websites", - "divider2": "Services", - "divider3": "Personalisation" + "debug": "Debug", + "login": "Login", + "logout": "Logout", + "profile": "Profile", + "vote": "Elections", + "scanner": "Scanotron 3000", + "feedback": "Feedback" }, "intro": { "slide1": { @@ -48,9 +56,21 @@ "title": "More to come...", "text": "New features are coming soon, do not hesitate to give us feedback to improve the app" }, - "updateSlide": { + "updateSlide0": { "title": "New in this update!", - "text": "The app got a new UI ! Faster, prettier and more modern, we hope you'll love it!" + "text": "Faster than ever and easier to use!\nThis update includes lots of changes to improve your experience.\nUse the brand new feedback button on the home screen to talk to the developer!" + }, + "updateSlide1": { + "title": "Improved Planex!", + "text": "You now have access to new controls, improved display, and you can also mark groups as favorites." + }, + "updateSlide2": { + "title": "Scanotron 3000!", + "text": "Say hello to Scanotron 3000!\nAvailable from the Qr-Code button on the home screen, it will help you get information about clubs and events around the campus.\n(Useless right now but we have hope for next year)" + }, + "updateSlide3": { + "title": "Amicale Account!", + "text": "You can now connect to your Amicale INSAT account from within the app! See all available clubs and more to come!\nClick on the login button from the home screen." }, "aprilFoolsSlide": { "title": "New in this update!", @@ -76,6 +96,7 @@ "homeScreen": { "listUpdated": "List updated!", "listUpdateFail": "Error while updating list", + "servicesButton": "More services", "newsFeed": "Campus News", "dashboard": { "seeMore": "Click to see more", @@ -83,44 +104,24 @@ "todayEventsSubtitleNA": "No events today", "todayEventsSubtitle": " event coming today", "todayEventsSubtitlePlural": " events coming today", - "proximoTitle": "Proximo", - "proximoSubtitleNA": "No articles available", - "proximoSubtitle": " article available", - "proximoSubtitlePlural": " articles available", - "tutorinsaSubtitleNA": "No tutorial available", - "tutorinsaSubtitle": " tutorial available", - "tutorinsaSubtitlePlural": " tutorials available", - "proxiwashTitle": "Available machines", - "proxiwashSubtitleNA": "No machines available", - "proxiwashSubtitle1": " dryer", - "proxiwashSubtitle1Plural": " dryers", - "proxiwashSubtitle2": " washer", - "proxiwashSubtitle2Plural": " washers", - "menuTitle": "Today's menu", - "menuSubtitleNA": "No menu available", - "menuSubtitle": "Click here to see the menu" + "amicaleTitle": "The Amicale", + "amicaleConnect": "Login", + "amicaleConnected": "See available services" } }, - "planningScreen": { - "wipTitle": "WORK IN PROGRESS", - "wipSubtitle": "Soon, every event at the INSA Toulouse in one place !" - }, "aboutScreen": { "appstore": "See on the Appstore", "playstore": "See on the Playstore", - "bugs": "Report Bugs", - "bugsDescription": "Reporting bugs helps us make the app better. Please be as precise as possible when describing your problem!", - "bugsMail": "Send a Mail", - "bugsGit": "Open an issue on Git", "changelog": "Changelog", "license": "License", "debug": "Debug", "team": "Team", - "author": "Author", + "author": "Author and maintainer", "authorMail": "Send an email", - "additionalDev": "Additional developer", + "additionalDev": "Development help", "technologies": "Technologies", "reactNative": "Made with React Native", + "expo": "Built with Expo", "libs": "Libraries used" }, "proximoScreen": { @@ -200,16 +201,149 @@ "planexScreen": { "enableStartScreen": "Come here often? Set it as default screen!", "enableStartOK": "Yes please!", - "enableStartCancel": "Later" + "enableStartCancel": "Later", + "noGroupSelected": "No group selected. Please select your group using the big beautiful red button bellow.", + "favorites": "Favorites" }, "availableRoomScreen": { "normalRoom": "Work", "computerRoom": "Computer", "bibRoom": "Bib'Box" }, + "profileScreen": { + "personalInformation": "Personal information", + "noData": "No data", + "editInformation": "Edit Information", + "clubs": "Your clubs", + "clubsSubtitle": "Click on a club to show its information", + "isMember": "Member", + "isManager": "Manager", + "membership": "Membership Fee", + "membershipSubtitle": "Allows you to take part in various activities", + "membershipPayed": "Payed", + "membershipNotPayed": "Not payed" + }, + "scannerScreen": { + "errorPermission": "Scanotron 3000 needs access to the camera in order to scan QR codes.\nThe camera will never be used for any other purpose.", + "buttonPermission": "Grant camera access", + "errorTitle": "QR code invalid", + "errorMessage": "The QR code scanned could not be recognised, please make sure it is valid.", + "helpButton": "What can I scan?", + "helpTitle": "How to use Scanotron 3000", + "helpMessage": "Find Campus QR codes posted by clubs and events, scan them and get instant access to detailed information!" + }, + "loginScreen": { + "title": "Amicale account", + "subtitle": "Please enter your credentials", + "email": "Email", + "emailError": "Please enter a valid email", + "password": "Password", + "passwordError": "Please enter a password", + "login": "Login", + "resetPassword": "Forgot Password", + "whyAccountTitle": "Why have an account?", + "whyAccountSub": "What can you do wth an account", + "whyAccountParagraph": "An Amicale account allows you to take part in several activities around campus. You can join a club, or even create your own!", + "whyAccountParagraph2": "Logging into your Amicale account on the app will allow you to see all available clubs on the campus, vote for the upcoming elections, and more to come!", + "noAccount": "No Account? Go to the Amicale's building during open hours to create one." + }, + "errors": { + "title": "Error!", + "badCredentials": "Email or password invalid.", + "badToken": "You are not logged in. Please login and try again.", + "noConsent": "You did not give your consent for data processing to the Amicale.", + "badInput": "Invalid input. Please try again.", + "forbidden": "You do not have access to this data.", + "connectionError": "Network error. Please check your internet connection.", + "serverError": "Server error. Please contact support.", + "unknown": "Unknown error. Please contact support." + }, + "clubs": { + "clubList": "Club list", + "managers": "Managers", + "managersSubtitle": "These people make the club live", + "managersUnavailable": "This club has no one :(", + "categories": "Categories", + "categoriesFilterMessage": "Click on a category to filter the list", + "clubContact": "Contact the club", + "amicaleContact": "Contact the Amicale", + "invalidClub": "Could not find the club. Please make sure the club you are trying to access is valid.", + "about": { + "text": "The clubs, making the campus live, with more than sixty clubs offering various activities! From the philosophy club to the PABI (Production Artisanale de Bière Insaienne), without forgetting the multiple music and dance clubs, you will surely find an activity that suits you!", + "title": "A question ?", + "subtitle": "Ask the Amicale", + "message": "You have a question concerning the clubs?\nYou want to revive or create a club?\nContact the Amicale at the following address:" + } + }, + "amicaleAbout": { + "title": "A question ?", + "subtitle": "Ask the Amicale", + "message": "You want to revive a club?\nYou want to start a new project?\nHere are al the contacts you need! Do not hesitate to write a mail or send a message to the Amicale's Facebook page!", + "roles": { + "interSchools": "Inter Schools", + "culture": "Culture", + "animation": "Animation", + "clubs": "Clubs", + "event": "Events", + "tech": "Technique", + "communication": "Communication", + "intraSchools": "Alumni / IAT", + "publicRelations": "Public Relations" + } + }, + "voteScreen": { + "select": { + "title": "Elections open", + "subtitle": "Vote now!", + "sendButton": "Send Vote", + "dialogTitle": "Send Vote?", + "dialogTitleLoading": "Sending vote...", + "dialogMessage": "Are you sure you want to send your vote? You will not be able to change it." + }, + "tease": { + "title": "Elections incoming", + "subtitle": "Be ready to vote!", + "message": "Vote start:" + }, + "wait": { + "titleSubmitted": "Vote submitted!", + "titleEnded": "Votes closed", + "subtitle": "Waiting for results...", + "messageSubmitted": "Vote submitted successfully.", + "messageVoted": "Thank you for your participation.", + "messageDate": "Results available:", + "messageDateUndefined": "Results will be available shortly" + }, + "results": { + "title": "Results", + "subtitle": "Available until:", + "totalVotes": "Total votes:", + "votes": "votes" + }, + "title": { + "title": "The Elections", + "subtitle": "Why your vote is important", + "paragraph1": "The Amicale's elections is the right moment for you to choose the next team, which will handle different projects on the campus, help organizing your favorite events, animate the campus life during the whole year, and relay your ideas to the administration, so that your campus life is the most enjoyable possible!\nYour turn to make a change!\uD83D\uDE09", + "paragraph2": "Note: If there is only one list, it is still important to vote to show your support, so that the administration knows the current list is supported by students. It is always a plus when taking difficult decisions! \uD83D\uDE09" + } + }, + "dialog": { + "ok": "OK", + "yes": "Yes", + "cancel": "Cancel", + "disconnect": { + "title": "Disconnect", + "titleLoading": "Disconnecting...", + "message": "Are you sure you want to disconnect from your Amicale account?" + } + }, "general": { "loading": "Loading...", - "networkError": "Unable to contact servers. Make sure you are connected to Internet." + "retry": "Retry", + "networkError": "Unable to contact servers. Make sure you are connected to Internet.", + "goBack": "Go Back", + "goForward": "Go Forward", + "openInBrowser": "Open in Browser" }, "date": { "daysOfWeek": { @@ -235,5 +369,45 @@ "november": "November", "december": "December" } + }, + "game": { + "title": "Game", + "pause": "Game Paused", + "pauseMessage": "The game is paused", + "resume": "Resume", + "restart": { + "text": "Restart", + "confirm": "Are you sure you want to restart?", + "confirmMessage": "You will lose you progress, continue?", + "confirmYes": "Yes", + "confirmNo": "No" + }, + "gameOver": { + "text": "Game Over", + "score": "Score: ", + "level": "Level: ", + "time": "Time: ", + "exit": "leave Game" + } + }, + "servicesScreen": { + "amicale": "The Amicale", + "students": "Student services", + "insa": "INSA services", + "notLoggedIn": "Not logged in" + }, + "planningScreen": { + "invalidEvent": "Could not find the event. Please make sure the event you are trying to access is valid." + }, + "feedbackScreen": { + "bugs": "Report Bugs", + "bugsSubtitle": "Did you find a bug? Let us know!", + "bugsDescription": "Reporting bugs helps us make the app better. To do so, use one of the buttons below and be as precise as possible when describing your problem!", + "feedback": "Feedback", + "feedbackSubtitle": "Let us know what you think!", + "feedbackDescription": "Do you have a feature you want to be added/changed/removed, want to give your opinion on the app or simply chat with the dev? Use one of the links below!", + "contactMeans": "Using Gitea is recommended, to use it simply login with your INSA account.", + "homeButtonTitle": "Feedback/Bug report", + "homeButtonSubtitle": "Contact the devs" } } diff --git a/translations/fr.json b/translations/fr.json index 1c5d442..3c098d0 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -1,10 +1,17 @@ { "screens": { "home": "Accueil", - "planning": "Planning", + "planning": "Événements", "planningDisplayScreen": "Détails", + "clubDisplayScreen": "Détails", + "feedDisplayScreen": "Détails", + "clubsAbout": "Les Clubs", + "amicaleAbout": "Contact", + "amicaleWebsite": "Site de l'Amicale", "proxiwash": "Proxiwash", + "services": "Services", "proximo": "Proximo", + "proximoArticles": "Articles", "menuSelf": "Menu du RU", "settings": "Paramètres", "availableRooms": "Salles dispo", @@ -12,12 +19,13 @@ "bluemind": "Mails INSA", "ent": "ENT INSA", "about": "À Propos", - "debug": "Debug" - }, - "sidenav": { - "divider1": "Sites étudiants", - "divider2": "Services", - "divider3": "Personnalisation" + "debug": "Debug", + "login": "Se Connecter", + "logout": "Se Déconnecter", + "profile": "Profil", + "vote": "Élections", + "scanner": "Scanotron 3000", + "feedback": "Votre avis" }, "intro": { "slide1": { @@ -48,9 +56,21 @@ "title": "Plus à venir...", "text": "D'autres fonctionnalités arrivent bientôt, n'hésitez pas à nous donner votre avis pour améliorer l'appli" }, - "updateSlide": { + "updateSlide0": { "title": "Nouveau dans cette mise à jour !", - "text": "L'appli fait peau neuve ! Avec une interface plus rapide, plus jolie et moderne, nous espérons que vous allez l'apprécier !" + "text": "Plus rapide que jamais et plus simple à utiliser !\nCette mise à jour contient de nombreux changements pour améliorer votre expérience.\nUtilisez le tout nouveau bouton de Feedback pour parler directement au développeur!" + }, + "updateSlide1": { + "title": "Planex tout beau !", + "text": "Vous avez maintenant accès à de nouveaux contrôles, un affichage amélioré, et vous pouvez marquer des groupes en favoris." + }, + "updateSlide2": { + "title": "Scanotron 3000 !", + "text": "Dites bonjour à Scanotron 3000 !\nDisponible depuis le bouton Qr-Code sur le menu principal, il vous aidera à avoir des informations sur les clubs et les événements du campus.\n(Inutile tout de suite mais on verra l'année pro)" + }, + "updateSlide3": { + "title": "Compte Amicale !", + "text": "Vous pouvez maintenant vous connecter à votre compte Amicale depuis l'appli ! Accédez à la liste des clubs et plus à venir !\nCliquez sur le bouton Se Connecter dans le menu principal." }, "aprilFoolsSlide": { "title": "Nouveau dans cette mise à jour !", @@ -76,6 +96,7 @@ "homeScreen": { "listUpdated": "List mise à jour!", "listUpdateFail": "Erreur lors de la mise à jour de la liste", + "servicesButton": "Plus de services", "newsFeed": "Nouvelles du campus", "dashboard": { "seeMore": "Cliquez pour plus d'infos", @@ -83,44 +104,24 @@ "todayEventsSubtitleNA": "Pas d'événement", "todayEventsSubtitle": " événement aujourd'hui", "todayEventsSubtitlePlural": " événements aujourd'hui", - "proximoTitle": "Proximo", - "proximoSubtitleNA": "pas d'article en vente", - "proximoSubtitle": " article disponible", - "proximoSubtitlePlural": " articles disponibles", - "tutorinsaSubtitleNA": "Aucun tutorat disponible", - "tutorinsaSubtitle": " tutorat disponible", - "tutorinsaSubtitlePlural": " tutorats disponibles", - "proxiwashTitle": "Machines disponibles", - "proxiwashSubtitleNA": "Pas de machine disponible", - "proxiwashSubtitle1": " sèche-linge", - "proxiwashSubtitle1Plural": " sèche-linges", - "proxiwashSubtitle2": " lave-linge", - "proxiwashSubtitle2Plural": " lave-linges", - "menuTitle": "Menu d'aujourd'hui", - "menuSubtitleNA": "Pas de menu disponible", - "menuSubtitle": "Cliquez ici pour voir le menu" + "amicaleTitle": "L'Amicale", + "amicaleConnect": "Se connecter", + "amicaleConnected": "Voir les services disponibles" } }, - "planningScreen": { - "wipTitle": "WORK IN PROGRESS", - "wipSubtitle": "Bientôt, tous les évènements de l'INSA Toulouse en un seul endroit !" - }, "aboutScreen": { "appstore": "Voir sur l'Appstore", "playstore": "Voir sur le Playstore", - "bugs": "Rapporter des Bugs", - "bugsDescription": "Rapporter les bugs nous aide à améliorer l'appli. Merci de décrire votre problème le plus précisément possible !", - "bugsMail": "Envoyer un Mail", - "bugsGit": "Ouvrir un ticket sur Git", "changelog": "Historique des modifications", "license": "Licence", "debug": "Debug", "team": "Équipe", - "author": "Auteur", + "author": "Auteur et mainteneur", "authorMail": "Envoyer un mail", - "additionalDev": "Développeur additionnel", + "additionalDev": "Aide au développement", "technologies": "Technologies", "reactNative": "Créé avec React Native", + "expo": "Compilé avec Expo", "libs": "Librairies utilisées" }, "proximoScreen": { @@ -155,7 +156,7 @@ "loading": "Chargement...", "description": "C'est le service de laverie proposé par promologis pour les résidences INSA (On t'en voudra pas si tu loges pas sur le campus et que tu fais ta machine ici). Le local situé au pied du R2 avec ses 3 sèche-linges et 9 machines est ouvert 7J/7 24h/24 ! Ici tu peux vérifier leur disponibilité ! Tu peux amener ta lessive, la prendre sur place ou encore mieux l'acheter au Proximo (moins chère qu'à la laverie directement). Tu peux payer par CB ou espèces.", "informationTab": "Informations", - "paymentTab" : "Paiement", + "paymentTab": "Paiement", "tariffs": "Tarifs", "washersTariff": "3€ la machine + 0.80€ avec la lessive.", "dryersTariff": "0.35€ pour 5min de sèche linge.", @@ -182,7 +183,6 @@ "error": "Il y a eu une erreur et il est impossible de récupérer les informations de cette machine. Veuillez nous excuser pour le gène occasionnée.", "notificationErrorTitle": "Erreur", "notificationErrorDescription": "Impossible de créer les notifications. Merci de vérifier que vous avez activé les notifications puis redémarrez l'appli." - }, "states": { "finished": "TERMINÉ", @@ -201,16 +201,149 @@ "planexScreen": { "enableStartScreen": "Vous venez souvent ici ? Démarrez l'appli sur cette page!", "enableStartOK": "Oui svp!", - "enableStartCancel": "Plus tard" + "enableStartCancel": "Plus tard", + "noGroupSelected": "Pas de group sélectionné. Merci de choisir un groupe avec le beau bouton rouge ci-dessous.", + "favorites": "Favoris" }, "availableRoomScreen": { "normalRoom": "Travail", "computerRoom": "Ordi", "bibRoom": "Bib'Box" }, + "profileScreen": { + "personalInformation": "Informations Personnelles", + "noData": "Pas de données", + "editInformation": "Modifier les informations", + "clubs": "Vos clubs", + "clubsSubtitle": "Cliquez sur un club pour afficher ses informations", + "isMember": "Membre", + "isManager": "Responsable", + "membership": "Cotisation", + "membershipSubtitle": "Permet de participer à diverses activités", + "membershipPayed": "Payée", + "membershipNotPayed": "Non payée" + }, + "scannerScreen": { + "errorPermission": "Scanotron 3000 a besoin d'accéder à la caméra pour scanner des QR codes.\nLa caméra ne sera jamais utilisée autrement.", + "buttonPermission": "Autoriser l'accès à la caméra", + "errorTitle": "QR code invalide", + "errorMessage": "Le QR code scannée n'a pas été reconnu. Merci de vérifier sa validité.", + "helpButton": "Quoi scanner ?", + "helpTitle": "Comment utiliser Scanotron 3000", + "helpMessage": "Trouvez des QR codes Campus affichés par des clubs ou des respo d'évenements, scannez les et accédez à des informations détaillées !" + }, + "loginScreen": { + "title": "Compte Amicale", + "subtitle": "Entrez vos identifiants", + "email": "Email", + "emailError": "Merci d'entrer un email valide", + "password": "Mot de passe", + "passwordError": "Merci d'entrer un mot de passe", + "login": "Connexion", + "resetPassword": "Mdp oublié", + "whyAccountTitle": "Pourquoi avoir un compte?", + "whyAccountSub": "Ce que vous pouvez faire avec un compte", + "whyAccountParagraph": "Un compte Amicale vous donne la possibilité de participer à diverses activités sur le campus. Vous pouvez rejoindre des clubs ou même créer le votre !", + "whyAccountParagraph2": "Vous connecter à votre compte Amicale sur l'appli vous permettra de voir tous les clubs en activité, de voter pour les prochaines élections, et plus à venir !", + "noAccount": "Pas de compte ? Passez à l'Amicale pendant une perm pour en créer un." + }, + "errors": { + "title": "Erreur !", + "badCredentials": "Email ou mot de passe invalide.", + "badToken": "Vous n'êtes pas connecté. Merci de vous connecter puis réessayez.", + "noConsent": "Vous n'avez pas donné votre consentement pour l'utilisation de vos données personnelles.", + "badInput": "Entrée invalide. Merci de réessayer.", + "forbidden": "Vous n'avez pas accès à cette information.", + "connectionError": "Erreur de réseau. Merci de vérifier votre connexion Internet.", + "serverError": "Erreur de serveur. Merci de contacter le support.", + "unknown": "Erreur inconnue. Merci de contacter le support." + }, + "clubs": { + "clubList": "Liste des clubs", + "managers": "Responsables", + "managersSubtitle": "Ces personnes font vivre le club", + "managersUnavailable": "Ce club est tout seul :(", + "categories": "Catégories", + "categoriesFilterMessage": "Cliquez sur une catégorie pour filtrer la liste", + "clubContact": "Contacter le club", + "amicaleContact": "Contacter l'Amicale", + "invalidClub": "Impossible de trouver le club. Merci de vérifier que le club que vous voulez voir est valide.", + "about": { + "text": "Les clubs, c'est ce qui fait vivre le campus au quotidien, plus d'une soixantaine de clubs qui proposent des activités diverses et variées ! Du club Philosophie au PABI (Production Artisanale de Bière Insaienne), en passant par les multiples clubs de musique et de danse, vous trouverez forcément une activité qui vous permettra de vous épanouir sur le campus !", + "title": "Une question ?", + "subtitle": "Posez vos questions à l'Amicale", + "message": "Vous avez question concernant les clubs ?\nVous voulez reprendre ou créer un club ?\nContactez les responsables au mail ci-dessous :" + } + }, + "amicaleAbout": { + "title": "Une Question ?", + "subtitle": "Posez vos questions à l'Amicale", + "message": "Vous voulez reprendre un club ?\nVous voulez vous lancer dans un projet ?\nVoici tous les contacts de l'amicale ! N'hésitez pas à nous écrire par mail ou sur la page facebook de l'Amicale !", + "roles": { + "interSchools": "Inter Écoles", + "culture": "Culture", + "animation": "Animation", + "clubs": "Clubs", + "event": "Événements", + "tech": "Technique", + "communication": "Communication", + "intraSchools": "Alumni / IAT", + "publicRelations": "Relations Publiques" + } + }, + "voteScreen": { + "select": { + "title": "Élections ouvertes", + "subtitle": "Votez maintenant !", + "sendButton": "Envoyer votre vote", + "dialogTitle": "Envoyer votre vote ?", + "dialogTitleLoading": "Envoi du vote...", + "dialogMessage": "Êtes vous sûr de vouloir envoyer votre vote ? Vous ne pourrez plus le changer." + }, + "tease": { + "title": "Les élections arrivent", + "subtitle": "Préparez vous à voter !", + "message": "Début des votes :" + }, + "wait": { + "titleSubmitted": "Vote envoyé !", + "titleEnded": "Votes fermés", + "subtitle": "Attente des résultats...", + "messageSubmitted": "Votre vote a bien été envoyé.", + "messageVoted": "Merci pour votre participation.", + "messageDate": "Disponibilité des résultats :", + "messageDateUndefined": "les résultats seront disponibles sous peu." + }, + "results": { + "title": "Résultats", + "subtitle": "Disponibles jusqu'à :", + "totalVotes": "Nombre total de votes :", + "votes": "votes" + }, + "title": { + "title": "Les Élections", + "subtitle": "Pourquoi votre vote est important", + "paragraph1": "Les élections de l'amicale, c'est le moment pour vous de choisir la prochaine équipe qui portera les différents projets du campus, qui soutiendra les organisations de vos événements favoris, qui vous proposera des animations tout au long de l'année, et qui poussera vos idées à l’administration pour que la vie de campus soit des plus riches !\nAlors à vous de jouer ! \uD83D\uDE09", + "paragraph2": "NB : Si par cas il n'y a qu'une liste qui se présente, il est important que tout le monde vote, afin qui la liste puisse montrer à l’administration que les INSAiens la soutiennent ! Ça compte toujours pour les décisions difficiles ! \uD83D\uDE09" + } + }, + "dialog": { + "ok": "OK", + "yes": "Oui", + "cancel": "Annuler", + "disconnect": { + "title": "Déconnexion", + "titleLoading": "Déconnexion...", + "message": "Voulez vous vraiment vous déconnecter de votre compte Amicale ??" + } + }, "general": { "loading": "Chargement...", - "networkError": "Impossible de contacter les serveurs. Assurez-vous d'être connecté à internet." + "retry": "Réessayer", + "networkError": "Impossible de contacter les serveurs. Assurez-vous d'être connecté à internet.", + "goBack": "Suivant", + "goForward": "Précédent", + "openInBrowser": "Ouvrir dans le navigateur" }, "date": { "daysOfWeek": { @@ -236,5 +369,45 @@ "november": "Novembre", "december": "Décembre" } + }, + "game": { + "title": "Jeu", + "pause": "Pause", + "pauseMessage": "Le jeu est en pause", + "resume": "Continuer", + "restart": { + "text": "Redémarrer", + "confirm": "Êtes vous sûr de vouloir redémarrer ?", + "confirmMessage": "Tout votre progrès sera perdu, continuer ?", + "confirmYes": "Oui", + "confirmNo": "Non" + }, + "gameOver": { + "text": "Game Over", + "score": "Score: ", + "level": "Niveau: ", + "time": "Temps: ", + "exit": "Quitter" + } + }, + "servicesScreen": { + "amicale": "L'Amicale", + "students": "Services étudiants", + "insa": "Services de l'INSA", + "notLoggedIn": "Non connecté" + }, + "planningScreen": { + "invalidEvent": "Impossible de trouver l'événement. Merci de vérifier que l'événement que vous voulez voir est valide." + }, + "feedbackScreen": { + "bugs": "Rapporter des Bugs", + "bugsSubtitle": "Vous avez trouvé un bug ?", + "bugsDescription": "Rapporter les bugs nous aide à améliorer l'appli. Pour cela, merci d'utiliser un des boutons ci-dessous et de décrire votre problème le plus précisément possible !", + "feedback": "Feedback", + "feedbackSubtitle": "Did nous ce que vous pensez!", + "feedbackDescription": "Vous voulez voir une fonctionnalité ajoutée/modifiée/supprimée ?, vous voulez donner votre opinion sur l'appli ou simplement discuter avec les devs ? Utilisez un des liens ci-dessous !", + "contactMeans": "L'utilisation de Gitea est recommandée, pour l'utiliser, connectez vous avec vos identifiants INSA.", + "homeButtonTitle": "Feedback/Bugs", + "homeButtonSubtitle": "Contacter les devs" } } diff --git a/utils/NotificationsManager.js b/utils/NotificationsManager.js deleted file mode 100644 index 368fa96..0000000 --- a/utils/NotificationsManager.js +++ /dev/null @@ -1,188 +0,0 @@ -// @flow - -import * as Permissions from 'expo-permissions'; -import {Notifications} from 'expo'; -import AsyncStorageManager from "./AsyncStorageManager"; -import LocaleManager from "./LocaleManager"; -import passwords from "../passwords"; - -const EXPO_TOKEN_SERVER = 'https://etud.insa-toulouse.fr/~amicale_app/expo_notifications/save_token.php'; - -/** - * Static class used to manage notifications sent to the user - */ -export default class NotificationsManager { - - /** - * Async function asking permission to send notifications to the user - * - * @returns {Promise} - */ - static async askPermissions() { - const {status: existingStatus} = await Permissions.getAsync(Permissions.NOTIFICATIONS); - let finalStatus = existingStatus; - if (existingStatus !== 'granted') { - const {status} = await Permissions.askAsync(Permissions.NOTIFICATIONS); - finalStatus = status; - } - return finalStatus === 'granted'; - } - - /** - * Async function sending a notification without delay to the user - * - * @param title {String} Notification title - * @param body {String} Notification body text - * @returns {Promise} Notification Id - */ - static async sendNotificationImmediately(title: string, body: string) { - await NotificationsManager.askPermissions(); - return await Notifications.presentLocalNotificationAsync({ - title: title, - body: body, - }); - }; - - /** - * Async function sending notification at the specified time - * - * @param title Notification title - * @param body Notification body text - * @param time Time at which we should send the notification - * @param data Data to send with the notification, used for listeners - * @param androidChannelID - * @returns {Promise} Notification Id - */ - static async scheduleNotification(title: string, body: string, time: number, data: Object, androidChannelID: string): Promise { - await NotificationsManager.askPermissions(); - let date = new Date(); - date.setTime(time); - return Notifications.scheduleLocalNotificationAsync( - { - title: title, - body: body, - data: data, - ios: { // configuration for iOS. - sound: true - }, - android: { // configuration for Android. - channelId: androidChannelID, - } - }, - { - time: time, - }, - ); - }; - - /** - * Async function used to cancel the notification of a specific ID - * @param notificationID {Number} The notification ID - * @returns {Promise} - */ - static async cancelScheduledNotification(notificationID: number) { - await Notifications.cancelScheduledNotificationAsync(notificationID); - } - - /** - * Save expo token to allow sending notifications to this device. - * This token is unique for each device and won't change. - * It only needs to be fetched once, then it will be saved in storage. - * - * @return {Promise} - */ - static async initExpoToken() { - let token = AsyncStorageManager.getInstance().preferences.expoToken.current; - if (token === '') { - try { - await NotificationsManager.askPermissions(); - let expoToken = await Notifications.getExpoPushTokenAsync(); - // Save token for instant use later on - AsyncStorageManager.getInstance().savePref(AsyncStorageManager.getInstance().preferences.expoToken.key, expoToken); - } catch(e) { - console.log(e); - } - } - } - - static async forceExpoTokenUpdate() { - await NotificationsManager.askPermissions(); - let expoToken = await Notifications.getExpoPushTokenAsync(); - // Save token for instant use later on - AsyncStorageManager.getInstance().savePref(AsyncStorageManager.getInstance().preferences.expoToken.key, expoToken); - } - - static getMachineNotificationWatchlist(callback: Function) { - let token = AsyncStorageManager.getInstance().preferences.expoToken.current; - if (token !== '') { - let data = { - function: 'get_machine_watchlist', - password: passwords.expoNotifications, - token: token, - }; - fetch(EXPO_TOKEN_SERVER, { - method: 'POST', - headers: new Headers({ - Accept: 'application/json', - 'Content-Type': 'application/json', - }), - body: JSON.stringify(data) // <-- Post parameters - }).then((response) => response.json()) - .then((responseJson) => { - callback(responseJson); - }); - } - } - - /** - * Ask the server to enable/disable notifications for the specified machine - * - * @param machineID - * @param isEnabled - */ - static setupMachineNotification(machineID: string, isEnabled: boolean) { - let token = AsyncStorageManager.getInstance().preferences.expoToken.current; - if (token !== '') { - let data = { - function: 'setup_machine_notification', - password: passwords.expoNotifications, - locale: LocaleManager.getCurrentLocale(), - token: token, - machine_id: machineID, - enabled: isEnabled - }; - fetch(EXPO_TOKEN_SERVER, { - method: 'POST', - headers: new Headers({ - Accept: 'application/json', - 'Content-Type': 'application/json', - }), - body: JSON.stringify(data) // <-- Post parameters - }); - } - } - - /** - * Send the selected reminder time for notifications to the server - * @param time - */ - static setMachineReminderNotificationTime(time: number) { - let token = AsyncStorageManager.getInstance().preferences.expoToken.current; - if (token !== '') { - let data = { - function: 'set_machine_reminder', - password: passwords.expoNotifications, - token: token, - time: time, - }; - fetch(EXPO_TOKEN_SERVER, { - method: 'POST', - headers: new Headers({ - Accept: 'application/json', - 'Content-Type': 'application/json', - }), - body: JSON.stringify(data) // <-- Post parameters - }); - } - } -} diff --git a/utils/PlanningEventManager.js b/utils/PlanningEventManager.js deleted file mode 100644 index b9cb140..0000000 --- a/utils/PlanningEventManager.js +++ /dev/null @@ -1,83 +0,0 @@ - -export default class PlanningEventManager { - static isEventBefore(event1: Object, event2: Object) { - let date1 = new Date(); - let date2 = new Date(); - let timeArray = PlanningEventManager.getEventStartTime(event1).split(":"); - date1.setHours(parseInt(timeArray[0]), parseInt(timeArray[1])); - timeArray = PlanningEventManager.getEventStartTime(event2).split(":"); - date2.setHours(parseInt(timeArray[0]), parseInt(timeArray[1])); - return date1 < date2; - } - - static getEventStartDate(event: Object) { - return event.date_begin.split(" ")[0]; - } - - static getEventStartTime(event: Object) { - if (event !== undefined && Object.keys(event).length > 0 && event.date_begin !== null) - return PlanningEventManager.formatTime(event.date_begin.split(" ")[1]); - else - return ""; - } - - static getEventEndTime(event: Object) { - if (event !== undefined && Object.keys(event).length > 0 && event.date_end !== null) - return PlanningEventManager.formatTime(event.date_end.split(" ")[1]); - else - return ""; - } - - static getFormattedTime(event: Object) { - if (PlanningEventManager.getEventEndTime(event) !== "") - return PlanningEventManager.getEventStartTime(event) + " - " + PlanningEventManager.getEventEndTime(event); - else - return PlanningEventManager.getEventStartTime(event); - } - - static formatTime(time: string) { - let array = time.split(':'); - return array[0] + ':' + array[1]; - } - - /** - * Convert the date string given by in the event list json to a date object - * @param dateString - * @return {Date} - */ - static stringToDate(dateString: ?string): ?Date { - let date = new Date(); - if (dateString === undefined || dateString === null) - date = undefined; - else if (dateString.split(' ').length > 1) { - let timeStr = dateString.split(' ')[1]; - date.setHours(parseInt(timeStr.split(':')[0]), parseInt(timeStr.split(':')[1]), 0); - } else - date = undefined; - return date; - } - - static padStr(i: number) { - return (i < 10) ? "0" + i : "" + i; - } - - static getFormattedEventTime(event: Object): string { - let formattedStr = ''; - let startDate = PlanningEventManager.stringToDate(event['date_begin']); - let endDate = PlanningEventManager.stringToDate(event['date_end']); - if (startDate !== undefined && startDate !== null && endDate !== undefined && endDate !== null) - formattedStr = PlanningEventManager.padStr(startDate.getHours()) + ':' + PlanningEventManager.padStr(startDate.getMinutes()) + - ' - ' + PlanningEventManager.padStr(endDate.getHours()) + ':' + PlanningEventManager.padStr(endDate.getMinutes()); - else if (startDate !== undefined && startDate !== null) - formattedStr = PlanningEventManager.padStr(startDate.getHours()) + ':' + PlanningEventManager.padStr(startDate.getMinutes()); - return formattedStr - } - - static isDescriptionEmpty (description: string) { - return description - .replace('

', '') - .replace('

', '') - .replace('
', '').trim() === ''; - } - -} diff --git a/utils/WebDataManager.js b/utils/WebDataManager.js deleted file mode 100644 index 2ee84cc..0000000 --- a/utils/WebDataManager.js +++ /dev/null @@ -1,33 +0,0 @@ - -/** - * Class used to get json data from the web - */ -export default class WebDataManager { - - FETCH_URL: string; - lastDataFetched: Object = {}; - - - constructor(url) { - this.FETCH_URL = url; - } - - /** - * Read data from FETCH_URL and return it. - * If no data was found, returns an empty object - * - * @return {Promise} - */ - async readData() { - let fetchedData: Object = {}; - try { - let response = await fetch(this.FETCH_URL); - fetchedData = await response.json(); - } catch (error) { - throw new Error('Could not read FetchedData from server'); - } - this.lastDataFetched = fetchedData; - return fetchedData; - } - -}