Adds line count screen.

This commit is contained in:
Aaron Louie 2020-09-05 23:16:46 -04:00
parent 8b7ef5c8fd
commit 42d195b9de
8 changed files with 231 additions and 101 deletions

165
App.tsx
View File

@ -6,25 +6,18 @@ import {BarCodeEvent, BarCodeScanner, PermissionResponse} from 'expo-barcode-sca
import * as firebase from 'firebase'; import * as firebase from 'firebase';
import 'firebase/firestore'; import 'firebase/firestore';
import React, {ReactElement, useCallback, useEffect, useState} from 'react'; import React, {ReactElement, useCallback, useEffect, useState} from 'react';
import {AppRegistry, SafeAreaView, Text, View, YellowBox} from 'react-native'; import {AppRegistry, SafeAreaView, View, YellowBox} from 'react-native';
import { import {Appbar, DefaultTheme, Provider as PaperProvider, Snackbar, Title,} from 'react-native-paper';
Appbar,
Button,
DefaultTheme,
HelperText,
Provider as PaperProvider, RadioButton,
Snackbar,
Subheading,
TextInput,
Title,
} from 'react-native-paper';
import {expo as appExpo} from './app.json'; import {expo as appExpo} from './app.json';
import {CancelButton} from './components/Common'; import {CancelButton} from './components/Common';
import {InputLineCountButton, InputLineCountScreen} from './components/LineCount';
import {BarCodeDisplay, PrintButton, PrintingMessage} from './components/Print'; import {BarCodeDisplay, PrintButton, PrintingMessage} from './components/Print';
import {IdNumberInput, InputIdButton, ScanButton, Scanner} from './components/Scan'; import {IdNumberInput, InputIdButton, ScanButton, Scanner} from './components/Scan';
import {SettingsScreen} from './components/Settings';
import {colors, styles} from './components/Styles'; import {colors, styles} from './components/Styles';
import {BarcodeScannerAppState} from './models/BarcodeScannerAppState'; import {BarcodeScannerAppState} from './models/BarcodeScannerAppState';
import {CameraType, ElementProps, StateProps} from './models/ElementProps'; import {CameraType, ElementProps, StateProps} from './models/ElementProps';
import {LineCount} from './models/LineCount';
import {Sample} from './models/Sample'; import {Sample} from './models/Sample';
const firebaseConfig = { const firebaseConfig = {
@ -49,6 +42,8 @@ YellowBox.ignoreWarnings([
const db = firebase.firestore(); const db = firebase.firestore();
const samplesCollection = db.collection('samples'); const samplesCollection = db.collection('samples');
const countsCollection = db.collection('counts');
const dateFormat = 'yyyyMMddHHmm';
const theme = { const theme = {
...DefaultTheme, ...DefaultTheme,
@ -63,6 +58,7 @@ export default function Main() {
const [locationStr, setLocationStr] = useState<string>('4321'); const [locationStr, setLocationStr] = useState<string>('4321');
const [errorMessage, setErrorMessage] = useState<string>(''); const [errorMessage, setErrorMessage] = useState<string>('');
const [samples, setSamples] = useState<Sample[]>([]); const [samples, setSamples] = useState<Sample[]>([]);
const [lineCounts, setLineCounts] = useState<LineCount[]>([]);
const [cameraType, setCameraType] = useState<CameraType>('back'); const [cameraType, setCameraType] = useState<CameraType>('back');
useEffect(() => { useEffect(() => {
@ -74,7 +70,7 @@ export default function Main() {
} }
}); });
const unsubscribe = samplesCollection.onSnapshot(querySnapshot => { const unsubscribeSamples = samplesCollection.onSnapshot(querySnapshot => {
// Transform and sort the data returned from Firebase // Transform and sort the data returned from Firebase
const samplesFirestore = querySnapshot const samplesFirestore = querySnapshot
.docChanges() .docChanges()
@ -88,7 +84,21 @@ export default function Main() {
appendSamples(samplesFirestore); appendSamples(samplesFirestore);
}); });
return () => unsubscribe() const unsubscribeCounts = countsCollection.onSnapshot(querySnapshot => {
// Transform and sort the data returned from Firebase
const lineCountsFirestore = querySnapshot
.docChanges()
.filter(({type}) => type === 'added')
.map(({doc}) => {
const lineCount = doc.data();
return {...lineCount, createdAt: lineCount.createdAt.toDate()} as LineCount;
})
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
appendLineCounts(lineCountsFirestore);
});
return () => unsubscribeSamples()
}, []); }, []);
const _doNothing = () => { const _doNothing = () => {
@ -101,6 +111,10 @@ export default function Main() {
setErrorMessage(''); setErrorMessage('');
setAppState(BarcodeScannerAppState.INPUT); setAppState(BarcodeScannerAppState.INPUT);
}; };
const _inputLineCount = () => {
setErrorMessage('');
setAppState(BarcodeScannerAppState.COUNT);
};
const _print = () => setAppState(BarcodeScannerAppState.PRINTING); const _print = () => setAppState(BarcodeScannerAppState.PRINTING);
const _printed = () => setAppState(BarcodeScannerAppState.PRINTED); const _printed = () => setAppState(BarcodeScannerAppState.PRINTED);
const _home = () => setAppState(BarcodeScannerAppState.DEFAULT); const _home = () => setAppState(BarcodeScannerAppState.DEFAULT);
@ -116,7 +130,7 @@ export default function Main() {
if (pattern.test(barCodeString)) { if (pattern.test(barCodeString)) {
const cardId = e.data.slice(0, 9); const cardId = e.data.slice(0, 9);
const newSampleDate = new Date(); const newSampleDate = new Date();
const newSampleId = [cardId, format(newSampleDate, 'yyyyMMddHHmm'), locationStr].join('-'); const newSampleId = [cardId, format(newSampleDate, dateFormat), locationStr].join('-');
setSampleId(newSampleId); setSampleId(newSampleId);
setBarCodeId(cardId); setBarCodeId(cardId);
@ -128,16 +142,32 @@ export default function Main() {
} }
}; };
const handleLineCountSubmitted = (newCount: number) => {
const now = new Date();
const newId = `${locationStr}-${format(now, dateFormat)}`;
const newData: LineCount = {
id: newId,
lineCount: newCount,
locationId: locationStr,
createdAt: now,
};
sendDataToFirebase([newData], countsCollection);
}
const appendSamples = useCallback((newSamples) => { const appendSamples = useCallback((newSamples) => {
setSamples((previousSamples) => previousSamples.concat(newSamples)); setSamples((previousSamples) => previousSamples.concat(newSamples));
}, [samples]); }, [samples]);
const sendDataToFirebase = async (newSamples: Sample[]) => { const appendLineCounts = useCallback((newLineCounts) => {
const writes = newSamples.map(s => samplesCollection.doc(s.id).set(s)); setLineCounts((previousLineCounts) => previousLineCounts.concat(newLineCounts));
}, [lineCounts]);
const sendDataToFirebase = async (newData: Array<Sample|LineCount>, collection: firebase.firestore.CollectionReference) => {
const writes = newData.map((s: Sample|LineCount) => collection.doc(s.id).set(s));
await Promise.all(writes); await Promise.all(writes);
} }
function ErrorMessage(props: ElementProps): ReactElement { const ErrorMessage = (props: ElementProps): ReactElement => {
return <View style={styles.fullScreen}> return <View style={styles.fullScreen}>
<View style={styles.container}> <View style={styles.container}>
<ScanButton onClicked={_scan}/> <ScanButton onClicked={_scan}/>
@ -151,94 +181,24 @@ export default function Main() {
</View> </View>
} }
function LoadingMessage(props: ElementProps): ReactElement { const LoadingMessage = (props: ElementProps): ReactElement => {
return <Snackbar return <Snackbar
visible={appState === BarcodeScannerAppState.INITIAL} visible={appState === BarcodeScannerAppState.INITIAL}
onDismiss={_doNothing} onDismiss={_doNothing}
>Loading...</Snackbar>; >Loading...</Snackbar>;
} }
function SuccessMessage(props: ElementProps): ReactElement { const SuccessMessage = (props: ElementProps): ReactElement => {
return <Title>Your barcode label has printed successfully.</Title>; return <Title>Your barcode label has printed successfully.</Title>;
} }
function ActionButtons(props: ElementProps): ReactElement { const ActionButtons = (props: ElementProps): ReactElement => {
return <View> return <View>
<PrintButton onClicked={_print}/> <PrintButton onClicked={_print}/>
<CancelButton onClicked={_home}/> <CancelButton onClicked={_home}/>
</View> </View>
} }
function SettingsScreen(props: ElementProps): ReactElement {
const [inputStr, setInputStr] = useState<string>(locationStr);
const pattern = /^[\d]{4}$/;
const hasErrors = () => {
return !pattern.test(inputStr);
};
return <View style={styles.settings}>
<View style={{marginBottom: 10}}>
<Subheading style={{color: DefaultTheme.colors.text}}>Which camera to scan bar codes with?</Subheading>
<RadioButton.Group
onValueChange={value => setCameraType(value as CameraType)}
value={cameraType as string}
>
<View style={styles.row}>
<Text>Front</Text>
<RadioButton
value="front"
color={colors.primary}
uncheckedColor={colors.accent}
/>
</View>
<View style={styles.row}>
<Text>Back</Text>
<RadioButton
value="back"
color={colors.primary}
uncheckedColor={colors.accent}
/>
</View>
</RadioButton.Group>
</View>
<View style={{marginBottom: 10}}>
<Subheading style={{color: DefaultTheme.colors.text, marginBottom: 60}}>
Please do NOT change this unless you know what you are doing. Entering an incorrect location number may
prevent patients from getting accurate info about their test results.
</Subheading>
<TextInput
label="Location #"
value={inputStr}
onChangeText={inputStr => setInputStr(inputStr)}
mode="outlined"
theme={DefaultTheme}
/>
<HelperText type="error" visible={hasErrors()}>
Location number must be exactly 4 digits. No other characters are allowed.
</HelperText>
<Button
icon="content-save"
mode="contained"
color={colors.primary}
style={{marginBottom: 10}}
disabled={hasErrors()}
onPress={() => {
setLocationStr(inputStr);
_home();
}}
>Save</Button>
<Button
icon="cancel"
mode="outlined"
color={colors.primary}
onPress={_home}
>Cancel</Button>
</View>
</View>
}
function App(props: StateProps): ReactElement { function App(props: StateProps): ReactElement {
switch (props.appState) { switch (props.appState) {
case BarcodeScannerAppState.INITIAL: case BarcodeScannerAppState.INITIAL:
@ -247,6 +207,7 @@ export default function Main() {
return <View style={styles.container}> return <View style={styles.container}>
<ScanButton onClicked={_scan}/> <ScanButton onClicked={_scan}/>
<InputIdButton onClicked={_inputIdNumber}/> <InputIdButton onClicked={_inputIdNumber}/>
<InputLineCountButton onClicked={_inputLineCount}/>
</View>; </View>;
case BarcodeScannerAppState.PRINTED: case BarcodeScannerAppState.PRINTED:
// Upload any changes to Firebase // Upload any changes to Firebase
@ -258,15 +219,13 @@ export default function Main() {
return { return {
id: s, id: s,
barcodeId: propsArray[0], barcodeId: propsArray[0],
createdAt: parse(propsArray[1], 'yyyyMMddHHmm', new Date()), createdAt: parse(propsArray[1], dateFormat, new Date()),
locationId: propsArray[2], locationId: propsArray[2],
} as Sample; } as Sample;
}); });
sendDataToFirebase(newSamples); sendDataToFirebase(newSamples, samplesCollection).then(_home);
}); });
_home();
return <SuccessMessage/>; return <SuccessMessage/>;
case BarcodeScannerAppState.PRINTING: case BarcodeScannerAppState.PRINTING:
return <View style={styles.container}> return <View style={styles.container}>
@ -300,8 +259,22 @@ export default function Main() {
onCancel={_home} onCancel={_home}
cameraType={undefined} cameraType={undefined}
/>; />;
case BarcodeScannerAppState.COUNT:
return <InputLineCountScreen
onSave={handleLineCountSubmitted}
onCancel={_home}
/>;
case BarcodeScannerAppState.SETTINGS: case BarcodeScannerAppState.SETTINGS:
return <SettingsScreen/>; return <SettingsScreen
cameraType={cameraType}
locationStr={locationStr}
onSave={(newCameraType: CameraType, newLocationStr: string) => {
setCameraType(newCameraType);
setLocationStr(newLocationStr);
_home();
}}
onCancel={_home}
/>;
default: default:
return <ErrorMessage/>; return <ErrorMessage/>;
} }

View File

@ -4,7 +4,7 @@
"name": "uva-covid19-testing-kiosk", "name": "uva-covid19-testing-kiosk",
"slug": "uva-covid19-testing-kiosk", "slug": "uva-covid19-testing-kiosk",
"version": "1.0.0", "version": "1.0.0",
"orientation": "landscape", "orientation": "portrait",
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"splash": { "splash": {
"image": "./assets/splash.png", "image": "./assets/splash.png",

66
components/LineCount.tsx Normal file
View File

@ -0,0 +1,66 @@
import {ButtonProps, InputLineCountScreenProps} from '../models/ElementProps';
import React, {ReactElement, useState} from 'react';
import {View} from 'react-native';
import {DefaultTheme, Subheading, Title, RadioButton, Paragraph, TextInput, HelperText, Button} from 'react-native-paper';
import {TextInput as NumberInput} from 'react-native';
import {colors, styles} from './Styles';
export const InputLineCountButton = (props: ButtonProps): ReactElement => {
return <Button
icon="timer"
mode="text"
color={colors.onBackground}
onPress={props.onClicked}
style={{marginTop: 30}}
>Enter Line Count</Button>;
};
export const InputLineCountScreen = (props: InputLineCountScreenProps): ReactElement => {
const [newLineCount, setNewLineCount] = useState<string>('0');
const hasErrors = () => {
if (newLineCount !== undefined && newLineCount !== null) {
const newInt = parseInt(newLineCount, 10);
return (
isNaN(newInt) ||
!isFinite(newInt) ||
newInt < 0 ||
newInt > 14000
);
} else {
return true;
}
};
return <View style={styles.settings}>
<Title style={{color: DefaultTheme.colors.text}}>Enter Line Count</Title>
<View style={{marginBottom: 10}}>
<Subheading style={{color: DefaultTheme.colors.text}}>How many people are waiting in line?</Subheading>
<TextInput
label="# of people"
value={newLineCount}
onChangeText={inputStr => setNewLineCount(inputStr)}
mode="outlined"
theme={DefaultTheme}
keyboardType="numeric"
/>
<HelperText type="error" visible={hasErrors()}>
Line count must be a whole number greater than 0.
</HelperText>
<Button
icon="content-save"
mode="contained"
color={colors.primary}
style={{marginBottom: 10}}
disabled={hasErrors()}
onPress={() => props.onSave(parseInt(newLineCount, 10))}
>Save</Button>
<Button
icon="cancel"
mode="outlined"
color={colors.primary}
onPress={props.onCancel}
>Cancel</Button>
</View>
</View>
}

69
components/Settings.tsx Normal file
View File

@ -0,0 +1,69 @@
import React, {ReactElement, useState} from 'react';
import {View} from 'react-native';
import {DefaultTheme, Subheading, Title, RadioButton, Paragraph, TextInput, HelperText, Button} from 'react-native-paper';
import {CameraType, SettingsScreenProps} from '../models/ElementProps';
import {colors, styles} from './Styles';
export const SettingsScreen = (props: SettingsScreenProps): ReactElement => {
const [newCameraType, setNewCameraType] = useState<CameraType>(props.cameraType);
const [newLocationStr, setNewLocationStr] = useState<string>(props.locationStr);
const pattern = /^[\d]{4}$/;
const hasErrors = () => {
return !pattern.test(newLocationStr);
};
return <View style={styles.settings}>
<Title style={{color: DefaultTheme.colors.text}}>Settings</Title>
<View style={{marginBottom: 40}}>
<Subheading style={{color: DefaultTheme.colors.text}}>Camera to Use</Subheading>
<RadioButton.Group
onValueChange={value => setNewCameraType(value as CameraType)}
value={newCameraType as string}
>
<RadioButton.Item
value="front"
label="Front"
theme={DefaultTheme}
/>
<RadioButton.Item
value="back"
label="Back"
theme={DefaultTheme}
/>
</RadioButton.Group>
</View>
<View style={{marginBottom: 10}}>
<Subheading style={{color: DefaultTheme.colors.text}}>Location Code</Subheading>
<Paragraph style={{color: DefaultTheme.colors.text}}>
Please do NOT change this unless you know what you are doing. Entering an incorrect location number may
prevent patients from getting accurate info about their test results.
</Paragraph>
<TextInput
label="Location #"
value={newLocationStr}
onChangeText={inputStr => setNewLocationStr(inputStr)}
mode="outlined"
theme={DefaultTheme}
/>
<HelperText type="error" visible={hasErrors()}>
Location number must be exactly 4 digits. No other characters are allowed.
</HelperText>
<Button
icon="content-save"
mode="contained"
color={colors.primary}
style={{marginBottom: 10}}
disabled={hasErrors()}
onPress={() => props.onSave(newCameraType, newLocationStr)}
>Save</Button>
<Button
icon="cancel"
mode="outlined"
color={colors.primary}
onPress={props.onCancel}
>Cancel</Button>
</View>
</View>
}

View File

@ -136,6 +136,9 @@ export const styles = StyleSheet.create({
safeAreaView: { safeAreaView: {
flex: 1 flex: 1
}, },
radio: {
backgroundColor: '#EEEEEE',
},
row: { row: {
flex: 1, flex: 1,
flexDirection: 'row', flexDirection: 'row',
@ -143,10 +146,10 @@ export const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
}, },
settings: { settings: {
flex: 1, // flex: 1,
alignItems: 'stretch', // alignItems: 'stretch',
flexDirection: 'column', // flexDirection: 'column',
justifyContent: 'center', // justifyContent: 'center',
padding: 80, padding: 80,
backgroundColor: DefaultTheme.colors.background, backgroundColor: DefaultTheme.colors.background,
color: DefaultTheme.colors.text, color: DefaultTheme.colors.text,

View File

@ -4,6 +4,7 @@ export enum BarcodeScannerAppState {
SCANNING = 'SCANNING', SCANNING = 'SCANNING',
SCANNED = 'SCANNED', SCANNED = 'SCANNED',
INPUT = 'INPUT', INPUT = 'INPUT',
COUNT = 'COUNT',
PRINTING = 'PRINTING', PRINTING = 'PRINTING',
PRINTED = 'PRINTED', PRINTED = 'PRINTED',
ERROR = 'ERROR', ERROR = 'ERROR',

View File

@ -23,6 +23,18 @@ export interface ButtonProps extends ElementProps {
onClicked: () => void; onClicked: () => void;
} }
export interface InputLineCountScreenProps extends ElementProps {
onSave: (newCount: number) => void;
onCancel: () => void;
}
export interface SettingsScreenProps extends ElementProps {
cameraType: CameraType;
locationStr: string;
onSave: (newCameraType: CameraType, newLocationStr: string) => void;
onCancel: () => void;
}
export interface ScannerProps extends ElementProps { export interface ScannerProps extends ElementProps {
onScanned: BarCodeScannedCallback; onScanned: BarCodeScannedCallback;
onCancel: () => void; onCancel: () => void;

6
models/LineCount.tsx Normal file
View File

@ -0,0 +1,6 @@
export interface LineCount {
id: string;
lineCount: number;
locationId: string;
createdAt: Date;
}