├── mybuddy-api
├── firebase.json
├── .firebaserc
├── README.md
├── functions
│ ├── mybuddy.exe
│ ├── gulpfile.js
│ ├── package.json
│ ├── .gitignore
│ ├── strategies.js
│ ├── config.js
│ ├── notifications.js
│ ├── index.js
│ └── firestore.js
└── .gitignore
├── mybuddy
├── .watchmanconfig
├── screens
│ ├── UsersScreen
│ │ ├── index.js
│ │ ├── UserTab.js
│ │ └── UserScreen.js
│ ├── index.js
│ ├── AppointmentsScreen
│ │ ├── index.js
│ │ ├── AppointmentsTabNavigator
│ │ │ ├── index.js
│ │ │ ├── UpcomingAppointmentsScreen.js
│ │ │ └── CalendarScreen.js
│ │ └── AppointmentFormScreen.js
│ ├── TeamsScreen
│ │ ├── index.js
│ │ ├── Forms
│ │ │ ├── AddUserToTeam.js
│ │ │ ├── AddTeam.js
│ │ │ └── EditTeam.js
│ │ ├── TeamsScreen.js
│ │ └── TeamMembersScreen.js
│ ├── BuddyAppointmentsScreen
│ │ ├── index.js
│ │ ├── BuddyRequestsScreen.js
│ │ ├── UpcomingAppointmentsScreen.js
│ │ └── CalendarScreen.js
│ ├── EmergenciesScreen.js
│ ├── UnauthorisedUserScreen.js
│ ├── SettingsScreen.js
│ ├── LoginScreen.js
│ └── HomeScreen.js
├── components
│ ├── Navigation
│ │ ├── index.js
│ │ ├── createDrawerNavigatorWithHeader.js
│ │ └── withHeader.js
│ ├── __tests__
│ │ ├── LoadingPage-test.js
│ │ ├── MyTitle-test.js
│ │ ├── __snapshots__
│ │ │ ├── LoadingPage-test.js.snap
│ │ │ ├── MyTitle-test.js.snap
│ │ │ ├── SearchBar-test.js.snap
│ │ │ └── FormInput-test.js.snap
│ │ ├── FormInput-test.js
│ │ └── SearchBar-test.js
│ ├── LoadingPage.js
│ ├── Card
│ │ └── index.js
│ ├── MyTitle.js
│ ├── CardItem
│ │ ├── index.js
│ │ ├── BuddyAppointmentCardItem.js
│ │ ├── EmergencyCardItem.js
│ │ ├── AppointmentInfo.js
│ │ └── AppointmentCardItem.js
│ ├── Form
│ │ ├── FormInput.js
│ │ ├── ManagerPicker.js
│ │ ├── DateTimePicker.js
│ │ └── BuddyPicker.js
│ ├── SearchBar.js
│ ├── DrawerMenu.js
│ ├── CardList
│ │ └── index.js
│ ├── Dropdown.js
│ └── SwipeToConfirm.js
├── assets
│ ├── images
│ │ ├── icon.png
│ │ ├── logo.png
│ │ ├── button.png
│ │ ├── splash.png
│ │ ├── robot-dev.png
│ │ └── robot-prod.png
│ └── fonts
│ │ ├── Roboto-Medium.ttf
│ │ └── SpaceMono-Regular.ttf
├── api
│ ├── index.js
│ ├── emergencies.js
│ ├── auth.js
│ ├── users.js
│ ├── teams.js
│ └── appointments.js
├── .gitignore
├── babel.config.js
├── navigation
│ ├── index.js
│ ├── NavigationService.js
│ ├── keys.js
│ ├── utils.js
│ └── Navigator.js
├── constants
│ ├── Urls.js
│ ├── Keys.js
│ ├── Colors.js
│ └── Layout.js
├── __tests__
│ ├── __snapshots__
│ │ └── App-test.js.snap
│ └── App-test.js
├── firebase.js
├── app.json
├── App.js
├── package.json
└── utils
│ ├── AsyncStorage.js
│ └── ErrorBoundary.js
├── .gitignore
└── README.md
/mybuddy-api/firebase.json:
--------------------------------------------------------------------------------
1 | {}
--------------------------------------------------------------------------------
/mybuddy/.watchmanconfig:
--------------------------------------------------------------------------------
1 | {}
2 |
--------------------------------------------------------------------------------
/mybuddy/screens/UsersScreen/index.js:
--------------------------------------------------------------------------------
1 | export * from './UserScreen'
--------------------------------------------------------------------------------
/mybuddy/components/Navigation/index.js:
--------------------------------------------------------------------------------
1 | export * from './createDrawerNavigatorWithHeader'
2 |
--------------------------------------------------------------------------------
/mybuddy-api/.firebaserc:
--------------------------------------------------------------------------------
1 | {
2 | "projects": {
3 | "default": "mybuddy-47e82"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/mybuddy/assets/images/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hr/mybuddy/master/mybuddy/assets/images/icon.png
--------------------------------------------------------------------------------
/mybuddy/assets/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hr/mybuddy/master/mybuddy/assets/images/logo.png
--------------------------------------------------------------------------------
/mybuddy/api/index.js:
--------------------------------------------------------------------------------
1 | export * from './users'
2 | export * from './appointments'
3 | export * from './teams'
4 |
--------------------------------------------------------------------------------
/mybuddy/assets/images/button.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hr/mybuddy/master/mybuddy/assets/images/button.png
--------------------------------------------------------------------------------
/mybuddy/assets/images/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hr/mybuddy/master/mybuddy/assets/images/splash.png
--------------------------------------------------------------------------------
/mybuddy/assets/images/robot-dev.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hr/mybuddy/master/mybuddy/assets/images/robot-dev.png
--------------------------------------------------------------------------------
/mybuddy/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/**/*
2 | .expo/*
3 | npm-debug.*
4 | *.jks
5 | *.p12
6 | *.key
7 | *.mobileprovision
8 |
--------------------------------------------------------------------------------
/mybuddy/assets/images/robot-prod.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hr/mybuddy/master/mybuddy/assets/images/robot-prod.png
--------------------------------------------------------------------------------
/mybuddy/assets/fonts/Roboto-Medium.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hr/mybuddy/master/mybuddy/assets/fonts/Roboto-Medium.ttf
--------------------------------------------------------------------------------
/mybuddy/assets/fonts/SpaceMono-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hr/mybuddy/master/mybuddy/assets/fonts/SpaceMono-Regular.ttf
--------------------------------------------------------------------------------
/mybuddy/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = function(api) {
2 | api.cache(true);
3 | return {
4 | presets: ['babel-preset-expo'],
5 | };
6 | };
7 |
--------------------------------------------------------------------------------
/mybuddy-api/README.md:
--------------------------------------------------------------------------------
1 | # myBuddy API
2 |
3 | ## Run
4 |
5 | ```
6 | $ npm start
7 | ```
8 |
9 | You will have a server successfully running on `http://localhost:3000`
--------------------------------------------------------------------------------
/mybuddy/navigation/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Exports the navigation details and constants.
3 | */
4 |
5 | export * from './Navigator'
6 | export * from './keys'
7 | export * from './NavigationService'
8 | export * from './utils'
9 |
--------------------------------------------------------------------------------
/mybuddy-api/functions/mybuddy.exe:
--------------------------------------------------------------------------------
1 | _ __
2 | ( / ) / /
3 | _ _ _ , /--< , , __/ __/ __ ,
4 | / / / (_/_/___/(_/_(_/_(_/_/ (_/_
5 | / /
6 | ' '
7 |
--------------------------------------------------------------------------------
/mybuddy/constants/Urls.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Constants class for any urls required throughout the application.
3 | */
4 |
5 | // Cloud functions url for firebase API.
6 | export const BASE_URL = 'https://europe-west1-mybuddy-47e82.cloudfunctions.net/api'
7 |
--------------------------------------------------------------------------------
/mybuddy/constants/Keys.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Constants class for user access level.
3 | * The three access levels: user, manager & admin.
4 | */
5 |
6 | export const USER = 'user'
7 | export const TEAM_MANAGER = 'teamManager'
8 | export const ADMIN = 'admin'
9 |
--------------------------------------------------------------------------------
/mybuddy/__tests__/__snapshots__/App-test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`App snapshot renders the loading screen 1`] = `null`;
4 |
5 | exports[`Skips loading screen renders the root without loading screen 1`] = `null`;
6 |
--------------------------------------------------------------------------------
/mybuddy/screens/index.js:
--------------------------------------------------------------------------------
1 | export * from './AppointmentsScreen'
2 | export * from './HomeScreen'
3 | export * from './LoginScreen'
4 | export * from './SettingsScreen'
5 | export * from './UnauthorisedUserScreen'
6 | export * from './UsersScreen'
7 | export * from './TeamsScreen'
8 | export * from './BuddyAppointmentsScreen'
9 | export * from './EmergenciesScreen'
10 |
--------------------------------------------------------------------------------
/mybuddy/components/__tests__/LoadingPage-test.js:
--------------------------------------------------------------------------------
1 | import 'react-native';
2 | import React from 'react';
3 | import LoadingPage from '../LoadingPage';
4 | import renderer from 'react-test-renderer';
5 |
6 | describe('Loading page rendering', () => {
7 | it('loading page renders correctly', () => {
8 | expect(renderer.create().toJSON()).toMatchSnapshot()
9 | })
10 | });
--------------------------------------------------------------------------------
/mybuddy/constants/Colors.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Enumerated constant colour values.
3 | */
4 |
5 | const DEFAULT_COLORS = {
6 | primaryColor: '#005EB8',
7 | secondaryColor: '#EA952F',
8 | tabIconDefault: '#ffffff',
9 | errorText: '#FF0000',
10 | warningBackground: '#EAEB5E',
11 | warningText: '#666804',
12 | dangerColor: '#BF3B00',
13 | personalAppointmentsMarkColor: '#42a7f4'
14 | }
15 |
16 | export default DEFAULT_COLORS
17 |
--------------------------------------------------------------------------------
/mybuddy/firebase.js:
--------------------------------------------------------------------------------
1 | import firebase from 'firebase'
2 | import 'firebase/firestore'
3 |
4 | const config = {
5 | apiKey: '[ADD IT HERE]',
6 | authDomain: '[ADD IT HERE]',
7 | databaseURL: '[ADD IT HERE]',
8 | projectId: '[ADD IT HERE]',
9 | storageBucket: '[ADD IT HERE]',
10 | messagingSenderId: '[ADD IT HERE]'
11 | }
12 |
13 | firebase.initializeApp(config)
14 | export const db = firebase.firestore()
15 | export default firebase
16 |
--------------------------------------------------------------------------------
/mybuddy/navigation/NavigationService.js:
--------------------------------------------------------------------------------
1 | // https://reactnavigation.org/docs/en/navigating-without-navigation-prop.html
2 | import { NavigationActions } from 'react-navigation'
3 |
4 | let _navigator
5 |
6 | export const setTopLevelNavigator = navigatorRef => {
7 | _navigator = navigatorRef
8 | }
9 |
10 | export const navigate = (routeName, params = {}) => {
11 | _navigator.dispatch(
12 | NavigationActions.navigate({
13 | routeName,
14 | params
15 | })
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/mybuddy/components/__tests__/MyTitle-test.js:
--------------------------------------------------------------------------------
1 | import 'react-native'
2 | import React from 'react'
3 | import MyTitle from '../MyTitle'
4 | import renderer from 'react-test-renderer'
5 | import { Text } from 'native-base'
6 |
7 | describe('My title rendering', () => {
8 | it('My title bar renders correctly', () => {
9 | expect(
10 | renderer
11 | .create(
12 |
13 | test
14 |
15 | )
16 | .toJSON()
17 | ).toMatchSnapshot()
18 | })
19 | })
20 |
--------------------------------------------------------------------------------
/mybuddy/screens/AppointmentsScreen/index.js:
--------------------------------------------------------------------------------
1 | import { createStackNavigator } from 'react-navigation'
2 |
3 | import AppointmentsTabNavigator from './AppointmentsTabNavigator'
4 | import AppointmentFormScreen from './AppointmentFormScreen'
5 |
6 | export const AppointmentsScreen = createStackNavigator(
7 | {
8 | Base: AppointmentsTabNavigator,
9 | AppointmentForm: AppointmentFormScreen
10 | },
11 | {
12 | headerMode: 'none',
13 | mode: 'modal',
14 | navigationOptions: {
15 | gesturesEnabled: false
16 | }
17 | }
18 | )
19 |
--------------------------------------------------------------------------------
/mybuddy/constants/Layout.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Holds constants for the layout of the window such as dimension and width.
3 | * Exports them as constants to refer to.
4 | */
5 |
6 | import { Dimensions } from 'react-native'
7 |
8 | // The width of the window.
9 | const width = Dimensions.get('window').width
10 |
11 | // The height of the window.
12 | const height = Dimensions.get('window').height
13 |
14 | export default {
15 | window: {
16 | width,
17 | height,
18 | },
19 | isSmallDevice: width < 375, // Smaller screen devices such as iPhone 5.
20 | }
21 |
--------------------------------------------------------------------------------
/mybuddy/components/Navigation/createDrawerNavigatorWithHeader.js:
--------------------------------------------------------------------------------
1 | import { createDrawerNavigator } from 'react-navigation'
2 | import withHeader from './withHeader'
3 |
4 | const _withHeaders = RouteConfigs =>
5 | Object.entries(RouteConfigs).reduce(
6 | (obj, [routeName, screen]) => ({
7 | ...obj,
8 | [routeName]: withHeader(screen, routeName)
9 | }),
10 | {}
11 | )
12 |
13 | export const createDrawerNavigatorWithHeader = (
14 | RouteConfigs,
15 | DrawerNavigatorConfig = {}
16 | ) => createDrawerNavigator(_withHeaders(RouteConfigs), DrawerNavigatorConfig)
17 |
--------------------------------------------------------------------------------
/mybuddy/components/LoadingPage.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { StyleSheet } from 'react-native'
3 | import { Container, Spinner } from 'native-base'
4 |
5 | import DEFAULT_COLORS from '../constants/Colors'
6 |
7 | export default class LoadingPage extends Component {
8 | render() {
9 | return (
10 |
11 |
12 |
13 | )
14 | }
15 | }
16 |
17 | const styles = StyleSheet.create({
18 | container: {
19 | justifyContent: 'center'
20 | }
21 | })
22 |
--------------------------------------------------------------------------------
/mybuddy/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "mybuddy",
4 | "slug": "mybuddy",
5 | "privacy": "public",
6 | "sdkVersion": "32.0.0",
7 | "platforms": ["ios", "android"],
8 | "version": "1.0.0",
9 | "orientation": "portrait",
10 | "icon": "./assets/images/icon.png",
11 | "splash": {
12 | "image": "./assets/images/splash.png",
13 | "resizeMode": "contain"
14 | },
15 | "updates": {
16 | "fallbackToCacheTimeout": 0
17 | },
18 | "assetBundlePatterns": ["**/*"],
19 | "ios": {
20 | "supportsTablet": true
21 | },
22 | "scheme": "mybuddy"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/mybuddy/__tests__/App-test.js:
--------------------------------------------------------------------------------
1 | import 'react-native';
2 | import React from 'react';
3 | import App from '../App';
4 | import renderer from 'react-test-renderer';
5 |
6 | describe('App snapshot', () => {
7 | it('renders the loading screen', async () => {
8 | const appSnapshot = renderer.create().toJSON();
9 | expect(appSnapshot).toMatchSnapshot();
10 | });
11 | });
12 |
13 | describe('Skips loading screen', () => {
14 | it('renders the root without loading screen', async () => {
15 | const appSkipLoadingScreen = renderer.create().toJSON();
16 | expect(appSkipLoadingScreen).toMatchSnapshot();
17 | });
18 | })
19 |
--------------------------------------------------------------------------------
/mybuddy/components/__tests__/__snapshots__/LoadingPage-test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Loading page rendering loading page renders correctly 1`] = `
4 |
18 |
29 |
30 | `;
31 |
--------------------------------------------------------------------------------
/mybuddy/navigation/keys.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Holds constant values for the navigation bar screens and titles.
3 | */
4 |
5 | export const LOGIN_SCREEN = 'login',
6 | UNAUTHORISED_USER_SCREEN = 'unauthorised',
7 | ADMIN_PORTAL = 'admin',
8 | TEAM_MANAGER_PORTAL = 'teamManager',
9 | CLINICIAN_PORTAL = 'clinician',
10 | ADMIN_OR_USER_PORTAL = 'adminOrUser',
11 | HOME_SCREEN = 'Home',
12 | APPOINTMENTS_SCREEN = 'Appointments',
13 | BUDDY_APPOINTMENTS_SCREEN = 'Buddy Appointments',
14 | SETTINGS_SCREEN = 'Settings',
15 | TEAM_SCREEN = 'Team',
16 | TEAMS_SCREEN = 'Teams',
17 | USERS_SCREEN = 'Users',
18 | TEAM_MEMBERS_SCREEN = 'TeamMembers',
19 | EMERGENCIES_SCREEN = 'Emergencies'
20 |
--------------------------------------------------------------------------------
/mybuddy/screens/UsersScreen/UserTab.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { FlatList } from 'react-native'
3 | import { Content } from 'native-base'
4 | import { ListItem } from 'react-native-elements'
5 | import _ from 'lodash'
6 |
7 | export class UserTab extends Component {
8 | renderItem = ({ item }) => (
9 |
14 | )
15 |
16 | render() {
17 | return (
18 |
19 | user.key}
22 | renderItem={this.renderItem}
23 | />
24 |
25 | )
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/mybuddy-api/functions/gulpfile.js:
--------------------------------------------------------------------------------
1 | const gulp = require('gulp'),
2 | nodemon = require('gulp-nodemon'),
3 | config = require('./config')
4 |
5 | const ENV = config.IN_DEV ? 'development' : 'production'
6 |
7 | gulp.task('nodemon', function (done) {
8 | nodemon({
9 | script: 'index.js',
10 | levels: 10, // trace, show everything
11 | ignore: ['*.log'],
12 | nodeArgs: ['--inspect'],
13 | ext: 'js json',
14 | events: {
15 | start: `cat ./mybuddy.exe && echo 'Started myBuddy API (${ENV})'`,
16 | restart: "osascript -e 'display notification \"App restarted due to:\n'$FILENAME'\" with title \"nodemon\"'"
17 | },
18 | done: done
19 | })
20 | })
21 |
22 | gulp.task('default', gulp.parallel('nodemon'))
--------------------------------------------------------------------------------
/mybuddy/components/Card/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { StyleSheet } from 'react-native'
3 | import { Card } from 'native-base'
4 | import PropTypes from 'prop-types'
5 |
6 | import CardItem from '../CardItem'
7 |
8 | const styles = StyleSheet.create({
9 | card: { alignSelf: 'center', width: '95%' }
10 | })
11 |
12 | export default class CustomCard extends Component {
13 | render() {
14 | if (this.props.children) {
15 | return {this.props.children}
16 | }
17 |
18 | return (
19 |
20 |
21 |
22 | )
23 | }
24 | }
25 |
26 | CustomCard.propTypes = {
27 | children: PropTypes.node
28 | }
29 |
--------------------------------------------------------------------------------
/mybuddy/screens/TeamsScreen/index.js:
--------------------------------------------------------------------------------
1 | import { createStackNavigator } from 'react-navigation'
2 |
3 | import TeamScreen from './TeamsScreen'
4 | import TeamMembersScreen from './TeamMembersScreen'
5 |
6 | import AddTeam from './Forms/AddTeam'
7 | import AddUserToTeam from './Forms/AddUserToTeam'
8 | import EditTeam from './Forms/EditTeam'
9 |
10 | const Screens = createStackNavigator(
11 | {
12 | Base: TeamScreen,
13 | TeamMembers: TeamMembersScreen
14 | },
15 | { headerMode: 'none' }
16 | )
17 |
18 | export const TeamsScreen = createStackNavigator(
19 | {
20 | Base: Screens,
21 | AddTeamForm: AddTeam,
22 | AddUserToTeam: AddUserToTeam,
23 | EditTeam: EditTeam
24 | },
25 | {
26 | headerMode: 'none',
27 | mode: 'modal',
28 | navigationOptions: {
29 | gesturesEnabled: false
30 | }
31 | }
32 | )
33 |
--------------------------------------------------------------------------------
/mybuddy/components/MyTitle.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { Title } from 'native-base'
3 | import PropTypes from 'prop-types'
4 |
5 | export default class MyTitle extends Component {
6 | render () {
7 | const { style, ...props } = this.props
8 | return (
9 |
21 | {this.props.children}
22 |
23 | )
24 | }
25 | }
26 |
27 | MyTitle.propTypes = {
28 | children: PropTypes.oneOfType([
29 | PropTypes.string,
30 | PropTypes.node,
31 | PropTypes.arrayOf(PropTypes.string, PropTypes.node)
32 | ]).isRequired,
33 | style: PropTypes.object
34 | }
35 |
--------------------------------------------------------------------------------
/mybuddy/screens/BuddyAppointmentsScreen/index.js:
--------------------------------------------------------------------------------
1 | import { createMaterialTopTabNavigator } from 'react-navigation'
2 |
3 | import UpcomingAppointmentsScreen from './UpcomingAppointmentsScreen'
4 | import CalendarScreen from './CalendarScreen'
5 | import BuddyRequestsScreen from './BuddyRequestsScreen'
6 |
7 | import DEFAULT_COLORS from '../../constants/Colors'
8 |
9 | const tabBarOptions = {
10 | scrollEnabled: false,
11 | indicatorStyle: {
12 | backgroundColor: DEFAULT_COLORS.primaryColor
13 | },
14 | labelStyle: { fontWeight: 'bold' },
15 | style: {
16 | backgroundColor: DEFAULT_COLORS.secondaryColor
17 | }
18 | }
19 |
20 | export const BuddyAppointmentsScreen = createMaterialTopTabNavigator(
21 | {
22 | Requests: BuddyRequestsScreen,
23 | Upcoming: UpcomingAppointmentsScreen,
24 | Calendar: CalendarScreen
25 | },
26 | { tabBarOptions }
27 | )
28 |
--------------------------------------------------------------------------------
/mybuddy/api/emergencies.js:
--------------------------------------------------------------------------------
1 | import { db } from '../firebase'
2 | import { getCurrentUserAsync } from './users'
3 |
4 | /**
5 | * Fetch all emergencies in the current users team,
6 | * excluding any issued by the current user themselves.
7 | a* @param onSnapshot function to run on update
8 | * @returns Promise(unsubscribe)
9 | */
10 | export const allEmergenciesOnSnapshot = async onSnapshot => {
11 | const user = await getCurrentUserAsync()
12 | return db
13 | .collection('emergencies')
14 | .where('team', '==', user.team)
15 | .where('status', '==', 'alert')
16 | .onSnapshot(async querySnapshot => {
17 | const emergencies = querySnapshot.docs
18 | .filter(doc => doc.data().user.id !== user.uid)
19 | .map(doc => {
20 | return {
21 | ...doc.data(),
22 | emergencyRef: doc.ref
23 | }
24 | })
25 | onSnapshot(emergencies)
26 | })
27 | }
28 |
--------------------------------------------------------------------------------
/mybuddy/components/__tests__/__snapshots__/MyTitle-test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`My title rendering My title bar renders correctly 1`] = `
4 |
28 |
38 | test
39 |
40 |
41 | `;
42 |
--------------------------------------------------------------------------------
/mybuddy/navigation/utils.js:
--------------------------------------------------------------------------------
1 | /**
2 | * A utility class used to return the initial display after login for a user.
3 | */
4 |
5 | import {
6 | LOGIN_SCREEN,
7 | ADMIN_PORTAL,
8 | UNAUTHORISED_USER_SCREEN,
9 | TEAM_MANAGER_PORTAL,
10 | CLINICIAN_PORTAL
11 | } from './keys'
12 | import { ADMIN, TEAM_MANAGER, USER } from '../constants/Keys'
13 |
14 | /**
15 | * Returns the initial portal/screen to display just after login.
16 | * Based on the user access level, different screens are available to different access levels.
17 | * An invalid user will be returned back to the login screen.
18 | * @param user - the user.
19 | */
20 | export const getInitialRouteName = user => {
21 | if (!user) return LOGIN_SCREEN
22 | switch (user.accessLevel) {
23 | case ADMIN:
24 | return ADMIN_PORTAL
25 | case TEAM_MANAGER:
26 | return TEAM_MANAGER_PORTAL
27 | case USER:
28 | return CLINICIAN_PORTAL
29 | default:
30 | return UNAUTHORISED_USER_SCREEN
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/mybuddy/components/CardItem/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { CardItem, Text } from 'native-base'
3 | import PropTypes from 'prop-types'
4 |
5 | import AppointmentCardItem from './AppointmentCardItem'
6 | import BuddyAppointmentCardItem from './BuddyAppointmentCardItem'
7 | import EmergencyCardItem from './EmergencyCardItem'
8 |
9 | export default class CustomCardItem extends Component {
10 | render() {
11 | const { children, type, ...props } = this.props
12 |
13 | if (children) {
14 | return {children}
15 | }
16 |
17 | switch (type) {
18 | case 'appointment':
19 | return
20 | case 'buddy-appointment':
21 | return
22 | case 'emergency':
23 | return
24 | default:
25 | return error
26 | }
27 | }
28 | }
29 |
30 | CustomCardItem.propTypes = {
31 | children: PropTypes.node
32 | }
33 |
--------------------------------------------------------------------------------
/mybuddy-api/functions/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mybuddy-api",
3 | "version": "0.0.1",
4 | "private": true,
5 | "engines": {
6 | "node": "8"
7 | },
8 | "scripts": {
9 | "start": "./node_modules/.bin/gulp",
10 | "firestart": "npm run shell",
11 | "serve": "firebase serve --only functions",
12 | "shell": "firebase functions:shell",
13 | "deploy": "firebase deploy --only functions",
14 | "logs": "firebase functions:log"
15 | },
16 | "dependencies": {
17 | "body-parser": "^1.15.2",
18 | "cookie-parser": "^1.4.3",
19 | "expo-server-sdk": "^3.2.0",
20 | "express": "^4.16.4",
21 | "express-async-handler": "^1.1.4",
22 | "express-auth-parser": "^0.1.2",
23 | "firebase-admin": "^7.0.0",
24 | "firebase-functions": "^2.2.0",
25 | "lodash": "^4.17.11",
26 | "method-override": "^2.3.6",
27 | "moment": "^2.24.0",
28 | "morgan": "^1.9.1",
29 | "passport": "^0.4.0",
30 | "passport-azure-ad": "^4.0.0",
31 | "passport-http-bearer": "^1.0.1"
32 | },
33 | "devDependencies": {
34 | "gulp": "^4.0.0",
35 | "gulp-nodemon": "^2.4.2",
36 | "nodemon": "^1.18.10"
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/mybuddy/App.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { AppLoading, Font } from 'expo'
3 |
4 | import { createNavigator } from './navigation/Navigator'
5 | import ErrorBoundary from './utils/ErrorBoundary'
6 | import { getCurrentUserAsync } from './api'
7 |
8 | export default class App extends Component {
9 | state = {
10 | isLoading: true,
11 | Navigator: null
12 | }
13 |
14 | async componentDidMount() {
15 | const [_, user] = await Promise.all([
16 | this._loadResourcesAsync(),
17 | getCurrentUserAsync()
18 | ])
19 | const Navigator = createNavigator(user)
20 | this.setState({ isLoading: false, Navigator })
21 | }
22 |
23 | render() {
24 | if (this.state.isLoading) return
25 |
26 | const { Navigator } = this.state
27 | return (
28 |
29 |
30 |
31 | )
32 | }
33 |
34 | _loadResourcesAsync = async () => {
35 | Promise.all([
36 | Font.loadAsync({
37 | Roboto: require('native-base/Fonts/Roboto.ttf'),
38 | Roboto_medium: require('native-base/Fonts/Roboto_medium.ttf')
39 | })
40 | ])
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/mybuddy/screens/EmergenciesScreen.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { Container, Content } from 'native-base'
3 |
4 | import Card from '../components/Card'
5 | import MyTitle from '../components/MyTitle'
6 | import { allEmergenciesOnSnapshot } from '../api/emergencies'
7 |
8 | export class EmergenciesScreen extends Component {
9 | state = {
10 | emergencies: []
11 | }
12 |
13 | async componentDidMount() {
14 | this.unsubscribe = await allEmergenciesOnSnapshot(emergencies =>
15 | this.setState({ emergencies })
16 | )
17 | }
18 |
19 | componentWillUnmount() {
20 | this.unsubscribe()
21 | }
22 |
23 | render() {
24 | // If there is at least one emergency
25 | if (Boolean(this.state.emergencies.length)) {
26 | return (
27 |
28 |
29 | {this.state.emergencies.map((emergency, i) => (
30 |
31 | ))}
32 |
33 |
34 | )
35 | }
36 |
37 | return (
38 | No active emergencies
39 | )
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/mybuddy/components/__tests__/FormInput-test.js:
--------------------------------------------------------------------------------
1 | import 'react-native'
2 | import React from 'react'
3 | import FormInput from '../Form/FormInput'
4 | import renderer from 'react-test-renderer'
5 |
6 | const props = (value, touched, error) => ({
7 | field: {
8 | name: 'test'
9 | },
10 | form: {
11 | values: {
12 | test: value
13 | },
14 | touched: {
15 | test: touched
16 | },
17 | errors: {
18 | test: error
19 | }
20 | }
21 | });
22 |
23 | describe('DateTimePicker snapshot', () => {
24 | jest.useFakeTimers()
25 | Date.now = jest.fn(() => 1503187200000)
26 | it('form input renders without errors', async () => {
27 | const tree = renderer
28 | .create()
29 | .toJSON()
30 | expect(tree).toMatchSnapshot()
31 | })
32 | })
33 |
34 | describe('Form input renders with errors', () => {
35 | jest.useFakeTimers()
36 | Date.now = jest.fn(() => 1503187200000)
37 | it('form input renders with errors', async () => {
38 | const tree = renderer
39 | .create()
40 | .toJSON()
41 | expect(tree).toMatchSnapshot()
42 | })
43 | })
44 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Created by https://www.gitignore.io/api/macos,windows
2 | # Edit at https://www.gitignore.io/?templates=macos,windows
3 |
4 | ### macOS ###
5 | # General
6 | .DS_Store
7 | .AppleDouble
8 | .LSOverride
9 |
10 | # Icon must end with two \r
11 | Icon
12 |
13 | # Thumbnails
14 | ._*
15 |
16 | # Node Modules
17 | node_modules
18 |
19 | # Files that might appear in the root of a volume
20 | .DocumentRevisions-V100
21 | .fseventsd
22 | .Spotlight-V100
23 | .TemporaryItems
24 | .Trashes
25 | .VolumeIcon.icns
26 | .com.apple.timemachine.donotpresent
27 |
28 | # Directories potentially created on remote AFP share
29 | .AppleDB
30 | .AppleDesktop
31 | Network Trash Folder
32 | Temporary Items
33 | .apdisk
34 |
35 | # VSCode
36 | .vscode
37 |
38 | ### Windows ###
39 | # Windows thumbnail cache files
40 | Thumbs.db
41 | ehthumbs.db
42 | ehthumbs_vista.db
43 |
44 | # Dump file
45 | *.stackdump
46 |
47 | # Folder config file
48 | [Dd]esktop.ini
49 |
50 | # Recycle Bin used on file shares
51 | $RECYCLE.BIN/
52 |
53 | # Windows Installer files
54 | *.cab
55 | *.msi
56 | *.msix
57 | *.msm
58 | *.msp
59 |
60 | # Windows shortcuts
61 | *.lnk
62 |
63 | # End of https://www.gitignore.io/api/macos,windows
64 |
65 | /vids
66 | **/node_modules
67 | package-lock.json
68 | .expo
69 |
--------------------------------------------------------------------------------
/mybuddy/components/__tests__/SearchBar-test.js:
--------------------------------------------------------------------------------
1 | import 'react-native';
2 | import React from 'react';
3 | import SearchBar from '../SearchBar';
4 | import renderer from 'react-test-renderer';
5 |
6 | /**
7 | * Returns a search bar component.
8 | * If getInstance is true, will return an instance of the component else a JSON of it.
9 | * @param getInstance - If we want an instance of the search bar or not.
10 | */
11 | let getSearchBar = function(getInstance) {
12 | if (getInstance)
13 | return renderer.create().getInstance()
14 | else
15 | return renderer.create().toJSON()
16 | }
17 |
18 | //The search bar as JSON
19 | var searchBarAsJSON = getSearchBar(false)
20 |
21 | //The search bar instance
22 | var searchBarInstance = getSearchBar(true)
23 |
24 | describe('Search bar rendering', () => {
25 | it('search bar renders correctly', () => {
26 | expect(searchBarAsJSON).toMatchSnapshot()
27 | })
28 | });
29 |
30 | describe('Search bar text field', () => {
31 | it('search bar starts with no search text', () => {
32 | expect(searchBarInstance.state.searchText).toEqual('')
33 | })
34 | it('Set search text to a value', () => {
35 | searchBarInstance.setState({searchText: 'Man utd'})
36 | expect(searchBarInstance.state.searchText).toEqual('Man utd')
37 | })
38 | })
--------------------------------------------------------------------------------
/mybuddy/screens/BuddyAppointmentsScreen/BuddyRequestsScreen.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { Container, Content } from 'native-base'
3 |
4 | import Card from '../../components/Card'
5 | import { buddyRequestsOnSnapshot } from '../../api'
6 | import MyTitle from '../../components/MyTitle'
7 | import LoadingPage from '../../components/LoadingPage'
8 |
9 | export default class BuddyRequestsScreen extends Component {
10 | state = {
11 | appointments: {},
12 | isLoading: true
13 | }
14 |
15 | async componentDidMount() {
16 | this.unsubscribe = await buddyRequestsOnSnapshot(appointments =>
17 | this.setState({
18 | appointments,
19 | isLoading: false
20 | })
21 | )
22 | }
23 |
24 | componentWillUnmount() {
25 | this.unsubscribe()
26 | }
27 |
28 | render() {
29 | if (this.state.isLoading) {
30 | return
31 | }
32 |
33 | const appointments = this.state.appointments.map((appointment, i) => (
34 |
35 | ))
36 |
37 | if (Boolean(appointments.length)) {
38 | return (
39 |
40 | {appointments}
41 |
42 | )
43 | }
44 |
45 | return (
46 | No outstanding requests
47 | )
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/mybuddy-api/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | firebase-debug.log*
8 |
9 | # Firebase cache
10 | .firebase/
11 |
12 | # Firebase config
13 |
14 | # Uncomment this if you'd like others to create their own Firebase project.
15 | # For a team working on the same Firebase project(s), it is recommended to leave
16 | # it commented so all members can deploy to the same project(s) in .firebaserc.
17 | # .firebaserc
18 |
19 | # Runtime data
20 | pids
21 | *.pid
22 | *.seed
23 | *.pid.lock
24 |
25 | # Directory for instrumented libs generated by jscoverage/JSCover
26 | lib-cov
27 |
28 | # Coverage directory used by tools like istanbul
29 | coverage
30 |
31 | # nyc test coverage
32 | .nyc_output
33 |
34 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
35 | .grunt
36 |
37 | # Bower dependency directory (https://bower.io/)
38 | bower_components
39 |
40 | # node-waf configuration
41 | .lock-wscript
42 |
43 | # Compiled binary addons (http://nodejs.org/api/addons.html)
44 | build/Release
45 |
46 | # Dependency directories
47 | node_modules/
48 |
49 | # Optional npm cache directory
50 | .npm
51 |
52 | # Optional eslint cache
53 | .eslintcache
54 |
55 | # Optional REPL history
56 | .node_repl_history
57 |
58 | # Output of 'npm pack'
59 | *.tgz
60 |
61 | # Yarn Integrity file
62 | .yarn-integrity
63 |
64 | # dotenv environment variables file
65 | .env
66 |
--------------------------------------------------------------------------------
/mybuddy-api/functions/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | firebase-debug.log*
8 |
9 | # Firebase cache
10 | .firebase/
11 |
12 | # Firebase config
13 |
14 | # Uncomment this if you'd like others to create their own Firebase project.
15 | # For a team working on the same Firebase project(s), it is recommended to leave
16 | # it commented so all members can deploy to the same project(s) in .firebaserc.
17 | # .firebaserc
18 |
19 | # Runtime data
20 | pids
21 | *.pid
22 | *.seed
23 | *.pid.lock
24 |
25 | # Directory for instrumented libs generated by jscoverage/JSCover
26 | lib-cov
27 |
28 | # Coverage directory used by tools like istanbul
29 | coverage
30 |
31 | # nyc test coverage
32 | .nyc_output
33 |
34 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
35 | .grunt
36 |
37 | # Bower dependency directory (https://bower.io/)
38 | bower_components
39 |
40 | # node-waf configuration
41 | .lock-wscript
42 |
43 | # Compiled binary addons (http://nodejs.org/api/addons.html)
44 | build/Release
45 |
46 | # Dependency directories
47 | node_modules/
48 |
49 | # Optional npm cache directory
50 | .npm
51 |
52 | # Optional eslint cache
53 | .eslintcache
54 |
55 | # Optional REPL history
56 | .node_repl_history
57 |
58 | # Output of 'npm pack'
59 | *.tgz
60 |
61 | # Yarn Integrity file
62 | .yarn-integrity
63 |
64 | # dotenv environment variables file
65 | .env
66 |
--------------------------------------------------------------------------------
/mybuddy/api/auth.js:
--------------------------------------------------------------------------------
1 | import { AuthSession } from 'expo'
2 |
3 | import firebase, { db } from '../firebase'
4 | import { navigate } from '../navigation/NavigationService'
5 | import { LOGIN_SCREEN } from '../navigation/keys'
6 | import { BASE_URL } from '../constants/Urls'
7 |
8 | /**
9 | * Log user in using expo's AuthSession,
10 | * then store the user object in AsyncStorage.
11 | * If successful return the user object, else
12 | * return null.
13 | * @return Promise(user) | Promise(null)
14 | */
15 | export const login = async () => {
16 | const results = await AuthSession.startAsync({
17 | authUrl: `${BASE_URL}/auth/login?redirectUrl=${AuthSession.getRedirectUrl()}`
18 | })
19 | if (results.type === 'success') {
20 | const { result, customToken } = results.params
21 | if (result == 0) {
22 | await firebase.auth().signInWithCustomToken(customToken)
23 | const userRef = db
24 | .collection('users')
25 | .doc(firebase.auth().currentUser.uid)
26 | const user = await userRef.get().then(doc => ({ ...doc.data(), userRef }))
27 | return user
28 | } else {
29 | navigate(LOGIN_SCREEN, {
30 | error: { message: 'Authentication failed please contact an admin.' }
31 | })
32 | }
33 | }
34 | }
35 |
36 | /**
37 | * Remove user from local storage, sign out
38 | * from firebase, and invalidate id_token.
39 | */
40 | export const logout = async () => {
41 | await firebase.auth().signOut()
42 | }
43 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # myBuddy
2 | The myBuddy app is a fully functional cross-platform (Android & iOS) mobile application specially designed for the **Maudsley NHS Foundation Trust** Helping Families Team which are a specialist mental health service for families in complex circumstances. It's a buddy system for the clinicians that allows them to keep track of their appointments, ensure their attendance and ensure the safety of the clinicians when they see their patients by having them check in/out at scheduled times, and notify members of the team if they haven't already. The entire app is centered around the objective to provide a seamless experience for the Helping Families Team Members at the NHS.
3 |
4 | Structure
5 | - `mybuddy` - the app (React Native / Expo)
6 | - `mybuddy-api` - the server (ExpressJS / Firebase Cloud Functions)
7 |
8 |
9 | Learn more at https://habibrehman.com/work/mybuddy
10 |
11 | ## Development
12 |
13 | [](https://github.com/standard/standard)
14 |
15 | ### Setup
16 |
17 | Clone the repo
18 |
19 | ```bash
20 | git clone https://github.com/HR/mybuddy.git && cd mybuddy
21 | ```
22 |
23 | ### Run
24 |
25 | ```bash
26 | cd mybuddy && npm install && npm start
27 | ```
28 |
29 |
30 | ## Contributors
31 | - Habib Rehman
32 | - Chow-Ching (Chris) Jim
33 | - Oluwafemi (Femi) Oladipo
34 | - Spenser Smart
35 | - Akshat Sood
36 | - Salich (Sal) Memet Efenti Chousein
37 | - Eugene Fong
38 |
--------------------------------------------------------------------------------
/mybuddy/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "main": "node_modules/expo/AppEntry.js",
3 | "scripts": {
4 | "start": "./node_modules/.bin/expo start",
5 | "clean-start": "npm start -- -c",
6 | "android": "expo start --android",
7 | "ios": "expo start --ios",
8 | "eject": "expo eject",
9 | "test": "node ./node_modules/jest/bin/jest.js --watchAll"
10 | },
11 | "jest": {
12 | "preset": "jest-expo"
13 | },
14 | "dependencies": {
15 | "@expo/samples": "2.1.1",
16 | "expo": "^32.0.0",
17 | "firebase": "^5.8.6",
18 | "formik": "^1.5.1",
19 | "fuzzy-search": "^3.0.1",
20 | "inflector-js": "^1.0.1",
21 | "jwt-decode": "^2.2.0",
22 | "lodash": "^4.17.11",
23 | "moment": "^2.24.0",
24 | "native-base": "^2.12.0",
25 | "react": "16.5.0",
26 | "react-native": "https://github.com/expo/react-native/archive/sdk-32.0.0.tar.gz",
27 | "react-native-action-button": "^2.8.5",
28 | "react-native-calendars": "^1.22.0",
29 | "react-native-elements": "^1.1.0",
30 | "react-native-modal": "^9.0.0",
31 | "react-native-modal-datetime-picker": "^6.0.0",
32 | "react-native-picker-select": "^6.1.0",
33 | "react-native-search-box": "0.0.19",
34 | "react-native-snap-carousel": "^3.7.5",
35 | "react-native-swipe-verify": "^0.1.5",
36 | "react-navigation": "^3.0.9",
37 | "xdate": "^0.8.2",
38 | "yup": "^0.27.0"
39 | },
40 | "devDependencies": {
41 | "babel-preset-expo": "^5.0.0",
42 | "husky": "^1.3.1",
43 | "jest": "^24.5.0",
44 | "jest-expo": "^32.0.0"
45 | },
46 | "private": true
47 | }
48 |
--------------------------------------------------------------------------------
/mybuddy/api/users.js:
--------------------------------------------------------------------------------
1 | import firebase, { db } from '../firebase'
2 |
3 | /**
4 | * Returns user object corresponding to the
5 | * userUid passed in, else null
6 | * @param userUid
7 | * @returns user | null
8 | */
9 | export const _getUserAsync = async userUid => {
10 | const userRef = db.collection('users').doc(userUid)
11 | return userRef.get().then(doc => {
12 | return { ...doc.data(), userRef: doc.ref }
13 | })
14 | }
15 |
16 | /**
17 | * Returns user object corresponding to current
18 | * logged in user.
19 | * @returns user
20 | */
21 | export const getCurrentUserAsync = async () => {
22 | const [unsubscribe, user] = await new Promise(resolve => {
23 | const unsubscribe = firebase.auth().onAuthStateChanged(async authUser => {
24 | let user = null
25 | if (authUser) user = await _getUserAsync(authUser.uid)
26 | resolve([unsubscribe, user])
27 | })
28 | })
29 | unsubscribe()
30 | return user
31 | }
32 |
33 | /**
34 | * Fetch user details and watch for changes
35 | * @param onSnapshot function to run update
36 | * @returns Promise(unsubscribe)
37 | */
38 | export const currentUserOnSnapshot = async onSnapshot => {
39 | const user = await getCurrentUserAsync()
40 | return user.userRef.onSnapshot(userDoc => {
41 | onSnapshot(userDoc.data())
42 | })
43 | }
44 |
45 | /**
46 | * Fetch all users without a team
47 | * @param onSnapshot function to run update
48 | * @returns Promise(unsubscribe)
49 | */
50 | export const usersWithoutTeamOnSnapshot = async onSnapshot => {
51 | return db
52 | .collection('users')
53 | .where('team', '==', null)
54 | .onSnapshot(querySnapshot => {
55 | const teamlessUsers = querySnapshot.docs.map(userDoc => {
56 | return userDoc.data()
57 | })
58 | onSnapshot(teamlessUsers)
59 | })
60 | }
61 |
--------------------------------------------------------------------------------
/mybuddy/screens/AppointmentsScreen/AppointmentsTabNavigator/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { StyleSheet } from 'react-native'
3 | import { Icon } from 'native-base'
4 | import { createMaterialTopTabNavigator } from 'react-navigation'
5 | import ActionButton from 'react-native-action-button'
6 |
7 | import CalendarScreen from './CalendarScreen'
8 | import UpcomingAppointmentsScreen from './UpcomingAppointmentsScreen'
9 | import DEFAULT_COLORS from '../../../constants/Colors'
10 |
11 | const tabBarOptions = {
12 | scrollEnabled: false,
13 | indicatorStyle: {
14 | backgroundColor: DEFAULT_COLORS.primaryColor
15 | },
16 | labelStyle: { fontWeight: 'bold' },
17 | style: {
18 | backgroundColor: DEFAULT_COLORS.secondaryColor
19 | }
20 | }
21 |
22 | const Screens = createMaterialTopTabNavigator(
23 | {
24 | Upcoming: UpcomingAppointmentsScreen,
25 | Calendar: CalendarScreen
26 | },
27 | { tabBarOptions }
28 | )
29 |
30 | export default class extends Component {
31 | static router = Screens.router
32 |
33 | _newAppointment = () => {
34 | this.props.navigation.navigate('AppointmentForm')
35 | }
36 |
37 | render() {
38 | return (
39 | <>
40 |
41 | (
45 |
50 | )}
51 | />
52 | >
53 | )
54 | }
55 | }
56 |
57 | const styles = StyleSheet.create({
58 | actionButtonIcon: {
59 | fontSize: 20,
60 | height: 22,
61 | color: 'white'
62 | }
63 | })
64 |
--------------------------------------------------------------------------------
/mybuddy/api/teams.js:
--------------------------------------------------------------------------------
1 | import { db } from '../firebase'
2 |
3 | /**
4 | * Fetch teams
5 | * @param onSnapshot function to run on update
6 | * @param clauses where conditions
7 | * @param transformation function to transform the shape of the data returned
8 | * @returns Promise(unsubscribe)
9 | */
10 | export const allTeamsOnSnapshot = async (
11 | onSnapshot,
12 | clauses = [],
13 | transformation
14 | ) => {
15 | let teamsRef = db.collection('teams')
16 | clauses.forEach(clause => {
17 | teamsRef = teamsRef.where(...clause)
18 | })
19 | return (
20 | teamsRef
21 | // listen real time
22 | .onSnapshot(
23 | async querySnapshot => {
24 | let teams = querySnapshot.docs.map(doc => {
25 | return {
26 | ...doc.data(),
27 | teamRef: db.collection('teams').doc(doc.id),
28 | id: doc.id
29 | }
30 | })
31 |
32 | if (transformation) {
33 | teams = transformation(teams)
34 | }
35 |
36 | onSnapshot(teams)
37 | },
38 | error => console.warn(error)
39 | )
40 | )
41 | }
42 |
43 | /**
44 | * Fetch all users in the managers team excluding
45 | * @param manager object
46 | * @param onSnapshot function to run on update
47 | */
48 | export const managersTeamMembersOnSnapshot = manager => async onSnapshot => {
49 | return db
50 | .collection('users')
51 | .where('team', '==', manager.team)
52 | .onSnapshot(
53 | querySnapshot => {
54 | const teamMembers = querySnapshot.docs
55 | .filter(doc => doc.id !== manager.uid)
56 | .map(doc => ({
57 | ...doc.data(),
58 | userRef: doc.ref
59 | }))
60 |
61 | onSnapshot(teamMembers)
62 | },
63 | error => console.warn(error)
64 | )
65 | }
66 |
--------------------------------------------------------------------------------
/mybuddy/screens/BuddyAppointmentsScreen/UpcomingAppointmentsScreen.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { Container, Content, View } from 'native-base'
3 |
4 | import Card from '../../components/Card'
5 | import MyTitle from '../../components/MyTitle'
6 | import LoadingPage from '../../components/LoadingPage'
7 | import { buddyAppointmentsThisWeekOnSnapshot } from '../../api'
8 |
9 | export default class UpcomingAppointmentsScreen extends Component {
10 | state = {
11 | appointments: {},
12 | isLoading: true
13 | }
14 |
15 | async componentDidMount() {
16 | this.unsubscribe = await buddyAppointmentsThisWeekOnSnapshot(
17 | appointments =>
18 | this.setState({
19 | appointments,
20 | isLoading: false
21 | }),
22 | true
23 | )
24 | }
25 |
26 | componentWillUnmount() {
27 | this.unsubscribe()
28 | }
29 |
30 | render() {
31 | if (this.state.isLoading) {
32 | return
33 | }
34 |
35 | const appointments = Object.entries(this.state.appointments)
36 | // remove keys with no appointments
37 | .filter(([_, appointments]) => Boolean(appointments.length))
38 | // display remaining keys
39 | .map(([title, appointments], i) => (
40 |
41 | {title}
42 | {appointments.map((appointment, i) => (
43 |
44 | ))}
45 |
46 | ))
47 |
48 | if (Boolean(appointments.length)) {
49 | return (
50 |
51 | {appointments}
52 |
53 | )
54 | }
55 |
56 | return (
57 |
58 | No more appointments this week
59 |
60 | )
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/mybuddy/components/Form/FormInput.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { Item, Label, Input, Text } from 'native-base'
3 | import { StyleSheet } from 'react-native'
4 |
5 | const capitalize = s => {
6 | if (typeof s !== 'string') return ''
7 | return s.charAt(0).toUpperCase() + s.slice(1)
8 | }
9 |
10 | export default class FormInput extends Component {
11 | _updateFormik = value => {
12 | const {
13 | field,
14 | form: { setFieldValue }
15 | } = this.props
16 |
17 | try {
18 | if (this.props.keyboardType === 'numeric') {
19 | const number = Number(value)
20 | if (number || !value) {
21 | setFieldValue(field.name, number || '')
22 | }
23 | } else {
24 | setFieldValue(field.name, value)
25 | }
26 | } catch (error) {}
27 | }
28 |
29 | render() {
30 | const {
31 | field, // { name, value, onChange, onBlur }
32 | form: { touched, errors, values }, // touched, errors, setXXXX, handleXXXX, dirty, isValid, status, etc.
33 | required,
34 | ...props
35 | } = this.props
36 | return (
37 | <>
38 | -
43 |
46 |
51 |
52 | {touched[field.name] && errors[field.name] && (
53 |
54 | {errors[field.name]}
55 |
56 | )}
57 | >
58 | )
59 | }
60 | }
61 |
62 | const styles = StyleSheet.create({
63 | item: {
64 | alignSelf: 'center',
65 | width: '90%',
66 | marginTop: 10
67 | }
68 | })
69 |
--------------------------------------------------------------------------------
/mybuddy/components/SearchBar.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import PropTypes from 'prop-types'
3 | import FuzzySearch from 'fuzzy-search'
4 | import { SearchBar as SearchBox } from 'react-native-elements'
5 |
6 | import DEFAULT_COLORS from '../constants/Colors'
7 |
8 | /**
9 | * Represents the search bar that can be used throughout the application.
10 | * The fuzzy search algorithm is used on the data and the result is returned.
11 | */
12 | export default class Search extends Component {
13 | state = {
14 | searchText: ''
15 | }
16 |
17 | /**
18 | * Updates the search to search for the text inside the search bar.
19 | * The text is formatted by removing whitespace and ensuring the appropriate split is used.
20 | */
21 | updateSearch = searchText => {
22 | this.setState({ searchText })
23 | const results = new FuzzySearch(this.props.data, this.props.properties, {
24 | sort: true
25 | }).search(
26 | searchText
27 | .trim()
28 | .split('/[ .,/-]/')
29 | .join('')
30 | )
31 | this.props.onSearch(results)
32 | }
33 |
34 | render() {
35 | return (
36 |
55 | )
56 | }
57 | }
58 |
59 | Search.proptypes = {
60 | data: PropTypes.array.isRequired,
61 | onSearch: PropTypes.func.isRequired,
62 | placeholder: PropTypes.string,
63 | properties: PropTypes.array.isRequired
64 | }
65 |
--------------------------------------------------------------------------------
/mybuddy/screens/AppointmentsScreen/AppointmentsTabNavigator/UpcomingAppointmentsScreen.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { Container, Content, View } from 'native-base'
3 |
4 | import Card from '../../../components/Card'
5 | import MyTitle from '../../../components/MyTitle'
6 | import LoadingPage from '../../../components/LoadingPage'
7 | import { appointmentsThisWeekOnSnapshot } from '../../../api'
8 |
9 | export default class UpcomingAppointmentsScreen extends Component {
10 | state = {
11 | appointments: {},
12 | isLoading: true
13 | }
14 |
15 | async componentDidMount() {
16 | this.unsubscribe = await appointmentsThisWeekOnSnapshot(
17 | appointments =>
18 | this.setState({
19 | appointments,
20 | isLoading: false
21 | }),
22 | true
23 | )
24 | }
25 |
26 | componentWillUnmount() {
27 | this.unsubscribe()
28 | }
29 |
30 | render() {
31 | if (this.state.isLoading) {
32 | return
33 | }
34 |
35 | const appointments = Object.entries(this.state.appointments)
36 | // remove keys with no appointments
37 | .filter(([_, appointments]) => Boolean(appointments.length))
38 | // display remaining keys
39 | .map(([title, appointments], i) => (
40 |
41 | {title}
42 | {appointments.map((appointment, i) => {
43 | return (
44 |
51 | )
52 | })}
53 |
54 | ))
55 |
56 | if (Boolean(appointments.length)) {
57 | return (
58 |
59 | {appointments}
60 |
61 | )
62 | }
63 |
64 | return (
65 |
66 | No more appointments this week
67 |
68 | )
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/mybuddy/components/DrawerMenu.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { SafeAreaView, StyleSheet } from 'react-native'
3 | import { DrawerItems } from 'react-navigation'
4 | import { Text, Content, View } from 'native-base'
5 |
6 | import Colors from '../constants/Colors'
7 | import firebase from '../firebase'
8 | import SwipeToConfirm from './SwipeToConfirm'
9 |
10 | export default class DrawerMenu extends Component {
11 | state = {
12 | displayName: ''
13 | }
14 |
15 | componentDidMount() {
16 | this.unsubscribe = firebase.auth().onAuthStateChanged(user => {
17 | this.setState({ displayName: user ? user.displayName : '' })
18 | })
19 | }
20 |
21 | componentWillUnmount() {
22 | this.unsubscribe()
23 | }
24 |
25 | render() {
26 | return (
27 | <>
28 |
29 | {this.state.displayName}
30 |
36 |
37 |
38 |
39 |
40 |
41 | >
42 | )
43 | }
44 | }
45 |
46 | // https://reactnavigation.org/docs/en/drawer-navigator.html#contentoptions-for-draweritems
47 | const contentOptions = {
48 | activeTintColor: 'white',
49 | activeBackgroundColor: Colors.secondaryColor,
50 | inactiveTintColor: 'white',
51 | inactiveBackgroundColor: Colors.primaryColor,
52 | itemsContainerStyle: {
53 | marginVertical: 0
54 | },
55 | iconContainerStyle: {
56 | opacity: 1
57 | }
58 | }
59 |
60 | const styles = StyleSheet.create({
61 | container: {
62 | backgroundColor: Colors.primaryColor,
63 | flex: 1
64 | },
65 | displayName: {
66 | textAlign: 'center',
67 | fontSize: 40,
68 | color: 'white',
69 | flexWrap: 'wrap',
70 | paddingTop: 5,
71 | paddingBottom: 5
72 | },
73 | drawer: {
74 | flex: 1
75 | }
76 | })
77 |
--------------------------------------------------------------------------------
/mybuddy/components/Navigation/withHeader.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { StyleSheet } from 'react-native'
3 | import { Header, Left, Body, Right, Button, Icon, Title } from 'native-base'
4 | import { createStackNavigator } from 'react-navigation'
5 | import PropTypes from 'prop-types'
6 |
7 | import DEFAULT_COLORS from '../../constants/Colors'
8 |
9 | // https://github.com/react-navigation/react-navigation/issues/1886#issuecomment-311665040
10 | const HeaderBar = ({ navigation, scene }) => {
11 | const { rightComponent } = scene.descriptor.options
12 | return (
13 |
18 |
19 |
22 |
23 |
24 |
33 | {navigation.state.key.toUpperCase()}
34 |
35 |
36 | {rightComponent}
37 |
38 | )
39 | }
40 |
41 | HeaderBar.propTypes = {
42 | navigation: PropTypes.shape({
43 | openDrawer: PropTypes.func.isRequired,
44 | closeDrawer: PropTypes.func.isRequired,
45 | toggleDrawer: PropTypes.func.isRequired,
46 | dispatch: PropTypes.func.isRequired,
47 | state: PropTypes.object.isRequired
48 | }).isRequired
49 | }
50 |
51 | const styles = StyleSheet.create({
52 | header: {
53 | backgroundColor: DEFAULT_COLORS.primaryColor,
54 | paddingBottom: 10
55 | },
56 | headerItem: {
57 | color: 'white'
58 | }
59 | })
60 |
61 | export default (screen, routeName) => {
62 | screen.navigationOptions = { ...screen.navigationOptions, header: HeaderBar }
63 | return createStackNavigator(
64 | { [routeName]: screen },
65 | {
66 | headerMode: 'screen',
67 | navigationOptions: {
68 | gesturesEnabled: false
69 | }
70 | }
71 | )
72 | }
73 |
--------------------------------------------------------------------------------
/mybuddy/utils/AsyncStorage.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Class to manage asynchronised storage on the device.
3 | */
4 |
5 | import { AsyncStorage } from 'react-native'
6 |
7 | /**
8 | * Stores an object with the key specified.
9 | * Will overwrite the key by default and has option to merge.
10 | * key - The key to store with the object.
11 | * obj - The object to store.
12 | * isMerging - If we want to merge.
13 | */
14 | AsyncStorage.storeObj = async (key, obj, isMerging = false) => {
15 | // console.log(`-> Storing object with key ${key}:`, obj)
16 | try {
17 | if (isMerging) {
18 | let objInStorage = await this.retrieveObj(key) //Deserialized object as JSON array.
19 | if (!objInStorage) objInStorage = {} //If the object is empty, create an empty object in order to merge.
20 | let obj = Object.assign(objInStorage, obj)
21 | }
22 | const serialisedObj = JSON.stringify(obj)
23 | if (!serialisedObj) { //Check if the object is serializable.
24 | console.log(
25 | `Could not store object for key ${key} as its serialised value is:`,
26 | serialisedObj
27 | )
28 | return
29 | }
30 | await AsyncStorage.setItem(key, serialisedObj)
31 | // console.log(`Successfully stored object with key ${key}:`, obj)
32 | } catch (error) {
33 | // Error retrieving data
34 | console.log(`Error storing object with key ${key}:`, obj)
35 | console.log(error)
36 | }
37 | }
38 |
39 | /**
40 | * Retrieve an object with the specified key it was serialised with.
41 | * key - The key the object was saved with.
42 | */
43 | AsyncStorage.retrieveObj = async key => {
44 | // console.log(`-> Retrieving object with key: ${key}`)
45 | try {
46 | let savedObject = await AsyncStorage.getItem(key)
47 | if (savedObject !== null) {
48 | // console.log(`Successfully retrieved object with {${key}: ${val}}`)
49 | savedObject = JSON.parse(savedObject)
50 | // console.log(`Successfully deserialised object with ${key}`, val)
51 | }
52 | return savedObject
53 | } catch (error) {
54 | console.log(`Error retrieving object with key ${key}`)
55 | console.log(error)
56 | return null
57 | }
58 | }
59 |
60 | export default AsyncStorage
61 |
--------------------------------------------------------------------------------
/mybuddy/screens/TeamsScreen/Forms/AddUserToTeam.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { Container, Content, Text, Button, CardItem, Body } from 'native-base'
3 |
4 | import MyTitle from '../../../components/MyTitle'
5 | import { db } from '../../../firebase'
6 | import { usersWithoutTeamOnSnapshot } from '../../../api'
7 |
8 | export default class AddUserToTeam extends Component {
9 | state = {
10 | team: {},
11 | users: []
12 | }
13 |
14 | async componentDidMount() {
15 | const team = this.props.navigation.getParam('team')
16 | this.setState({
17 | team
18 | })
19 |
20 | this.unsubscribe = await usersWithoutTeamOnSnapshot(users =>
21 | this.setState({ users })
22 | )
23 | }
24 |
25 | componentWillUnmount() {
26 | this.unsubscribe()
27 | }
28 |
29 | async _handleSubmit(userId) {
30 | // add selected user to current team on firebase
31 | db.collection('users')
32 | .doc(userId)
33 | .update({
34 | team: db.doc('teams/' + this.team.id)
35 | })
36 | this.props.navigation.goBack()
37 | }
38 |
39 | _renderUsers = () => {
40 | return this.state.users.map(user => (
41 | this._handleSubmit(user.uid)}
47 | >
48 |
49 | {user.displayName}
50 | {user.email}
51 |
52 |
53 | ))
54 | }
55 |
56 | render() {
57 | return (
58 |
59 |
62 |
63 | Add User to Team
64 |
65 | Team: {this.state.team.name}
66 |
67 | {this.state.users.length ? (
68 | this._renderUsers()
69 | ) : (
70 |
71 | No users without a team found
72 |
73 | )}
74 |
75 |
76 | )
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/mybuddy/screens/TeamsScreen/Forms/AddTeam.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { Container, Content, Text, Button } from 'native-base'
3 | import { StyleSheet } from 'react-native'
4 | import { withFormik, Field } from 'formik'
5 | import * as yup from 'yup'
6 |
7 | import { db } from '../../../firebase'
8 | import MyTitle from '../../../components/MyTitle'
9 | import FormInput from '../../../components/Form/FormInput'
10 | import ManagerPicker from '../../../components/Form/ManagerPicker'
11 |
12 | class AddTeam extends Component {
13 | render() {
14 | return (
15 |
16 |
24 |
25 | Add New Team
26 |
27 |
28 |
36 |
37 |
38 | )
39 | }
40 | }
41 |
42 | export default withFormik({
43 | mapPropsToValues: () => {
44 | return {
45 | name: '',
46 | manager: ''
47 | }
48 | },
49 | handleSubmit: async (values, { props, setSubmitting }) => {
50 | console.log(values)
51 |
52 | try {
53 | const team = await db.collection('teams').add({
54 | name: values.name,
55 | manager: db.collection('users').doc(values.manager)
56 | })
57 | await db
58 | .collection('users')
59 | .doc(values.manager)
60 | .update({
61 | team,
62 | accessLevel: 'manager'
63 | })
64 | props.navigation.goBack()
65 | } catch (error) {
66 | console.warn(error)
67 | setFieldError('root', 'Unable to complete your request.')
68 | setSubmitting(false)
69 | }
70 | },
71 | validationSchema: yup.object({
72 | name: yup
73 | .string()
74 | .max(15)
75 | .required(),
76 | manager: yup.string().required('you must select a manager')
77 | })
78 | })(AddTeam)
79 |
--------------------------------------------------------------------------------
/mybuddy/screens/UnauthorisedUserScreen.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { Container, Text, Button, View } from 'native-base'
3 | import { StyleSheet } from 'react-native'
4 |
5 | import LoadingPage from '../components/LoadingPage'
6 | import { getCurrentUserAsync } from '../api'
7 | import { logout } from '../api/auth'
8 | import { LOGIN_SCREEN, UNAUTHORISED_USER_SCREEN } from '../navigation/keys'
9 | import { getInitialRouteName } from '../navigation/utils'
10 |
11 | export class UnauthorisedUserScreen extends Component {
12 | state = {
13 | isLoading: false
14 | }
15 |
16 | _logout = async () => {
17 | await logout()
18 | this.props.navigation.navigate(LOGIN_SCREEN)
19 | }
20 |
21 | _reload = async () => {
22 | // Show loading screen when initializing
23 | this.setState({ isLoading: true })
24 | try {
25 | const user = await getCurrentUserAsync()
26 |
27 | // User is unauthorized
28 | if (!user) this._logout()
29 |
30 | // Take the user to the unauthorized screen and display instructions
31 | const initialRouteName = getInitialRouteName(user)
32 | if (initialRouteName !== UNAUTHORISED_USER_SCREEN) {
33 | this.props.navigation.navigate(initialRouteName)
34 | } else {
35 | this.setState({ isLoading: false })
36 | }
37 | } catch (error) {
38 | error.message = '[Unauthorized User]: ' + error.message
39 | console.warn(error)
40 | }
41 | }
42 |
43 | render() {
44 | if (this.state.isLoading) {
45 | return
46 | }
47 |
48 | return (
49 |
50 |
51 | You currently do not have access rights to use this application,
52 | please contact an admin or your team manager. Press below to try
53 | again, or to sign out.
54 |
55 |
56 |
59 |
62 |
63 |
64 | )
65 | }
66 | }
67 |
68 | const styles = StyleSheet.create({
69 | container: {
70 | justifyContent: 'space-evenly'
71 | },
72 | buttons: {
73 | flexDirection: 'row',
74 | justifyContent: 'space-around',
75 | paddingTop: 20
76 | }
77 | })
78 |
--------------------------------------------------------------------------------
/mybuddy/components/CardList/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { Card, CardItem, Text, Icon, Title } from 'native-base'
3 | import PropTypes from 'prop-types'
4 | import { StyleSheet } from 'react-native'
5 | import Inflector from 'inflector-js'
6 | import { withNavigation } from 'react-navigation'
7 |
8 | import CustomCardItem from '../CardItem'
9 | import {
10 | APPOINTMENTS_SCREEN,
11 | BUDDY_APPOINTMENTS_SCREEN,
12 | EMERGENCIES_SCREEN
13 | } from '../../navigation/keys'
14 |
15 | const styles = StyleSheet.create({
16 | card: { alignSelf: 'center', width: '95%' }
17 | })
18 |
19 | const toTitleCase = text =>
20 | Inflector.pluralize(text)
21 | .toLowerCase()
22 | .split('-')
23 | .map(word => Inflector.capitalize(word))
24 | .join(' ')
25 |
26 | class CustomCardList extends Component {
27 | state = {
28 | showList: false
29 | }
30 |
31 | _toggleList = () => {
32 | this.setState(({ showList }) => ({
33 | showList: !showList
34 | }))
35 | }
36 |
37 | _navigateTo = () => {
38 | switch (this.props.type) {
39 | case 'appointment':
40 | return APPOINTMENTS_SCREEN
41 | case 'buddy-appointment':
42 | return BUDDY_APPOINTMENTS_SCREEN
43 | case 'emergency':
44 | return EMERGENCIES_SCREEN
45 | default:
46 | return ''
47 | }
48 | }
49 |
50 | render() {
51 | const { type, ...props } = this.props
52 | return (
53 |
54 |
55 |
56 | {toTitleCase(type)}
57 |
62 |
63 | {this.state.showList && (
64 | this.props.navigation.navigate(this._navigateTo())}
68 | >
69 |
70 | View More
71 |
72 |
73 | )}
74 | {this.state.showList &&
75 | this.props.data.map((datum, i) => (
76 |
77 | ))}
78 |
79 | )
80 | }
81 | }
82 |
83 | CustomCardList.propTypes = {
84 | data: PropTypes.array.isRequired
85 | }
86 |
87 | export default withNavigation(CustomCardList)
88 |
--------------------------------------------------------------------------------
/mybuddy/screens/BuddyAppointmentsScreen/CalendarScreen.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { View } from 'react-native'
3 | import { Agenda } from 'react-native-calendars'
4 | import moment from 'moment'
5 |
6 | import { buddyAppointmentsBetweenDatesOnSnapshot } from '../../api'
7 | import Card from '../../components/Card'
8 |
9 | export default class CalendarScreen extends Component {
10 | state = {
11 | items: {}
12 | }
13 |
14 | subscriptions = []
15 |
16 | componentWillUnmount() {
17 | this.subscriptions.forEach(unsubscribe => unsubscribe())
18 | }
19 |
20 | _loadItems = async day => {
21 | if (this.state.items.hasOwnProperty(day.dateString)) return
22 |
23 | // Extract a specific month
24 | const startOfMonth = moment(day.dateString).startOf('month')
25 | const endOfMonth = moment(day.dateString).endOf('month')
26 |
27 | const unsubscribe = await buddyAppointmentsBetweenDatesOnSnapshot(
28 | startOfMonth.toDate(),
29 | endOfMonth.toDate()
30 | )(appointments => {
31 | let items = {}
32 |
33 | for (
34 | let m = moment(startOfMonth);
35 | m.isSameOrBefore(endOfMonth);
36 | m.add(1, 'days')
37 | ) {
38 | items[m.format('YYYY-MM-DD')] = []
39 | }
40 |
41 | appointments.forEach(appointment => {
42 | // Normalize the appointments' times fetched from the database
43 | const dateString = moment(appointment.startDateTime).format(
44 | 'YYYY-MM-DD'
45 | )
46 | // Merge it with existing calendar items
47 | items = {
48 | ...items,
49 | [dateString]: [...items[dateString], appointment]
50 | }
51 | })
52 |
53 | this.setState(prevState => ({ items: { ...prevState.items, ...items } }))
54 | })
55 |
56 | this.subscriptions.push(unsubscribe)
57 | }
58 |
59 | _rowHasChanged = (r1, r2) => {
60 | // Detect if a specific row has changed
61 | return r1 !== r2
62 | }
63 |
64 | render() {
65 | return (
66 | (this.calendar = c)}
68 | items={this.state.items}
69 | loadItemsForMonth={this._loadItems}
70 | renderItem={this._renderItem}
71 | renderEmptyDate={this._renderEmptyDate}
72 | rowHasChanged={this._rowHasChanged}
73 | />
74 | )
75 | }
76 |
77 | _renderItem = item => {
78 | return
79 | }
80 |
81 | _renderEmptyDate = () => (
82 | // Render an empty date to fill up the days with no agenda
83 |
89 | )
90 | }
91 |
--------------------------------------------------------------------------------
/mybuddy/screens/AppointmentsScreen/AppointmentsTabNavigator/CalendarScreen.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { View } from 'react-native'
3 | import { Agenda } from 'react-native-calendars'
4 | import moment from 'moment'
5 |
6 | import { appointmentsBetweenDatesOnSnapshot } from '../../../api'
7 | import Card from '../../../components/Card'
8 |
9 | export default class CalendarScreen extends Component {
10 | state = {
11 | items: {}
12 | }
13 |
14 | subscriptions = []
15 |
16 | componentWillUnmount() {
17 | this.subscriptions.forEach(unsubscribe => unsubscribe())
18 | }
19 |
20 | _loadItems = async day => {
21 | if (this.state.items.hasOwnProperty(day.dateString)) return
22 |
23 | // Extract a specific month
24 | const startOfMonth = moment(day.dateString).startOf('month')
25 | const endOfMonth = moment(day.dateString).endOf('month')
26 |
27 | const unsubscribe = await appointmentsBetweenDatesOnSnapshot(
28 | startOfMonth.toDate(),
29 | endOfMonth.toDate()
30 | )(appointments => {
31 | let items = {}
32 |
33 | // create object of arrays for every date in month
34 | for (
35 | let m = moment(startOfMonth);
36 | m.isSameOrBefore(endOfMonth);
37 | m.add(1, 'days')
38 | ) {
39 | items[m.format('YYYY-MM-DD')] = []
40 | }
41 |
42 | // add appointments array on each date
43 | appointments.forEach(appointment => {
44 | // Normalize the appointments' times fetched from the database
45 | const dateString = moment(appointment.startDateTime).format(
46 | 'YYYY-MM-DD'
47 | )
48 | // Merge it with existing calendar items
49 | items = {
50 | ...items,
51 | [dateString]: [...items[dateString], appointment]
52 | }
53 | })
54 |
55 | this.setState(prevState => ({ items: { ...prevState.items, ...items } }))
56 | })
57 |
58 | this.subscriptions.push(unsubscribe)
59 | }
60 |
61 | _rowHasChanged = (r1, r2) => {
62 | // Detect if a specific row has changed
63 | return r1 !== r2
64 | }
65 |
66 | render() {
67 | return (
68 | (this.calendar = c)}
70 | items={this.state.items}
71 | loadItemsForMonth={this._loadItems}
72 | renderItem={this._renderItem}
73 | renderEmptyDate={this._renderEmptyDate}
74 | rowHasChanged={this._rowHasChanged}
75 | />
76 | )
77 | }
78 |
79 | _renderItem = item => {
80 | return
81 | }
82 |
83 | _renderEmptyDate = () => (
84 | // Render an empty date to fill up the days with no agenda
85 |
91 | )
92 | }
93 |
--------------------------------------------------------------------------------
/mybuddy/components/CardItem/BuddyAppointmentCardItem.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { Text, CardItem, Button, Icon } from 'native-base'
3 | import PropTypes from 'prop-types'
4 |
5 | import AppointmentInfo from './AppointmentInfo'
6 |
7 | export default class BuddyAppointmentCardItem extends Component {
8 | state = {
9 | clinicianName: ''
10 | }
11 |
12 | async componentDidMount() {
13 | try {
14 | const { user } = this.props
15 | const { displayName } = await user.get().then(doc => doc.data())
16 | this.setState({ clinicianName: displayName })
17 | } catch (error) {
18 | this.setState({ clinicianName: 'unavailable' })
19 | }
20 | }
21 |
22 | _handleSendReminder = () => {}
23 |
24 | _handleAccept = () => {
25 | this.props.appointmentRef.update({ 'buddy.status': 'confirmed' })
26 | }
27 |
28 | _handleReject = () => {
29 | this.props.appointmentRef.update({
30 | 'buddy.user': null,
31 | 'buddy.status': null
32 | })
33 | }
34 |
35 | render() {
36 | return (
37 | <>
38 |
44 | >
45 | )
46 | }
47 |
48 | _renderRequester = () => {
49 | return (
50 | <>
51 |
56 | {this.state.clinicianName}
57 | >
58 | )
59 | }
60 |
61 | _renderButtons = () => {
62 | const {
63 | buddy: { status }
64 | } = this.props
65 | return (
66 |
67 | {status === 'confirmed' ? (
68 | <>
69 |
77 | >
78 | ) : (
79 | <>
80 |
88 |
96 | >
97 | )}
98 |
99 | )
100 | }
101 | }
102 |
103 | BuddyAppointmentCardItem.propTypes = {
104 | appointmentRef: PropTypes.object.isRequired,
105 | buddy: PropTypes.shape({ status: PropTypes.string }).isRequired
106 | }
107 |
--------------------------------------------------------------------------------
/mybuddy/components/Dropdown.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { Platform, StyleSheet } from 'react-native'
3 | import { View, Text } from 'native-base'
4 | import PropTypes from 'prop-types'
5 | import RNPickerSelect from 'react-native-picker-select'
6 |
7 | export default class Dropdown extends Component {
8 | constructor(props) {
9 | super(props)
10 | this._normalizeItems = this._normalizeItems.bind(this)
11 | this.items = null
12 | }
13 | /**
14 | * items are the dropdown selections
15 | * format: {label: 'Orange', value: 'orange', key: 'orange', color: 'orange'}
16 | * label and value are required, key and color are optional
17 | * value can be any data type
18 | * must have at least one item, cannot be empty
19 | */
20 | static propTypes = {
21 | style: PropTypes.object,
22 | placeholderText: PropTypes.string, // placeholder={{label: 'Select a color...',value: null}}
23 | dropdownTitle: PropTypes.string,
24 | onValueChange: PropTypes.func.isRequired,
25 | items: PropTypes.arrayOf(PropTypes.object).isRequired, // [{label: 'Orange', value: 'orange'},...]
26 | value: PropTypes.any
27 | }
28 |
29 | componentWillMount = async () => {
30 | console.log(`Normalizing array items input: ${JSON.stringify(this.props.items)}`)
31 | await this._normalizeItems()
32 | console.log(`Items normalized: ${JSON.stringify(this.items)}`)
33 | }
34 |
35 | _normalizeItems = () => {
36 | this.items = this.props.items.map((item) => {
37 | return {
38 | label: item,
39 | value: item
40 | }
41 | })
42 | }
43 |
44 | render() {
45 | return (
46 |
47 |
57 | {this.props.dropdownTitle}
58 |
59 |
70 |
71 | )
72 | }
73 | }
74 |
75 | const styles = StyleSheet.create({
76 | defaultStyles: {
77 | marginLeft: 30,
78 | marginRight: 30,
79 | width: '100%'
80 | }
81 | })
82 |
83 | const pickerSelectStyles = StyleSheet.create({
84 | inputIOS: {
85 | marginLeft: 30,
86 | marginRight: 30,
87 | fontSize: 12,
88 | paddingTop: 13,
89 | paddingHorizontal: 10,
90 | paddingBottom: 12,
91 | borderWidth: 1,
92 | borderColor: 'gainsboro',
93 | borderRadius: 4,
94 | backgroundColor: 'white',
95 | color: 'black'
96 | }
97 | })
98 |
--------------------------------------------------------------------------------
/mybuddy/screens/TeamsScreen/TeamsScreen.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { db } from '../../firebase'
3 | import { StyleSheet } from 'react-native'
4 | import {
5 | Container,
6 | Text,
7 | Content,
8 | CardItem,
9 | Body,
10 | Icon,
11 | Right
12 | } from 'native-base'
13 | import ActionButton from 'react-native-action-button'
14 | import omit from 'lodash/omit'
15 |
16 | import DEFAULT_COLORS from '../../constants/Colors'
17 | import SearchBar from '../../components/SearchBar'
18 | import { allTeamsOnSnapshot } from '../../api'
19 |
20 | export default class TeamsScreen extends Component {
21 | state = {
22 | teams: [],
23 | visibleTeams: []
24 | }
25 |
26 | async componentDidMount() {
27 | this.unsubscribe = await allTeamsOnSnapshot(async teams => {
28 | teams = await Promise.all(
29 | teams.map(async team => {
30 | const manager = await team.manager
31 | .get()
32 | .then(doc => ({ ...doc.data(), id: doc.id }))
33 | console.log(manager.displayName)
34 | return { ...team, manager }
35 | })
36 | )
37 | this.setState({ teams, visibleTeams: teams })
38 | })
39 | }
40 |
41 | componentWillUnmount() {
42 | this.unsubscribe()
43 | }
44 |
45 | _search = visibleTeams => {
46 | // Search for all teams available
47 | this.setState({ visibleTeams })
48 | }
49 |
50 | _renderTeams = () => {
51 | return this.state.visibleTeams.map(team => (
52 |
58 | this.props.navigation.navigate('TeamMembers', {
59 | managerId: team.manager.id
60 | })
61 | }
62 | >
63 |
64 | {team.name}
65 | Manager: {team.manager.displayName || 'n/a'}
66 |
67 |
68 |
69 |
70 |
71 | ))
72 | }
73 |
74 | render() {
75 | return (
76 |
77 |
83 | {this._renderTeams()}
84 | this.props.navigation.navigate('AddTeamForm')}
87 | renderIcon={() => (
88 |
93 | )}
94 | />
95 |
96 | )
97 | }
98 | }
99 |
100 | const styles = StyleSheet.create({
101 | actionButtonIcon: {
102 | fontSize: 20,
103 | height: 22,
104 | color: 'white'
105 | }
106 | })
107 |
--------------------------------------------------------------------------------
/mybuddy/components/Form/ManagerPicker.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { View, Text, Button, Row, Item, Label, Input } from 'native-base'
3 | import { StyleSheet } from 'react-native'
4 | import RNPickerSelect from 'react-native-picker-select'
5 |
6 | import { db } from '../../firebase'
7 |
8 | export default class ManagerPicker extends Component {
9 | state = {
10 | selectedManager: {},
11 | users: []
12 | }
13 |
14 | async componentDidMount() {
15 | await db
16 | .collection('users')
17 | .where('team', '==', null)
18 | .get()
19 | .then(querySnapshot =>
20 | querySnapshot.docs.forEach(doc =>
21 | this.setState(prevState => {
22 | const { displayName } = doc.data()
23 | return {
24 | users: [
25 | ...prevState.users,
26 | {
27 | label: displayName,
28 | value: { displayName, user: doc.id }
29 | }
30 | ]
31 | }
32 | })
33 | )
34 | )
35 | }
36 |
37 | _updateFormik = value => {
38 | const {
39 | field,
40 | form: { setFieldValue }
41 | } = this.props
42 | setFieldValue(field.name, value)
43 | }
44 |
45 | _selectManager = selectedManager => {
46 | this.setState({ selectedManager })
47 | this._updateFormik(selectedManager.user)
48 | }
49 |
50 | render() {
51 | const {
52 | field,
53 | form: { touched, errors },
54 | required
55 | } = this.props
56 | return (
57 |
58 |
59 |
60 | -
65 |
66 |
71 |
72 |
73 |
74 | (this.pickerSelectRef = ps)}
80 | >
81 |
84 |
85 |
86 |
87 | {touched[field.name] && errors[field.name] && (
88 |
89 | {errors[field.name]}
90 |
91 | )}
92 |
93 | )
94 | }
95 | }
96 |
97 | const styles = StyleSheet.create({
98 | item: {
99 | alignSelf: 'center',
100 | flex: 1,
101 | marginLeft: 20,
102 | marginRight: 20,
103 | marginBottom: 5
104 | }
105 | })
106 |
--------------------------------------------------------------------------------
/mybuddy/screens/TeamsScreen/Forms/EditTeam.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { Container, Content, Text, Button, View } from 'native-base'
3 | import { StyleSheet } from 'react-native'
4 | import { withFormik, Field } from 'formik'
5 | import * as yup from 'yup'
6 |
7 | import MyTitle from '../../../components/MyTitle'
8 | import FormInput from '../../../components/Form/FormInput'
9 | import { db } from '../../../firebase'
10 | import ManagerPicker from '../../../components/Form/ManagerPicker'
11 |
12 | class EditTeam extends Component {
13 | render () {
14 | return (
15 |
16 |
19 |
20 | Edit Team
21 |
22 |
23 |
24 |
25 |
26 |
29 |
30 |
31 |
32 | )
33 | }
34 | }
35 |
36 | const styles = StyleSheet.create({
37 | form: {
38 | paddingTop: 10,
39 | paddingBottom: 10
40 | },
41 | buttons: {
42 | flexDirection: 'row',
43 | justifyContent: 'space-evenly',
44 | paddingTop: 20
45 | }
46 | })
47 |
48 | export default withFormik({
49 | mapPropsToValues: props => {
50 | const team = props.navigation.getParam('team') || {}
51 | return {
52 | name: team.name || '',
53 | manager: team.manager || ''
54 | }
55 | },
56 | handleSubmit: async (values, { setSubmitting }) => {
57 | const team = this.props.navigation.getParam('team')
58 | if (team.manager.id === values.manager.id) {
59 | // If the Manager has not been updated
60 | db.collection('teams')
61 | .doc(team.id)
62 | .set({
63 | name: values.name
64 | })
65 | } else {
66 | // If the Manager has been updated
67 | // Update Access of Previous Manager
68 | db.collection('users')
69 | .doc(team.manager.id)
70 | .set({
71 | access: 'clinician',
72 | team: null
73 | })
74 | // Add new manager to the team and update that user's access
75 | await db
76 | .collection('teams')
77 | .doc(team.id)
78 | .set({
79 | name: values.name,
80 | manager: db.doc('users/' + values.manager.id)
81 | })
82 | .then(docRef => {
83 | db.collection('users')
84 | .doc(values.manager.id)
85 | .update({
86 | team: db.doc('teams/' + docRef.id),
87 | accessLevel: 'manager'
88 | })
89 | })
90 | }
91 | props.navigation.goBack()
92 | },
93 | validationSchema: yup.object({
94 | name: yup
95 | .string()
96 | .max(15)
97 | .required(),
98 | manager: yup.string().required('you must select a manager')
99 | })
100 | })(EditTeam)
101 |
--------------------------------------------------------------------------------
/mybuddy/utils/ErrorBoundary.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Responsible for customising the way errors are received and handled.
3 | * A custom global error handler is created.
4 | */
5 |
6 | import React from 'react'
7 | import { Alert } from 'react-native'
8 | import { Updates } from 'expo'
9 |
10 | if(!__DEV__) {
11 | // Console.log will be disabled in production
12 | console.log = () => {}
13 | }
14 |
15 | // The default error handler.
16 | const defaultErrorHandler = ErrorUtils.getGlobalHandler()
17 |
18 | /**
19 | * Builds the global error handler for errors throughout the application.
20 | * @param error - The error to handle.
21 | * @param isFatal - If the error is fatal (will kill the app).
22 | */
23 | const globalErrorHandler = (error, isFatal) => {
24 | console.log(`Error: { isFatal:${isFatal}, error:${error}}`)
25 | // Display different message if error is fatal/non-fatal.
26 | isFatal
27 | ? error.message = "[Fatal unhandled exception] " + error.message
28 | : error.message = "[Nonfatal unhandled exception] " + error.message
29 | console.log(error)
30 | if(!__DEV__) {
31 | // Use alert popup if the application is not in developer mode.
32 | Alert.alert(
33 | 'Oops!',
34 | 'An unknown error occurred\nPlease check and try again',
35 | [
36 | {
37 | text: 'Restart the App',
38 | onPress: () => Updates.reload()
39 | },
40 | {
41 | text: 'OK',
42 | onPress: () => console.log('OK Pressed, error message dismissed'),
43 | style: 'cancel'
44 | }
45 | ],
46 | { cancelable: false }
47 | )
48 | }
49 | // Pass the error onto the default react native error handler
50 | defaultErrorHandler(error, isFatal)
51 | }
52 |
53 | // Set the global error handler to the custom global error handler.
54 | ErrorUtils.setGlobalHandler(globalErrorHandler)
55 |
56 | /**
57 | * Represents the error boundary class which is a component.
58 | * An error will be displayed as an alert popup with text.
59 | * Displays an option to restart the application or dismiss the error.
60 | * If no errors were caught, it will render its children.
61 | */
62 | class ErrorBoundary extends React.Component {
63 | constructor(props) {
64 | super(props)
65 | this.state = {
66 | hasError: false
67 | }
68 | }
69 |
70 | /**
71 | * Catches lifecycle errors.
72 | * The error is a string.
73 | * @param error - The error.
74 | * @param information - The information.
75 | */
76 | async componentDidCatch(error, information) {
77 | console.log(`React native lifecycle exception: ${JSON.stringify(information)}`)
78 | await this.setState({hasError: true})
79 | error.message = "[React native lifecycle exception] " + error.message
80 | console.log(error)
81 | }
82 |
83 | render() {
84 | if(this.state.hasError && !__DEV__) {
85 | Alert.alert(
86 | 'Oops!',
87 | 'An unknown error occurred\nPlease check and try again',
88 | [
89 | {
90 | text: 'Restart the App',
91 | onPress: () => Updates.reload()
92 | },
93 | {
94 | text: 'OK',
95 | onPress: () => console.log('OK Pressed, error message dismissed'),
96 | style: 'cancel'
97 | }
98 | ],
99 | { cancelable: false }
100 | )
101 | }
102 | return this.props.children
103 | }
104 | }
105 |
106 | export default ErrorBoundary
107 |
--------------------------------------------------------------------------------
/mybuddy/navigation/Navigator.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Manages organising the tabs in the navigation.
3 | * Tabs displayed on navigation depends on user access level.
4 | */
5 |
6 | import React from 'react'
7 | import { createAppContainer, createSwitchNavigator } from 'react-navigation'
8 | import { AsyncStorage } from 'react-native'
9 | import { createDrawerNavigatorWithHeader } from '../components/Navigation'
10 |
11 | import { setTopLevelNavigator } from './NavigationService'
12 | import { getInitialRouteName } from './utils'
13 |
14 | import DrawerMenu from '../components/DrawerMenu'
15 | import {
16 | HomeScreen,
17 | LoginScreen,
18 | AppointmentsScreen,
19 | SettingsScreen,
20 | TeamsScreen,
21 | UnauthorisedUserScreen,
22 | BuddyAppointmentsScreen,
23 | EmergenciesScreen,
24 | UserScreen
25 | } from '../screens'
26 |
27 | import {
28 | APPOINTMENTS_SCREEN,
29 | SETTINGS_SCREEN,
30 | LOGIN_SCREEN,
31 | HOME_SCREEN,
32 | UNAUTHORISED_USER_SCREEN,
33 | CLINICIAN_PORTAL,
34 | TEAM_MANAGER_PORTAL,
35 | ADMIN_PORTAL,
36 | TEAM_SCREEN,
37 | TEAMS_SCREEN,
38 | BUDDY_APPOINTMENTS_SCREEN,
39 | EMERGENCIES_SCREEN,
40 | USERS_SCREEN
41 | } from './keys'
42 |
43 | const ClinicianDrawerNavigator = createDrawerNavigatorWithHeader(
44 | {
45 | [HOME_SCREEN]: HomeScreen,
46 | [APPOINTMENTS_SCREEN]: AppointmentsScreen,
47 | [BUDDY_APPOINTMENTS_SCREEN]: BuddyAppointmentsScreen,
48 | [EMERGENCIES_SCREEN]: EmergenciesScreen,
49 | [SETTINGS_SCREEN]: SettingsScreen
50 | },
51 | {
52 | contentComponent: DrawerMenu
53 | }
54 | )
55 |
56 | const TeamManagerDrawerNavigator = createDrawerNavigatorWithHeader(
57 | {
58 | [HOME_SCREEN]: HomeScreen,
59 | [APPOINTMENTS_SCREEN]: AppointmentsScreen,
60 | [BUDDY_APPOINTMENTS_SCREEN]: BuddyAppointmentsScreen,
61 | [EMERGENCIES_SCREEN]: EmergenciesScreen,
62 | // [TEAM_SCREEN]: TeamScreen,
63 | [USERS_SCREEN]: UserScreen,
64 | [SETTINGS_SCREEN]: SettingsScreen
65 | },
66 | {
67 | contentComponent: DrawerMenu
68 | }
69 | )
70 |
71 | const AdminDrawerNavigator = createDrawerNavigatorWithHeader(
72 | {
73 | [HOME_SCREEN]: HomeScreen,
74 | [APPOINTMENTS_SCREEN]: AppointmentsScreen,
75 | [BUDDY_APPOINTMENTS_SCREEN]: BuddyAppointmentsScreen,
76 | [EMERGENCIES_SCREEN]: EmergenciesScreen,
77 | [TEAMS_SCREEN]: TeamsScreen,
78 | [USERS_SCREEN]: UserScreen,
79 | [SETTINGS_SCREEN]: SettingsScreen
80 | },
81 | {
82 | contentComponent: DrawerMenu
83 | }
84 | )
85 |
86 | const AuthSwitchNavigator = initialRouteName => {
87 | return createSwitchNavigator(
88 | {
89 | [LOGIN_SCREEN]: LoginScreen,
90 | [ADMIN_PORTAL]: AdminDrawerNavigator,
91 | [TEAM_MANAGER_PORTAL]: TeamManagerDrawerNavigator,
92 | [CLINICIAN_PORTAL]: ClinicianDrawerNavigator,
93 | [UNAUTHORISED_USER_SCREEN]: UnauthorisedUserScreen
94 | },
95 | { initialRouteName, resetOnBlur: true }
96 | )
97 | }
98 |
99 | export const createNavigator = user => props => {
100 | persistenceKey = __DEV__ ? 'NavigationStateDEV' : null
101 | AsyncStorage.removeItem('NavigationStateDEV')
102 | const initialRouteName = getInitialRouteName(user)
103 | const App = createAppContainer(AuthSwitchNavigator(initialRouteName))
104 |
105 | return (
106 | {
108 | setTopLevelNavigator(navigatorRef)
109 | }}
110 | // persistenceKey={persistenceKey}
111 | />
112 | )
113 | }
114 |
--------------------------------------------------------------------------------
/mybuddy/components/CardItem/EmergencyCardItem.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { Platform, Linking } from 'react-native'
3 | import { Button, Col, CardItem, Text, Title, Icon, Row } from 'native-base'
4 | import PropTypes from 'prop-types'
5 |
6 | export default class EmergencyCardItem extends Component {
7 | constructor(props) {
8 | super(props)
9 | this.state = {
10 | user: {},
11 | appointment: {},
12 | location: null
13 | }
14 | this._openGps = this._openGps.bind(this)
15 | this._updateEmergency = this._updateEmergency.bind(this)
16 | }
17 |
18 | async componentDidMount() {
19 | const user = await this.props.user.get().then(doc => doc.data())
20 | const appointment = this.props.appointment
21 | ? await this.props.appointment.get().then(doc => doc.data())
22 | : {}
23 | let location = this.props.location ? this.props.location : null
24 | this.setState({ user, appointment, location })
25 | }
26 |
27 | _openGps = () => {
28 | console.log(`Location object acquired: ${JSON.stringify(this.props.location)}`)
29 | const scheme = Platform.select({ ios: 'maps:0,0?q=', android: 'geo:0,0?q=' })
30 | const latLng = `${this.props.location.latitude},${this.props.location.longitude}`
31 | const label = 'Custom Label'
32 | const url = Platform.select({
33 | ios: `${scheme}${label}@${latLng}`,
34 | android: `${scheme}${latLng}(${label})`
35 | })
36 | Linking.openURL(url)
37 | }
38 |
39 | _updateEmergency = () => {
40 | this.props.emergencyRef.update({ status: 'resolved' })
41 | this.props.user.update({ currentEmergency: null })
42 | }
43 |
44 | render() {
45 | return (
46 | <>
47 |
48 |
49 | {this.state.user.displayName}
50 |
51 |
52 | Appointment Address: {this.state.appointment.address || 'n/a'}
53 |
54 |
55 |
56 | Last Known: {
57 | this.state.location
58 | ? `${this.state.location.street} ${this.state.location.city} ${this.state.location.country} ${this.state.location.postalCode}`
59 | : 'n/a'
60 | }
61 |
62 |
63 |
64 | {this.state.user.email}
65 |
66 |
67 |
68 | {this.state.user.contact || 'n/a'}
69 |
70 |
71 |
72 |
73 |
81 |
89 |
90 | >
91 | )
92 | }
93 | }
94 |
95 | EmergencyCardItem.propTypes = {
96 | user: PropTypes.object.isRequired,
97 | appointment: PropTypes.object,
98 | emergencyRef: PropTypes.object.isRequired
99 | }
100 |
--------------------------------------------------------------------------------
/mybuddy/components/Form/DateTimePicker.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { StyleSheet } from 'react-native'
3 | import { Button, Text, View } from 'native-base'
4 | import DateTime from 'react-native-modal-datetime-picker'
5 | import moment from 'moment'
6 |
7 | export default class DateTimePicker extends Component {
8 | state = {
9 | startDateTime: moment(),
10 | isDatePickerVisible: false,
11 | isTimePickerVisible: false
12 | }
13 |
14 | componentDidMount() {
15 | const {
16 | field,
17 | form: { values }
18 | } = this.props
19 | const startDateTime = values[field.name]
20 | if (startDateTime) {
21 | this.setState({ startDateTime: moment(startDateTime) })
22 | }
23 | }
24 |
25 | _togglePicker = DateOrTime => () => {
26 | this.setState(prevState => ({
27 | [`is${DateOrTime}PickerVisible`]: !prevState[
28 | `is${DateOrTime}PickerVisible`
29 | ]
30 | }))
31 | }
32 |
33 | _handleDatePicked = dateJs => {
34 | const date = moment(dateJs)
35 | this.setState(
36 | ({ startDateTime }) => ({
37 | startDateTime: startDateTime
38 | .year(date.year())
39 | .month(date.month())
40 | .date(date.date())
41 | }),
42 | () => this._updateFormik()
43 | )
44 |
45 | this._togglePicker('Date')()
46 | }
47 |
48 | _handleTimePicked = timeJs => {
49 | const time = moment(timeJs)
50 | this.setState(
51 | ({ startDateTime }) => ({
52 | startDateTime: startDateTime.hour(time.hour()).minute(time.minute())
53 | }),
54 | () => this._updateFormik()
55 | )
56 |
57 | this._togglePicker('Time')()
58 | }
59 |
60 | _updateFormik = () => {
61 | const {
62 | field,
63 | form: { setFieldValue }
64 | } = this.props
65 | setFieldValue(field.name, this.state.startDateTime.toDate())
66 | }
67 |
68 | _getDate = () => {
69 | return this.state.startDateTime.format('MMM DD, YYYY')
70 | }
71 |
72 | _getTime = () => {
73 | return this.state.startDateTime.format('HH:mm')
74 | }
75 |
76 | render() {
77 | return (
78 |
79 | {/* Date Picker */}
80 |
88 |
96 | {/* Time Picker */}
97 |
105 |
115 |
116 | )
117 | }
118 | }
119 |
120 | const styles = StyleSheet.create({
121 | container: {
122 | paddingTop: 30,
123 | flexDirection: 'row',
124 | justifyContent: 'center'
125 | },
126 | item: {
127 | width: '45%',
128 | justifyContent: 'center'
129 | }
130 | })
131 |
--------------------------------------------------------------------------------
/mybuddy/screens/SettingsScreen.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { StyleSheet } from 'react-native'
3 | import {
4 | Container,
5 | Content,
6 | Text,
7 | CardItem,
8 | Right,
9 | Left,
10 | Body,
11 | Icon,
12 | Badge,
13 | Row,
14 | Button
15 | } from 'native-base'
16 |
17 | import { TextInput } from 'react-native'
18 |
19 | import { logout } from '../api/auth'
20 | import { getCurrentUserAsync } from '../api/users'
21 | import { LOGIN_SCREEN } from '../navigation/keys'
22 | import { navigate } from '../navigation/NavigationService'
23 | import { db } from '../firebase'
24 |
25 | export class SettingsScreen extends Component {
26 | static navigationOptions = {
27 | rightComponent: (
28 | {
31 | await logout()
32 | navigate(LOGIN_SCREEN)
33 | }}
34 | >
35 | LOG OUT
36 |
37 | )
38 | }
39 |
40 | state = {
41 | user: {},
42 | team: {},
43 | manager: {},
44 | contact: ''
45 | }
46 |
47 | async componentDidMount () {
48 | const { team, ...user } = await getCurrentUserAsync()
49 | const { manager, ...teamData } = team
50 | ? await team.get().then(doc => doc.data())
51 | : {}
52 | const managerData = manager
53 | ? await manager.get().then(doc => doc.data())
54 | : {}
55 | this.setState({
56 | user,
57 | team: teamData,
58 | manager: managerData,
59 | contact: user.contact || ''
60 | })
61 | }
62 |
63 | _handleSubmit () {
64 | if (this.state.contact === this.state.user.contact) {
65 | } else {
66 | db.collection('users')
67 | .doc(this.state.user.uid)
68 | .update({
69 | contact: this.state.contact
70 | })
71 | }
72 | }
73 |
74 | render () {
75 | return (
76 |
77 |
80 |
81 |
82 |
83 |
87 |
88 | {this.state.user.displayName}
89 | {this.state.user.email}
90 |
91 | {this.state.user.accessLevel}
92 |
93 |
94 |
95 |
96 |
97 |
98 | Team
99 |
100 | {this.state.team.name}
101 |
102 |
103 |
104 | Manager
105 |
106 | {this.state.manager.displayName}
107 |
108 |
109 |
110 | {/* */}
111 | Contact Number
112 | {/*
*/}
113 |
114 | this.setState({ contact: text })}
121 | />
122 |
123 |
124 |
125 |
126 | )
127 | }
128 | }
129 |
130 | const styles = StyleSheet.create({
131 | boldText: {
132 | fontWeight: 'bold'
133 | }
134 | })
135 |
--------------------------------------------------------------------------------
/mybuddy/screens/LoginScreen.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { Platform, Linking, StyleSheet, Image, Alert } from 'react-native'
3 | import { Container, Button, Text } from 'native-base'
4 | import { Notifications, Permissions, Constants } from 'expo'
5 |
6 | import Layout from '../constants/Layout'
7 | import { login as authLogin } from '../api/auth'
8 | import { getInitialRouteName } from '../navigation/utils'
9 |
10 | export class LoginScreen extends Component {
11 | _login = async () => {
12 | // Try to login user
13 | const user = await authLogin()
14 | if (!user) return
15 |
16 | // Updates the pushToken so that notifications can come through
17 | const pushToken = (await this._registerForPushNotificationsAsync()) || null
18 | await user.userRef.update({ pushToken })
19 |
20 | // Take the user to the right screen
21 | const initialRouteName = getInitialRouteName(user)
22 | this.props.navigation.navigate(initialRouteName)
23 | }
24 |
25 | _registerForPushNotificationsAsync = async () => {
26 | if (!Constants.isDevice) return
27 |
28 | const { status: existingStatus } = await Permissions.getAsync(
29 | Permissions.NOTIFICATIONS
30 | )
31 |
32 | // Check if push notification permission is granted
33 | let finalStatus = existingStatus
34 | if (existingStatus !== 'granted') {
35 | const { status } = await Permissions.askAsync(Permissions.NOTIFICATIONS)
36 | finalStatus = status
37 | }
38 |
39 | // If push notification permission is not granted yet
40 | if (finalStatus !== 'granted') {
41 | // Deep link to Settings for iOS
42 | Platform.OS === 'ios'
43 | ? Alert.alert(
44 | 'Enable Notifications',
45 | 'Please enable push notification in settings to get the most out of this app.',
46 | [
47 | {
48 | text: 'SETTINGS',
49 | onPress: () => {
50 | !__DEV__
51 | ? Linking.openURL('app-settings://notification/expo')
52 | : Linking.openURL('app-settings:')
53 | },
54 | style: 'cancel'
55 | },
56 | {
57 | text: 'OK',
58 | onPress: () =>
59 | console.log('OK Pressed, error message dismissed')
60 | }
61 | ],
62 | { cancelable: false }
63 | )
64 | : Alert.alert(
65 | 'Enable Notifications',
66 | 'Please enable push notification in settings to get the most out of this app.',
67 | [
68 | {
69 | text: 'OK',
70 | onPress: () =>
71 | console.log('OK Pressed, error message dismissed')
72 | }
73 | ],
74 | { cancelable: false }
75 | )
76 | return
77 | }
78 |
79 | return Notifications.getExpoPushTokenAsync()
80 | }
81 |
82 | render() {
83 | const error = this.props.navigation.getParam('error', null)
84 | return (
85 |
86 |
92 | {error && {error.message}}
93 |
96 |
97 | )
98 | }
99 | }
100 |
101 | const styles = StyleSheet.create({
102 | container: {
103 | justifyContent: 'space-around',
104 | alignItems: 'center'
105 | },
106 | warning: {
107 | color: 'red'
108 | },
109 | button: {
110 | alignSelf: 'center'
111 | }
112 | })
113 |
--------------------------------------------------------------------------------
/mybuddy/screens/HomeScreen.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { Container, Content, Button, Text } from 'native-base'
3 | import Carousel from 'react-native-snap-carousel'
4 |
5 | import Card from '../components/Card'
6 |
7 | import {
8 | appointmentsThisWeekOnSnapshot,
9 | buddyAppointmentsThisWeekOnSnapshot,
10 | currentUserOnSnapshot
11 | } from '../api'
12 | import {
13 | APPOINTMENTS_SCREEN,
14 | BUDDY_APPOINTMENTS_SCREEN,
15 | EMERGENCIES_SCREEN
16 | } from '../navigation/keys'
17 | import { allEmergenciesOnSnapshot } from '../api/emergencies'
18 | import Layout from '../constants/Layout'
19 | import DEFAULT_COLORS from '../constants/Colors'
20 |
21 | export class HomeScreen extends Component {
22 | state = {
23 | user: {},
24 | emergencies: [],
25 | appointments: [],
26 | buddyAppointments: []
27 | }
28 |
29 | subscriptions = []
30 |
31 | async componentDidMount() {
32 | const currentUser = await currentUserOnSnapshot(user =>
33 | this.setState({ user })
34 | )
35 | const appointments = await appointmentsThisWeekOnSnapshot(appointments =>
36 | this.setState({ appointments })
37 | )
38 | const buddyAppointmentsThisWeek = await buddyAppointmentsThisWeekOnSnapshot(buddyAppointments =>
39 | this.setState({ buddyAppointments })
40 | )
41 | const allEmergencies = await allEmergenciesOnSnapshot(emergencies =>
42 | this.setState({ emergencies })
43 | )
44 | // Make promises cancellable
45 | await this.subscriptions.push(currentUser, appointments, buddyAppointmentsThisWeek, allEmergencies)
46 | }
47 |
48 | componentWillUnmount() {
49 | this.subscriptions.forEach(unsubscribe => unsubscribe())
50 | }
51 |
52 | render() {
53 | return (
54 |
55 | {this.state.user.currentEmergency && (
56 |
65 | You have an emergency active!
66 |
67 | )}
68 |
69 |
75 |
81 |
82 |
88 |
94 |
95 |
103 |
109 |
110 |
111 | )
112 | }
113 |
114 | _renderEmergencies = ({ item, index }) => {
115 | return
116 | }
117 |
118 | _renderAppointments = ({ item, index }) => {
119 | return
120 | }
121 |
122 | _renderBuddyAppointments = ({ item, index }) => {
123 | return
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/mybuddy/screens/TeamsScreen/TeamMembersScreen.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { StyleSheet } from 'react-native'
3 | import ActionButton from 'react-native-action-button'
4 | import {
5 | Container,
6 | Text,
7 | Content,
8 | CardItem,
9 | Icon,
10 | Left,
11 | Button,
12 | SwipeRow
13 | } from 'native-base'
14 |
15 | import MyTitle from '../../components/MyTitle'
16 | import Card from '../../components/Card'
17 | import DEFAULT_COLORS from '../../constants/Colors'
18 |
19 | import { managersTeamMembersOnSnapshot } from '../../api'
20 | import { db } from '../../firebase'
21 |
22 | export default class TeamMembersScreen extends Component {
23 | state = {
24 | team: {},
25 | manager: {},
26 | users: []
27 | }
28 |
29 | async componentDidMount() {
30 | const managerId = this.props.navigation.getParam('managerId')
31 | const manager = await db
32 | .collection('users')
33 | .doc(managerId)
34 | .get()
35 | .then(doc => ({ ...doc.data(), userRef: doc.ref, id: doc.id }))
36 |
37 | this.setState({ manager })
38 |
39 | this.unsubscribe = await managersTeamMembersOnSnapshot(manager)(users =>
40 | this.setState({ users })
41 | )
42 | }
43 |
44 | componentWillUnmount() {
45 | if (this.unsubscribe) this.unsubscribe()
46 | }
47 |
48 | _renderUsers = () => {
49 | // Check if there are team members
50 | if (!this.state.users.length) {
51 | return No Team Members
52 | }
53 |
54 | return (
55 |
56 | {this.state.users.map(({ userRef, ...user }) => (
57 |
62 |
63 |
64 |
69 | {user.displayName}
70 |
71 |
72 | >
73 | }
74 | right={
75 |
78 | }
79 | />
80 | ))}
81 |
82 | )
83 | }
84 |
85 | render() {
86 | const team = this.props.navigation.getParam('team')
87 | return (
88 |
89 |
92 |
93 |
94 | {this.state.team.name} Members
95 |
96 | Manager: {this.state.manager.displayName || 'n/a'}
97 |
98 | {this._renderUsers()}
99 |
100 |
101 |
102 | this.props.navigation.navigate('EditTeam', { team })}
106 | >
107 |
108 |
109 |
110 |
114 | this.props.navigation.navigate('AddUserToTeam', { team })
115 | }
116 | >
117 |
118 |
119 |
120 |
121 | )
122 | }
123 | }
124 |
125 | const styles = StyleSheet.create({
126 | actionButtonIcon: {
127 | fontSize: 20,
128 | height: 22,
129 | color: 'white'
130 | },
131 | removeIcon: {
132 | fontSize: 40,
133 | color: 'white'
134 | },
135 | cardItem: {
136 | fontSize: 40,
137 | color: 'gray',
138 | marginLeft: 5,
139 | marginRight: 5
140 | },
141 | noMembers: {
142 | fontStyle: 'italic',
143 | fontSize: 15
144 | },
145 | card: { paddingBottom: 0, paddingTop: 0 }
146 | })
147 |
--------------------------------------------------------------------------------
/mybuddy/components/CardItem/AppointmentInfo.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { StyleSheet } from 'react-native'
3 | import { Text, CardItem, Icon, Title, Row, Col } from 'native-base'
4 | import PropTypes from 'prop-types'
5 | import moment from 'moment'
6 |
7 | export default class AppointmentInfo extends Component {
8 | state = {
9 | style: {},
10 | showDescription: false
11 | }
12 |
13 | componentDidMount() {
14 | this.setState({
15 | style: { backgroundColor: this.props.backgroundColor || 'white' }
16 | })
17 | }
18 |
19 | _toggleDescription = () => {
20 | this.setState(({ showDescription }) => ({
21 | showDescription: !showDescription
22 | }))
23 | }
24 |
25 | render() {
26 | return (
27 | <>
28 | {this._renderHeader()}
29 |
30 |
31 | {/* Location & Date */}
32 |
33 |
34 |
39 | {this.props.address}
40 |
41 | {this.props.info}
42 |
43 |
44 | {/* Time */}
45 |
46 |
47 |
52 |
53 | {moment(this.props.startDateTime).format('MMM DD, YYYY')}
54 |
55 |
56 |
57 |
62 |
63 | {moment(this.props.startDateTime).format('HH:mm')} -{' '}
64 | {moment(this.props.endDateTime).format('HH:mm')}
65 |
66 |
67 |
68 |
69 |
70 | {this.props.buttonsComponent}
71 | {this._renderDescription()}
72 | >
73 | )
74 | }
75 |
76 | _renderHeader = () => {
77 | if (!this.props.header) return null
78 |
79 | if (this.props.renderHeader) {
80 | // renderProp function
81 | return this.props.renderHeader({ ...this.props })
82 | }
83 |
84 | if (this.props.isCard) {
85 | return (
86 |
90 |
91 | {this.props.title}
92 |
93 |
98 |
99 | )
100 | }
101 |
102 | return (
103 |
104 | {this.props.title}
105 |
106 | )
107 | }
108 |
109 | _renderDescription = () => {
110 | return (
111 | this.state.showDescription && (
112 |
121 | {this.props.description || 'no details provided'}
122 |
123 | )
124 | )
125 | }
126 | }
127 |
128 | AppointmentInfo.propTypes = {
129 | // Data
130 | title: PropTypes.string.isRequired,
131 | address: PropTypes.string.isRequired,
132 | startDateTime: PropTypes.object.isRequired,
133 | endDateTime: PropTypes.object.isRequired,
134 | // Properties
135 | buddy: PropTypes.any.isRequired,
136 | header: PropTypes.bool,
137 | isCard: PropTypes.bool,
138 | renderHeader: PropTypes.func,
139 | buttonsComponent: PropTypes.node,
140 | backgroundColor: PropTypes.string
141 | }
142 |
143 | AppointmentInfo.defaultProps = {
144 | header: true,
145 | isCard: false
146 | }
147 |
148 | const styles = StyleSheet.create({
149 | center: {
150 | alignItems: 'center'
151 | }
152 | })
153 |
--------------------------------------------------------------------------------
/mybuddy/components/__tests__/__snapshots__/SearchBar-test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Search bar rendering search bar renders correctly 1`] = `
4 |
19 |
27 |
48 |
58 |
59 |
106 |
107 |
108 |
109 |
110 |
133 |
144 |
151 |
152 |
153 |
154 |
155 | `;
156 |
--------------------------------------------------------------------------------
/mybuddy/screens/UsersScreen/UserScreen.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { Container, Content, Tabs, Tab, Text } from 'native-base'
3 | import _ from 'lodash'
4 | import { UserTab } from './UserTab'
5 | import SearchBar from '../../components/SearchBar'
6 | import firebase from '../../firebase'
7 | import Colors from '../../constants/Colors'
8 |
9 | export class UserScreen extends Component {
10 | constructor() {
11 | super()
12 | this.state = {
13 | loading: false,
14 | data: [],
15 | fullData: []
16 | }
17 | this.ref = firebase.firestore().collection('users')
18 | this.unsubscribe = null
19 | }
20 |
21 | componentDidMount() {
22 | this.unsubscribe = this.ref.onSnapshot(this._onCollectionUpdate)
23 | }
24 |
25 | componentWillUnmount() {
26 | this.unsubscribe()
27 | }
28 |
29 | _onCollectionUpdate = querySnapshot => {
30 | // If user is part of a team, fetch relevant data from db
31 | const users = []
32 | querySnapshot.forEach(doc => {
33 | const { displayName, email, team, accessLevel, uid } = doc.data()
34 | if (team) {
35 | this.setState({ loading: true })
36 | team
37 | .get()
38 | .then(teamDoc => {
39 | const { name } = teamDoc.data()
40 | users.push({
41 | key: uid,
42 | email, // DocumentSnapshot
43 | displayName,
44 | teamName: name,
45 | accessLevel
46 | })
47 | this.setState({ loading: false })
48 | })
49 | .catch(err => {
50 | console.log(err)
51 | })
52 | } else {
53 | users.push({
54 | key: uid,
55 | email, // DocumentSnapshot
56 | displayName,
57 | teamName: 'None',
58 | accessLevel
59 | })
60 | }
61 | })
62 | this.setState({
63 | loading: false,
64 | data: users,
65 | fullData: users
66 | })
67 | }
68 |
69 | _search = visibleUsers => {
70 | // Search for all users available
71 | this.setState({ data: visibleUsers })
72 | }
73 |
74 | render() {
75 | if (this.state.loading) {
76 | return (
77 |
78 |
79 | Loading...
80 |
81 |
82 | )
83 | } else {
84 | return (
85 |
86 |
92 |
98 |
108 |
109 | user.accessLevel === 'clinician'
112 | )}
113 | />
114 |
115 |
116 |
126 | user.accessLevel === 'teamManager'
129 | )}
130 | />
131 |
132 |
142 | user.accessLevel === 'admin'
145 | )}
146 | />
147 |
148 |
149 |
150 | )
151 | }
152 | }
153 | }
154 |
--------------------------------------------------------------------------------
/mybuddy/components/Form/BuddyPicker.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { View, Title, Text, Button, Row, Item, Label, Input } from 'native-base'
3 | import { StyleSheet } from 'react-native'
4 | import RNPickerSelect from 'react-native-picker-select'
5 |
6 | import { db } from '../../firebase'
7 | import { getCurrentUserAsync } from '../../api'
8 |
9 | export default class BuddyPicker extends Component {
10 | state = {
11 | currentBuddy: {},
12 | selectedBuddy: {},
13 | team: []
14 | }
15 |
16 | async componentDidMount() {
17 | const {
18 | field,
19 | form: { values }
20 | } = this.props
21 |
22 | const currentBuddy = values[field.name]
23 | this.setState({
24 | currentBuddy,
25 | selectedBuddy: currentBuddy
26 | })
27 |
28 | const user = await getCurrentUserAsync()
29 |
30 | await db
31 | .collection('users')
32 | // all users in the current users team
33 | .where('team', '==', user.team)
34 | .get()
35 | .then(querySnapshot =>
36 | querySnapshot.docs
37 | // remove the current user from results
38 | .filter(doc => doc.id !== user.uid)
39 | // all each user the list in state
40 | .forEach(doc =>
41 | this.setState(prevState => {
42 | const { displayName } = doc.data()
43 | // user object of shape {label: ..., value: ...}
44 | return {
45 | team: [
46 | ...prevState.team,
47 | {
48 | label: displayName,
49 | value:
50 | currentBuddy.user === doc.id
51 | ? currentBuddy
52 | : { user: doc.id, status: 'pending' }
53 | }
54 | ]
55 | }
56 | })
57 | )
58 | )
59 | }
60 |
61 | _updateFormik = value => {
62 | const {
63 | field,
64 | form: { setFieldValue }
65 | } = this.props
66 | setFieldValue(field.name, value)
67 | }
68 |
69 | _selectBuddy = selectedBuddy => {
70 | this.setState({ selectedBuddy })
71 | this._updateFormik(selectedBuddy)
72 | }
73 |
74 | _resetBuddy = () => {
75 | this.setState({ selectedBuddy: this.state.currentBuddy })
76 | this._updateFormik(this.state.currentBuddy)
77 | }
78 |
79 | render() {
80 | const {
81 | field,
82 | form: { touched, errors }
83 | } = this.props
84 | return (
85 |
86 | Buddy
87 |
88 | {this._renderField('label', 'Name')}
89 |
90 | (this.pickerSelectRef = ps)}
96 | >
97 |
100 |
101 |
102 |
103 |
104 | {this._renderField('value.status', 'Status')}
105 |
106 |
115 |
116 |
117 | {touched[field.name] && errors[field.name] && (
118 |
119 | {errors[field.name]}
120 |
121 | )}
122 | {this._renderWarningText()}
123 |
124 | )
125 | }
126 |
127 | _renderField = (fieldName, label) => {
128 | const buddy =
129 | this.state.team.find(
130 | item => item.value.user === this.state.selectedBuddy.user
131 | ) || {}
132 | let displayValue
133 | if (fieldName === 'label') {
134 | displayValue = buddy.label
135 | } else if (fieldName === 'value.status') {
136 | displayValue = buddy.value ? buddy.value.status : null
137 | }
138 | return (
139 | <>
140 | -
141 |
142 |
143 |
144 | >
145 | )
146 | }
147 |
148 | _renderWarningText = () => {
149 | const { currentBuddy, selectedBuddy } = this.state
150 | if (
151 | currentBuddy.status === 'confirmed' &&
152 | currentBuddy.user !== selectedBuddy.user
153 | ) {
154 | return (
155 |
163 | Warning: you are about to cancel a confirmed buddy, press reset if you
164 | do not intend to do this.
165 |
166 | )
167 | }
168 | }
169 | }
170 |
171 | const styles = StyleSheet.create({
172 | item: {
173 | alignSelf: 'center',
174 | flex: 1,
175 | marginLeft: 20,
176 | marginRight: 20,
177 | marginBottom: 5
178 | }
179 | })
180 |
--------------------------------------------------------------------------------
/mybuddy/components/CardItem/AppointmentCardItem.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { Alert } from 'react-native'
3 | import { Text, CardItem, Button, Row, Icon } from 'native-base'
4 | import { withNavigation } from 'react-navigation'
5 | import PropTypes from 'prop-types'
6 | import moment from 'moment'
7 | import omit from 'lodash/omit'
8 |
9 | import AppointmentInfo from './AppointmentInfo'
10 | import { getCurrentUserAsync } from '../../api'
11 |
12 | class AppointmentCardItem extends Component {
13 | state = {
14 | showDescription: false,
15 | buddyName: null,
16 | statusColor: null
17 | }
18 |
19 | componentDidMount() {
20 | this._setRequester(this.props.buddy)
21 | }
22 |
23 | componentDidUpdate(prevProps) {
24 | if (this.props.buddy !== prevProps.buddy) {
25 | this._setRequester(this.props.buddy)
26 | }
27 | }
28 |
29 | _setRequester = async buddy => {
30 | const buddyName = buddy.user
31 | ? await buddy.user.get().then(doc => doc.data().displayName)
32 | : null
33 |
34 | let statusColor = null
35 | switch (buddy.status) {
36 | case 'confirmed':
37 | statusColor = 'green'
38 | break
39 | case 'pending':
40 | statusColor = 'orange'
41 | break
42 | default:
43 | statusColor = 'red'
44 | }
45 | this.setState({ buddyName, statusColor })
46 | }
47 |
48 | _handleCheckIn = async () => {
49 | if (moment(this.props.startDateTime) >= moment().add(30, 'minutes')) {
50 | alert('You cannot check in until 30 minutes before your appointment')
51 | return
52 | }
53 | const user = await getCurrentUserAsync()
54 | user.userRef.update({ currentAppointment: this.props.appointmentRef })
55 | this.props.appointmentRef.update({ status: 'in-progress' })
56 | }
57 |
58 | _handleCheckOut = async () => {
59 | const user = await getCurrentUserAsync()
60 | user.userRef.update({ currentAppointment: null })
61 | this.props.appointmentRef.update({ status: 'done' })
62 | }
63 |
64 | _handleEdit = async () => {
65 | const buddyId = this.props.buddy.user
66 | ? await this.props.buddy.user.get().then(doc => doc.id)
67 | : null
68 |
69 | const updatedAppointment = omit(
70 | {
71 | ...this.props,
72 | buddy: {
73 | ...this.props.buddy,
74 | user: buddyId
75 | }
76 | },
77 | ['user', 'appointmentRef']
78 | )
79 |
80 | this.props.navigation.navigate('AppointmentForm', {
81 | appointment: updatedAppointment
82 | })
83 | }
84 |
85 | _handleExtend = () => {
86 | // Show message popup and options to extend
87 | Alert.alert('Extend Appointment', 'Add minutes to the end time...', [
88 | {
89 | text: '15 minutes',
90 | onPress: () =>
91 | this.props.appointmentRef.update({
92 | endDateTime: moment(this.props.endDateTime)
93 | .add(15, 'minutes')
94 | .toDate()
95 | })
96 | },
97 | {
98 | text: '30 minutes',
99 | onPress: () =>
100 | this.props.appointmentRef.update({
101 | endDateTime: moment(this.props.endDateTime)
102 | .add(30, 'minutes')
103 | .toDate()
104 | })
105 | },
106 | {
107 | text: 'Cancel',
108 | style: 'cancel'
109 | }
110 | ])
111 | }
112 |
113 | render() {
114 | return (
115 | <>
116 |
121 | >
122 | )
123 | }
124 |
125 | _renderBuddyInfo = () => {
126 | return (
127 | <>
128 |
133 | {this.state.buddyName || 'none selected'}
134 | >
135 | )
136 | }
137 |
138 | _renderButtons = () => {
139 | const { status, canEdit, canCheckIn } = this.props
140 | return (
141 |
142 | {status === 'in-progress' ? (
143 | <>
144 |
152 |
155 | >
156 | ) : (
157 | <>
158 | {canCheckIn && (
159 |
167 | )}
168 | {canEdit && (
169 |
172 | )}
173 | >
174 | )}
175 |
176 | )
177 | }
178 | }
179 |
180 | AppointmentCardItem.propTypes = {
181 | appointmentRef: PropTypes.object.isRequired,
182 | status: PropTypes.string.isRequired,
183 | canEdit: PropTypes.bool.isRequired,
184 | canCheckIn: PropTypes.bool.isRequired
185 | }
186 |
187 | AppointmentCardItem.defaultProps = {
188 | canEdit: false,
189 | canCheckIn: false
190 | }
191 |
192 | export default withNavigation(AppointmentCardItem)
193 |
--------------------------------------------------------------------------------
/mybuddy/components/__tests__/__snapshots__/FormInput-test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`DateTimePicker snapshot form input renders without errors 1`] = `
4 |
33 |
46 |
56 |
70 | Test
71 |
72 |
73 |
74 |
97 |
98 | `;
99 |
100 | exports[`Form input renders with errors form input renders with errors 1`] = `
101 | Array [
102 |
131 |
144 |
154 |
168 | Test
169 |
170 |
171 |
172 |
195 | ,
196 | ,
216 | ]
217 | `;
218 |
--------------------------------------------------------------------------------
/mybuddy/api/appointments.js:
--------------------------------------------------------------------------------
1 | import { db } from '../firebase'
2 | import { getCurrentUserAsync } from './users'
3 | import moment from 'moment'
4 |
5 | const thisWeekClause = [
6 | [
7 | 'startDateTime',
8 | '>=',
9 | moment()
10 | .startOf('day')
11 | .toDate()
12 | ],
13 | [
14 | 'startDateTime',
15 | '<=',
16 | moment()
17 | .startOf('isoWeek')
18 | .add(1, 'week')
19 | .toDate()
20 | ]
21 | ]
22 |
23 | /**
24 | * Turn array of appointments into an object of arrays,
25 | * with keys Today, Tomorrow and This Week.
26 | * @param appointments
27 | */
28 | const _reshapeWeek = appointments => {
29 | const apps = appointments.reduce(
30 | (obj, appointment) => {
31 | let label
32 | if (moment(appointment.startDateTime).isSame(moment(), 'day')) {
33 | label = 'Today'
34 | } else if (
35 | moment(appointment.startDateTime).isSame(moment().add(1, 'day'), 'day')
36 | ) {
37 | label = 'Tomorrow'
38 | } else {
39 | label = 'This Week'
40 | }
41 | return {
42 | ...obj,
43 | [label]: [...obj[label], appointment]
44 | }
45 | },
46 | { Today: [], Tomorrow: [], ['This Week']: [] }
47 | )
48 |
49 | return apps
50 | }
51 |
52 | /**
53 | * Fetch users appointments this week
54 | * @param onSnapshot function to run on update
55 | * @param reshape boolean if true object grouped today tomorrow this week
56 | * else return array of appoinments
57 | * @returns Promise(unsubscribe)
58 | */
59 | export const appointmentsThisWeekOnSnapshot = async (
60 | onSnapshot,
61 | reshape = false
62 | ) => {
63 | const user = await getCurrentUserAsync()
64 | const clauses = [...thisWeekClause, ['user', '==', user.userRef]]
65 | return await _allAppointmentsOnSnapshot(
66 | onSnapshot,
67 | clauses,
68 | reshape ? _reshapeWeek : null,
69 | true
70 | )
71 | }
72 |
73 | /**
74 | * Fetch all user appointments between dates
75 | * @param startDate Date to start search from (inclusive)
76 | * @param {*} endDate Date to end search on (inclusive)
77 | a* @param onSnapshot function to run on update
78 | * @returns Promise(unsubscribe)
79 | */
80 | export const appointmentsBetweenDatesOnSnapshot = (
81 | startDate,
82 | endDate
83 | ) => async onSnapshot => {
84 | const user = await getCurrentUserAsync()
85 | const clauses = [
86 | ['user', '==', user.userRef],
87 | ['startDateTime', '>=', startDate],
88 | ['startDateTime', '<=', endDate]
89 | ]
90 | return await _allAppointmentsOnSnapshot(onSnapshot, clauses)
91 | }
92 |
93 | /**
94 | * Fetch buddy appointments this week
95 | * @param onSnapshot function to run on update
96 | * @returns Promise(unsubscribe)
97 | */
98 | export const buddyAppointmentsThisWeekOnSnapshot = async (
99 | onSnapshot,
100 | reshape = false
101 | ) => {
102 | const user = await getCurrentUserAsync()
103 | const clauses = [
104 | ...thisWeekClause,
105 | ['buddy.user', '==', user.userRef],
106 | ['buddy.status', '==', 'confirmed']
107 | ]
108 | return await _allAppointmentsOnSnapshot(
109 | onSnapshot,
110 | clauses,
111 | reshape ? _reshapeWeek : null
112 | )
113 | }
114 |
115 | /**
116 | * Fetch all buddy appointments between dates
117 | * @param startDate Date to start search from (inclusive)
118 | * @param {*} endDate Date to end search on (inclusive)
119 | a* @param onSnapshot function to run on update
120 | * @returns Promise(unsubscribe)
121 | */
122 | export const buddyAppointmentsBetweenDatesOnSnapshot = (
123 | startDate,
124 | endDate
125 | ) => async onSnapshot => {
126 | const user = await getCurrentUserAsync()
127 | const clauses = [
128 | ['buddy.user', '==', user.userRef],
129 | ['startDateTime', '>=', startDate],
130 | ['startDateTime', '<=', endDate]
131 | ]
132 | return await _allAppointmentsOnSnapshot(onSnapshot, clauses)
133 | }
134 |
135 | /**
136 | * Fetch buddy appointments this week
137 | * @param onSnapshot function to run on update
138 | * @returns Promise(unsubscribe)
139 | */
140 | export const buddyRequestsOnSnapshot = async onSnapshot => {
141 | const user = await getCurrentUserAsync()
142 | const clauses = [
143 | ['buddy.user', '==', user.userRef],
144 | ['buddy.status', '==', 'pending']
145 | ]
146 | return await _allAppointmentsOnSnapshot(onSnapshot, clauses)
147 | }
148 |
149 | /**
150 | * Fetch appointments
151 | * @param onSnapshot function to run update
152 | * @param clauses where conditions
153 | * @param transformation function to transform the shape of the data returned
154 | * @returns Promise(unsubscribe)
155 | */
156 | const _allAppointmentsOnSnapshot = async (
157 | onSnapshot,
158 | clauses = [],
159 | transformation = null,
160 | notDone = false
161 | ) => {
162 | let appointmentsRef = db.collection('appointments')
163 | clauses.forEach(clause => {
164 | appointmentsRef = appointmentsRef.where(...clause)
165 | })
166 | return (
167 | appointmentsRef
168 | .orderBy('startDateTime')
169 | // listen real time
170 | .onSnapshot(
171 | querySnapshot => {
172 | let appointments = []
173 |
174 | appointments = querySnapshot.docs
175 | // only keep appointments where status is not done
176 | .filter(doc => (notDone ? doc.data().status !== 'done' : true))
177 | .map(doc => {
178 | const { startDateTime, endDateTime, ...data } = doc.data()
179 |
180 | return {
181 | ...data,
182 | startDateTime: new Date(startDateTime.seconds * 1000),
183 | endDateTime: new Date(endDateTime.seconds * 1000),
184 | appointmentRef: doc.ref,
185 | id: doc.id
186 | }
187 | })
188 |
189 | if (transformation) {
190 | appointments = transformation(appointments)
191 | }
192 |
193 | onSnapshot(appointments)
194 | },
195 | error => console.warn(error)
196 | )
197 | )
198 | }
199 |
--------------------------------------------------------------------------------
/mybuddy/components/SwipeToConfirm.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { Location, Permissions } from 'expo'
3 | import { Icon, Text, Button, View } from 'native-base'
4 | import { Platform, Linking, StyleSheet, SafeAreaView, Alert } from 'react-native'
5 | import { withNavigation } from 'react-navigation'
6 | import Modal from 'react-native-modal'
7 | import RNSwipeVerify from 'react-native-swipe-verify'
8 |
9 | import { db } from '../firebase'
10 | import { getCurrentUserAsync } from '../api/users'
11 | import DEFAULT_COLORS from '../constants/Colors'
12 |
13 | class SwipeToConfirm extends Component {
14 | constructor(props) {
15 | super(props)
16 | this.state = {
17 | isModalVisible: false,
18 | location: null
19 | }
20 | this._getLocationAsync = this._getLocationAsync.bind(this)
21 | this._toggleModal = this._toggleModal.bind(this)
22 | this._createEmergency = this._createEmergency.bind(this)
23 | this._renderModal = this._renderModal.bind(this)
24 | }
25 |
26 | componentWillMount() {
27 | // Will not work on Sketch in an Android emulator
28 | // Get user's location whenever the user initiates an emergency
29 | this._getLocationAsync()
30 | }
31 |
32 | componentDidUpdate(_, prevState) {
33 | if (this.state.isModalVisible !== prevState.isModalVisible) {
34 | this.props.navigation.closeDrawer()
35 | }
36 | }
37 |
38 | // Gets all the (gps) location data
39 | async _getLocationAsync() {
40 | // Ask permission and deep link to settings
41 | let {status} = await Permissions.askAsync(Permissions.LOCATION)
42 | if (status !== 'granted') {
43 | // Deep link to Settings for iOS
44 | Platform.OS === 'ios'
45 | ? (
46 | Alert.alert(
47 | 'Enable Location Permissions',
48 | 'Please enable Location in settings to get the most out of this app.',
49 | [
50 | {
51 | text: 'SETTINGS',
52 | onPress: () => {
53 | !__DEV__
54 | ? Linking.openURL('app-settings://notification/expo')
55 | : Linking.openURL('app-settings:')
56 | },
57 | style: 'cancel'
58 | },
59 | {
60 | text: 'OK',
61 | onPress: () => console.log('OK Pressed, error message dismissed'),
62 | }
63 | ],
64 | { cancelable: false }
65 | )
66 | )
67 | : (
68 | Alert.alert(
69 | 'Enable Notifications',
70 | 'Please enable push notification in settings to get the most out of this app.',
71 | [
72 | {
73 | text: 'OK',
74 | onPress: () => console.log('OK Pressed, error message dismissed'),
75 | }
76 | ],
77 | { cancelable: false }
78 | )
79 | )
80 | return
81 | }
82 |
83 | // Get location lat/lon
84 | let rawLocation = await Location.getCurrentPositionAsync({})
85 | let {latitude, longitude} = rawLocation['coords']
86 | // Get city, street, region, country, postal code,...
87 | let resolvedLocation = await Location.reverseGeocodeAsync({latitude, longitude})
88 | // Get the first match
89 | let {city, country, street, postalCode, isoCountryCode} = resolvedLocation[0]
90 | // Format it into the required format
91 | let location = {
92 | latitude,
93 | longitude,
94 | city,
95 | country,
96 | street,
97 | postalCode,
98 | countryCode: isoCountryCode
99 | }
100 | console.log(`Got Location: ${JSON.stringify(location)}`)
101 | await this.setState({location})
102 | }
103 |
104 | _toggleModal = () => {
105 | this.setState({ isModalVisible: !this.state.isModalVisible })
106 | }
107 |
108 | _createEmergency = async () => {
109 | try {
110 | const user = await getCurrentUserAsync()
111 | const emergency = await db.collection('emergencies').add({
112 | status: 'alert',
113 | user: user.userRef,
114 | team: user.team,
115 | appointment: user.currentAppointment || null,
116 | location: this.state.location
117 | })
118 | user.userRef.update({ currentEmergency: emergency })
119 | } catch (error) {
120 | alert ('Emergency Alert Failure!')
121 | error.message = '[Emergency Alert Failure]: ' + error.message
122 | console.warn(error)
123 | }
124 | this.setState({ isModalVisible: false })
125 | }
126 |
127 | render() {
128 | return (
129 | <>
130 |
133 | {this._renderModal()}
134 | >
135 | )
136 | }
137 |
138 | _renderModal = () => (
139 |
140 |
141 |
142 | Are you sure, you want to create an emergency?
143 |
144 |
145 | }
154 | >
155 | swipe to confirm
156 |
157 |
158 |
165 |
166 |
167 | )
168 | }
169 |
170 | const styles = StyleSheet.create({
171 | emergencyButton: {
172 | alignSelf: 'center',
173 | backgroundColor: DEFAULT_COLORS.dangerColor,
174 | marginBottom: 10
175 | },
176 | cancelButton: {
177 | alignSelf: 'center'
178 | },
179 | modal: {
180 | flex: 1,
181 | justifyContent: 'space-around'
182 | }
183 | })
184 |
185 | export default withNavigation(SwipeToConfirm)
186 |
--------------------------------------------------------------------------------
/mybuddy-api/functions/strategies.js:
--------------------------------------------------------------------------------
1 | /******************************************************************************
2 | * Auth Strategies
3 | * For PassportJS
4 | ******************************************************************************/
5 | 'use strict'
6 |
7 | const AzureAD = require('passport-azure-ad'),
8 | BearerStrategy = require('passport-http-bearer')
9 | .Strategy,
10 | _ = require('lodash'),
11 | config = require('./config')
12 |
13 | /**
14 | * Returns the User record if the user exists and null if he does not
15 | * Throws the error if it fails to
16 | */
17 | async function getUser(uid, firebase) {
18 | try {
19 | let userRecord = await firebase.auth()
20 | .getUser(uid)
21 | return userRecord
22 | } catch (err) {
23 | if (err.code === 'auth/user-not-found') {
24 | return null
25 | }
26 | throw err
27 | }
28 | }
29 |
30 |
31 | /**
32 | * Azure Active Directory OpenID Connect Strategy used for login/signup
33 | *
34 | * Verifies the credentials and updates the corresponding user from the db if it
35 | * exists (login) or creates it otherwise (signup)
36 | **/
37 | exports.AzureOIDC = function (firebase, users) {
38 | return new AzureAD.OIDCStrategy(
39 | _.cloneDeep(config.creds),
40 | function (iss, sub, profile, accessToken, refreshToken, params, done) {
41 | if (!profile.oid) {
42 | return done(new Error('No oid found'), null)
43 | }
44 |
45 | let user = {}
46 | // Async verification
47 | getUser(profile.oid, firebase)
48 | .then(userRecord => {
49 | // Check if user exists
50 | if (!userRecord) {
51 | // User doesn't exist yet, so create it
52 | user = {
53 | uid: profile.oid,
54 | email: profile._json.email,
55 | emailVerified: true,
56 | displayName: profile._json.name,
57 | disabled: false
58 | }
59 | console.info('User does not exist so creating user', profile.oid)
60 | // Create user
61 | return firebase
62 | .auth()
63 | .createUser(user)
64 | .then(newUserRecord => {
65 | // See the UserRecord reference doc for the contents of newUserRecord
66 | console.debug('Successfully created new user: ', newUserRecord.uid)
67 | /**
68 | * Create a extended user object in Firestore in order to
69 | * add additional user properties (due to Firebase Auth User
70 | * Record limitations)
71 | **/
72 | // Store Mircosoft account access and refresh token
73 | user.auth = { accessToken, refreshToken }
74 | // Default user properties
75 | Object.assign(user, config.DEFAULT_USER)
76 | return users.doc(profile.oid)
77 | .set(user)
78 | })
79 | .then(userDoc => {
80 | console.debug('Successfully created new user in Firestore: ', userDoc)
81 | })
82 | } else {
83 | // User already exists so get the extended user object and attach it to it
84 | console.info('User already exists so getting', profile.oid)
85 | return users
86 | .doc(profile.oid)
87 | .get()
88 | .then(firebaseUser => {
89 | // Ensure user exists
90 | if (!firebaseUser.exists) {
91 | return done(new Error('No user found'), null)
92 | }
93 | // Add the extended user object data
94 | Object.assign(user, userRecord, firebaseUser.data())
95 | })
96 | }
97 | })
98 | .then(() => {
99 | // Create a new custom token for user
100 | return firebase.auth()
101 | .createCustomToken(profile.oid)
102 | })
103 | .then(customToken => {
104 | // Save the custom token as the ID token for the user
105 | // Set user auth params for client callback
106 | user.customToken = customToken
107 | console.debug('Sending back user', user)
108 | // Auth successfull so pass back params in the client callback
109 | done(null, user)
110 | })
111 | .catch(err => {
112 | // Catch all other errors
113 | console.error(err)
114 | done(err)
115 | })
116 | }
117 | )
118 | }
119 |
120 | /**
121 | * Azure Active Directory OAuth 2.0 Strategy used for authenticating API calls
122 | *
123 | * Verifies the user ID token and attaches it as a `req.user` property to the
124 | * request to make it available to the middlewares downstream
125 | */
126 | exports.AzureBearer = function (users) {
127 | return new AzureAD.BearerStrategy(
128 | _.omit(config.creds, ['scope']),
129 | function (token, done) {
130 | // Get user from db
131 | users
132 | .doc(token.oid)
133 | .get()
134 | .then(firebaseUser => {
135 | // Ensure user exists
136 | if (!firebaseUser.exists) {
137 | return done(new Error('No user found'), null)
138 | }
139 | console.info('Successfully retrieved user', firebaseUser.data()
140 | .uid)
141 | done(null, firebaseUser.data(), token)
142 | })
143 | .catch(err => done(err))
144 | }
145 | )
146 | }
147 |
148 | /**
149 | * Firebase OAuth 2.0 Strategy used for authenticating API calls
150 | *
151 | * Verifies the user ID token and attaches it as a `req.user` property to the
152 | * request to make it available to the middlewares downstream
153 | *
154 | * Only accepts ID tokens (does NOT accept custom tokens sent back at signup/
155 | * login). To obtain an ID token from a custom token on the client-side
156 | * First call `firebase.auth().signInWithCustomToken()`
157 | * https://firebase.google.com/docs/auth/web/custom-auth#authenticate-with-firebase
158 | * Then call `firebase.auth().currentUser.getToken()` on the signed in user
159 | * https://firebase.google.com/docs/auth/admin/verify-id-tokens#retrieve_id_tokens_on_clients
160 | */
161 | exports.FirebaseBearer = function (firebase) {
162 | return new BearerStrategy(function (token, done) {
163 | firebase
164 | .auth()
165 | .verifyIdToken(token)
166 | .then(decodedToken => {
167 | console.debug('ID token is valid:', decodedToken)
168 | // Retrieve user via its uid
169 | return firebase.auth()
170 | .getUser(decodedToken.uid)
171 | })
172 | .then(user => {
173 | console.debug('Successfully fetched user data:', user.toJSON())
174 | done(null, user)
175 | })
176 | .catch(err => {
177 | // Catch all other errors
178 | console.error(err)
179 | done(err)
180 | })
181 | })
182 | }
--------------------------------------------------------------------------------
/mybuddy-api/functions/config.js:
--------------------------------------------------------------------------------
1 | /******************************************************************************
2 | * Config
3 | * Defines the configuration and constants for the API
4 | ******************************************************************************/
5 | 'use strict'
6 | /**
7 | * Login/sign up constraint
8 | *
9 | * For dev purposes, set to allow anyone with a work and school accounts from
10 | * Azure AD and personal Microsoft accounts (MSA), such as hotmail.com,
11 | * outlook.com, and msn.com, to login/sign up To use the common endpoint, either
12 | * turn `validateIssuer` off, or provide the `issuer` value
13 | *
14 | * TODO: change to 'nhs.onmicrosoft.com' to restrict login/sign up to just NHS employees
15 | * (from https://s3-eu-west-1.amazonaws.com/comms-mat/Comms-Archive/NHSmail_O365+Hybrid+Onboarding+Guidance+Document.pdf)
16 | **/
17 | const TENANT_ID = 'common'
18 | const PRODUCTION_HOST = 'https://europe-west1-mybuddy-47e82.cloudfunctions.net'
19 |
20 | /******************************************************************************
21 | * General
22 | ******************************************************************************/
23 | exports.FUNCTION_NAME = 'api'
24 | exports.IN_DEV = process.env.NODE_ENV === 'development'
25 | exports.PORT = 3000
26 | exports.HOST = exports.IN_DEV
27 | ? `http://localhost:${exports.PORT}`
28 | : `${PRODUCTION_HOST}/${exports.FUNCTION_NAME}`
29 | exports.FIREBASE_REGION = 'europe-west1' // https://firebase.google.com/docs/functions/locations
30 | exports.COOKIE_MAX_AGE = 15 * 60 * 1000 // 15 minutes
31 |
32 | // Signup defaults
33 | exports.DEFAULT_ACCESS_LEVEL = 'user'
34 | exports.DEFAULT_USER = {
35 | accessLevel: exports.DEFAULT_ACCESS_LEVEL,
36 | team: null
37 | }
38 |
39 | /******************************************************************************
40 | * Notifications
41 | ******************************************************************************/
42 | exports.NOTIFICATIONS_STATES = {
43 | PENDING: 'pending',
44 | IN_PROGRESS: 'in-progress',
45 | DONE: 'done'
46 | }
47 | // In minutes
48 | exports.NOTIFICATIONS_INTERVALS = {
49 | CHECK_IN: 15,
50 | CHECK_OUT: [5, 10, 15, 20],
51 | CHECK_OUT_BUDDY: 30
52 | }
53 | exports.NOTIFICATIONS_BUDDY_REQUEST = {
54 | title: 'New buddy request',
55 | body: 'You have a new buddy request',
56 | data: {
57 | type: 'buddy-request'
58 | }
59 | }
60 | exports.NOTIFICATIONS_CHECK_IN = {
61 | title: 'Time to check in!',
62 | body: 'You have appointment coming right up so check in!',
63 | data: {
64 | type: 'check-in'
65 | }
66 | }
67 | exports.NOTIFICATIONS_CHECK_OUT = {
68 | title: 'Time to check out!',
69 | body: 'Your appointment has finished so check out!',
70 | data: {
71 | type: 'check-out'
72 | }
73 | }
74 | exports.NOTIFICATIONS_BUDDY_CHECK_OUT = {
75 | title: 'Check up on your buddy!',
76 | body: 'Your buddy has not checked out yet!',
77 | data: { type: 'check-out-buddy' }
78 | }
79 |
80 | exports.NOTIFICATIONS_EMERGENCY = name => ({
81 | title: 'Emergency Alert',
82 | body: `${name} initiated an emergency! 🚨`,
83 | data: { type: 'emergency' },
84 | priority: 'high'
85 | })
86 |
87 | /******************************************************************************
88 | * Azure AD
89 | ******************************************************************************/
90 | exports.creds = {
91 | // Required
92 | identityMetadata: `https://login.microsoftonline.com/${TENANT_ID}/v2.0/.well-known/openid-configuration`,
93 | // or equivalently: 'https://login.microsoftonline.com//v2.0/.well-known/openid-configuration'
94 |
95 | // Required, the client ID of your app in AAD
96 | clientID: '44af2bda-c9bc-406c-9397-ca62f5e63d56',
97 |
98 | // Required, must be 'code', 'code id_token', 'id_token code' or 'id_token'
99 | // If you want to get access_token, you must use 'code', 'code id_token' or 'id_token code'
100 | responseType: 'code id_token',
101 |
102 | // Required
103 | responseMode: 'form_post',
104 |
105 | // Required, the reply URL registered in AAD for your app
106 | redirectUrl: `${exports.HOST}/auth/openid/return`,
107 |
108 | // Required if we use http for redirectUrl
109 | allowHttpForRedirectUrl: true,
110 |
111 | // Required if `responseType` is 'code', 'id_token code' or 'code id_token'.
112 | // If app key contains '\', replace it with '\\'.
113 | clientSecret: 'anzXE1017](gbbxQHYPQ2;_',
114 |
115 | // Required to set to false if you don't want to validate issuer
116 | validateIssuer: false,
117 |
118 | // Required if you want to provide the issuer(s) you want to validate instead of using the issuer from metadata
119 | // issuer could be a string or an array of strings of the following form: 'https://sts.windows.net//v2.0'
120 | issuer: null,
121 |
122 | // Required to set to true if the `verify` function has 'req' as the first parameter
123 | passReqToCallback: false,
124 |
125 | // Use cookies instead of express session
126 | useCookieInsteadOfSession: true,
127 |
128 | // Required if `useCookieInsteadOfSession` is set to true. You can provide multiple set of key/iv pairs for key
129 | // rollover purpose. We always use the first set of key/iv pair to encrypt cookie, but we will try every set of
130 | // key/iv pair to decrypt cookie. Key can be any string of length 32, and iv can be any string of length 12.
131 | cookieEncryptionKeys: [
132 | { key: '12345678901234567890123456789012', iv: '123456789012' },
133 | { key: 'abcdefghijklmnopqrstuvwxyzabcdef', iv: 'abcdefghijkl' }
134 | ],
135 |
136 | // The additional scopes we want besides 'openid'.
137 | // 'profile' scope is required, the rest scopes are optional.
138 | // (1) if you want to receive refresh_token, use 'offline_access' scope
139 | // (2) if you want to get access_token for graph api, use the graph api url like 'https://graph.microsoft.com/mail.read'
140 | scope: [
141 | 'profile',
142 | 'offline_access',
143 | 'email',
144 | 'openid',
145 | 'Calendars.Read.Shared',
146 | 'Calendars.Read'
147 | ],
148 |
149 | // Optional, 'error', 'warn' or 'info'
150 | loggingLevel: 'info',
151 |
152 | // Optional, If this is set to true, no personal information such as tokens and claims will be logged. The default value is true.
153 | // loggingNoPII: false,
154 |
155 | // Optional. The lifetime of nonce in session or cookie, the default value is 3600 (seconds).
156 | nonceLifetime: null,
157 |
158 | // Optional. The max amount of nonce saved in session or cookie, the default value is 10.
159 | nonceMaxAmount: 10,
160 |
161 | // Optional. The clock skew allowed in token validation, the default value is 300 seconds.
162 | clockSkew: 300,
163 |
164 | // B2B
165 | isB2C: false
166 |
167 | // Access token from AAD may not be a JWT (in which case only AAD knows how to validate it)
168 | // So using this URL ensures we always do
169 | // resourceURL: null,
170 |
171 | // Optional, default value is clientID
172 | // audience: null
173 | }
174 |
--------------------------------------------------------------------------------
/mybuddy/screens/AppointmentsScreen/AppointmentFormScreen.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { Container, Content, Text, Button, View } from 'native-base'
3 | import { StyleSheet, Alert } from 'react-native'
4 | import { withFormik, Field } from 'formik'
5 | import * as yup from 'yup'
6 | import moment from 'moment'
7 |
8 | import firebase, { db } from '../../firebase'
9 |
10 | import MyTitle from '../../components/MyTitle'
11 | import FormInput from '../../components/Form/FormInput'
12 | import DateTimePicker from '../../components/Form/DateTimePicker'
13 | import BuddyPicker from '../../components/Form/BuddyPicker'
14 |
15 | class AppointmentScreen extends Component {
16 | _handleCancel = () => {
17 | // Go back to the previous screen if user cancels
18 | this.props.navigation.goBack()
19 | }
20 |
21 | _handleDelete = () => {
22 | // Set up a confirmation popup
23 | Alert.alert(
24 | 'Delete Appointment',
25 | 'Are you sure you want to delete this appointment?',
26 | [
27 | {
28 | text: 'OK',
29 | onPress: async () => {
30 | try {
31 | this.props.setSubmitting(true)
32 | await this.props.values.appointmentRef.delete()
33 | this.props.navigation.goBack()
34 | } catch (error) {
35 | console.warn(error)
36 | this.props.setFieldError(
37 | 'root',
38 | 'Unable to complete your request.'
39 | )
40 | this.props.setSubmitting(false)
41 | }
42 | }
43 | },
44 | {
45 | text: 'Cancel',
46 | style: 'cancel'
47 | }
48 | ]
49 | )
50 | }
51 |
52 | render() {
53 | const { errors } = this.props
54 | return (
55 |
56 |
64 |
65 | {this._renderTitle()}
66 | {errors['root'] && (
67 |
68 | {errors['root']}
69 |
70 | )}
71 | {this._renderForm()}
72 | {this._renderButtons()}
73 |
74 |
75 | )
76 | }
77 |
78 | _renderTitle = () => {
79 | // Update title if appointment is created, otherwise set it to create appointment
80 | const isUpdate = Boolean(this.props.navigation.getParam('appointment'))
81 | return isUpdate ? (
82 | Update Appointment
83 | ) : (
84 | Create Appointment
85 | )
86 | }
87 |
88 | _renderForm = () => (
89 | <>
90 |
91 |
92 |
93 |
94 |
101 |
102 | >
103 | )
104 |
105 | _renderButtons = () => {
106 | // isUpdate checks if the appointment has been created
107 | const isUpdate = Boolean(this.props.navigation.getParam('appointment'))
108 | return (
109 |
110 | {isUpdate ? (
111 | <>
112 |
119 |
126 | >
127 | ) : (
128 |
135 | )}
136 |
137 | )
138 | }
139 | }
140 |
141 | const styles = StyleSheet.create({
142 | buttons: {
143 | flexDirection: 'row',
144 | justifyContent: 'space-evenly',
145 | paddingTop: 20
146 | }
147 | })
148 |
149 | export default withFormik({
150 | mapPropsToValues: props => {
151 | const appointment = props.navigation.getParam('appointment') || {}
152 | // Populate the appointment items
153 | return {
154 | title: appointment.title || '',
155 | description: appointment.description || '',
156 | address: appointment.address || '',
157 | startDateTime: appointment.startDateTime || new Date(),
158 | minutes:
159 | appointment.startDateTime && appointment.endDateTime
160 | ? moment(appointment.endDateTime).diff(
161 | appointment.startDateTime,
162 | 'minutes'
163 | )
164 | : 0,
165 | user: db.collection('users').doc(firebase.auth().currentUser.uid),
166 | status: appointment.status || 'pending',
167 | buddy: { status: 'n/a', user: null, ...appointment.buddy },
168 | appointmentRef: appointment.id
169 | ? db.collection('appointments').doc(appointment.id)
170 | : db.collection('appointments').doc()
171 | }
172 | },
173 | // Send the form data to db
174 | handleSubmit: async (values, { props, setSubmitting, setFieldError }) => {
175 | const { appointmentRef, minutes, ...appointment } = values
176 |
177 | const endDateTime = moment(appointment.startDateTime)
178 | .add(minutes, 'minutes')
179 | .toDate()
180 |
181 | try {
182 | const buddyUser = appointment.buddy.user
183 | ? await db
184 | .collection('users')
185 | .doc(appointment.buddy.user)
186 | .get()
187 | .then(doc => doc.ref)
188 | : null
189 |
190 | const finalAppointment = {
191 | ...appointment,
192 | endDateTime,
193 | buddy: { ...appointment.buddy, user: buddyUser }
194 | }
195 |
196 | await appointmentRef.set(finalAppointment)
197 | props.navigation.goBack()
198 | } catch (error) {
199 | console.warn(error)
200 | setFieldError('root', 'Unable to complete your request.')
201 | setSubmitting(false)
202 | }
203 | },
204 | // Enforce appointment input types
205 | validationSchema: yup.object({
206 | title: yup
207 | .string()
208 | .max(25)
209 | .required(),
210 | description: yup.string().max(50),
211 | address: yup
212 | .string()
213 | .max(50)
214 | .required(),
215 | startDateTime: yup.date().required(),
216 | minutes: yup
217 | .number()
218 | .min(15)
219 | .max(1440)
220 | .required('duration is a required field')
221 | .integer()
222 | })
223 | })(AppointmentScreen)
224 |
--------------------------------------------------------------------------------
/mybuddy-api/functions/notifications.js:
--------------------------------------------------------------------------------
1 | /******************************************************************************
2 | * Expo notifications
3 | ******************************************************************************/
4 | 'use strict'
5 |
6 | const Expo = require('expo-server-sdk')
7 | .Expo,
8 | _ = require('lodash'),
9 | moment = require('moment'),
10 | config = require('./config'),
11 | INIT_QUEUE = { queue: [] },
12 | STATE_COLLECTION = 'state',
13 | NOTIFICATION_QUEUE_DOC = 'notification-queue'
14 |
15 | // Create a new Expo SDK client
16 | let expo = new Expo()
17 |
18 | // For sorting notifications by the soonest
19 | function notifComparator(a, b) {
20 | return a.scheduledAtEpoch - b.scheduledAtEpoch
21 | }
22 |
23 | /**
24 | * Sends a push notifications
25 | *
26 | * @param {Message || [Message]} messages Expo push messages as per https://docs.expo.io/versions/latest/guides/push-notifications/#message-format
27 | * @return {void}
28 | */
29 | exports.send = async function (messages) {
30 | // Ensure messages is an array
31 | if (!_.isArray(messages)) {
32 | messages = [messages]
33 | }
34 | console.log('Sending notifications', messages)
35 |
36 | // Filter by valid push tokens
37 | messages = messages.filter(message => Expo.isExpoPushToken(message.to))
38 |
39 | if (_.isEmpty(messages)) {
40 | // Throw an error if there are no valid push tokens
41 | return Promise.reject(new Error('No valid push tokens'))
42 | }
43 |
44 | // The Expo push notification service accepts batches of notifications. So
45 | // batch notifications to reduce the number of requests and to compress them
46 | // (notifications with similar content will get compressed)
47 | let chunks = expo.chunkPushNotifications(messages)
48 |
49 | // Send the chunks to the Expo push notification service one chunk at a
50 | // time to spread the load out over time
51 | for (let chunk of chunks) {
52 | try {
53 | let ticketChunk = await expo.sendPushNotificationsAsync(chunk)
54 | console.log('Notification sent', ticketChunk)
55 | if (ticketChunk.status === 'error') {
56 | console.error('An error occured while sending notification', chunk, ticketChunk)
57 | }
58 | } catch (error) {
59 | console.error(error)
60 | return Promise.reject(error)
61 | }
62 | }
63 | }
64 |
65 | /**
66 | * Schedules a push notification to be sent to a user
67 | *
68 | * @param {Object} db A Firestore instance
69 | * @param {Moment || [Moment]} scheduledAts The times to schedule the notification for
70 | * @param {String} pushToken The push token of the destined user
71 | * @param {Object} groupId The group id of the notification to group it
72 | *
73 | * @return {Promise} A Firestore DB transaction
74 | */
75 | exports.schedule = function (db, scheduledAts, payload, pushToken, groupId) {
76 | console.log('Scheduling notification')
77 | // Get notification queue data ref
78 | const notifQueueRef = db.collection(STATE_COLLECTION).doc(NOTIFICATION_QUEUE_DOC)
79 | // Run all ops as a db transaction to alleviate concurrency issues
80 | return db.runTransaction(async function (transaction) {
81 | const notifQueueDoc = await transaction.get(notifQueueRef)
82 | let notifQueue
83 | // Ensure notification queue exists
84 | if (!notifQueueDoc.exists) {
85 | // Initialise it
86 | notifQueue = _.cloneDeep(INIT_QUEUE)
87 | console.log('notifQueue does not exist so initializing it', INIT_QUEUE)
88 | } else {
89 | // Retrieve it
90 | notifQueue = notifQueueDoc.data()
91 | console.log('notifQueue already exists so using it', notifQueue)
92 | }
93 |
94 | // Ensure scheduledAts is an array
95 | if (!_.isArray(scheduledAts)) {
96 | scheduledAts = [scheduledAts]
97 | }
98 |
99 | // Schedule the notifications
100 | scheduledAts.forEach((scheduledAt) => {
101 | let notification = Object.assign({}, payload, {
102 | to: pushToken,
103 | scheduledAt: scheduledAt.toISOString(),
104 | scheduledAtEpoch: scheduledAt.unix(),
105 | groupId
106 | })
107 | // Add to the front of the queue
108 | notifQueue.queue.unshift(notification)
109 | console.log('Adding notification to queue', notification)
110 | })
111 | // Sort queue by the soonest to be processed
112 | notifQueue.queue.sort(notifComparator)
113 | transaction.set(notifQueueRef, notifQueue)
114 | console.log('Set queue', notifQueue)
115 | return transaction
116 | })
117 | }
118 |
119 | /**
120 | * Processes the scheduled push notification in the notification queue
121 | * Sends the scheduled notifications
122 | *
123 | * @param {Object} db A Firestore instance
124 | * @return {Promise} A promise for the notification send requests results
125 | */
126 | exports.process = function (db) {
127 | console.log('Processing notification')
128 | // Get notification queue data ref
129 | const notifQueueRef = db.collection(STATE_COLLECTION).doc(NOTIFICATION_QUEUE_DOC)
130 | // Run all ops as a db transaction to alleviate concurrency issues
131 | return db.runTransaction(async function (transaction) {
132 | const notifQueueDoc = await transaction.get(notifQueueRef)
133 | // Ensure notification queue exists
134 | if (!notifQueueDoc.exists) {
135 | // Doesn't exist so nothing to process
136 | console.log('Notification queue does not exist so nothing to proceess')
137 | return Promise.resolve()
138 | }
139 |
140 | // Retrieve the notification queue
141 | const notifQueue = notifQueueDoc.data()
142 | const nowEpoch = moment().unix()
143 | let notifMessages = []
144 | // Find all the notifications that need to be sent
145 | for (let i = 0; i < notifQueue.queue.length; i++) {
146 | if (notifQueue.queue[i].scheduledAtEpoch < nowEpoch) {
147 | // Add to messages to send
148 | let notifMessage = notifQueue.queue.shift()
149 | notifMessages.push(notifMessage)
150 | console.log('Enqueueing for processing notification', notifMessage)
151 | } else {
152 | // No more notifications to send
153 | break
154 | }
155 | }
156 |
157 | if (_.isEmpty(notifMessages)) {
158 | // Empty so nothing to process
159 | console.log('No new notifications to proceess')
160 | return Promise.resolve()
161 | }
162 |
163 | transaction.update(notifQueueRef, notifQueue)
164 | console.log('Updated notification queue', notifQueue)
165 | // Send notifications
166 | return exports.send(notifMessages)
167 | })
168 | }
169 |
170 | /**
171 | * Cancels a group of scheduled push notification in the notification queue
172 | *
173 | * @param {Object} db A Firestore instance
174 | * @param {String} groupId The ID of the group of notifications to cancel
175 | * @return {Promise} An empty promise
176 | */
177 | exports.cancel = function (db, groupId) {
178 | // Get notification queue data ref
179 | const notifQueueRef = db.collection(STATE_COLLECTION).doc(NOTIFICATION_QUEUE_DOC)
180 | // Run all ops as a db transaction to alleviate concurrency issues
181 | return db.runTransaction(async function (transaction) {
182 | const notifQueueDoc = await transaction.get(notifQueueRef)
183 | // Ensure notification queue exists
184 | if (!notifQueueDoc.exists) {
185 | // Doesn't exist so nothing to process
186 | return Promise.resolve()
187 | }
188 |
189 | // Retrieve the notification queue
190 | const notifQueue = notifQueueDoc.data()
191 |
192 | if (_.isEmpty(notifQueue.queue)) {
193 | // Empty so nothing to process
194 | return Promise.resolve()
195 | }
196 |
197 | // Filter the group of notifications out
198 | notifQueue.queue = notifQueue.queue.filter(notif => notif.groupId !== groupId)
199 | // Sort queue by the soonest to be processed
200 | notifQueue.queue.sort(notifComparator)
201 | transaction.update(notifQueueRef, notifQueue)
202 | return Promise.resolve()
203 | })
204 | }
--------------------------------------------------------------------------------
/mybuddy-api/functions/index.js:
--------------------------------------------------------------------------------
1 | /******************************************************************************
2 | * myBuddy API v1
3 | * Primary entry point to the API
4 | ******************************************************************************/
5 | 'use strict'
6 |
7 | const express = require('express'),
8 | asyncHandler = require('express-async-handler'),
9 | querystring = require('querystring'),
10 | cookieParser = require('cookie-parser'),
11 | bodyParser = require('body-parser'),
12 | methodOverride = require('method-override'),
13 | passport = require('passport'),
14 | authParser = require('express-auth-parser'),
15 | morgan = require('morgan'),
16 | firebase = require('firebase-admin'),
17 | _ = require('lodash'),
18 | moment = require('moment'),
19 | config = require('./config'),
20 | firestore = require('./firestore'),
21 | notifications = require('./notifications'),
22 | authStrategies = require('./strategies')
23 |
24 | const functions = require('firebase-functions').region(config.FIREBASE_REGION)
25 |
26 | // Firebase Cloud Functions mounts the app at HOST/FUNCTION_NAME
27 | const BASE_PATH = config.IN_DEV ? '' : `/${config.FUNCTION_NAME}`
28 | const AUTH_SUCCESS = 0
29 | const AUTH_FAIL = 1
30 | const AUTH_FAIL_PATH = `${BASE_PATH}/auth/callback?result=${AUTH_FAIL}`
31 | // Init Firebase
32 | firebase.initializeApp()
33 | // Get DB refs
34 | const db = firebase.firestore()
35 | // User data ref
36 | const users = db.collection('users')
37 |
38 | // Setup passport to use the auth strategies
39 | const AzureOIDCStrategy = authStrategies.AzureOIDC(firebase, users)
40 | const FirebaseBearerStrategy = authStrategies.FirebaseBearer(firebase)
41 | passport.use(AzureOIDCStrategy)
42 | passport.use(FirebaseBearerStrategy)
43 |
44 | /**
45 | * App init
46 | */
47 | const app = express()
48 |
49 | app.use(morgan('dev'))
50 | app.use(methodOverride())
51 | app.use(cookieParser())
52 | app.use(authParser)
53 | app.use(bodyParser.json())
54 | app.use(bodyParser.urlencoded({ extended: true }))
55 |
56 | // Initialize Passport
57 | // Use passport.session() middleware
58 | app.use(passport.initialize())
59 | app.use(passport.session())
60 |
61 | /**
62 | * Middleware for authenticated routes to ensure the user is already logged in
63 | * throws a 401 if not
64 | */
65 | const ensureAuthenticated = passport.authenticate('bearer', { session: false })
66 |
67 | /******************************************************************************
68 | * Routes
69 | ******************************************************************************/
70 |
71 | /**
72 | * Processes the notifications queue
73 | *
74 | * To be polled at an interval
75 | */
76 | app.get(
77 | '/notifications/process',
78 | asyncHandler(async (req, res, next) => {
79 | await notifications.process(db)
80 | res.send('done ✅')
81 | })
82 | )
83 |
84 | /**
85 | * Sends push notifications
86 | *
87 | * Requires a `messages` param of the format defined at
88 | * https://docs.expo.io/versions/latest/guides/push-notifications/#message-format
89 | */
90 | app.post(
91 | '/notifications/send',
92 | asyncHandler(async (req, res, next) => {
93 | let messages = req.body.messages
94 | console.log('Messgaes', messages)
95 | if (!messages) {
96 | res.status(400).send('Bad Request: messages missing g')
97 | return
98 | }
99 | await notifications.send(messages)
100 | res.send('sent ✅')
101 | })
102 | )
103 |
104 | /**
105 | * Client auth url (the `authUrl`)
106 | *
107 | * Initiates the client login/signup (OpenID Connect) flow
108 | * Final auth result is passed back to the client at the specified, as a query
109 | * param, `redirectUrl` along with the user data (on success) being encoded as a
110 | * query string with the param `result` being:
111 | * 0 - success
112 | * 1 - failure
113 | *
114 | * And, on success, the user data being:
115 | * uid - the user's ID (the Microsoft account's `oid`)
116 | * name - the user's full name
117 | * email - the user's email
118 | * customToken - the custom token to be used to sign into Firebase on the client-side
119 | * (https://firebase.google.com/docs/auth/web/custom-auth#authenticate-with-firebase)
120 | * accessToken - the access token for accessing the user's Micorsoft account data
121 | * refreshToken - the refresh token for the access token (above)
122 | */
123 | app.get(
124 | '/auth/login',
125 | function(req, res, next) {
126 | let redirectUrl = req.query.redirectUrl
127 | // Ensure redirectUrl is passed
128 | if (!redirectUrl) {
129 | res.status(400).send('Bad Request: the redirectUrl is missing g')
130 | return
131 | }
132 | // Save the specified redirectUrl as a cookie to be used as the client callback
133 | res.cookie('redirectUrl', redirectUrl, { maxAge: config.COOKIE_MAX_AGE })
134 | console.info('Starting login/signup')
135 | passport.authenticate('azuread-openidconnect', {
136 | response: res, // required
137 | session: false, // do not persist
138 | failureRedirect: AUTH_FAIL_PATH
139 | })(req, res, next)
140 | },
141 | function(req, res) {
142 | res.redirect(AUTH_FAIL_PATH)
143 | }
144 | )
145 |
146 | /**
147 | * Server auth callback (the Redirect URL)
148 | *
149 | * Processes the login/signup result received from Azure AD authenticating the
150 | * content returned in query (such as authorization code)
151 | **/
152 | app.post(
153 | '/auth/openid/return',
154 | function(req, res, next) {
155 | passport.authenticate('azuread-openidconnect', {
156 | response: res, // required
157 | session: false, // do not persist
158 | failureRedirect: AUTH_FAIL_PATH
159 | })(req, res, next)
160 | },
161 | function(req, res) {
162 | // Flatten the user data and pass it back to the client side
163 | let resUser = Object.assign(
164 | { result: AUTH_SUCCESS },
165 | _.pick(req.user, ['customToken'])
166 | )
167 | let redirectUrl = encodeURI(
168 | req.cookies.redirectUrl + '?' + querystring.stringify(resUser)
169 | )
170 | console.info(
171 | `Finished processing result from Azure AD. Redirecting to ${redirectUrl}`
172 | )
173 | res.redirect(redirectUrl)
174 | }
175 | )
176 |
177 | /**
178 | * Gotta catch em all!
179 | */
180 | app.get('*', function(req, res) {
181 | res.status(404).send('404 welcome to the abyss!')
182 | })
183 |
184 | /******************************************************************************
185 | * Server
186 | ******************************************************************************/
187 |
188 | /**
189 | * Start dev server when in dev environment
190 | */
191 | if (config.IN_DEV) {
192 | app.listen(config.PORT, function() {
193 | console.info(`myBuddy running at ${config.HOST}`)
194 | })
195 | }
196 |
197 | // Expose Express API as a single Firebase Cloud Function
198 | exports[config.FUNCTION_NAME] = functions.https.onRequest(app)
199 |
200 | /******************************************************************************
201 | * Cloud Firestore functions
202 | * Invoked on Firestore changes
203 | * https://firebase.google.com/docs/firestore/extend-with-functions
204 | ******************************************************************************/
205 |
206 | /**
207 | * Listens to new appointments to send buddy request notifications
208 | */
209 | exports.createAppointment = functions.firestore
210 | .document('appointments/{appointmentId}')
211 | .onCreate((snap, context) => firestore.createAppointment(snap, context, db))
212 |
213 | /**
214 | * Listens to appointments status updates to enqueue/dequeue notifications
215 | */
216 | exports.updateAppointment = functions.firestore
217 | .document('appointments/{appointmentId}')
218 | .onUpdate((snap, context) => firestore.updateAppointment(snap, context, db))
219 |
220 | /**
221 | * Listens to appointments deletion to cancel remaining notifications
222 | */
223 | exports.deleteAppointment = functions.firestore
224 | .document('appointments/{appointmentId}')
225 | .onDelete((snap, context) => firestore.deleteAppointment(snap, context, db))
226 |
227 | /**
228 | * Listens to new emergencies to send emergency notification to everyone
229 | * in the users team
230 | */
231 | exports.createEmergency = functions.firestore
232 | .document('emergencies/{emergencyId}')
233 | .onCreate((snap, context) => firestore.createEmergency(snap, context, users))
234 |
--------------------------------------------------------------------------------
/mybuddy-api/functions/firestore.js:
--------------------------------------------------------------------------------
1 | /******************************************************************************
2 | * Cloud Firestore functions
3 | * Invoked on Firestore changes
4 | * https://firebase.google.com/docs/firestore/extend-with-functions
5 | ******************************************************************************/
6 | 'use strict'
7 |
8 | const notifications = require('./notifications'),
9 | _ = require('lodash'),
10 | moment = require('moment'),
11 | config = require('./config')
12 |
13 | /**
14 | * Calculates the scheduleAts from the appointment date
15 | *
16 | * @param {Moment} appointmentDate the appointment date
17 | * @param {Number || [Number]} intervals the scheduling intervals
18 | * @param {String} op the moment operation to apply (add, subtract,...)
19 | * @return {[Moment]} the scheduledAts
20 | */
21 | function calcScheduleAts(appointmentDate, intervals, op) {
22 | // Ensure intervals is an array
23 | if (!_.isArray(intervals)) {
24 | intervals = [intervals]
25 | }
26 |
27 | return intervals.map(interval => {
28 | // Ensure immutability of appointmentDate
29 | return appointmentDate.clone()[op](interval, 'minutes')
30 | })
31 | }
32 |
33 | /**
34 | * Schedules the checkout notifications for the buddy and clinician
35 | *
36 | * @param {Document} appointment appointment document
37 | * @param {String} appointmentId the appointment id
38 | * @param {Object} db database ref
39 | * @return {Promise} a promise that's rejected if any work fails
40 | */
41 | function scheduleCheckOuts(appointment, appointmentId, db) {
42 | let promises = []
43 | // Momentify notification due date
44 | const endDate = moment(appointment.endDateTime.toDate())
45 |
46 | // Schedule clinician checkout notifications
47 | promises.push(
48 | appointment.user.get().then(userDoc => {
49 | const user = userDoc.data()
50 | // Ensure the user and push token exist
51 | if (!user || (user && !user.pushToken)) {
52 | return Promise.resolve()
53 | }
54 | // Calculate times to schedule the notifications
55 | const scheduledAts = calcScheduleAts(
56 | endDate,
57 | config.NOTIFICATIONS_INTERVALS.CHECK_OUT,
58 | 'add'
59 | )
60 | console.log('Scheduling clinician checkout notifications', scheduledAts)
61 | return notifications.schedule(
62 | db,
63 | scheduledAts,
64 | config.NOTIFICATIONS_CHECK_OUT,
65 | user.pushToken,
66 | appointmentId
67 | )
68 | })
69 | )
70 |
71 | // Schedule buddy check out notification if specified
72 | if (appointment.buddy.user) {
73 | promises.push(
74 | appointment.buddy.user.get().then(buddyDoc => {
75 | const buddy = buddyDoc.data()
76 | // Ensure the user and push token exist
77 | if (!buddy || (buddy && !buddy.pushToken)) {
78 | return Promise.resolve()
79 | }
80 | // Calculate buddy check out notification time
81 | const scheduledAts = calcScheduleAts(
82 | endDate,
83 | config.NOTIFICATIONS_INTERVALS.CHECK_OUT_BUDDY,
84 | 'add'
85 | )
86 | console.log('Scheduling buddy checkout notifications', scheduledAts)
87 | return notifications.schedule(
88 | db,
89 | scheduledAts,
90 | config.NOTIFICATIONS_BUDDY_CHECK_OUT,
91 | buddy.pushToken,
92 | appointmentId
93 | )
94 | })
95 | )
96 | }
97 |
98 | return Promise.all(promises)
99 | }
100 |
101 | /**
102 | * Listens to new appointments to send buddy request notifications
103 | */
104 | exports.createAppointment = (appointmentDoc, context, db) => {
105 | // Get the newly created appointment
106 | const appointment = appointmentDoc.data()
107 | console.log('Created appointment', context.params.appointmentId)
108 | let promises = []
109 | // Send check in notification
110 | promises.push(
111 | appointment.user.get().then(userDoc => {
112 | const user = userDoc.data()
113 | console.log('Got user', user)
114 | // Ensure the user and push token exist
115 | if (!user || (user && !user.pushToken)) {
116 | console.log('user or push token does not exist')
117 | return Promise.resolve()
118 | }
119 | const notifDueDate = moment(appointment.startDateTime.toDate())
120 | // Calculate time to schedule the notification for
121 | const scheduledAt = calcScheduleAts(
122 | notifDueDate,
123 | config.NOTIFICATIONS_INTERVALS.CHECK_IN,
124 | 'subtract'
125 | )
126 | console.log(
127 | 'Scheduling notification',
128 | scheduledAt,
129 | config.NOTIFICATIONS_CHECK_IN
130 | )
131 | return notifications.schedule(
132 | db,
133 | scheduledAt,
134 | config.NOTIFICATIONS_CHECK_IN,
135 | user.pushToken,
136 | context.params.appointmentId
137 | )
138 | })
139 | )
140 |
141 | // Send buddy request notification if buddy specified
142 | if (appointment.buddy.user) {
143 | promises.push(
144 | appointment.buddy.user.get().then(buddyDoc => {
145 | const buddy = buddyDoc.data()
146 | // Ensure the buddy and push token exist
147 | if (!buddy || (buddy && !buddy.pushToken)) {
148 | return Promise.resolve()
149 | }
150 | let notification = Object.assign(
151 | {
152 | to: buddy.pushToken
153 | },
154 | config.NOTIFICATIONS_BUDDY_REQUEST
155 | )
156 | return notifications.send(notification)
157 | })
158 | )
159 | }
160 | // Terminate async background function once all promises have been processed
161 | return Promise.all(promises)
162 | }
163 |
164 | /**
165 | * Listens to appointments status updates to enqueue/dequeue notifications
166 | */
167 | exports.updateAppointment = (appointmentDoc, context, db) => {
168 | // Get new appointment
169 | const newAppointment = appointmentDoc.after.data()
170 | // Get previous appointment before this update
171 | const previousAppointment = appointmentDoc.before.data()
172 | console.log('Changed appointment', context.params.appointmentId)
173 |
174 | // Detect appointment status changes
175 | if (newAppointment.status !== previousAppointment.status) {
176 | console.log(`Appointment status changed to ${newAppointment.status}`)
177 | switch (newAppointment.status) {
178 | case config.NOTIFICATIONS_STATES.IN_PROGRESS:
179 | // Checkin
180 | // Schedule checkout notifications for appointment
181 | return scheduleCheckOuts(
182 | newAppointment,
183 | context.params.appointmentId,
184 | db
185 | )
186 | case config.NOTIFICATIONS_STATES.DONE:
187 | // Checkout
188 | // Cancel remaining checkout notifications for appointment
189 | return notifications.cancel(db, context.params.appointmentId)
190 | default:
191 | console.log('Unknown appointment state')
192 | return Promise.resolve()
193 | }
194 | }
195 |
196 | // Detect appointment extensions
197 | if (!newAppointment.endDateTime.isEqual(previousAppointment.endDateTime)) {
198 | console.log(`Appointment extended to ${newAppointment.endDateTime}`)
199 | let promises = []
200 | // Cancel all currently scheduled notifications for appointment
201 | promises.push(notifications.cancel(db, context.params.appointmentId))
202 | // Re-schedule checkout notifications for appointment
203 | return _.concat(
204 | promises,
205 | scheduleCheckOuts(newAppointment, context.params.appointmentId, db)
206 | )
207 | }
208 | }
209 |
210 | /**
211 | * Listens to appointments deletion to cancel remaining notifications
212 | */
213 | exports.deleteAppointment = (appointmentDoc, context, db) => {
214 | // Get the deleted appointment
215 | console.log('Deleted appointment', context.params.appointmentId)
216 | // Cancel any scheduled notifications
217 | return notifications.cancel(db, context.params.appointmentId)
218 | }
219 |
220 | /**
221 | * Listens to new emergencies to send emergency notification to everyone
222 | * in the users team
223 | */
224 | exports.createEmergency = async (emergencyDoc, context, users) => {
225 | const userRef = emergencyDoc.data().user
226 | const userData = await userRef.get().then(userDoc => userDoc.data())
227 | console.log(userData.displayName, 'initiated an emergency')
228 |
229 | // Check if user has a team
230 | if (!userData.team) {
231 | console.warn(userData.displayName, 'is not a in team!')
232 | return
233 | }
234 |
235 | console.log('Created emergency', context.params.emergencyId)
236 | return (
237 | users
238 | // Get users in the same team
239 | .where('team', '==', userData.team)
240 | .get()
241 | .then(querySnapshot => {
242 | return (
243 | querySnapshot.docs
244 | // Don't send notification to user that initiated request
245 | .filter(userDoc => userDoc.id !== userData.uid)
246 | // Remove users with no pushToken
247 | .filter(userDoc => Boolean(userDoc.data().pushToken))
248 | // Construct messages array
249 | .map(userDoc => {
250 | const { pushToken, displayName } = userDoc.data()
251 | console.log('Sending emergency notifications to:', displayName)
252 | return Object.assign(
253 | { to: pushToken },
254 | config.NOTIFICATIONS_EMERGENCY(userData.displayName)
255 | )
256 | })
257 | )
258 | })
259 | // Send notifications
260 | .then(messages => notifications.send(messages))
261 | )
262 | }
263 |
--------------------------------------------------------------------------------