├── run-server ├── assets ├── images │ ├── icon.png │ ├── favicon.png │ ├── old-man.png │ ├── splash.png │ ├── avocado-man.png │ ├── adaptive-icon.png │ ├── RecordingButton.png │ └── RecordingIndicator.png └── fonts │ └── SpaceMono-Regular.ttf ├── gcloud-functions ├── .firebaserc ├── firebase.json └── functions │ ├── .gitignore │ ├── tsconfig.json │ ├── package.json │ ├── src │ └── index.ts │ └── tslint.json ├── babel.config.js ├── constants ├── Contact.ts ├── Layout.ts └── Colors.ts ├── components ├── EvenSpacedView.tsx ├── StyledText.tsx ├── __tests__ │ ├── StyledText-test.js │ └── __snapshots__ │ │ └── StyledText-test.js.snap ├── Themed.tsx ├── EpisodeListingOverview.tsx ├── TimePicker.tsx ├── EpisodeOverlay.tsx ├── EpisodeRecallOverlay.tsx ├── EditScreenInfo.tsx ├── EpisodeTutorialOverlay.tsx └── EpisodeInputFields.tsx ├── .gitignore ├── hooks ├── useColorScheme.web.ts ├── useColorScheme.ts ├── useMainNavigation.ts └── useCachedResources.ts ├── utils ├── AsyncStorageUtils.ts ├── StylingUtils.ts └── TimeUtils.ts ├── tsconfig.json ├── .expo-shared └── assets.json ├── .github └── workflows │ ├── test.yml │ └── tsc.yml ├── navigation ├── LinkingConfiguration.ts ├── MainStack.tsx ├── MainNavigationContext.ts ├── CameraFlowNavigator.tsx └── MainNavigator.tsx ├── README.md ├── types.tsx ├── screens ├── OnboardingScreen │ ├── Day2+3 │ │ ├── Intro2Day2.tsx │ │ ├── Intro1Day2.tsx │ │ └── Intro3Day2.tsx │ ├── Day1 │ │ ├── Intro2.tsx │ │ ├── Intro3.tsx │ │ ├── Intro4.tsx │ │ ├── Intro5.tsx │ │ └── Intro1.tsx │ ├── AIntroScreen.tsx │ └── index.tsx ├── NotFoundScreen.tsx ├── MainScreen.tsx ├── LetsStartRecording.tsx ├── EpisodeInputFlow │ ├── EpisodeInputCommonStyles.tsx │ ├── EpisodeEditScreen.tsx │ ├── EpisodeInputScreen.tsx │ ├── EpisodeConfirmationScreen.tsx │ └── EpisodeDisplayScreen.tsx ├── ClearEpisodeScreen.tsx ├── EpisodeRecallOverview.tsx ├── EpisodeRecallFinished.tsx ├── ThankYouScreen.tsx ├── PrepareFace.tsx ├── CameraRecording.tsx └── EpisodePrediction.tsx ├── app.json ├── clients └── firebaseInteractor.ts ├── package.json └── App.tsx /run-server: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | cd Avocado && yarn start && cd .. 4 | -------------------------------------------------------------------------------- /assets/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/mgh-video-journal/main/assets/images/icon.png -------------------------------------------------------------------------------- /gcloud-functions/.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "mgh-video-journal" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /assets/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/mgh-video-journal/main/assets/images/favicon.png -------------------------------------------------------------------------------- /assets/images/old-man.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/mgh-video-journal/main/assets/images/old-man.png -------------------------------------------------------------------------------- /assets/images/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/mgh-video-journal/main/assets/images/splash.png -------------------------------------------------------------------------------- /assets/images/avocado-man.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/mgh-video-journal/main/assets/images/avocado-man.png -------------------------------------------------------------------------------- /assets/images/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/mgh-video-journal/main/assets/images/adaptive-icon.png -------------------------------------------------------------------------------- /assets/fonts/SpaceMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/mgh-video-journal/main/assets/fonts/SpaceMono-Regular.ttf -------------------------------------------------------------------------------- /assets/images/RecordingButton.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/mgh-video-journal/main/assets/images/RecordingButton.png -------------------------------------------------------------------------------- /assets/images/RecordingIndicator.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandboxnu/mgh-video-journal/main/assets/images/RecordingIndicator.png -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function(api) { 2 | api.cache(true); 3 | return { 4 | presets: ['babel-preset-expo'], 5 | }; 6 | }; 7 | -------------------------------------------------------------------------------- /constants/Contact.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | contactPhone: "978 435 2207", 3 | contactLink: "mailto:mghremoteagingstudy@partners.org", 4 | }; 5 | -------------------------------------------------------------------------------- /components/EvenSpacedView.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { View } from "./Themed"; 3 | 4 | export function EvenSpacedView() { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /gcloud-functions/firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "functions": { 3 | "predeploy": [ 4 | "npm --prefix \"$RESOURCE_DIR\" run lint", 5 | "npm --prefix \"$RESOURCE_DIR\" run build" 6 | ] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /gcloud-functions/functions/.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled JavaScript files 2 | **/*.js 3 | **/*.js.map 4 | 5 | # TypeScript v1 declaration files 6 | typings/ 7 | 8 | # Node.js dependency directory 9 | node_modules/ 10 | 11 | src/DO_NOT_ADD.json 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/**/* 2 | .expo/* 3 | Avocado/node_modules/**/* 4 | Avocado/.expo/* 5 | npm-debug.* 6 | *.jks 7 | *.p8 8 | *.p12 9 | *.key 10 | *.mobileprovision 11 | *.orig.* 12 | web-build/ 13 | .idea/ 14 | 15 | # macOS 16 | .DS_Store 17 | -------------------------------------------------------------------------------- /components/StyledText.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { Text, TextProps } from './Themed'; 4 | 5 | export function MonoText(props: TextProps) { 6 | return ; 7 | } 8 | -------------------------------------------------------------------------------- /hooks/useColorScheme.web.ts: -------------------------------------------------------------------------------- 1 | // useColorScheme from react-native does not support web currently. You can replace 2 | // this with react-native-appearance if you would like theme support on web. 3 | export default function useColorScheme() { 4 | return 'light'; 5 | } -------------------------------------------------------------------------------- /utils/AsyncStorageUtils.ts: -------------------------------------------------------------------------------- 1 | export const STORAGE_KEYS = { 2 | startDay: () => "StartDay", 3 | daysEpisodes: (recordingDay: number) => `Day${recordingDay}Episodes`, 4 | episodeRecall: () => "EpisodeRecall", 5 | currentState: () => "CurrentState", 6 | participantId: () => "ParticipantId", 7 | }; 8 | -------------------------------------------------------------------------------- /constants/Layout.ts: -------------------------------------------------------------------------------- 1 | import { Dimensions } from 'react-native'; 2 | 3 | const width = Dimensions.get('window').width; 4 | const height = Dimensions.get('window').height; 5 | 6 | export default { 7 | window: { 8 | width, 9 | height, 10 | }, 11 | isSmallDevice: width < 375, 12 | }; 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "jsx": "react-native", 5 | "lib": ["dom", "esnext"], 6 | "moduleResolution": "node", 7 | "noEmit": true, 8 | "skipLibCheck": true, 9 | "resolveJsonModule": true, 10 | "strict": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.expo-shared/assets.json: -------------------------------------------------------------------------------- 1 | { 2 | "e997a5256149a4b76e6bfd6cbf519c5e5a0f1d278a3d8fa1253022b03c90473b": true, 3 | "af683c96e0ffd2cf81287651c9433fa44debc1220ca7cb431fe482747f34a505": true, 4 | "12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true, 5 | "40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true 6 | } 7 | -------------------------------------------------------------------------------- /components/__tests__/StyledText-test.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | 4 | import { MonoText } from '../StyledText'; 5 | 6 | it(`renders correctly`, () => { 7 | const tree = renderer.create(Snapshot test!).toJSON(); 8 | 9 | expect(tree).toMatchSnapshot(); 10 | }); 11 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | pull_request: 5 | 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v1 13 | - name: install node v12 14 | uses: actions/setup-node@v1 15 | with: 16 | node-version: 12 17 | - uses: bahmutov/npm-install@v1 18 | - run: yarn test 19 | -------------------------------------------------------------------------------- /gcloud-functions/functions/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "noImplicitReturns": true, 5 | "noUnusedLocals": true, 6 | "outDir": "lib", 7 | "sourceMap": true, 8 | "strict": true, 9 | "target": "es2017", 10 | "types": [] 11 | }, 12 | "compileOnSave": true, 13 | "include": ["src"], 14 | "exclude": ["../../node_modules", "node_modules"] 15 | } 16 | -------------------------------------------------------------------------------- /utils/StylingUtils.ts: -------------------------------------------------------------------------------- 1 | import Colors from "../constants/Colors"; 2 | 3 | export const continueButtonStyle = (notDisabled: boolean) => ({ 4 | style: { 5 | backgroundColor: notDisabled 6 | ? Colors.allowedButtonColor 7 | : Colors.disabledButtonColor, 8 | flex: 1, 9 | justifyContent: "center", 10 | alignItems: "stretch", 11 | maxHeight: 60, 12 | borderRadius: 20, 13 | } as const, 14 | }); 15 | -------------------------------------------------------------------------------- /components/__tests__/__snapshots__/StyledText-test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renders correctly 1`] = ` 4 | 19 | Snapshot test! 20 | 21 | `; 22 | -------------------------------------------------------------------------------- /hooks/useColorScheme.ts: -------------------------------------------------------------------------------- 1 | import { ColorSchemeName, useColorScheme as _useColorScheme } from 'react-native'; 2 | 3 | // The useColorScheme value is always either light or dark, but the built-in 4 | // type suggests that it can be null. This will not happen in practice, so this 5 | // makes it a bit easier to work with. 6 | export default function useColorScheme(): NonNullable { 7 | return _useColorScheme() as NonNullable; 8 | } 9 | -------------------------------------------------------------------------------- /.github/workflows/tsc.yml: -------------------------------------------------------------------------------- 1 | name: typescript 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | typescript: 8 | name: typescript 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v1 12 | - name: install node v12 13 | uses: actions/setup-node@v1 14 | with: 15 | node-version: 12 16 | - run: yarn install 17 | - run: cd gcloud-functions/functions/ && yarn install && cd ../../ 18 | - run: yarn tsc 19 | -------------------------------------------------------------------------------- /navigation/LinkingConfiguration.ts: -------------------------------------------------------------------------------- 1 | import * as Linking from 'expo-linking'; 2 | 3 | export default { 4 | prefixes: [Linking.makeUrl('/')], 5 | config: { 6 | screens: { 7 | Root: { 8 | screens: { 9 | TabOne: { 10 | screens: { 11 | TabOneScreen: 'one', 12 | CameraRecording: 'camera-recording' 13 | }, 14 | }, 15 | TabTwo: { 16 | screens: { 17 | TabTwoScreen: 'two', 18 | }, 19 | }, 20 | }, 21 | }, 22 | NotFound: '*', 23 | }, 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mgh-video-journal 2 | 3 | We are developing a mobile application to allow researchers to analyze the memory retention of senior citizens participating in their study. 4 | 5 | The app will be used by each participant for 3 consecutive days. The patients will be able to schedule and record multiple "episodes" during their day, and then the next day will be asked to describe episodes from the previous day. On the last day, they will not record episodes. 6 | 7 | The goal of this project is to create an MVP over Dec 2020 - Jan 2021 that can be used by researchers. 8 | 9 | Our Tech Stack: 10 | - React Native 11 | - Jest 12 | -------------------------------------------------------------------------------- /hooks/useMainNavigation.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { 3 | MainNavigationContext, 4 | NavigationState, 5 | } from "../navigation/MainNavigationContext"; 6 | 7 | // When you want to navigate to other screens, you use this hook, and call the navigate function. 8 | // Pass in the state needed for the next screen, and then update the MainNavigator to handle the next screen (if it is not already handled) 9 | export function useMainNavigation(): { 10 | navigate: (screen: NavigationState) => void; 11 | } { 12 | let context = useContext(MainNavigationContext); 13 | return { 14 | navigate: context, 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /types.tsx: -------------------------------------------------------------------------------- 1 | import { NavigationState } from "./navigation/MainNavigationContext"; 2 | 3 | export type RootStackParamList = { 4 | Root: undefined; 5 | NotFound: undefined; 6 | }; 7 | 8 | export type AppStateStorage = { 9 | date: Date; 10 | state: NavigationState; 11 | }; 12 | 13 | export type EpisodeInputStackParamList = { 14 | EpisodeInput: undefined; 15 | EpisodeDisplay: undefined; 16 | EpisodeConfirmation: undefined; 17 | EpisodeEdit: undefined; 18 | }; 19 | 20 | export type Episode = { 21 | name: string; 22 | date: string; // ISO String 23 | startTime: string; // ISO String 24 | endTime: string; // ISO String 25 | initials: string; 26 | recordingDay: number; 27 | }; 28 | -------------------------------------------------------------------------------- /screens/OnboardingScreen/Day2+3/Intro2Day2.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import AIntroScreen, { styles as abstractStyles } from "../AIntroScreen"; 3 | import { Text } from "../../../components/Themed"; 4 | 5 | export default function Intro2Day2() { 6 | return ( 7 | 8 | 9 | { 10 | "Today we will begin by asking you some questions about previous episodes. We'll walk you through this step by step.\n\nAfterwards, you will list and record Episodes for events that happened today. This will be the same as you have done previously. Again, we'll remind of what to do each step, when you get there." 11 | } 12 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /screens/OnboardingScreen/Day1/Intro2.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import AIntroScreen, { styles as abstractStyles } from "../AIntroScreen"; 3 | import { Text } from "../../../components/Themed"; 4 | 5 | export default function Intro2() { 6 | return ( 7 | 8 | 9 | { 10 | "First, you will break up your day into a series of episodes. Think of your day as a continuous series of episodes in a film. Each episode typically lasts between 15 minutes and 2 hours.\n\nAn episode might begin or end when you change locations, end one activity and start another, or there is a change in the people you are interacting with." 11 | } 12 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /screens/OnboardingScreen/Day1/Intro3.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import AIntroScreen, { styles as abstractStyles } from "../AIntroScreen"; 3 | import { Image } from "react-native"; 4 | import { Text, View } from "../../../components/Themed"; 5 | 6 | export default function Intro3() { 7 | return ( 8 | 9 | 10 | 14 | 15 | Please give each episode a brief name so you can reference it as you 16 | move through the diary. Then answer some brief questions about each 17 | episode. 18 | 19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /constants/Colors.ts: -------------------------------------------------------------------------------- 1 | const tintColorLight = "#2f95dc"; 2 | const tintColorDark = "#fff"; 3 | 4 | export default { 5 | light: { 6 | text: "#000", 7 | background: "#fff", 8 | tint: tintColorLight, 9 | tabIconDefault: "#ccc", 10 | tabIconSelected: tintColorLight, 11 | }, 12 | dark: { 13 | text: "#fff", 14 | background: "#000", 15 | tint: tintColorDark, 16 | tabIconDefault: "#ccc", 17 | tabIconSelected: tintColorDark, 18 | }, 19 | seeingFaceRectBorderColor: "#00A51A", 20 | notSeeingFaceRectBorderColor: "#A50000", 21 | disabledButtonColor: "#8D8D8D", 22 | allowedButtonColor: "#E16162", 23 | avocadoGreen: "#004643", 24 | textInputBorder: "#DCDCDC", 25 | textInputFill: "#FBFBFB", 26 | darkOverlay: "rgba(0, 70, 67, .93)", 27 | lightOverlay: "rgba(255, 255, 255, 0.8)", 28 | linkOrange: "#e16162", 29 | darkText: "#0f3433", 30 | }; 31 | -------------------------------------------------------------------------------- /screens/OnboardingScreen/Day1/Intro4.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import AIntroScreen, { styles as abstractStyles } from "../AIntroScreen"; 3 | import { Text, View } from "../../../components/Themed"; 4 | import { Image } from "react-native"; 5 | 6 | export default function Intro4() { 7 | return ( 8 | 9 | 10 | 14 | 15 | After you have identified and named your episodes, you will complete a 16 | video recording for each episode. In each video, you will give a 17 | detailed description of that episode. 18 | 19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "MGHVideoJournal", 4 | "slug": "MGHVideoJournal", 5 | "version": "1.0.2", 6 | "orientation": "portrait", 7 | "icon": "./assets/images/icon.png", 8 | "scheme": "myapp", 9 | "userInterfaceStyle": "light", 10 | "splash": { 11 | "image": "./assets/images/splash.png", 12 | "resizeMode": "contain", 13 | "backgroundColor": "#ffffff" 14 | }, 15 | "updates": { 16 | "fallbackToCacheTimeout": 0 17 | }, 18 | "assetBundlePatterns": ["**/*"], 19 | "ios": { 20 | "supportsTablet": true, 21 | "bundleIdentifier": "com.sandboxnu.mghvideojournal-1" 22 | }, 23 | "android": { 24 | "adaptiveIcon": { 25 | "foregroundImage": "./assets/images/adaptive-icon.png", 26 | "backgroundColor": "#FFFFFF" 27 | } 28 | }, 29 | "web": { 30 | "favicon": "./assets/images/favicon.png" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /gcloud-functions/functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "functions", 3 | "scripts": { 4 | "lint": "tslint --project tsconfig.json", 5 | "build": "mkdir lib && cp src/DO_NOT_ADD.json lib && tsc", 6 | "serve": "npm run build && firebase emulators:start --only functions", 7 | "shell": "npm run build && firebase functions:shell", 8 | "start": "npm run shell", 9 | "deploy": "firebase deploy --only functions", 10 | "logs": "firebase functions:log" 11 | }, 12 | "engines": { 13 | "node": "12" 14 | }, 15 | "main": "lib/index.js", 16 | "dependencies": { 17 | "@types/node-fetch": "^2.5.7", 18 | "dropbox": "^8.2.0", 19 | "firebase-admin": "^8.10.0", 20 | "firebase-functions": "^3.6.1", 21 | "node-fetch": "^2.6.1" 22 | }, 23 | "devDependencies": { 24 | "firebase-functions-test": "^0.2.0", 25 | "tslint": "^5.12.0", 26 | "typescript": "^3.8.0" 27 | }, 28 | "private": true 29 | } 30 | -------------------------------------------------------------------------------- /screens/OnboardingScreen/Day2+3/Intro1Day2.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { Text, View } from "../../../components/Themed"; 3 | import Colors from "../../../constants/Colors"; 4 | import { StyleSheet } from "react-native"; 5 | 6 | interface Intro1Day2Props { 7 | recordingDay: number; 8 | setCanScroll?: (canScrollHuh: boolean) => void; 9 | } 10 | export default function Intro1Day2({ 11 | recordingDay, 12 | setCanScroll, 13 | }: Intro1Day2Props) { 14 | // make sure the user can scroll 15 | useEffect(() => setCanScroll?.(true), []); 16 | 17 | return ( 18 | 19 | 20 | Welcome to Day {recordingDay} of your Video Diary. 21 | 22 | 23 | ); 24 | } 25 | 26 | export const styles = StyleSheet.create({ 27 | container: { 28 | flex: 1, 29 | justifyContent: "center", 30 | }, 31 | headerText: { 32 | fontSize: 40, 33 | fontFamily: "Inter_700Bold", 34 | color: Colors.avocadoGreen, 35 | textAlign: "center", 36 | }, 37 | }); 38 | -------------------------------------------------------------------------------- /screens/NotFoundScreen.tsx: -------------------------------------------------------------------------------- 1 | import { StackScreenProps } from '@react-navigation/stack'; 2 | import * as React from 'react'; 3 | import { StyleSheet, Text, TouchableOpacity } from 'react-native'; 4 | import { View } from "../components/Themed"; 5 | 6 | import { RootStackParamList } from '../types'; 7 | 8 | export default function NotFoundScreen({ 9 | navigation, 10 | }: StackScreenProps) { 11 | return ( 12 | 13 | This screen doesn't exist. 14 | { 15 | navigation.setOptions({title: "nope"}); 16 | }} style={styles.link}> 17 | Go to home screen! 18 | 19 | 20 | ); 21 | } 22 | 23 | const styles = StyleSheet.create({ 24 | container: { 25 | flex: 1, 26 | alignItems: 'center', 27 | justifyContent: 'center', 28 | padding: 20, 29 | }, 30 | title: { 31 | fontSize: 20, 32 | fontWeight: 'bold', 33 | }, 34 | link: { 35 | marginTop: 15, 36 | paddingVertical: 15, 37 | }, 38 | linkText: { 39 | fontSize: 14, 40 | color: '#2e78b7', 41 | }, 42 | }); 43 | -------------------------------------------------------------------------------- /clients/firebaseInteractor.ts: -------------------------------------------------------------------------------- 1 | import firebase from "firebase/app"; 2 | import "firebase/auth"; 3 | import "firebase/storage"; 4 | 5 | const firebaseConfig = { 6 | apiKey: "AIzaSyDV3FObRh1ylPJkgowm1eXQw4XIykKkbDQ", 7 | authDomain: "mgh-video-journal.firebaseapp.com", 8 | projectId: "mgh-video-journal", 9 | storageBucket: "mgh-video-journal.appspot.com", 10 | messagingSenderId: "253050399833", 11 | appId: "1:253050399833:web:093bc484f3816d35fd9548", 12 | measurementId: "G-TVJY0CJ7CV", 13 | }; 14 | 15 | if (firebase.apps.length == 0) { 16 | firebase.initializeApp(firebaseConfig); 17 | } 18 | 19 | export const signIn = async (email: string): Promise => { 20 | await firebase.auth().signInAnonymously(); 21 | }; 22 | 23 | export const uploadFileToFirebase = async ( 24 | name: string, 25 | fileURI: string 26 | ): Promise => { 27 | let file = await fetch(fileURI); 28 | firebase 29 | .storage() 30 | .ref(`extra-check/${name}`) 31 | .put(await file.blob()); 32 | }; 33 | 34 | export const uploadJSONToFirebase = async ( 35 | name: string, 36 | object: Object 37 | ): Promise => { 38 | const json = JSON.stringify(object); 39 | const blob = new Blob([json], { type: "application/json" }); 40 | await firebase.storage().ref(`extra-check/${name}`).put(blob); 41 | }; 42 | -------------------------------------------------------------------------------- /screens/OnboardingScreen/AIntroScreen.tsx: -------------------------------------------------------------------------------- 1 | import { StyleSheet } from "react-native"; 2 | import { Text, View } from "../../components/Themed"; 3 | import React, { ReactChild } from "react"; 4 | import Colors from "../../constants/Colors"; 5 | 6 | export interface AIntroScreenProps { 7 | headerText: string; 8 | children: ReactChild | Array; 9 | } 10 | 11 | export default function AIntroScreen({ 12 | headerText, 13 | children, 14 | }: AIntroScreenProps) { 15 | return ( 16 | 17 | 18 | {headerText} 19 | 20 | {children} 21 | 22 | ); 23 | } 24 | 25 | export const styles = StyleSheet.create({ 26 | container: { 27 | flex: 1, 28 | padding: 50, 29 | justifyContent: "space-around", 30 | }, 31 | header: { 32 | justifyContent: "center", 33 | marginVertical: 60, 34 | }, 35 | headerText: { 36 | fontSize: 40, 37 | fontFamily: "Inter_700Bold", 38 | color: Colors.avocadoGreen, 39 | }, 40 | body: { flex: 1, justifyContent: "space-between" }, 41 | bodyText: { 42 | color: Colors.darkText, 43 | fontSize: 18, 44 | fontFamily: "Arimo_400Regular", 45 | }, 46 | childrenBody: { 47 | marginTop: -50, 48 | flex: 1, 49 | }, 50 | image: { 51 | alignSelf: "center", 52 | marginBottom: 20, 53 | }, 54 | }); 55 | -------------------------------------------------------------------------------- /gcloud-functions/functions/src/index.ts: -------------------------------------------------------------------------------- 1 | process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; 2 | 3 | import * as functions from "firebase-functions"; 4 | import * as admin from "firebase-admin"; 5 | import fetch from "node-fetch"; 6 | const token = require("./DO_NOT_ADD.json"); 7 | 8 | if (admin.apps.length === 0) { 9 | admin.initializeApp(); 10 | } 11 | 12 | exports.exportDropbox = functions.storage 13 | .object() 14 | .onFinalize(async (object) => { 15 | const filePath = object.name; 16 | if (filePath == null) { 17 | return; 18 | } 19 | 20 | const fileName = filePath.split("/"); 21 | if (fileName[0] === "extra-check") { 22 | fileName.shift(); 23 | const fbFile = await admin.storage().bucket().file(filePath).download(); 24 | await fetch("https://content.dropboxapi.com/2/files/upload", { 25 | body: Buffer.concat(fbFile), 26 | method: "POST", 27 | headers: { 28 | Authorization: `Bearer ${token["not_the_access_token"]}`, 29 | "Content-Type": "application/octet-stream", 30 | "Dropbox-API-Arg": JSON.stringify({ 31 | path: "/R56_Video_Diary/" + fileName.join("/"), 32 | mode: "add", 33 | autorename: true, 34 | strict_conflict: false, 35 | mute: false, 36 | }), 37 | }, 38 | }) 39 | .then(console.log) 40 | .catch(console.log); 41 | } 42 | 43 | await admin.storage().bucket().file(filePath).delete().catch(console.log); 44 | }); 45 | -------------------------------------------------------------------------------- /screens/OnboardingScreen/Day1/Intro5.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { StyleSheet } from "react-native"; 3 | import { Button, Text, View } from "../../../components/Themed"; 4 | import Colors from "../../../constants/Colors"; 5 | import { useMainNavigation } from "../../../hooks/useMainNavigation"; 6 | import { NavigationScreens } from "../../../navigation/MainNavigationContext"; 7 | 8 | export default function Intro5() { 9 | const navigation = useMainNavigation(); 10 | return ( 11 | 12 | 13 | Let's walk through each step together! 14 | 15 | 23 | 24 | ); 25 | } 26 | 27 | const styles = StyleSheet.create({ 28 | container: { 29 | padding: "10%", 30 | flex: 1, 31 | backgroundColor: Colors.avocadoGreen, 32 | flexDirection: "column", 33 | }, 34 | headerText: { 35 | fontSize: 40, 36 | fontFamily: "Inter_700Bold", 37 | color: "white", 38 | marginTop: "auto", 39 | marginBottom: "auto", 40 | }, 41 | getStartedButton: { 42 | marginVertical: 50, 43 | backgroundColor: Colors.allowedButtonColor, 44 | borderRadius: 20, 45 | }, 46 | buttonText: { 47 | textAlign: "center", 48 | fontFamily: "Inter_600SemiBold", 49 | fontSize: 18, 50 | color: "white", 51 | marginVertical: 20, 52 | }, 53 | }); 54 | -------------------------------------------------------------------------------- /navigation/MainStack.tsx: -------------------------------------------------------------------------------- 1 | import { createStackNavigator } from "@react-navigation/stack"; 2 | import * as React from "react"; 3 | import { EpisodeInputStackParamList } from "../types"; 4 | import EpisodeInputScreen from "../screens/EpisodeInputFlow/EpisodeInputScreen"; 5 | import EpisodeDisplayScreen from "../screens/EpisodeInputFlow/EpisodeDisplayScreen"; 6 | import EpisodeConfirmationScreen from "../screens/EpisodeInputFlow/EpisodeConfirmationScreen"; 7 | import EpisodeEditScreen from "../screens/EpisodeInputFlow/EpisodeEditScreen"; 8 | 9 | // Each tab has its own navigation stack, you can read more about this pattern here: 10 | // https://reactnavigation.org/docs/tab-based-navigation#a-stack-navigator-for-each-tab 11 | const EpisodeInputStack = createStackNavigator(); 12 | 13 | interface EpisodeInputNavigatorProps { 14 | recordingDay: number; 15 | } 16 | 17 | export default function EpisodeInputNavigator({ 18 | recordingDay, 19 | }: EpisodeInputNavigatorProps) { 20 | const EpisodeInput = () => ; 21 | return ( 22 | 23 | 28 | 33 | 38 | 43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /navigation/MainNavigationContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | import { Episode } from "../types"; 3 | 4 | // All screens that exist that do not need props passed to them, if props need to be passed, add it to NavigationState 5 | export enum NavigationScreens { 6 | intro, 7 | createEpisode, 8 | predictions, 9 | episodeListingOverview, 10 | secondEpisodeListingOverview, 11 | episodeRecallOverview, 12 | onboarding, 13 | episodeRecallFinished, 14 | episodeClearing, 15 | } 16 | 17 | // The state for when the episodes have been added, but need to be recorded 18 | interface NeedToRecordEpisodes { 19 | type: "recordEpisodes"; 20 | episodes: Episode[]; 21 | } 22 | 23 | // The state for when the episodes have been added, but need to be recorded 24 | interface EpisodeRecall { 25 | type: "episodeRecall"; 26 | episodes: Episode[]; 27 | } 28 | 29 | // The state for when the predictions have been given 30 | interface GivenPredictions { 31 | type: "givenPredictions"; 32 | predictions: String[]; 33 | } 34 | 35 | // the state for onboarding day 2 and 3 36 | interface OnboardingDay2Or3 { 37 | type: "onboarding2or3"; 38 | recordingDay: 2 | 3; 39 | } 40 | 41 | // the final thank you screen after data has been uploaded 42 | interface ThankYou { 43 | type: "thankYou"; 44 | recordingDay: number; 45 | } 46 | 47 | // A basic state that only has a screen 48 | interface BasicNavigationState { 49 | type: NavigationScreens; 50 | } 51 | 52 | // All possible states that we can be in. Add your navigation state here for new screens 53 | export type NavigationState = 54 | | BasicNavigationState 55 | | NeedToRecordEpisodes 56 | | GivenPredictions 57 | | EpisodeRecall 58 | | OnboardingDay2Or3 59 | | ThankYou; 60 | 61 | // The context to pass down the navigation updater function through 62 | export const MainNavigationContext = createContext((_: NavigationState) => {}); 63 | -------------------------------------------------------------------------------- /components/Themed.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { 3 | Text as DefaultText, 4 | View as DefaultView, 5 | TouchableOpacity as DefaultButton, 6 | } from "react-native"; 7 | 8 | import Colors from "../constants/Colors"; 9 | import useColorScheme from "../hooks/useColorScheme"; 10 | 11 | export function useThemeColor( 12 | props: { light?: string; dark?: string }, 13 | colorName: keyof typeof Colors.light & keyof typeof Colors.dark 14 | ) { 15 | const theme = useColorScheme(); 16 | const colorFromProps = props[theme]; 17 | 18 | if (colorFromProps) { 19 | return colorFromProps; 20 | } else { 21 | return Colors[theme][colorName]; 22 | } 23 | } 24 | 25 | type ThemeProps = { 26 | lightColor?: string; 27 | darkColor?: string; 28 | }; 29 | 30 | export type TextProps = ThemeProps & DefaultText["props"]; 31 | export type ViewProps = ThemeProps & DefaultView["props"]; 32 | export type ButtonProps = ThemeProps & DefaultButton["props"]; 33 | 34 | export function Text(props: TextProps) { 35 | const { style, lightColor, darkColor, ...otherProps } = props; 36 | const color = useThemeColor({ light: "black", dark: "black" }, "text"); 37 | 38 | return ; 39 | } 40 | 41 | export function View(props: ViewProps) { 42 | const { style, lightColor, darkColor, ...otherProps } = props; 43 | const backgroundColor = useThemeColor( 44 | { light: "white", dark: "white" }, 45 | "background" 46 | ); 47 | 48 | return ; 49 | } 50 | 51 | export function Button(props: ButtonProps) { 52 | const { style, lightColor, darkColor, ...otherProps } = props; 53 | const color = useThemeColor({ light: lightColor, dark: darkColor }, "text"); 54 | 55 | return ( 56 | 60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /screens/MainScreen.tsx: -------------------------------------------------------------------------------- 1 | import AsyncStorage from "@react-native-async-storage/async-storage"; 2 | import React, { useEffect } from "react"; 3 | import { Button, StyleSheet } from "react-native"; 4 | import { signIn } from "../clients/firebaseInteractor"; 5 | 6 | import EditScreenInfo from "../components/EditScreenInfo"; 7 | import { Text, View } from "../components/Themed"; 8 | import { useMainNavigation } from "../hooks/useMainNavigation"; 9 | import { NavigationScreens } from "../navigation/MainNavigationContext"; 10 | 11 | export default function MainScreen() { 12 | let context = useMainNavigation(); 13 | useEffect(() => { 14 | // The email gets ignored rn, but will be used when we get to the email auth 15 | signIn("email"); 16 | }, []); 17 | return ( 18 | 19 | My New 20 | 25 | 26 | 25 | 26 | ); 27 | }; 28 | const Styles = StyleSheet.create({ 29 | container: { 30 | width: "100%", 31 | height: "100%", 32 | paddingHorizontal: "5%", 33 | justifyContent: "center", 34 | }, 35 | title: { 36 | fontSize: 40, 37 | fontWeight: "700", 38 | }, 39 | startRecordingText: { 40 | fontSize: 30, 41 | fontWeight: "500", 42 | marginVertical: 10, 43 | }, 44 | subText: { 45 | fontSize: 18, 46 | fontWeight: "400", 47 | }, 48 | innerSubtext: { 49 | fontSize: 18, 50 | fontWeight: "700", 51 | }, 52 | button: { 53 | width: "100%", 54 | backgroundColor: Colors.allowedButtonColor, 55 | position: "absolute", 56 | bottom: "10%", 57 | left: "5%", 58 | }, 59 | buttonText: { 60 | color: "white", 61 | fontSize: 20, 62 | fontWeight: "400", 63 | textAlign: "center", 64 | margin: 20, 65 | }, 66 | }); 67 | 68 | export default LetsStartRecording; 69 | -------------------------------------------------------------------------------- /components/EpisodeListingOverview.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, FunctionComponent } from "react"; 2 | import { StyleSheet } from "react-native"; 3 | import { View, Text } from "./Themed"; 4 | 5 | interface EpisodeListingOverviewProps { 6 | recallDay: number; 7 | } 8 | export const EpisodeListingOverview: FunctionComponent = ({ 9 | recallDay, 10 | }) => { 11 | return ( 12 | 13 | Great! 14 | Let’s start recording 15 | 16 | For this first recording, we would like you to please tell us the names 17 | of all of your Episodes from Day {recallDay}. {"\n\n"}If you cannot 18 | remember the exact title, you can try to identify the episode by a 19 | short, few word description. {"\n\n"} 20 | Do not tell us any 21 | episodes from today, only from Day {recallDay}. 22 | 23 | 24 | ); 25 | }; 26 | 27 | const episodeListingStyles = StyleSheet.create({ 28 | container: { 29 | backgroundColor: "transparent", 30 | minHeight: "60%", 31 | width: "100%", 32 | padding: 20, 33 | justifyContent: "space-around", 34 | }, 35 | title: { 36 | alignItems: "flex-end", 37 | fontFamily: "Inter_700Bold", 38 | fontStyle: "normal", 39 | fontSize: 40, 40 | lineHeight: 48, 41 | color: "#FFFFFF", 42 | }, 43 | subheader: { 44 | alignItems: "flex-end", 45 | fontFamily: "Inter_500Medium", 46 | fontStyle: "normal", 47 | fontSize: 30, 48 | lineHeight: 36, 49 | color: "#FFFFFF", 50 | }, 51 | text: { 52 | alignItems: "flex-end", 53 | fontFamily: "Arimo_400Regular", 54 | fontStyle: "normal", 55 | fontSize: 18, 56 | lineHeight: 26, 57 | color: "#FFFFFF", 58 | }, 59 | doNot: { 60 | alignItems: "flex-end", 61 | fontFamily: "Arimo_700Bold", 62 | fontStyle: "normal", 63 | fontSize: 18, 64 | lineHeight: 26, 65 | color: "#FFFFFF", 66 | }, 67 | }); 68 | -------------------------------------------------------------------------------- /screens/OnboardingScreen/Day2+3/Intro3Day2.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import AIntroScreen, { styles as abstractStyles } from "../AIntroScreen"; 3 | import { Button, Text } from "../../../components/Themed"; 4 | import Colors from "../../../constants/Colors"; 5 | import { Linking, StyleSheet } from "react-native"; 6 | import { NavigationScreens } from "../../../navigation/MainNavigationContext"; 7 | import { useMainNavigation } from "../../../hooks/useMainNavigation"; 8 | import Contact from "../../../constants/Contact"; 9 | 10 | export default function Intro3Day2() { 11 | const navigation = useMainNavigation(); 12 | return ( 13 | 14 | 15 | As always, if you have questions or concerns, please contact a research 16 | staff member at{" "} 17 | Linking.openURL(Contact.contactLink)} 20 | > 21 | here 22 | {" "} 23 | or by phone at{" "} 24 | Linking.openURL(`tel:${Contact.contactPhone}`)} 27 | > 28 | {Contact.contactPhone} 29 | 30 | .{"\n\n"}Thank you! 31 | 32 | 42 | 43 | ); 44 | } 45 | 46 | const styles = StyleSheet.create({ 47 | getStartedButton: { 48 | marginVertical: 50, 49 | backgroundColor: Colors.allowedButtonColor, 50 | borderRadius: 20, 51 | }, 52 | buttonText: { 53 | textAlign: "center", 54 | fontFamily: "Inter_600SemiBold", 55 | fontSize: 18, 56 | color: "white", 57 | marginVertical: 20, 58 | }, 59 | bodyContact: { 60 | color: Colors.linkOrange, 61 | }, 62 | bodyLink: { 63 | color: Colors.linkOrange, 64 | textDecorationLine: "underline", 65 | }, 66 | }); 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "node_modules/expo/AppEntry.js", 3 | "husky": { 4 | "hooks": { 5 | "pre-commit": "pretty-quick --staged" 6 | } 7 | }, 8 | "scripts": { 9 | "start": "expo start --tunnel", 10 | "android": "expo start --android", 11 | "ios": "expo start --ios", 12 | "web": "expo start --web", 13 | "eject": "expo eject", 14 | "test": "jest" 15 | }, 16 | "jest": { 17 | "preset": "jest-expo" 18 | }, 19 | "dependencies": { 20 | "@expo-google-fonts/arimo": "^0.1.0", 21 | "@expo-google-fonts/inter": "^0.1.0", 22 | "@expo/vector-icons": "^12.0.0", 23 | "@react-native-async-storage/async-storage": "^1.13.2", 24 | "@react-native-community/datetimepicker": "^3.0.8", 25 | "@react-native-community/masked-view": "0.1.10", 26 | "@react-navigation/bottom-tabs": "^5.11.1", 27 | "@react-navigation/native": "^5.8.9", 28 | "@react-navigation/stack": "^5.12.6", 29 | "@types/react-native-vector-icons": "^6.4.6", 30 | "expo": "~40.0.0", 31 | "expo-asset": "~8.2.1", 32 | "expo-av": "~8.7.0", 33 | "expo-blur": "~8.2.2", 34 | "expo-camera": "~9.1.0", 35 | "expo-constants": "~9.3.0", 36 | "expo-face-detector": "~8.4.0", 37 | "expo-file-system": "~9.3.0", 38 | "expo-font": "~8.4.0", 39 | "expo-linking": "~2.0.0", 40 | "expo-splash-screen": "~0.8.0", 41 | "expo-status-bar": "~1.0.3", 42 | "expo-web-browser": "~8.6.0", 43 | "firebase": "^8.2.1", 44 | "react": "16.13.1", 45 | "react-dom": "16.13.1", 46 | "react-native": "https://github.com/expo/react-native/archive/sdk-40.0.1.tar.gz", 47 | "react-native-gesture-handler": "~1.8.0", 48 | "react-native-modal-datetime-picker": "^9.1.0", 49 | "react-native-safe-area-context": "3.1.9", 50 | "react-native-screens": "~2.15.0", 51 | "react-native-swiper": "^1.6.0", 52 | "react-native-vector-icons": "^7.1.0", 53 | "react-native-web": "~0.13.12" 54 | }, 55 | "devDependencies": { 56 | "@babel/core": "~7.9.0", 57 | "@types/react": "~16.9.35", 58 | "@types/react-native": "~0.63.2", 59 | "husky": "^4.3.6", 60 | "jest-expo": "^40.0.1", 61 | "prettier": "^2.2.1", 62 | "pretty-quick": "^3.1.0", 63 | "typescript": "~4.0.0" 64 | }, 65 | "private": true 66 | } 67 | -------------------------------------------------------------------------------- /components/TimePicker.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Button, Text, View } from "./Themed"; 3 | import Icon from "react-native-vector-icons/Octicons"; 4 | import { StyleProp, ViewStyle, StyleSheet } from "react-native"; 5 | import DateTimePickerModal from "react-native-modal-datetime-picker"; 6 | import { convertToLocale } from "../utils/TimeUtils"; 7 | 8 | interface TimeProps { 9 | time: Date | undefined; 10 | setTime: (time: Date | undefined) => void; 11 | label: string; 12 | style: StyleProp; 13 | } 14 | 15 | export function TimePicker({ time, setTime, label, style }: TimeProps) { 16 | const [isVisible, setVisibility] = useState(false); 17 | const handleConfirm = (date: Date, setTime: (time: Date) => void) => { 18 | setTime(date); 19 | hideDatePicker(); 20 | }; 21 | 22 | const showDatePicker = () => { 23 | setVisibility(true); 24 | }; 25 | 26 | const hideDatePicker = () => { 27 | setVisibility(false); 28 | }; 29 | return ( 30 | 31 | 44 | handleConfirm(date, setTime)} 49 | onCancel={hideDatePicker} 50 | /> 51 | 52 | ); 53 | } 54 | 55 | const datePickerStyles = StyleSheet.create({ 56 | timeButton: { 57 | backgroundColor: "transparent", 58 | }, 59 | contentWrapper: { 60 | marginHorizontal: 15, 61 | flexDirection: "row", 62 | alignItems: "center", 63 | }, 64 | icon: { 65 | marginLeft: "auto", 66 | }, 67 | text: { 68 | fontFamily: "Arimo_400Regular", 69 | fontStyle: "normal", 70 | fontSize: 18, 71 | lineHeight: 22, 72 | paddingVertical: 15, 73 | }, 74 | }); 75 | -------------------------------------------------------------------------------- /utils/TimeUtils.ts: -------------------------------------------------------------------------------- 1 | import AsyncStorage from "@react-native-async-storage/async-storage"; 2 | import { STORAGE_KEYS } from "./AsyncStorageUtils"; 3 | 4 | // the length of a day in milliseconds 5 | const DAY_IN_MS = 24 * 60 * 60 * 1000; 6 | const EXACT_DATE = () => new Date(); 7 | // standardized current date that does not factor in the time it was created, just the date 8 | export const getCurrentDate = () => { 9 | const currentDate = EXACT_DATE(); 10 | // Subtract 5 hours so that we are doing this based on 5AM to 5AM 11 | const currentMGHDay = new Date(currentDate.getTime() - 5 * 60 * 1000 * 60); 12 | return new Date( 13 | currentMGHDay.getFullYear(), 14 | currentMGHDay.getMonth(), 15 | currentMGHDay.getDate(), 16 | 0 17 | ); 18 | }; 19 | 20 | export const areDaysEqual = (day1: Date, day2: Date) => { 21 | return ( 22 | new Date( 23 | day1.getFullYear(), 24 | day1.getMonth(), 25 | day1.getDate(), 26 | 0 27 | ).toISOString() === 28 | new Date( 29 | day2.getFullYear(), 30 | day2.getMonth(), 31 | day2.getDate(), 32 | 0 33 | ).toISOString() 34 | ); 35 | }; 36 | 37 | export const calculateRecordingDay = (startDay: string) => { 38 | // check which "day" it is for them (day 1, 2, 3) based on the start day. 39 | // When you subtract them, it should only ever be 0 (same day), DAY_IN_MS (1 day later) or DAY_IN_MS * 2 (2 days later) since 40 | // current date and start day should always be the same time of day 41 | const msApart = getCurrentDate().getTime() - new Date(startDay).getTime(); 42 | return msApart / DAY_IN_MS + 1; 43 | }; 44 | 45 | export const retrieveRecordingDay = async () => { 46 | const startDay = await AsyncStorage.getItem(STORAGE_KEYS.startDay()); 47 | if (startDay === null) { 48 | // if the startDay hasn't been set, this is their first time on this page, so set it to the current date 49 | // current date is only based on year/month/date so the time will always be the same across days 50 | await AsyncStorage.setItem( 51 | STORAGE_KEYS.startDay(), 52 | getCurrentDate().toISOString() 53 | ); 54 | return 1; 55 | } else { 56 | // Add one so it's standardixed as day 1, 2, or 3 57 | return calculateRecordingDay(startDay); 58 | } 59 | }; 60 | 61 | export const convertToLocale = (iso: string): string => { 62 | return new Date(iso).toLocaleTimeString("en-US", { 63 | hour: "numeric", 64 | minute: "2-digit", 65 | }); 66 | }; 67 | -------------------------------------------------------------------------------- /screens/OnboardingScreen/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, useState } from "react"; 2 | import { StyleSheet } from "react-native"; 3 | import Intro1 from "./Day1/Intro1"; 4 | import Intro2 from "./Day1/Intro2"; 5 | import Intro3 from "./Day1/Intro3"; 6 | import Intro4 from "./Day1/Intro4"; 7 | import Intro5 from "./Day1/Intro5"; 8 | import Intro1Day2 from "./Day2+3/Intro1Day2"; 9 | import Intro2Day2 from "./Day2+3/Intro2Day2"; 10 | import Intro3Day2 from "./Day2+3/Intro3Day2"; 11 | import { View } from "../../components/Themed"; 12 | import Swiper from "react-native-swiper"; 13 | import Colors from "../../constants/Colors"; 14 | 15 | interface OnboardingScreenProps { 16 | views: ReactElement[]; 17 | } 18 | 19 | export const Day1Screens = [ 20 | , 21 | , 22 | , 23 | , 24 | , 25 | ]; 26 | export const Day2And3Screens = (recordingDay: number) => [ 27 | , 28 | , 29 | , 30 | ]; 31 | 32 | export default function OnboardingScreen({ views }: OnboardingScreenProps) { 33 | const [index, setIndex] = useState(0); 34 | const [canScroll, setCanScroll] = useState(false); 35 | 36 | return ( 37 | 38 | setIndex(i)} 42 | dotColor="transparent" 43 | scrollEnabled={canScroll} 44 | dotStyle={ 45 | index !== views.length - 1 && canScroll 46 | ? { ...styles.dotStyle, ...styles.dotSizing } 47 | : styles.noDotStyle 48 | } 49 | activeDotColor={Colors.allowedButtonColor} 50 | activeDotStyle={ 51 | index !== views.length - 1 && canScroll 52 | ? styles.dotSizing 53 | : styles.noDotStyle 54 | } 55 | > 56 | {views.map((element) => React.cloneElement(element, { setCanScroll }))} 57 | 58 | 59 | ); 60 | } 61 | 62 | const styles = StyleSheet.create({ 63 | swiper: { 64 | flex: 1, 65 | }, 66 | dotStyle: { 67 | borderColor: Colors.allowedButtonColor, 68 | borderWidth: 1, 69 | }, 70 | dotSizing: { 71 | borderRadius: 10, 72 | width: 20, 73 | height: 20, 74 | marginLeft: 10, 75 | marginRight: 10, 76 | marginBottom: 50, 77 | }, 78 | noDotStyle: { 79 | display: "none", 80 | }, 81 | }); 82 | -------------------------------------------------------------------------------- /screens/EpisodeInputFlow/EpisodeInputCommonStyles.tsx: -------------------------------------------------------------------------------- 1 | import { StyleSheet } from "react-native"; 2 | import Colors from "../../constants/Colors"; 3 | 4 | export const containerStyles = StyleSheet.create({ 5 | container: { 6 | padding: 30, 7 | flex: 1, 8 | justifyContent: "space-around", 9 | }, 10 | titleContainer: { 11 | justifyContent: "space-between", 12 | flexDirection: "row", 13 | alignItems: "flex-start", 14 | marginTop: 30, 15 | }, 16 | inputContainer: { 17 | flexDirection: "column", 18 | }, 19 | buttonsContainer: { 20 | alignContent: "stretch", 21 | }, 22 | }); 23 | 24 | export const styles = StyleSheet.create({ 25 | title: { 26 | fontFamily: "Inter_500Medium", 27 | fontStyle: "normal", 28 | fontSize: 30, 29 | lineHeight: 36, 30 | textAlign: "left", 31 | color: Colors.avocadoGreen, 32 | }, 33 | input: { 34 | justifyContent: "center", 35 | marginTop: "2%", 36 | }, 37 | inputHeader: { 38 | fontFamily: "Arimo_400Regular", 39 | fontStyle: "normal", 40 | fontSize: 18, 41 | lineHeight: 22, 42 | marginVertical: "2%", 43 | color: Colors.darkText, 44 | }, 45 | inputText: { 46 | fontFamily: "Arimo_400Regular", 47 | fontStyle: "normal", 48 | fontSize: 18, 49 | lineHeight: 22, 50 | marginVertical: "2%", 51 | color: Colors.darkText, 52 | }, 53 | greyText: { 54 | color: "#9c9c9c", 55 | }, 56 | textInput: { 57 | borderWidth: 1, 58 | borderColor: "#979797", 59 | borderStyle: "solid", 60 | fontFamily: "Arimo_400Regular", 61 | fontStyle: "normal", 62 | fontSize: 20, 63 | lineHeight: 22, 64 | color: "#000000", 65 | backgroundColor: "white", 66 | padding: 15, 67 | }, 68 | timePickers: { 69 | flexDirection: "row", 70 | justifyContent: "space-between", 71 | }, 72 | timePickerContainer: { 73 | borderWidth: 1, 74 | borderColor: "#979797", 75 | borderStyle: "solid", 76 | flex: 150, 77 | width: "45%", 78 | }, 79 | button: { 80 | marginVertical: "3%", 81 | justifyContent: "center", 82 | alignItems: "center", 83 | height: 60, 84 | borderRadius: 20, 85 | }, 86 | buttonGrey: { 87 | backgroundColor: "#8d8d8d", 88 | }, 89 | buttonRed: { 90 | backgroundColor: Colors.allowedButtonColor, 91 | }, 92 | buttonText: { 93 | color: "#FFFFFF", 94 | fontFamily: "Inter_400Regular", 95 | fontStyle: "normal", 96 | fontSize: 20, 97 | lineHeight: 22, 98 | }, 99 | }); 100 | -------------------------------------------------------------------------------- /components/EpisodeOverlay.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from "react"; 2 | import { StyleSheet } from "react-native"; 3 | import { Episode } from "../types"; 4 | import { convertToLocale } from "../utils/TimeUtils"; 5 | import { View, Text } from "./Themed"; 6 | 7 | interface EpisodeOverlayProps { 8 | episode: Episode; 9 | index: number; 10 | } 11 | 12 | export const EpisodeOverlay: FunctionComponent = ({ 13 | episode, 14 | index, 15 | }) => { 16 | return ( 17 | 18 | Episode {index + 1} 19 | 20 | 21 | Title 22 | {episode.name} 23 | 24 | 25 | Time 26 | {`${convertToLocale( 27 | episode.startTime 28 | )} - ${convertToLocale(episode.endTime)}`} 29 | 30 | 31 | 32 | Together with 33 | 34 | 35 | {episode.initials || "N/A"} 36 | 37 | 38 | 39 | ); 40 | }; 41 | 42 | const episodeOverlayStyles = StyleSheet.create({ 43 | container: { 44 | backgroundColor: "transparent", 45 | minHeight: "60%", 46 | width: "100%", 47 | padding: 30, 48 | justifyContent: "space-around", 49 | }, 50 | divider: { 51 | height: 2, 52 | backgroundColor: "white", 53 | }, 54 | title: { 55 | alignItems: "flex-end", 56 | fontFamily: "Inter_700Bold", 57 | fontStyle: "normal", 58 | fontSize: 40, 59 | lineHeight: 48, 60 | color: "#FFFFFF", 61 | }, 62 | detailContainer: { 63 | backgroundColor: "transparent", 64 | }, 65 | episodeDetails: { 66 | alignItems: "flex-end", 67 | fontFamily: "Inter_500Medium", 68 | fontStyle: "normal", 69 | fontSize: 30, 70 | lineHeight: 36, 71 | color: "#FFFFFF", 72 | }, 73 | episodeDetailTitle: { 74 | alignItems: "flex-end", 75 | fontFamily: "Arimo_400Regular", 76 | fontStyle: "normal", 77 | fontSize: 18, 78 | lineHeight: 26, 79 | color: "#FFFFFF", 80 | }, 81 | }); 82 | -------------------------------------------------------------------------------- /components/EpisodeRecallOverlay.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, FunctionComponent } from "react"; 2 | import { StyleSheet } from "react-native"; 3 | import Colors from "../constants/Colors"; 4 | import { Episode } from "../types"; 5 | import { convertToLocale } from "../utils/TimeUtils"; 6 | import { View, Text } from "./Themed"; 7 | 8 | interface EpisodeRecallOverlay { 9 | episode: Episode; 10 | } 11 | 12 | export const EpisodeRecallOverlay: FunctionComponent = ({ 13 | episode, 14 | }) => { 15 | return ( 16 | 17 | Day 1 18 | {episode.name} 19 | 20 | 21 | Time 22 | {`${convertToLocale(episode.startTime)} - ${convertToLocale( 25 | episode.endTime 26 | )}`} 27 | 28 | 29 | 30 | Together with 31 | 32 | 33 | {episode.initials || "N/A"} 34 | 35 | 36 | 37 | ); 38 | }; 39 | 40 | const episodeRecallOverlayStyles = StyleSheet.create({ 41 | container: { 42 | backgroundColor: "transparent", 43 | minHeight: "60%", 44 | width: "100%", 45 | padding: 30, 46 | justifyContent: "space-around", 47 | }, 48 | divider: { 49 | height: 2, 50 | backgroundColor: Colors.avocadoGreen, 51 | }, 52 | title: { 53 | alignItems: "flex-end", 54 | fontFamily: "Inter_700Bold", 55 | fontStyle: "normal", 56 | fontSize: 40, 57 | lineHeight: 48, 58 | color: Colors.avocadoGreen, 59 | }, 60 | detailContainer: { 61 | backgroundColor: "transparent", 62 | }, 63 | episodeDetails: { 64 | alignItems: "flex-end", 65 | fontFamily: "Inter_500Medium", 66 | fontStyle: "normal", 67 | fontSize: 30, 68 | lineHeight: 36, 69 | color: Colors.avocadoGreen, 70 | }, 71 | episodeDetailTitle: { 72 | alignItems: "flex-end", 73 | fontFamily: "Arimo_400Regular", 74 | fontStyle: "normal", 75 | fontSize: 18, 76 | lineHeight: 26, 77 | color: Colors.avocadoGreen, 78 | }, 79 | }); 80 | -------------------------------------------------------------------------------- /navigation/CameraFlowNavigator.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, useState } from "react"; 2 | import CameraRecording from "../screens/CameraRecording"; 3 | import LetsStartRecording from "../screens/LetsStartRecording"; 4 | import PrepareFace from "../screens/PrepareFace"; 5 | import { useMainNavigation } from "../hooks/useMainNavigation"; 6 | import { NavigationState } from "./MainNavigationContext"; 7 | 8 | interface CameraFlowNavigatorProps { 9 | objects: T[]; 10 | overlayCreator: (object: T, index: number) => ReactElement; 11 | nameCreator: (object: T, index: number) => string; 12 | nextState: NavigationState; 13 | recordingDay: number; 14 | overlayBackgroundColor: string; 15 | } 16 | 17 | enum CurrentScreen { 18 | prepareFace, 19 | letsStart, 20 | recording, 21 | } 22 | 23 | function CameraFlowNavigator({ 24 | objects, 25 | overlayCreator, 26 | nameCreator, 27 | nextState, 28 | recordingDay, 29 | overlayBackgroundColor, 30 | }: CameraFlowNavigatorProps): ReactElement { 31 | let objectValues = objects; 32 | let overlayCreatorFunc = overlayCreator; 33 | const [currentObjectIndex, setCurrentObjectIndex] = useState(0); 34 | const [currentState, setCurrentState] = useState(CurrentScreen.prepareFace); 35 | const navigation = useMainNavigation(); 36 | switch (currentState) { 37 | case CurrentScreen.prepareFace: 38 | return ( 39 | 41 | setCurrentState( 42 | currentObjectIndex === 0 && recordingDay === 1 43 | ? CurrentScreen.letsStart 44 | : CurrentScreen.recording 45 | ) 46 | } 47 | /> 48 | ); 49 | case CurrentScreen.letsStart: 50 | return ( 51 | setCurrentState(CurrentScreen.recording)} 53 | /> 54 | ); 55 | case CurrentScreen.recording: 56 | return ( 57 | { 64 | if (currentObjectIndex + 1 >= objectValues.length) { 65 | navigation.navigate(nextState); 66 | } else { 67 | setCurrentObjectIndex(currentObjectIndex + 1); 68 | setCurrentState(CurrentScreen.prepareFace); 69 | } 70 | }} 71 | videoName={nameCreator( 72 | objectValues[currentObjectIndex], 73 | currentObjectIndex 74 | )} 75 | /> 76 | ); 77 | } 78 | } 79 | 80 | export default CameraFlowNavigator; 81 | -------------------------------------------------------------------------------- /screens/ClearEpisodeScreen.tsx: -------------------------------------------------------------------------------- 1 | import AsyncStorage from "@react-native-async-storage/async-storage"; 2 | import React, { useState } from "react"; 3 | import { 4 | Keyboard, 5 | StyleSheet, 6 | TextInput, 7 | TouchableWithoutFeedback, 8 | } from "react-native"; 9 | import { Button, Text, View } from "../components/Themed"; 10 | import Colors from "../constants/Colors"; 11 | import { useMainNavigation } from "../hooks/useMainNavigation"; 12 | import { NavigationScreens } from "../navigation/MainNavigationContext"; 13 | import { continueButtonStyle } from "../utils/StylingUtils"; 14 | 15 | export const ClearEpisodeScreen = () => { 16 | const mainNavigation = useMainNavigation(); 17 | const [password, setPassword] = useState(""); 18 | 19 | const validatePassword = async () => { 20 | if (password === "aVocado!") { 21 | await AsyncStorage.clear(); 22 | mainNavigation.navigate({ type: NavigationScreens.onboarding }); 23 | } 24 | }; 25 | return ( 26 | Keyboard.dismiss()} 28 | accessible={false} 29 | > 30 | 31 | 32 | Thank you for participating in the study! {"\n\n"} 33 | If you're a researcher, please put in the password to restart the 34 | study for the next participate. 35 | {"\n\n"} 36 | 37 | 44 | 50 | 51 | 52 | ); 53 | }; 54 | 55 | const ClearScreenStyles = StyleSheet.create({ 56 | container: { 57 | flex: 1, 58 | backgroundColor: Colors.avocadoGreen, 59 | justifyContent: "center", 60 | padding: 30, 61 | }, 62 | text: { 63 | color: "white", 64 | fontSize: 18, 65 | fontFamily: "Arimo_400Regular", 66 | textAlign: "center", 67 | }, 68 | textInput: { 69 | borderWidth: 1, 70 | borderColor: "#979797", 71 | borderStyle: "solid", 72 | fontFamily: "Arimo_400Regular", 73 | fontStyle: "normal", 74 | fontSize: 20, 75 | lineHeight: 22, 76 | color: Colors.avocadoGreen, 77 | backgroundColor: "#FFF", 78 | padding: 15, 79 | marginBottom: 30, 80 | }, 81 | buttonText: { 82 | textAlign: "center", 83 | fontSize: 20, 84 | fontWeight: "400", 85 | color: "white", 86 | }, 87 | }); 88 | -------------------------------------------------------------------------------- /screens/EpisodeRecallOverview.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent, useEffect, useState } from "react"; 2 | import { View, Text, Button } from "../components/Themed"; 3 | import { StyleSheet } from "react-native"; 4 | import { continueButtonStyle } from "../utils/StylingUtils"; 5 | import AsyncStorage from "@react-native-async-storage/async-storage"; 6 | import { STORAGE_KEYS } from "../utils/AsyncStorageUtils"; 7 | import { useMainNavigation } from "../hooks/useMainNavigation"; 8 | import Colors from "../constants/Colors"; 9 | 10 | function EvenSpacedView() { 11 | return ; 12 | } 13 | 14 | export const EpisodeRecallOverview: FunctionComponent = () => { 15 | const [episodes, setEpisodes] = useState([]); 16 | 17 | const navigation = useMainNavigation(); 18 | 19 | useEffect(() => { 20 | AsyncStorage.getItem(STORAGE_KEYS.episodeRecall()).then((episodes) => { 21 | if (episodes) { 22 | setEpisodes(JSON.parse(episodes)); 23 | } 24 | }); 25 | }, []); 26 | 27 | return ( 28 | 29 | 30 | 31 | Episode Recall 32 | 33 | 34 | Now we are going to select two Episodes from Day 1 for you to recall in 35 | detail. {"\n\n"} 36 | As before every recording, we will first make sure your face is visible 37 | in the window. {"\n\n"} 38 | Then on the following screen, we will tell you which Episode we would 39 | like you to do another recording for. 40 | 41 | 49 | 50 | ); 51 | }; 52 | 53 | const EpisodeRecallOverviewStyles = StyleSheet.create({ 54 | titleView: { 55 | flex: 1, 56 | margin: 0, 57 | padding: 0, 58 | backgroundColor: "transparent", 59 | }, 60 | title: { 61 | fontFamily: "Inter_700Bold", 62 | flex: 1, 63 | fontSize: 40, 64 | textAlign: "left", 65 | color: "white", 66 | }, 67 | container: { 68 | flex: 1, 69 | paddingTop: 25, 70 | paddingRight: 24, 71 | paddingLeft: 24, 72 | paddingBottom: 25, 73 | justifyContent: "flex-start", 74 | backgroundColor: Colors.avocadoGreen, 75 | }, 76 | subtext: { 77 | flex: 5, 78 | fontFamily: "Inter_400Regular", 79 | fontSize: 18, 80 | color: "white", 81 | }, 82 | button: { 83 | ...continueButtonStyle(true).style, 84 | maxHeight: 60, 85 | }, 86 | buttonText: { 87 | color: "white", 88 | textAlign: "center", 89 | fontSize: 18, 90 | fontFamily: "Inter_600SemiBold", 91 | }, 92 | }); 93 | -------------------------------------------------------------------------------- /components/EditScreenInfo.tsx: -------------------------------------------------------------------------------- 1 | import * as WebBrowser from 'expo-web-browser'; 2 | import React from 'react'; 3 | import { StyleSheet, TouchableOpacity } from 'react-native'; 4 | 5 | import Colors from '../constants/Colors'; 6 | import { MonoText } from './StyledText'; 7 | import { Text, View } from './Themed'; 8 | 9 | export default function EditScreenInfo({ path }: { path: string }) { 10 | return ( 11 | 12 | 13 | 17 | Open up the code for this screen: 18 | 19 | 20 | 24 | {path} 25 | 26 | 27 | 31 | Change any of the text, save the file, and your app will automatically update. 32 | 33 | 34 | 35 | 36 | 37 | 38 | Tap here if your app doesn't automatically update after making changes 39 | 40 | 41 | 42 | 43 | ); 44 | } 45 | 46 | function handleHelpPress() { 47 | WebBrowser.openBrowserAsync( 48 | 'https://docs.expo.io/get-started/create-a-new-app/#opening-the-app-on-your-phonetablet' 49 | ); 50 | } 51 | 52 | const styles = StyleSheet.create({ 53 | container: { 54 | flex: 1, 55 | backgroundColor: '#fff', 56 | }, 57 | developmentModeText: { 58 | marginBottom: 20, 59 | fontSize: 14, 60 | lineHeight: 19, 61 | textAlign: 'center', 62 | }, 63 | contentContainer: { 64 | paddingTop: 30, 65 | }, 66 | welcomeContainer: { 67 | alignItems: 'center', 68 | marginTop: 10, 69 | marginBottom: 20, 70 | }, 71 | welcomeImage: { 72 | width: 100, 73 | height: 80, 74 | resizeMode: 'contain', 75 | marginTop: 3, 76 | marginLeft: -10, 77 | }, 78 | getStartedContainer: { 79 | alignItems: 'center', 80 | marginHorizontal: 50, 81 | }, 82 | homeScreenFilename: { 83 | marginVertical: 7, 84 | }, 85 | codeHighlightText: { 86 | color: 'rgba(96,100,109, 0.8)', 87 | }, 88 | codeHighlightContainer: { 89 | borderRadius: 3, 90 | paddingHorizontal: 4, 91 | }, 92 | getStartedText: { 93 | fontSize: 17, 94 | lineHeight: 24, 95 | textAlign: 'center', 96 | }, 97 | helpContainer: { 98 | marginTop: 15, 99 | marginHorizontal: 20, 100 | alignItems: 'center', 101 | }, 102 | helpLink: { 103 | paddingVertical: 15, 104 | }, 105 | helpLinkText: { 106 | textAlign: 'center', 107 | }, 108 | }); 109 | -------------------------------------------------------------------------------- /screens/OnboardingScreen/Day1/Intro1.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Keyboard, 3 | Linking, 4 | StyleSheet, 5 | TextInput, 6 | TouchableWithoutFeedback, 7 | } from "react-native"; 8 | import { Text, View } from "../../../components/Themed"; 9 | import React, { useState } from "react"; 10 | import AIntroScreen, { styles as abstractStyles } from "../AIntroScreen"; 11 | import Colors from "../../../constants/Colors"; 12 | import Contact from "../../../constants/Contact"; 13 | import AsyncStorage from "@react-native-async-storage/async-storage"; 14 | import { STORAGE_KEYS } from "../../../utils/AsyncStorageUtils"; 15 | 16 | interface Intro1Props { 17 | setCanScroll?: (canScroll: boolean) => void; 18 | } 19 | 20 | export default function Intro1({ setCanScroll }: Intro1Props) { 21 | const [participantId, setParticipantId] = useState(""); 22 | const storeParticipantId = async () => { 23 | if (participantId !== "") { 24 | setCanScroll?.(true); 25 | await AsyncStorage.setItem(STORAGE_KEYS.participantId(), participantId); 26 | } 27 | }; 28 | return ( 29 | 30 | 31 | 32 | { 33 | "You will record a series of short videos describing your day. The next few pages explain how to do it. The diary should take approximately 20-30 minutes to complete.\n\nIf you have questions or concerns, please contact a research staff member " 34 | } 35 | Linking.openURL(Contact.contactLink)} 38 | > 39 | {"here"} 40 | 41 | {" or by phone at "} 42 | Linking.openURL(`tel:${Contact.contactPhone}`)} 45 | > 46 | {Contact.contactPhone} 47 | 48 | {".\n\n"} 49 | 50 | 51 | 52 | Please enter your participant ID: 53 | 54 | 62 | 63 | 64 | 65 | ); 66 | } 67 | 68 | const styles = StyleSheet.create({ 69 | bodyContact: { 70 | color: Colors.linkOrange, 71 | }, 72 | bodyLink: { 73 | color: Colors.linkOrange, 74 | textDecorationLine: "underline", 75 | }, 76 | inputHeader: { 77 | fontFamily: "Arimo_400Regular", 78 | fontStyle: "normal", 79 | fontSize: 18, 80 | lineHeight: 22, 81 | marginVertical: "2%", 82 | color: Colors.avocadoGreen, 83 | }, 84 | textInput: { 85 | borderWidth: 1, 86 | borderColor: "#979797", 87 | borderStyle: "solid", 88 | fontFamily: "Arimo_400Regular", 89 | fontStyle: "normal", 90 | fontSize: 20, 91 | lineHeight: 22, 92 | color: Colors.avocadoGreen, 93 | marginBottom: 50, 94 | padding: 15, 95 | }, 96 | }); 97 | -------------------------------------------------------------------------------- /components/EpisodeTutorialOverlay.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, FunctionComponent } from "react"; 2 | import { Keyboard, View, TouchableWithoutFeedback, Text } from "react-native"; 3 | import { Episode } from "../types"; 4 | import { 5 | containerStyles, 6 | styles, 7 | } from "../screens/EpisodeInputFlow/EpisodeInputCommonStyles"; 8 | import { EpisodeInputFields } from "./EpisodeInputFields"; 9 | import { StyleSheet } from "react-native"; 10 | import { Button } from "./Themed"; 11 | 12 | interface EpisodeTutorialOverlayProps { 13 | recordingDay: number; 14 | addEpisode: (episode: Episode) => void; 15 | } 16 | 17 | export const EpisodeTutorialOverlay: FunctionComponent = ({ 18 | recordingDay, 19 | addEpisode, 20 | }) => { 21 | const informationText = [ 22 | "Name your episode", 23 | "Indicate when the episode started (Start Time) and ended (End Time).", 24 | "Identify the important or main people that you interacted with, using their initials.", 25 | 'Tap "Add Episode" to add this episode to your today\'s diary.', 26 | ]; 27 | const [fieldState, setFieldState] = useState(0); 28 | return ( 29 | 30 | 31 | 32 | 33 | {/** this is only here to ensure the spacing is consistent */} 34 | 41 | {" \n\n"} 42 | 48 | {" "} 49 | 50 | 51 | 57 | {informationText[fieldState]} 58 | 59 | 60 | 67 | 77 | 78 | 79 | 80 | 81 | ); 82 | }; 83 | 84 | export const overlayStyles = StyleSheet.create({ 85 | overlayContainer: { 86 | position: "absolute", 87 | width: "100%", 88 | height: "100%", 89 | top: 0, 90 | left: 0, 91 | backgroundColor: "rgba(15, 52, 51, 0.95)", 92 | }, 93 | textColor: { 94 | color: "white", 95 | }, 96 | informationText: { 97 | color: "white", 98 | position: "absolute", 99 | width: "100%", 100 | top: 15, 101 | left: 0, 102 | textAlign: "center", 103 | }, 104 | }); 105 | -------------------------------------------------------------------------------- /App.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DarkTheme, 3 | DefaultTheme, 4 | NavigationContainer, 5 | } from "@react-navigation/native"; 6 | import { StatusBar } from "expo-status-bar"; 7 | import React, { useEffect, useState } from "react"; 8 | import { SafeAreaProvider } from "react-native-safe-area-context"; 9 | 10 | import useCachedResources from "./hooks/useCachedResources"; 11 | import useColorScheme from "./hooks/useColorScheme"; 12 | import MainNavigator from "./navigation/MainNavigator"; 13 | import LinkingConfiguration from "./navigation/LinkingConfiguration"; 14 | import { NavigationScreens } from "./navigation/MainNavigationContext"; 15 | import AsyncStorage from "@react-native-async-storage/async-storage"; 16 | import { STORAGE_KEYS } from "./utils/AsyncStorageUtils"; 17 | import { AppStateStorage } from "./types"; 18 | import { 19 | areDaysEqual, 20 | getCurrentDate, 21 | retrieveRecordingDay, 22 | } from "./utils/TimeUtils"; 23 | import { AppState } from "react-native"; 24 | 25 | export default function App() { 26 | const isLoadingComplete = useCachedResources(); 27 | const colorScheme = useColorScheme(); 28 | const [startingState, setStartingState] = useState( 29 | null 30 | ); 31 | 32 | // When getting the state from async storage, the date property is still stored as a string. This fixes that 33 | const fixDates = (state: AppStateStorage): AppStateStorage => { 34 | return { 35 | ...state, 36 | date: new Date(state.date), 37 | }; 38 | }; 39 | 40 | const validateCurrentDayOrGoToNext = async ( 41 | appStorage: AppStateStorage 42 | ): Promise => { 43 | if (appStorage.state.type === NavigationScreens.onboarding) { 44 | return appStorage; 45 | } 46 | // If the days are equal from what is on disk, then do nothing. The interesting case is when the days change 47 | if (areDaysEqual(appStorage.date, getCurrentDate())) { 48 | return appStorage; 49 | } else { 50 | const recordingDay = await retrieveRecordingDay(); 51 | if (recordingDay === 2 || recordingDay === 3) { 52 | return { 53 | state: { type: "onboarding2or3", recordingDay }, 54 | date: getCurrentDate(), 55 | }; 56 | } else { 57 | return { 58 | state: { type: NavigationScreens.episodeClearing }, 59 | date: getCurrentDate(), 60 | }; 61 | } 62 | } 63 | }; 64 | 65 | const updateCurrentState = async () => { 66 | AsyncStorage.getItem(STORAGE_KEYS.currentState()) 67 | .then((value) => 68 | value != null 69 | ? JSON.parse(value) 70 | : { 71 | state: { type: NavigationScreens.onboarding }, 72 | date: getCurrentDate(), 73 | } 74 | ) 75 | .then(fixDates) 76 | .then(validateCurrentDayOrGoToNext) 77 | .then(setStartingState); 78 | }; 79 | 80 | useEffect(() => { 81 | updateCurrentState(); 82 | const listenerHandler = (_: any) => updateCurrentState(); 83 | // Whenever the app changes state, we update our state 84 | AppState.addEventListener("change", listenerHandler); 85 | return () => AppState.removeEventListener("change", listenerHandler); 86 | }, []); 87 | 88 | if (!isLoadingComplete || startingState === null) { 89 | return null; 90 | } else { 91 | return ( 92 | 93 | 97 | 98 | 99 | 100 | 101 | ); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /screens/EpisodeRecallFinished.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent, useEffect, useState } from "react"; 2 | import { View, Text, Button } from "../components/Themed"; 3 | import { StyleSheet } from "react-native"; 4 | import { continueButtonStyle } from "../utils/StylingUtils"; 5 | import { useMainNavigation } from "../hooks/useMainNavigation"; 6 | import Colors from "../constants/Colors"; 7 | import { NavigationScreens } from "../navigation/MainNavigationContext"; 8 | 9 | function EvenSpacedView() { 10 | return ; 11 | } 12 | 13 | interface EpisodeRecallFinishedProps { 14 | recordingDay: number; 15 | } 16 | 17 | export const EpisodeRecallFinished: FunctionComponent = ({ 18 | recordingDay, 19 | }: EpisodeRecallFinishedProps) => { 20 | const navigation = useMainNavigation(); 21 | 22 | return ( 23 | 24 | 25 | 26 | 27 | Thank you for recalling your episodes 28 | 29 | 30 | {recordingDay === 2 ? ( 31 | <> 32 | 33 | We will now move onto the next part of the Video Diary for today, 34 | where you will list and tells us about Episodes that happened today. 35 | {"\n\n"} 36 | This will look the same as what you completed yesterday, but we will 37 | still provide you with instructions as reminders. 38 | 39 | 49 | 50 | ) : ( 51 | <> 52 | 53 | We will now move onto the next part of the Video Diary for today, 54 | where you will list Episodes that happened yesterday.{"\n\n"} 55 | This will look the same as what you completed yesterday, but we will 56 | still provide you with instructions as reminders. 57 | 58 | 70 | 71 | )} 72 | 73 | ); 74 | }; 75 | 76 | const EpisodeRecallOverviewStyles = StyleSheet.create({ 77 | titleView: { 78 | margin: 0, 79 | padding: 0, 80 | backgroundColor: "transparent", 81 | }, 82 | title: { 83 | fontFamily: "Inter_700Bold", 84 | fontSize: 40, 85 | textAlign: "left", 86 | color: "white", 87 | marginVertical: 50, 88 | }, 89 | container: { 90 | flex: 1, 91 | paddingTop: 25, 92 | paddingRight: 24, 93 | paddingLeft: 24, 94 | paddingBottom: 25, 95 | justifyContent: "flex-start", 96 | backgroundColor: Colors.avocadoGreen, 97 | }, 98 | subtext: { 99 | flex: 5, 100 | fontFamily: "Inter_400Regular", 101 | fontSize: 18, 102 | color: "white", 103 | }, 104 | button: { 105 | ...continueButtonStyle(true).style, 106 | maxHeight: 60, 107 | }, 108 | buttonText: { 109 | color: "white", 110 | textAlign: "center", 111 | fontSize: 18, 112 | fontFamily: "Inter_600SemiBold", 113 | }, 114 | }); 115 | -------------------------------------------------------------------------------- /screens/EpisodeInputFlow/EpisodeEditScreen.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigation } from "@react-navigation/native"; 2 | import React, { useState } from "react"; 3 | import { TextInput } from "react-native"; 4 | import { Button, Text, View } from "../../components/Themed"; 5 | import { Episode } from "../../types"; 6 | import { getCurrentDate } from "../../utils/TimeUtils"; 7 | import { TimePicker } from "../../components/TimePicker"; 8 | import { containerStyles, styles } from "./EpisodeInputCommonStyles"; 9 | 10 | let recordingDay: number = 1; 11 | 12 | interface EpisodeEditProps { 13 | episode: Episode; 14 | index: number; 15 | } 16 | 17 | export default function EpisodeEditScreen({ route }: any) { 18 | const { episode, index }: EpisodeEditProps = route.params; 19 | 20 | let navigation = useNavigation(); 21 | 22 | const [episodeName, setEpisodeName] = useState(episode.name); 23 | const [initials, setInitials] = useState(episode.initials); 24 | const [startTime, setStartTime] = useState( 25 | new Date(episode.startTime) 26 | ); 27 | const [endTime, setEndTime] = useState( 28 | new Date(episode.endTime) 29 | ); 30 | 31 | const saveEpisode = () => { 32 | if (validateEpisode()) { 33 | const newEpisode: Episode = { 34 | name: episodeName, 35 | initials, 36 | startTime: startTime!.toISOString(), 37 | endTime: endTime!.toISOString(), 38 | date: getCurrentDate().toISOString(), 39 | recordingDay, 40 | }; 41 | navigation.navigate("EpisodeConfirmation", { 42 | episode: newEpisode, 43 | index, 44 | }); 45 | } 46 | }; 47 | 48 | const validateEpisode = () => { 49 | return ( 50 | episodeName !== "" && startTime !== undefined && endTime !== undefined 51 | ); 52 | }; 53 | 54 | const hasError = !validateEpisode(); 55 | 56 | return ( 57 | 58 | 59 | Edit an episode 60 | 61 | 62 | 63 | Episode title 64 | 71 | 72 | 73 | {"Duration"} 74 | 75 | 81 | 82 | 88 | 89 | 90 | 91 | 92 | Persons involved{" "} 93 | 94 | (Optional) 95 | 96 | 97 | 104 | 105 | 106 | 107 | 117 | 118 | 119 | ); 120 | } 121 | -------------------------------------------------------------------------------- /screens/ThankYouScreen.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from "react"; 2 | import { Linking, StyleSheet } from "react-native"; 3 | import { View, Text } from "../components/Themed"; 4 | import Colors from "../constants/Colors"; 5 | import { continueButtonStyle } from "../utils/StylingUtils"; 6 | import Contact from "../constants/Contact"; 7 | import { EvenSpacedView } from "../components/EvenSpacedView"; 8 | 9 | interface ThankYouScreenProps { 10 | recordingDay: number; 11 | } 12 | 13 | export const ThankYouScreen: FunctionComponent = ({ 14 | recordingDay, 15 | }) => { 16 | return ( 17 | 18 | 19 | 20 | Thank you! 21 | 22 | 23 | {recordingDay === 1 || recordingDay === 2 ? ( 24 | 25 | Thank you for completing Day {recordingDay} of your Video Diary! 26 | Tomorrow you will complete Day {recordingDay + 1}. {"\n\n"} 27 | Remember, if you have questions or concerns, please contact a research 28 | staff member{" "} 29 | Linking.openURL(Contact.contactLink)} 32 | > 33 | here 34 | {" "} 35 | or by phone at{" "} 36 | Linking.openURL(`tel:${Contact.contactPhone}`)} 39 | > 40 | {Contact.contactPhone} 41 | 42 | . {"\n\n"} 43 | Thank you again for taking the time to participate in our study. We 44 | know your time is valuable and we are grateful for your contributions 45 | to science. {"\n\n"} 46 | You are now done for the day. We will see you again tomorrow! 47 | {"\n\n"} 48 | Please exit and close the app. 49 | 50 | ) : ( 51 | 52 | Thank you for completing Day {recordingDay} of your Video Diary! You 53 | are now complete with the study.{"\n\n"} 54 | Remember, if you have questions or concerns, please contact a research 55 | staff member{" "} 56 | Linking.openURL(Contact.contactLink)} 59 | > 60 | here 61 | {" "} 62 | or by phone at{" "} 63 | Linking.openURL(`tel:${Contact.contactPhone}`)} 66 | > 67 | {Contact.contactPhone} 68 | 69 | . {"\n\n"} 70 | Thank you again for taking the time to participate in our study. We 71 | know your time is valuable and we are grateful for your contributions 72 | to science. {"\n\n"} 73 | 74 | )} 75 | 76 | ); 77 | }; 78 | 79 | const ThankYouScreenStyles = StyleSheet.create({ 80 | titleView: { 81 | flex: 1, 82 | margin: 0, 83 | padding: 0, 84 | }, 85 | title: { 86 | fontWeight: "700", 87 | flex: 1, 88 | fontSize: 40, 89 | textAlign: "left", 90 | color: Colors.avocadoGreen, 91 | fontFamily: "Arimo_700Bold", 92 | lineHeight: 48.4, 93 | }, 94 | container: { 95 | flex: 1, 96 | paddingTop: 25, 97 | justifyContent: "flex-start", 98 | paddingRight: 24, 99 | paddingLeft: 24, 100 | paddingBottom: 25, 101 | }, 102 | subtext: { 103 | flex: 5, 104 | fontWeight: "400", 105 | fontSize: 18, 106 | fontFamily: "Arimo_400Regular", 107 | color: Colors.avocadoGreen, 108 | }, 109 | button: { 110 | ...continueButtonStyle(true).style, 111 | maxHeight: 60, 112 | }, 113 | bodyContact: { 114 | color: Colors.linkOrange, 115 | }, 116 | bodyLink: { 117 | color: Colors.linkOrange, 118 | textDecorationLine: "underline", 119 | }, 120 | }); 121 | -------------------------------------------------------------------------------- /screens/EpisodeInputFlow/EpisodeInputScreen.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigation } from "@react-navigation/native"; 2 | import React, { useState, useEffect } from "react"; 3 | import { Keyboard, TouchableWithoutFeedback } from "react-native"; 4 | import { Button, Text, View } from "../../components/Themed"; 5 | import Icon from "react-native-vector-icons/Octicons"; 6 | import { Episode } from "../../types"; 7 | import AsyncStorage from "@react-native-async-storage/async-storage"; 8 | import { STORAGE_KEYS } from "../../utils/AsyncStorageUtils"; 9 | import { containerStyles, styles } from "./EpisodeInputCommonStyles"; 10 | import { EpisodeInputFields } from "../../components/EpisodeInputFields"; 11 | import { EpisodeTutorialOverlay } from "../../components/EpisodeTutorialOverlay"; 12 | import Colors from "../../constants/Colors"; 13 | 14 | interface EpisodeInputScreenProps { 15 | recordingDay: number; 16 | } 17 | 18 | export default function EpisodeInputScreen({ 19 | recordingDay, 20 | }: EpisodeInputScreenProps) { 21 | let navigation = useNavigation(); 22 | 23 | const [episodes, setEpisodes] = useState([]); 24 | const [showTutorial, setShowTutorial] = useState(recordingDay === 1); 25 | 26 | const putEpisodesInStorage = async ( 27 | finished: boolean, 28 | episodes: Episode[] 29 | ) => { 30 | await AsyncStorage.setItem( 31 | STORAGE_KEYS.daysEpisodes(recordingDay), 32 | JSON.stringify(episodes) 33 | ); 34 | if (finished) { 35 | //@ts-ignore 36 | navigation.replace("EpisodeConfirmation", { episodes, recordingDay }); 37 | } 38 | }; 39 | 40 | const onMount = async () => { 41 | const todayEpisodes = await AsyncStorage.getItem( 42 | STORAGE_KEYS.daysEpisodes(recordingDay) 43 | ); 44 | if (todayEpisodes === null) { 45 | // if the todaysEpisodes hasn't been set, this is their first time on today so set it 46 | await AsyncStorage.setItem( 47 | STORAGE_KEYS.daysEpisodes(recordingDay), 48 | JSON.stringify([]) 49 | ); 50 | } else { 51 | // if it isn't their first time on today, they may have some episodes the already entered so add them 52 | setEpisodes(JSON.parse(todayEpisodes)); 53 | } 54 | }; 55 | 56 | const addEpisode = (episode: Episode) => { 57 | const newEpisodes = [...episodes, episode]; 58 | setEpisodes(newEpisodes); 59 | putEpisodesInStorage(false, newEpisodes); 60 | setShowTutorial(false); 61 | }; 62 | 63 | onMount(); 64 | 65 | return ( 66 | // accessible = false allows the input form continue to be accessible through VoiceOver 67 | 68 | <> 69 | 70 | 71 | 72 | Create an episode{"\n\n"} 73 | 74 | Please enter at least two episodes 75 | 76 | 77 | { 82 | navigation.navigate("EpisodeDisplay", { 83 | episodes, 84 | recordingDay, 85 | }); 86 | }} 87 | /> 88 | 89 | 93 | 103 | 104 | 105 | {showTutorial && ( 106 | 110 | )} 111 | 112 | 113 | ); 114 | } 115 | -------------------------------------------------------------------------------- /gcloud-functions/functions/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | // -- Strict errors -- 4 | // These lint rules are likely always a good idea. 5 | 6 | // Force function overloads to be declared together. This ensures readers understand APIs. 7 | "adjacent-overload-signatures": true, 8 | 9 | // Do not allow the subtle/obscure comma operator. 10 | "ban-comma-operator": true, 11 | 12 | // Do not allow internal modules or namespaces . These are deprecated in favor of ES6 modules. 13 | "no-namespace": true, 14 | 15 | // Do not allow parameters to be reassigned. To avoid bugs, developers should instead assign new values to new vars. 16 | "no-parameter-reassignment": true, 17 | 18 | // Force the use of ES6-style imports instead of /// imports. 19 | "no-reference": true, 20 | 21 | // Do not allow type assertions that do nothing. This is a big warning that the developer may not understand the 22 | // code currently being edited (they may be incorrectly handling a different type case that does not exist). 23 | "no-unnecessary-type-assertion": true, 24 | 25 | // Disallow nonsensical label usage. 26 | "label-position": true, 27 | 28 | // Disallows the (often typo) syntax if (var1 = var2). Replace with if (var2) { var1 = var2 }. 29 | "no-conditional-assignment": true, 30 | 31 | // Disallows constructors for primitive types (e.g. new Number('123'), though Number('123') is still allowed). 32 | "no-construct": true, 33 | 34 | // Do not allow super() to be called twice in a constructor. 35 | "no-duplicate-super": true, 36 | 37 | // Do not allow the same case to appear more than once in a switch block. 38 | "no-duplicate-switch-case": true, 39 | 40 | // Do not allow a variable to be declared more than once in the same block. Consider function parameters in this 41 | // rule. 42 | "no-duplicate-variable": [true, "check-parameters"], 43 | 44 | // Disallows a variable definition in an inner scope from shadowing a variable in an outer scope. Developers should 45 | // instead use a separate variable name. 46 | "no-shadowed-variable": true, 47 | 48 | // Empty blocks are almost never needed. Allow the one general exception: empty catch blocks. 49 | "no-empty": [true, "allow-empty-catch"], 50 | 51 | // Functions must either be handled directly (e.g. with a catch() handler) or returned to another function. 52 | // This is a major source of errors in Cloud Functions and the team strongly recommends leaving this rule on. 53 | "no-floating-promises": true, 54 | 55 | // Do not allow any imports for modules that are not in package.json. These will almost certainly fail when 56 | // deployed. 57 | "no-implicit-dependencies": false, 58 | 59 | // The 'this' keyword can only be used inside of classes. 60 | "no-invalid-this": true, 61 | 62 | // Do not allow strings to be thrown because they will not include stack traces. Throw Errors instead. 63 | "no-string-throw": true, 64 | 65 | // Disallow control flow statements, such as return, continue, break, and throw in finally blocks. 66 | "no-unsafe-finally": true, 67 | 68 | // Expressions must always return a value. Avoids common errors like const myValue = functionReturningVoid(); 69 | "no-void-expression": [true, "ignore-arrow-function-shorthand"], 70 | 71 | // Disallow duplicate imports in the same file. 72 | "no-duplicate-imports": true, 73 | 74 | // -- Strong Warnings -- 75 | // These rules should almost never be needed, but may be included due to legacy code. 76 | // They are left as a warning to avoid frustration with blocked deploys when the developer 77 | // understand the warning and wants to deploy anyway. 78 | 79 | // Warn when an empty interface is defined. These are generally not useful. 80 | "no-empty-interface": { "severity": "warning" }, 81 | 82 | // Warn when an import will have side effects. 83 | "no-import-side-effect": { "severity": "warning" }, 84 | 85 | // Warn when variables are defined with var. Var has subtle meaning that can lead to bugs. Strongly prefer const for 86 | // most values and let for values that will change. 87 | "no-var-keyword": { "severity": "warning" }, 88 | 89 | // Prefer === and !== over == and !=. The latter operators support overloads that are often accidental. 90 | "triple-equals": { "severity": "warning" }, 91 | 92 | // Warn when using deprecated APIs. 93 | "deprecation": { "severity": "warning" }, 94 | 95 | // -- Light Warnings -- 96 | // These rules are intended to help developers use better style. Simpler code has fewer bugs. These would be "info" 97 | // if TSLint supported such a level. 98 | 99 | // prefer for( ... of ... ) to an index loop when the index is only used to fetch an object from an array. 100 | // (Even better: check out utils like .map if transforming an array!) 101 | "prefer-for-of": { "severity": "warning" }, 102 | 103 | // Warns if function overloads could be unified into a single function with optional or rest parameters. 104 | "unified-signatures": { "severity": "warning" }, 105 | 106 | // Prefer const for values that will not change. This better documents code. 107 | "prefer-const": { "severity": "warning" }, 108 | 109 | // Multi-line object literals and function calls should have a trailing comma. This helps avoid merge conflicts. 110 | "trailing-comma": { "severity": "warning" } 111 | }, 112 | 113 | "defaultSeverity": "error" 114 | } 115 | -------------------------------------------------------------------------------- /components/EpisodeInputFields.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, FunctionComponent } from "react"; 2 | import { TextInput, View } from "react-native"; 3 | import { Button, Text } from "./Themed"; 4 | import { TimePicker } from "./TimePicker"; 5 | import { Episode } from "../types"; 6 | import { getCurrentDate } from "../utils/TimeUtils"; 7 | import { 8 | containerStyles, 9 | styles, 10 | } from "../screens/EpisodeInputFlow/EpisodeInputCommonStyles"; 11 | 12 | interface EpisodeInputFieldsProps { 13 | recordingDay: number; 14 | addEpisode: (episode: Episode) => void; 15 | darkMode?: boolean; 16 | updateState?: (state: number) => void; 17 | currentState?: number; 18 | } 19 | 20 | export const EpisodeInputFields: FunctionComponent = ({ 21 | recordingDay, 22 | addEpisode, 23 | darkMode, 24 | updateState, 25 | currentState, 26 | children, 27 | }) => { 28 | const [episodeName, setEpisodeName] = useState(""); 29 | const [initials, setInitials] = useState(""); 30 | const [startTime, setStartTime] = useState(undefined); 31 | const [endTime, setEndTime] = useState(undefined); 32 | const createEpisode = () => { 33 | if (validateEpisode()) { 34 | const newEpisode: Episode = { 35 | name: episodeName, 36 | initials, 37 | startTime: startTime!.toISOString(), 38 | endTime: endTime!.toISOString(), 39 | date: getCurrentDate().toISOString(), 40 | recordingDay, 41 | }; 42 | 43 | addEpisode(newEpisode); 44 | resetEpisodeInput(); 45 | } 46 | }; 47 | 48 | const textColorChange = darkMode ? { color: "white" } : {}; 49 | 50 | const resetEpisodeInput = () => { 51 | setEpisodeName(""); 52 | setInitials(""); 53 | setStartTime(undefined); 54 | setEndTime(undefined); 55 | }; 56 | 57 | const validateEpisode = () => { 58 | return ( 59 | episodeName !== "" && startTime !== undefined && endTime !== undefined 60 | ); 61 | }; 62 | const checkAndUpdateState = (newState: number) => { 63 | if (updateState && currentState !== undefined) 64 | updateState(Math.max(newState, currentState)); 65 | }; 66 | 67 | const stateOpacity = (value: number) => 68 | currentState !== undefined && currentState < value ? 0 : 100; 69 | 70 | const hasError = !validateEpisode(); 71 | return ( 72 | <> 73 | 74 | 75 | 76 | 77 | Episode title 78 | 79 | checkAndUpdateState(1)} 86 | /> 87 | 88 | 95 | 96 | {"Duration"} 97 | 98 | 103 | { 108 | setStartTime(time); 109 | endTime && checkAndUpdateState(2); 110 | }} 111 | /> 112 | 113 | { 118 | setEndTime(time); 119 | startTime && checkAndUpdateState(2); 120 | }} 121 | /> 122 | 123 | 124 | 131 | 132 | Persons involved{" "} 133 | 139 | (optional) 140 | 141 | 142 | checkAndUpdateState(3)} 149 | /> 150 | 151 | 152 | 153 | 160 | 170 | {children} 171 | 172 | 173 | 174 | ); 175 | }; 176 | -------------------------------------------------------------------------------- /screens/PrepareFace.tsx: -------------------------------------------------------------------------------- 1 | // imported to try and get face detection to work 2 | import * as _FaceDetector from "expo-face-detector"; 3 | import { Camera } from "expo-camera"; 4 | import React, { 5 | FunctionComponent, 6 | ReactElement, 7 | useEffect, 8 | useState, 9 | } from "react"; 10 | import { 11 | StyleProp, 12 | StyleSheet, 13 | useWindowDimensions, 14 | View, 15 | ViewStyle, 16 | Dimensions, 17 | } from "react-native"; 18 | import { Button, Text } from "../components/Themed"; 19 | import Colors from "../constants/Colors"; 20 | import { continueButtonStyle } from "../utils/StylingUtils"; 21 | 22 | const SpacingDiv = () => { 23 | return ; 24 | }; 25 | 26 | interface BottomInfoPromptProps { 27 | inScreen: boolean; 28 | } 29 | 30 | const BottomInfoPrompt: FunctionComponent< 31 | BottomInfoPromptProps & PrepareFaceProps 32 | > = ({ inScreen, finished }): ReactElement => { 33 | return ( 34 | 35 | {!inScreen && ( 36 | 37 | 38 | please adjust your camera 39 | 40 | 41 | )} 42 | 43 | 44 | 45 | Please check that your face is visible in the window below. When your 46 | face is centered, please press the button. 47 | 48 | 49 | 61 | 62 | 63 | 64 | ); 65 | }; 66 | 67 | const BottomInfoPromptStyles = StyleSheet.create({ 68 | container: { 69 | position: "absolute", 70 | bottom: 0, 71 | left: 0, 72 | width: "100%", 73 | minHeight: "25%", 74 | }, 75 | bottomInfoContainer: { 76 | backgroundColor: "rgba(0, 0, 0, 0.67)", 77 | justifyContent: "space-around", 78 | paddingHorizontal: "5%", 79 | paddingBottom: 20, 80 | }, 81 | text: { 82 | flex: 3, 83 | fontWeight: "400", 84 | fontSize: 18, 85 | color: "white", 86 | padding: 10, 87 | textAlign: "center", 88 | }, 89 | button: { 90 | flex: 3, 91 | padding: 10, 92 | justifyContent: undefined, 93 | alignItems: undefined, 94 | maxHeight: undefined, 95 | }, 96 | buttonText: { 97 | flex: 1, 98 | textAlign: "center", 99 | fontSize: 20, 100 | fontWeight: "400", 101 | color: "white", 102 | }, 103 | adjustCameraView: { 104 | borderRadius: 24, 105 | backgroundColor: "black", 106 | width: "75%", 107 | marginHorizontal: "12.5%", 108 | marginBottom: 5, 109 | }, 110 | adjustCameraText: { 111 | textAlign: "center", 112 | padding: 5, 113 | color: "white", 114 | flex: 1, 115 | }, 116 | }); 117 | 118 | interface Face { 119 | bounds: { 120 | origin: { x: number; y: number }; 121 | size: { width: number; height: number }; 122 | }; 123 | } 124 | 125 | interface PrepareFaceProps { 126 | finished: () => void; 127 | } 128 | 129 | const PrepareFace: FunctionComponent = ({ finished }) => { 130 | const [seeingFace, setSeeingFace] = useState(true); 131 | const [permissions, setHasPermission] = useState(false); 132 | // Using the hook so that if the orientation changes, it will update 133 | let dimensions = useWindowDimensions(); 134 | dimensions = Dimensions.get("screen"); 135 | let boundingBox = { 136 | left: dimensions.width / 20, 137 | width: (dimensions.width * 9) / 10, 138 | top: dimensions.height / 10, 139 | height: (dimensions.height * 2) / 3, 140 | }; 141 | useEffect(() => { 142 | Camera.requestPermissionsAsync().then((response) => { 143 | setHasPermission(response.status === "granted"); 144 | }); 145 | }, []); 146 | if (!permissions) { 147 | return Please give permissions; 148 | } 149 | const inBoundingBox = ({ bounds: { size, origin } }: Face): boolean => { 150 | return ( 151 | origin.x + size.width / 4 >= boundingBox.left && 152 | origin.x + (size.width * 3) / 4 <= boundingBox.left + boundingBox.width && 153 | origin.y + size.height / 4 >= boundingBox.top && 154 | origin.y + (size.height * 3) / 4 <= boundingBox.top + boundingBox.height 155 | ); 156 | }; 157 | return ( 158 | 159 | 163 | setSeeingFace(face.faces.some(inBoundingBox)) 164 | } 165 | flashMode={Camera.Constants.FlashMode.auto} 166 | /> 167 | 176 | 177 | 178 | ); 179 | }; 180 | 181 | const InViewRectStyle = ( 182 | left: number, 183 | top: number, 184 | width: number, 185 | height: number, 186 | seen: boolean 187 | ): StyleProp => ({ 188 | position: "absolute", 189 | left: left, 190 | top: top, 191 | width: width, 192 | height: height, 193 | borderWidth: 3, 194 | borderStyle: "dashed", 195 | borderColor: seen 196 | ? Colors.seeingFaceRectBorderColor 197 | : Colors.notSeeingFaceRectBorderColor, 198 | backgroundColor: "rgba(0, 0, 0, 0)", 199 | }); 200 | 201 | const Styles = StyleSheet.create({ 202 | container: { 203 | flex: 1, 204 | position: "relative", 205 | alignItems: "baseline", 206 | }, 207 | camera: { 208 | flex: 1, 209 | width: "100%", 210 | height: "100%", 211 | }, 212 | flip: { 213 | flex: 1, 214 | backgroundColor: "#0000", 215 | }, 216 | bottomRow: { 217 | bottom: 0, 218 | width: "100%", 219 | position: "absolute", 220 | flexDirection: "row", 221 | justifyContent: "space-around", 222 | alignContent: "center", 223 | padding: 10, 224 | }, 225 | takePicture: { 226 | flex: 1, 227 | backgroundColor: "#0000", 228 | }, 229 | text: { 230 | color: "black", 231 | textAlign: "center", 232 | }, 233 | faceShow: { 234 | color: "black", 235 | textAlign: "center", 236 | flex: 1, 237 | backgroundColor: "green", 238 | }, 239 | button: { 240 | backgroundColor: "#fff", 241 | }, 242 | }); 243 | 244 | export default PrepareFace; 245 | -------------------------------------------------------------------------------- /screens/EpisodeInputFlow/EpisodeConfirmationScreen.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigation } from "@react-navigation/native"; 2 | import React, { useEffect, useState } from "react"; 3 | import { StyleSheet, ScrollView } from "react-native"; 4 | import { Button, Text, View } from "../../components/Themed"; 5 | import { Episode } from "../../types"; 6 | import { Ionicons as Icon } from "@expo/vector-icons"; 7 | import { continueButtonStyle } from "../../utils/StylingUtils"; 8 | import { useMainNavigation } from "../../hooks/useMainNavigation"; 9 | import Colors from "../../constants/Colors"; 10 | import AsyncStorage from "@react-native-async-storage/async-storage"; 11 | import { STORAGE_KEYS } from "../../utils/AsyncStorageUtils"; 12 | import { convertToLocale } from "../../utils/TimeUtils"; 13 | 14 | interface EpisodeProps { 15 | episode: Episode; 16 | index: number; 17 | } 18 | 19 | function EditableEpisode({ episode, index }: EpisodeProps) { 20 | const navigator = useNavigation(); 21 | return ( 22 | 23 | 24 | 25 | {episode.name} 26 | 27 | {`${convertToLocale( 28 | episode.startTime 29 | )} - ${convertToLocale(episode.endTime)}`} 30 | {`with ${ 31 | episode.initials.trim() || "N/A" 32 | }`} 33 | 34 | 47 | 48 | ); 49 | } 50 | 51 | const episodeStyles = StyleSheet.create({ 52 | container: { 53 | display: "flex", 54 | flexDirection: "row", 55 | marginBottom: 10, 56 | height: 90, 57 | }, 58 | editButtonContainer: { 59 | flex: 1, 60 | backgroundColor: Colors.avocadoGreen, 61 | justifyContent: "center", 62 | alignItems: "center", 63 | }, 64 | editButton: { 65 | color: "white", 66 | fontFamily: "Inter_700Bold", 67 | fontStyle: "normal", 68 | fontSize: 40, 69 | lineHeight: 48, 70 | }, 71 | icon: { 72 | backgroundColor: Colors.avocadoGreen, 73 | }, 74 | infoContainer: { 75 | flex: 3, 76 | backgroundColor: "#EBEBEB", 77 | justifyContent: "center", 78 | padding: 20, 79 | }, 80 | episodeTitleText: { 81 | fontFamily: "Arimo_700Bold", 82 | fontStyle: "normal", 83 | fontSize: 18, 84 | lineHeight: 22, 85 | marginBottom: 5, 86 | }, 87 | episodeInfoText: { 88 | fontFamily: "Arimo_400Regular", 89 | fontStyle: "normal", 90 | fontSize: 18, 91 | lineHeight: 22, 92 | }, 93 | }); 94 | 95 | interface EpisodeConfirmationProps { 96 | episodes: Episode[]; 97 | recordingDay: number; 98 | } 99 | 100 | export default function EpisodeConfirmationScreen({ route }: any) { 101 | let mainNavigation = useMainNavigation(); 102 | 103 | const { episodes, recordingDay }: EpisodeConfirmationProps = route.params; 104 | const [state, setState] = useState(0); 105 | const [currentEpisodes, setCurrentEpisodes] = useState(episodes); 106 | 107 | const setRandomEpisodes = async () => { 108 | if ((await AsyncStorage.getItem(STORAGE_KEYS.episodeRecall())) === null) { 109 | const randomEpisodes = selectRandomEpisodes(currentEpisodes); 110 | await AsyncStorage.setItem( 111 | STORAGE_KEYS.episodeRecall(), 112 | JSON.stringify(randomEpisodes) 113 | ); 114 | } 115 | }; 116 | const selectRandomEpisodes = (episodes: Episode[]) => { 117 | const randomEpisodeInx = () => Math.floor(Math.random() * episodes.length); 118 | const firstElement = randomEpisodeInx(); 119 | let secondElement = randomEpisodeInx(); 120 | while (firstElement === secondElement) { 121 | secondElement = randomEpisodeInx(); 122 | } 123 | return [episodes[firstElement], episodes[secondElement]]; 124 | }; 125 | 126 | const editEpisode = (newEpisode: Episode, index: number) => { 127 | currentEpisodes[index] = newEpisode; 128 | setCurrentEpisodes(currentEpisodes); 129 | setState(state + 1); 130 | }; 131 | const { episode, index }: EpisodeProps = route.params; 132 | useEffect(() => { 133 | if (episode) editEpisode(episode, index); 134 | }, [episode]); 135 | 136 | return ( 137 | 138 | 139 | Day {recordingDay} Episodes 140 | 141 | Please double-check that these are the episodes of your day today. 142 | Please edit any details that are not correct. 143 | 144 | 145 | 146 | 147 | {currentEpisodes.map((episode: Episode, index: number) => ( 148 | 153 | ))} 154 | 155 | 156 | 172 | 173 | ); 174 | } 175 | const styles = StyleSheet.create({ 176 | container: { 177 | padding: 30, 178 | flex: 1, 179 | }, 180 | titleContainer: { 181 | marginTop: 40, 182 | flexDirection: "column", 183 | alignItems: "flex-start", 184 | }, 185 | title: { 186 | fontFamily: "Inter_500Medium", 187 | fontStyle: "normal", 188 | fontSize: 30, 189 | lineHeight: 36, 190 | textAlign: "left", 191 | color: Colors.avocadoGreen, 192 | }, 193 | bodyContainer: { 194 | flex: 6.75, 195 | overflow: "scroll", 196 | }, 197 | infoText: { 198 | marginVertical: 10, 199 | fontFamily: "Arimo_400Regular", 200 | fontStyle: "normal", 201 | fontSize: 18, 202 | lineHeight: 26, 203 | color: Colors.avocadoGreen, 204 | }, 205 | confirmText: { 206 | color: "#FFFFFF", 207 | fontFamily: "Inter_400Regular", 208 | fontStyle: "normal", 209 | fontSize: 20, 210 | lineHeight: 22, 211 | textAlign: "center", 212 | }, 213 | }); 214 | -------------------------------------------------------------------------------- /screens/EpisodeInputFlow/EpisodeDisplayScreen.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | import { 3 | StyleSheet, 4 | ScrollView, 5 | useWindowDimensions, 6 | Animated, 7 | TouchableOpacity, 8 | } from "react-native"; 9 | import { Text, View } from "../../components/Themed"; 10 | import { Episode } from "../../types"; 11 | import { convertToLocale } from "../../utils/TimeUtils"; 12 | import { Ionicons } from "@expo/vector-icons"; 13 | import Colors from "../../constants/Colors"; 14 | import AsyncStorage from "@react-native-async-storage/async-storage"; 15 | import { STORAGE_KEYS } from "../../utils/AsyncStorageUtils"; 16 | import { useNavigation } from "@react-navigation/native"; 17 | 18 | interface EpisodeProps { 19 | episode: Episode; 20 | index: number; 21 | deleteCallback: () => void; 22 | } 23 | 24 | function EpisodeDisplay({ episode, index, deleteCallback }: EpisodeProps) { 25 | const [clicked, setClicked] = useState(false); 26 | const { width } = useWindowDimensions(); 27 | const offset = useRef(new Animated.Value(0)).current; // Initial value for opacity: 0 28 | 29 | // animate the sliding delete button 30 | useEffect(() => { 31 | const toValue = clicked ? -width + 60 : 0; 32 | Animated.timing(offset, { 33 | toValue, 34 | duration: 200, 35 | useNativeDriver: true, 36 | }).start(); 37 | }, [clicked, offset]); 38 | 39 | // whenever clicked is true, set it back to false after 2 seconds 40 | useEffect(() => { 41 | if (clicked) { 42 | const callback = window.setTimeout(() => { 43 | setClicked(false); 44 | }, 2000); 45 | return () => window.clearTimeout(callback); 46 | } 47 | }, [clicked]); 48 | 49 | return ( 50 | 60 | 61 | 62 | {index} 63 | 64 | 65 | 66 | {episode.name} 67 | 68 | {`${convertToLocale( 69 | episode.startTime 70 | )} - ${convertToLocale(episode.endTime)}`} 71 | {`with ${episode.initials.trim() || "N/A"}`} 75 | 76 | 77 | setClicked(true)} 82 | /> 83 | 84 | 85 | 89 | Delete 90 | 91 | 92 | ); 93 | } 94 | 95 | const episodeStyles = StyleSheet.create({ 96 | container: { 97 | width: "100%", 98 | display: "flex", 99 | flexDirection: "row", 100 | marginBottom: 20, 101 | height: 120, 102 | }, 103 | numberContainer: { 104 | flex: 1, 105 | backgroundColor: Colors.avocadoGreen, 106 | justifyContent: "center", 107 | alignItems: "center", 108 | }, 109 | numberText: { 110 | color: "white", 111 | fontFamily: "Inter_700Bold", 112 | fontStyle: "normal", 113 | fontSize: 40, 114 | lineHeight: 48, 115 | }, 116 | infoContainer: { 117 | flex: 3, 118 | backgroundColor: "#EBEBEB", 119 | justifyContent: "center", 120 | padding: 20, 121 | }, 122 | episodeTitleText: { 123 | fontFamily: "Arimo_700Bold", 124 | fontStyle: "normal", 125 | fontSize: 18, 126 | lineHeight: 22, 127 | marginBottom: 5, 128 | color: Colors.avocadoGreen, 129 | }, 130 | episodeInfoText: { 131 | fontFamily: "Arimo_400Regular", 132 | fontStyle: "normal", 133 | fontSize: 18, 134 | lineHeight: 22, 135 | color: Colors.avocadoGreen, 136 | }, 137 | trashContainer: { 138 | flex: 1, 139 | justifyContent: "center", 140 | alignItems: "center", 141 | backgroundColor: "#EBEBEB", 142 | }, 143 | trashIcon: { 144 | color: Colors.allowedButtonColor, 145 | }, 146 | deleteButton: { 147 | width: "100%", 148 | display: "flex", 149 | flexDirection: "row", 150 | marginBottom: 20, 151 | height: 120, 152 | backgroundColor: Colors.allowedButtonColor, 153 | justifyContent: "center", 154 | alignItems: "center", 155 | }, 156 | deleteText: { 157 | fontFamily: "Arimo_700Bold", 158 | fontSize: 18, 159 | lineHeight: 22, 160 | color: "white", 161 | }, 162 | }); 163 | 164 | export default function EpisodeDisplayScreen({ route }: any) { 165 | const { episodes: initialEpisodes, recordingDay } = route.params; 166 | const [episodes, setEpisodes] = useState(initialEpisodes); 167 | const navigator = useNavigation(); 168 | 169 | return ( 170 | 171 | 172 | Current episodes 173 | navigator.goBack()} 178 | /> 179 | 180 | 181 | 182 | {episodes.length === 0 && ( 183 | No episodes yet. Go add some! 184 | )} 185 | 186 | {episodes.map((episode: Episode, index: number) => ( 187 | { 192 | const newEpisodes = episodes.filter((_, i) => i !== index); 193 | setEpisodes(newEpisodes); 194 | AsyncStorage.setItem( 195 | STORAGE_KEYS.daysEpisodes(recordingDay), 196 | JSON.stringify(newEpisodes) 197 | ); 198 | }} 199 | /> 200 | ))} 201 | 202 | 203 | 204 | ); 205 | } 206 | const styles = StyleSheet.create({ 207 | container: { 208 | padding: 30, 209 | flex: 1, 210 | }, 211 | titleContainer: { 212 | flex: 1, 213 | flexDirection: "row", 214 | alignItems: "center", 215 | justifyContent: "space-between", 216 | }, 217 | title: { 218 | fontFamily: "Inter_500Medium", 219 | fontStyle: "normal", 220 | fontSize: 30, 221 | lineHeight: 36, 222 | textAlign: "left", 223 | color: Colors.avocadoGreen, 224 | }, 225 | bodyContainer: { 226 | flex: 6.75, 227 | overflow: "scroll", 228 | }, 229 | noneText: { 230 | color: Colors.avocadoGreen, 231 | fontFamily: "Arimo_700Bold", 232 | fontStyle: "normal", 233 | fontSize: 18, 234 | lineHeight: 22, 235 | }, 236 | icon: { 237 | transform: [{ rotate: "45deg" }], 238 | color: Colors.avocadoGreen, 239 | }, 240 | }); 241 | -------------------------------------------------------------------------------- /navigation/MainNavigator.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | FunctionComponent, 3 | ReactElement, 4 | useCallback, 5 | useEffect, 6 | useState, 7 | } from "react"; 8 | import { LayoutAnimation, Platform, StyleSheet, UIManager } from "react-native"; 9 | import { signIn } from "../clients/firebaseInteractor"; 10 | import { View } from "../components/Themed"; 11 | import { EpisodeListingOverview } from "../components/EpisodeListingOverview"; 12 | import EpisodePredictionWrapper from "../screens/EpisodePrediction"; 13 | import { EpisodeRecallOverview } from "../screens/EpisodeRecallOverview"; 14 | import MainScreen from "../screens/MainScreen"; 15 | import { Episode } from "../types"; 16 | import { getCurrentDate, retrieveRecordingDay } from "../utils/TimeUtils"; 17 | import CameraFlowNavigator from "./CameraFlowNavigator"; 18 | import { 19 | MainNavigationContext, 20 | NavigationScreens, 21 | NavigationState, 22 | } from "./MainNavigationContext"; 23 | import EpisodeNavigator from "./MainStack"; 24 | import { EpisodeOverlay } from "../components/EpisodeOverlay"; 25 | import Colors from "../constants/Colors"; 26 | import { EpisodeRecallOverlay } from "../components/EpisodeRecallOverlay"; 27 | import AsyncStorage from "@react-native-async-storage/async-storage"; 28 | import { STORAGE_KEYS } from "../utils/AsyncStorageUtils"; 29 | import OnboardingScreen, { 30 | Day1Screens, 31 | Day2And3Screens, 32 | } from "../screens/OnboardingScreen"; 33 | import { EpisodeRecallFinished } from "../screens/EpisodeRecallFinished"; 34 | import { ClearEpisodeScreen } from "../screens/ClearEpisodeScreen"; 35 | import { ThankYouScreen } from "../screens/ThankYouScreen"; 36 | 37 | interface MainNavigatorProps { 38 | startingState: NavigationState; 39 | } 40 | const MainNavigator: FunctionComponent = ({ 41 | startingState, 42 | }) => { 43 | // If you want to test a specific flow, update this startingState to be wherever you want to go to 44 | const [navigationState, setNavigationState] = useState( 45 | startingState 46 | ); 47 | const [recordingDay, setRecordingDay] = useState(1); 48 | const [participantId, setParticipantId] = useState(""); 49 | 50 | useEffect(() => { 51 | setNavigationState(startingState); 52 | }, [startingState]); 53 | 54 | if (navigationState.type !== NavigationScreens.onboarding) { 55 | retrieveRecordingDay().then(setRecordingDay); 56 | } 57 | AsyncStorage.getItem(STORAGE_KEYS.participantId()).then( 58 | (v) => v && setParticipantId(v) 59 | ); 60 | 61 | useEffect(() => { 62 | // The email gets ignored rn, but will be used when we get to the email auth 63 | signIn("email"); 64 | }, []); 65 | 66 | useEffect(() => { 67 | if (navigationState.type !== NavigationScreens.onboarding) { 68 | AsyncStorage.setItem( 69 | STORAGE_KEYS.currentState(), 70 | JSON.stringify({ 71 | state: navigationState, 72 | date: getCurrentDate(), 73 | }) 74 | ); 75 | } 76 | }, [navigationState]); 77 | 78 | if ( 79 | Platform.OS === "android" && 80 | UIManager.setLayoutAnimationEnabledExperimental 81 | ) { 82 | UIManager.setLayoutAnimationEnabledExperimental(true); 83 | } 84 | 85 | // Get the correct screen from the given state 86 | const getScreen = ( 87 | navigationState: NavigationState 88 | ): ReactElement | undefined => { 89 | if (navigationState.type == NavigationScreens.intro) { 90 | return ; 91 | } else if (navigationState.type === "recordEpisodes") { 92 | return ( 93 | ( 97 | 98 | )} 99 | nameCreator={(episode: Episode, index: number) => 100 | `${participantId}/${participantId}_Day${recordingDay}_Episode${index}` 101 | } 102 | nextState={{ type: NavigationScreens.predictions }} 103 | recordingDay={recordingDay} 104 | /> 105 | ); 106 | } else if (navigationState.type == NavigationScreens.createEpisode) { 107 | return ; 108 | } else if (navigationState.type == NavigationScreens.predictions) { 109 | return ( 110 | 114 | ); 115 | } else if ( 116 | navigationState.type == NavigationScreens.episodeRecallOverview 117 | ) { 118 | return ; 119 | } else if (navigationState.type == "episodeRecall") { 120 | return ( 121 | ( 125 | 126 | )} 127 | nameCreator={(_, index: number) => 128 | `${participantId}/${participantId}_Day${recordingDay}_Episode${index}_recall` 129 | } 130 | nextState={{ type: NavigationScreens.episodeRecallFinished }} 131 | recordingDay={recordingDay} 132 | /> 133 | ); 134 | } else if ( 135 | navigationState.type == NavigationScreens.episodeRecallFinished 136 | ) { 137 | return ; 138 | } else if ( 139 | navigationState.type == NavigationScreens.episodeListingOverview 140 | ) { 141 | return ( 142 | } 146 | nameCreator={() => 147 | `${participantId}/${participantId}_Day${recordingDay}_episodeListing` 148 | } 149 | nextState={{ type: NavigationScreens.episodeRecallOverview }} 150 | recordingDay={recordingDay} 151 | /> 152 | ); 153 | } else if ( 154 | navigationState.type == NavigationScreens.secondEpisodeListingOverview 155 | ) { 156 | return ( 157 | } 161 | nameCreator={() => 162 | `${participantId}/${participantId}_Day${recordingDay}_episodeListing` 163 | } 164 | nextState={{ type: NavigationScreens.createEpisode }} 165 | recordingDay={recordingDay} 166 | /> 167 | ); 168 | } else if (navigationState.type === NavigationScreens.onboarding) { 169 | return ; 170 | } else if (navigationState.type === "onboarding2or3") { 171 | return ( 172 | 175 | ); 176 | } else if (navigationState.type === NavigationScreens.episodeClearing) { 177 | return ; 178 | } else if (navigationState.type === "thankYou") { 179 | return ; 180 | } else { 181 | return undefined; 182 | } 183 | }; 184 | 185 | let actualScreen = getScreen(navigationState); 186 | 187 | // Wrapper so that there is an animation. 188 | let navigationStateCallback = useCallback((navigation: NavigationState) => { 189 | LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); 190 | setNavigationState(navigation); 191 | }, []); 192 | 193 | return ( 194 | 195 | {actualScreen} 196 | 197 | ); 198 | }; 199 | 200 | const styles = StyleSheet.create({ 201 | container: { 202 | width: "100%", 203 | height: "100%", 204 | flexDirection: "row", 205 | position: "absolute", 206 | top: 0, 207 | left: 0, 208 | }, 209 | }); 210 | 211 | export default MainNavigator; 212 | -------------------------------------------------------------------------------- /screens/CameraRecording.tsx: -------------------------------------------------------------------------------- 1 | // imported to try and get face detection to work 2 | import * as _FaceDetector from "expo-face-detector"; 3 | import { Camera } from "expo-camera"; 4 | import React, { 5 | FunctionComponent, 6 | ReactElement, 7 | useEffect, 8 | useState, 9 | } from "react"; 10 | import { StyleSheet, View, Image } from "react-native"; 11 | import { Button, Text } from "../components/Themed"; 12 | import { uploadFileToFirebase } from "../clients/firebaseInteractor"; 13 | import Colors from "../constants/Colors"; 14 | import { BlurView } from "expo-blur"; 15 | 16 | export interface SavedOverlayProps { 17 | finished: () => void; 18 | } 19 | 20 | const SavedOverlay: FunctionComponent = ({ finished }) => { 21 | return ( 22 | 23 | 24 | Your video diary has been saved 25 | 26 | 27 | Before you go on to your next recording, please check that your face is 28 | visible in the window below. 29 | 30 | 33 | 34 | ); 35 | }; 36 | 37 | const SavedOverlayViews = StyleSheet.create({ 38 | container: { 39 | position: "absolute", 40 | left: 0, 41 | top: 0, 42 | width: "100%", 43 | height: "100%", 44 | backgroundColor: "rgba(0, 0, 0, 0.8)", 45 | justifyContent: "center", 46 | }, 47 | header: { 48 | color: "white", 49 | fontWeight: "500", 50 | fontSize: 30, 51 | margin: "5%", 52 | }, 53 | subTitle: { 54 | color: "white", 55 | fontWeight: "400", 56 | fontSize: 18, 57 | marginHorizontal: "5%", 58 | }, 59 | button: { 60 | position: "absolute", 61 | width: "90%", 62 | bottom: "10%", 63 | left: "5%", 64 | backgroundColor: Colors.allowedButtonColor, 65 | }, 66 | buttonText: { 67 | color: "white", 68 | fontSize: 20, 69 | textAlign: "center", 70 | padding: 20, 71 | }, 72 | }); 73 | 74 | const COUNTDOWN_TIME = 20; 75 | // ~150MB 76 | const MAX_FILE_SIZE = 150000000; 77 | const FourEightyP = "480P"; 78 | 79 | export interface CameraRecordingProps { 80 | overlay: ReactElement; 81 | finished: () => void; 82 | videoName: string; 83 | overlayBackgroundColor: string; 84 | } 85 | 86 | const CameraRecording: FunctionComponent = ({ 87 | overlay, 88 | finished, 89 | videoName, 90 | overlayBackgroundColor, 91 | }) => { 92 | const [recording, setRecording] = useState(false); 93 | const [camera, setCamera] = useState(null); 94 | const [time, setTime] = useState(0); 95 | const [countdownTime, setCountdownTime] = useState(COUNTDOWN_TIME); 96 | useEffect(() => { 97 | let unsubscribe: number | undefined = undefined; 98 | if (recording) { 99 | unsubscribe = window.setTimeout(() => setTime(time + 1000), 1000); 100 | } 101 | return () => window.clearTimeout(unsubscribe); 102 | }, [time, recording]); 103 | 104 | useEffect(() => { 105 | let unsubscribe: number | undefined = undefined; 106 | if (countdownTime > 0) { 107 | unsubscribe = window.setTimeout( 108 | () => setCountdownTime(countdownTime - 1), 109 | 1000 110 | ); 111 | } 112 | return () => window.clearTimeout(unsubscribe); 113 | }, [countdownTime]); 114 | 115 | if (countdownTime == 0 && !recording && camera) { 116 | setRecording(true); 117 | setCountdownTime(-1); 118 | camera 119 | .recordAsync({ 120 | maxFileSize: MAX_FILE_SIZE, 121 | quality: Camera.Constants.VideoQuality[FourEightyP], 122 | }) 123 | .then((vid) => { 124 | uploadFileToFirebase(`${videoName}.mov`, vid.uri); 125 | setRecording(false); 126 | }); 127 | } 128 | 129 | let dateTime = new Date(60 * 5 * 1000 - time); 130 | const toTens = (num: number) => (num >= 10 ? "" + num : "0" + num); 131 | if (dateTime.getTime() <= 0 && recording) { 132 | setRecording(false); 133 | camera?.stopRecording(); 134 | } 135 | const shouldShowCamera = countdownTime >= 0 || recording; 136 | return ( 137 | 138 | {shouldShowCamera && ( 139 | setCamera(camera)} 143 | flashMode={Camera.Constants.FlashMode.auto} 144 | /> 145 | )} 146 | {countdownTime >= 0 && !recording && ( 147 | 153 | {overlay} 154 | {`Your recording will begin in ${countdownTime} seconds.`} 163 | 164 | )} 165 | {recording && ( 166 | <> 167 | 168 | 169 | 183 | 184 | 185 | 189 | 190 | {toTens(dateTime.getMinutes()) + 191 | ":" + 192 | toTens(dateTime.getSeconds())} 193 | 194 | 195 | 196 | )} 197 | {!shouldShowCamera && } 198 | 199 | ); 200 | }; 201 | 202 | const Styles = StyleSheet.create({ 203 | container: { 204 | flex: 1, 205 | position: "relative", 206 | alignItems: "baseline", 207 | }, 208 | camera: { 209 | flex: 1, 210 | position: "absolute", 211 | top: 0, 212 | left: 0, 213 | width: "100%", 214 | height: "100%", 215 | }, 216 | bottomRow: { 217 | bottom: 0, 218 | width: "100%", 219 | position: "absolute", 220 | flexDirection: "row", 221 | justifyContent: "space-around", 222 | alignContent: "center", 223 | padding: 10, 224 | }, 225 | takePicture: { 226 | flex: 1, 227 | backgroundColor: "#0000", 228 | justifyContent: "center", 229 | }, 230 | text: { 231 | color: "white", 232 | textAlign: "center", 233 | }, 234 | totalTime: { 235 | backgroundColor: "rgba(0, 0, 0, 0.33)", 236 | padding: 10, 237 | position: "absolute", 238 | top: 40, 239 | right: 5, 240 | flexDirection: "row", 241 | justifyContent: "space-between", 242 | alignItems: "center", 243 | }, 244 | icon: { 245 | flex: 1, 246 | backgroundColor: "rgba(0, 0, 0, 0)", 247 | justifyContent: "center", 248 | marginBottom: 20, 249 | }, 250 | image: { 251 | width: 100, 252 | height: 100, 253 | alignSelf: "center", 254 | }, 255 | overlayView: { 256 | position: "absolute", 257 | width: "100%", 258 | height: "100%", 259 | backgroundColor: "rgba(0, 70, 67, .93)", 260 | top: 0, 261 | left: 0, 262 | justifyContent: "space-around", 263 | alignItems: "center", 264 | }, 265 | beginText: { 266 | color: "white", 267 | textAlign: "center", 268 | fontSize: 18, 269 | fontWeight: "400", 270 | }, 271 | recordingImage: { 272 | height: 16, 273 | width: 16, 274 | marginRight: 5, 275 | }, 276 | }); 277 | 278 | export default CameraRecording; 279 | -------------------------------------------------------------------------------- /screens/EpisodePrediction.tsx: -------------------------------------------------------------------------------- 1 | import AsyncStorage from "@react-native-async-storage/async-storage"; 2 | import { Ionicons } from "@expo/vector-icons"; 3 | import React, { FunctionComponent, useRef, useState } from "react"; 4 | import { 5 | Keyboard, 6 | ScrollView, 7 | StyleSheet, 8 | TextInput, 9 | TouchableWithoutFeedback, 10 | } from "react-native"; 11 | import { uploadJSONToFirebase } from "../clients/firebaseInteractor"; 12 | import { View, Text, Button } from "../components/Themed"; 13 | import Colors from "../constants/Colors"; 14 | import { continueButtonStyle } from "../utils/StylingUtils"; 15 | import { STORAGE_KEYS } from "../utils/AsyncStorageUtils"; 16 | import { useMainNavigation } from "../hooks/useMainNavigation"; 17 | import { EvenSpacedView } from "../components/EvenSpacedView"; 18 | 19 | interface EpisodePredictionWrapperProps { 20 | recordingDay: number; 21 | participantId: string; 22 | } 23 | 24 | const EpisodePredictionWrapper: FunctionComponent = ({ 25 | recordingDay, 26 | participantId, 27 | }) => { 28 | const [allAdded, setAllAdded] = useState(false); 29 | const [predictions, setPredictions] = useState([]); 30 | const hasUploadedRef = useRef(false); 31 | const [submitting, setSubmitting] = useState(false); 32 | 33 | const navigation = useMainNavigation(); 34 | 35 | // upload the data, but only do it once. keep track using a ref 36 | // this will upload immediately on day 3 (no predictions), or wait for allAdded on day1/2 37 | const shouldUpload = !( 38 | (recordingDay === 1 || recordingDay === 2) && 39 | !allAdded 40 | ); 41 | if (shouldUpload && !hasUploadedRef.current) { 42 | hasUploadedRef.current = true; 43 | setSubmitting(true); 44 | uploadEpisodeInfo(participantId, recordingDay, predictions); 45 | setSubmitting(false); 46 | navigation.navigate({ type: "thankYou", recordingDay }); 47 | } 48 | 49 | return ( 50 | setAllAdded(true)} 52 | predictions={predictions} 53 | setPredictions={setPredictions} 54 | loading={submitting} 55 | recordingDay={recordingDay} 56 | /> 57 | ); 58 | }; 59 | 60 | export const uploadEpisodeInfo = async ( 61 | participantId: string, 62 | recordingDay: number, 63 | predictions: string[] = [] 64 | ): Promise => { 65 | const fileName = `${participantId}/${participantId}_Day${recordingDay}_${new Date() 66 | .toDateString() 67 | .replaceAll(" ", "_")}.json`; 68 | 69 | let createdEpisodes = JSON.parse( 70 | (await AsyncStorage.getItem(STORAGE_KEYS.daysEpisodes(recordingDay)))! 71 | ); 72 | 73 | await uploadJSONToFirebase(fileName, { 74 | createdEpisodes, 75 | predictedEpisodes: predictions, 76 | }); 77 | }; 78 | 79 | interface EpisodePredictionProps { 80 | onFinish: () => void; 81 | predictions: string[]; 82 | setPredictions: (predictions: string[]) => void; 83 | loading: boolean; 84 | recordingDay: number; 85 | } 86 | 87 | const EpisodePrediction: FunctionComponent = ({ 88 | recordingDay, 89 | onFinish, 90 | predictions, 91 | loading, 92 | setPredictions, 93 | }) => { 94 | const [hasOneValue, setHasOneValue] = useState(false); 95 | return ( 96 | // accessible = false allows the input form continue to be accessible through VoiceOver 97 | 98 | 99 | You are almost done! 100 | 101 | Before you finish we would like you to tell us what you think your 102 | episodes for tomorrow (Day {recordingDay + 1}) will be. 103 | 104 | 105 | 106 | {predictions.map((val, idx) => ( 107 | 108 | { 114 | const newPredictions = predictions.map((e, i) => 115 | i !== idx ? e : t 116 | ); 117 | setPredictions(newPredictions); 118 | setHasOneValue(newPredictions.some((val) => val !== "")); 119 | }} 120 | /> 121 | { 127 | const newPredictions = predictions.filter( 128 | (_, index) => index !== idx 129 | ); 130 | setPredictions(newPredictions); 131 | setHasOneValue(newPredictions.some((val) => val !== "")); 132 | }} 133 | /> 134 | 135 | ))} 136 | 146 | 147 | 148 | 155 | 156 | 157 | ); 158 | }; 159 | 160 | const styles = StyleSheet.create({ 161 | container: { 162 | flex: 1, 163 | paddingTop: 25, 164 | justifyContent: "space-around", 165 | paddingRight: 24, 166 | paddingLeft: 24, 167 | paddingBottom: 25, 168 | }, 169 | header: { 170 | fontSize: 30, 171 | paddingTop: 20, 172 | color: Colors.avocadoGreen, 173 | fontFamily: "Arimo_400Regular", 174 | paddingBottom: 15, 175 | }, 176 | subheader: { 177 | fontSize: 18, 178 | color: Colors.avocadoGreen, 179 | fontFamily: "Arimo_400Regular", 180 | paddingBottom: 20, 181 | }, 182 | scrollView: { 183 | marginTop: 5, 184 | marginBottom: 5, 185 | }, 186 | textInput: { 187 | textAlign: "left", 188 | paddingLeft: 15, 189 | fontFamily: "Arimo_400Regular", 190 | fontSize: 18, 191 | color: Colors.avocadoGreen, 192 | flex: 1, 193 | }, 194 | textInputWrapper: { 195 | borderColor: Colors.textInputBorder, 196 | borderWidth: 1, 197 | borderRadius: 10, 198 | height: 52, 199 | marginBottom: 10, 200 | backgroundColor: Colors.textInputFill, 201 | justifyContent: "space-between", 202 | flexDirection: "row", 203 | width: "100%", 204 | alignItems: "center", 205 | }, 206 | addEpisodeButton: { 207 | height: 52, 208 | borderColor: Colors.textInputBorder, 209 | borderWidth: 1, 210 | borderStyle: "dashed", 211 | backgroundColor: "white", 212 | textAlign: "center", 213 | fontSize: 18, 214 | fontFamily: "Arimo_400Regular", 215 | }, 216 | addEpisodeText: { 217 | flex: 1, 218 | color: Colors.textInputBorder, 219 | textAlign: "center", 220 | }, 221 | continueText: { 222 | color: "white", 223 | textAlign: "center", 224 | }, 225 | icon: { 226 | transform: [{ rotate: "45deg" }], 227 | marginHorizontal: 10, 228 | }, 229 | }); 230 | 231 | const FinalScreen = () => { 232 | return ( 233 | 234 | 235 | Your diary for today has been saved! {"\n\n"} You may now exit the 236 | application. 237 | 238 | 239 | ); 240 | }; 241 | 242 | const FinalScreenStyles = StyleSheet.create({ 243 | container: { 244 | flex: 1, 245 | backgroundColor: Colors.avocadoGreen, 246 | justifyContent: "center", 247 | }, 248 | text: { 249 | color: "white", 250 | fontSize: 18, 251 | fontFamily: "Arimo_400Regular", 252 | textAlign: "center", 253 | }, 254 | }); 255 | 256 | export default EpisodePredictionWrapper; 257 | --------------------------------------------------------------------------------