├── 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 |
45 | );
46 | }
47 |
48 | const styles = StyleSheet.create({
49 | container: {
50 | flex: 1,
51 | alignItems: "center",
52 | justifyContent: "center",
53 | },
54 | title: {
55 | fontSize: 20,
56 | fontWeight: "bold",
57 | },
58 | separator: {
59 | marginVertical: 30,
60 | height: 1,
61 | width: "80%",
62 | },
63 | });
64 |
--------------------------------------------------------------------------------
/hooks/useCachedResources.ts:
--------------------------------------------------------------------------------
1 | import { Ionicons } from "@expo/vector-icons";
2 | import { loadAsync, useFonts } from "expo-font";
3 | import * as SplashScreen from "expo-splash-screen";
4 | import * as React from "react";
5 | import {
6 | Arimo_400Regular,
7 | Arimo_400Regular_Italic,
8 | Arimo_700Bold,
9 | Arimo_700Bold_Italic,
10 | } from "@expo-google-fonts/arimo";
11 | import {
12 | Inter_100Thin,
13 | Inter_200ExtraLight,
14 | Inter_300Light,
15 | Inter_400Regular,
16 | Inter_500Medium,
17 | Inter_600SemiBold,
18 | Inter_700Bold,
19 | Inter_800ExtraBold,
20 | Inter_900Black,
21 | } from "@expo-google-fonts/inter";
22 |
23 | export default function useCachedResources() {
24 | const [fontsLoaded] = useFonts({
25 | Arimo_400Regular,
26 | Arimo_400Regular_Italic,
27 | Arimo_700Bold,
28 | Arimo_700Bold_Italic,
29 | Inter_100Thin,
30 | Inter_200ExtraLight,
31 | Inter_300Light,
32 | Inter_400Regular,
33 | Inter_500Medium,
34 | Inter_600SemiBold,
35 | Inter_700Bold,
36 | Inter_800ExtraBold,
37 | Inter_900Black,
38 | });
39 |
40 | const [isLoadingComplete, setLoadingComplete] = React.useState(false);
41 |
42 | // Load any resources or data that we need prior to rendering the app
43 | React.useEffect(() => {
44 | async function loadResourcesAndDataAsync() {
45 | try {
46 | SplashScreen.preventAutoHideAsync();
47 |
48 | // Load fonts
49 | await loadAsync({
50 | ...Ionicons.font,
51 | "space-mono": require("../assets/fonts/SpaceMono-Regular.ttf"),
52 | });
53 | } catch (e) {
54 | // We might want to provide this error information to an error reporting service
55 | console.warn(e);
56 | } finally {
57 | setLoadingComplete(true);
58 | SplashScreen.hideAsync();
59 | }
60 | }
61 |
62 | loadResourcesAndDataAsync();
63 | }, []);
64 |
65 | return isLoadingComplete && fontsLoaded;
66 | }
67 |
--------------------------------------------------------------------------------
/screens/LetsStartRecording.tsx:
--------------------------------------------------------------------------------
1 | import React, { FunctionComponent } from "react";
2 | import { StyleSheet } from "react-native";
3 | import { View, Text, Button } from "../components/Themed";
4 | import Colors from "../constants/Colors";
5 |
6 | interface LetsStartRecordingProps {
7 | finished: () => void;
8 | }
9 | const LetsStartRecording: FunctionComponent = ({
10 | finished,
11 | }) => {
12 | return (
13 |
14 | Great!
15 | Let’s start recording
16 |
17 | Please{" "}
18 | describe your episode in detail
19 | . Describe where you were, what you thinking, feeling, doing and with
20 | whom. You can take as much time as you would like.{" "}
21 |
22 |
23 | Get Started!
24 |
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 | {
35 | navigation.navigate({
36 | type: NavigationScreens.episodeListingOverview,
37 | });
38 | }}
39 | >
40 | Get started
41 |
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 |
32 |
33 |
34 | {time ? convertToLocale(time.toISOString()) : label}
35 |
36 |
42 |
43 |
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 |
48 | Restart Study
49 |
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 | {
44 | navigation.navigate({ type: "episodeRecall", episodes });
45 | }}
46 | >
47 | Get Started!
48 |
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 |
74 | {/** this is only here to ensure the spacing is consistent */}
75 | Confirm episodes
76 |
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 | {
42 | navigation.navigate({ type: NavigationScreens.createEpisode });
43 | }}
44 | >
45 |
46 | Get Started!
47 |
48 |
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 | {
61 | navigation.navigate({
62 | type: NavigationScreens.secondEpisodeListingOverview,
63 | });
64 | }}
65 | >
66 |
67 | Get Started!
68 |
69 |
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 | {} : saveEpisode}
110 | style={{
111 | ...(hasError ? styles.buttonGrey : styles.buttonRed),
112 | ...styles.button,
113 | }}
114 | >
115 | Save Changes
116 |
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 | putEpisodesInStorage(true, episodes)}
95 | style={{
96 | ...(episodes.length < 2 ? styles.buttonGrey : styles.buttonRed),
97 | ...styles.button,
98 | }}
99 | disabled={episodes.length < 2}
100 | >
101 | Confirm episodes
102 |
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 | {} : createEpisode}
163 | style={{
164 | ...(hasError ? styles.buttonGrey : styles.buttonRed),
165 | ...styles.button,
166 | }}
167 | >
168 | Add episode
169 |
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 |
57 |
58 | My face is in the window
59 |
60 |
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 | {
37 | navigator.navigate("EpisodeEdit", { episode, index });
38 | }}
39 | >
40 |
46 |
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 | {
159 | await AsyncStorage.setItem(
160 | STORAGE_KEYS.daysEpisodes(recordingDay),
161 | JSON.stringify(currentEpisodes)
162 | );
163 | await setRandomEpisodes();
164 | mainNavigation.navigate({
165 | type: "recordEpisodes",
166 | episodes: currentEpisodes,
167 | });
168 | }}
169 | >
170 | Confirm Episodes
171 |
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 |
31 | Continue
32 |
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 | {
172 | if (camera) {
173 | camera.stopRecording();
174 | setRecording(false);
175 | }
176 | }}
177 | >
178 |
182 |
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 | {
139 | setPredictions(predictions.concat([""]));
140 | }}
141 | >
142 |
143 | + Add Episode
144 |
145 |
146 |
147 |
148 |
153 | Continue
154 |
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 |
--------------------------------------------------------------------------------