Refactors settings into config file. Syncs data with Firebase if connected.

This commit is contained in:
Aaron Louie 2020-09-14 13:41:22 -04:00
parent 632bb0c324
commit 93c7cdd008
12 changed files with 178 additions and 72 deletions

1
.gitignore vendored
View File

@ -668,3 +668,4 @@ buck-out/
.expo/* .expo/*
web-build/ web-build/
*.hprof *.hprof
config/default.tsx

30
App.tsx
View File

@ -16,7 +16,7 @@ import {IdNumberInput, InitialsInput, InputIdButton, ScanButton, Scanner} from '
import {SettingsScreen} from './components/Settings'; import {SettingsScreen} from './components/Settings';
import {styles, theme} from './components/Styles'; import {styles, theme} from './components/Styles';
import {sendDataToFirebase, SyncMessage} from './components/Sync'; import {sendDataToFirebase, SyncMessage} from './components/Sync';
import {dateFormat, firebaseConfig} from './config/default'; import {firebaseConfig, defaults} from './config/default';
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 {LineCount} from './models/LineCount';
@ -33,20 +33,20 @@ if (firebase.apps.length === 0) {
} }
const db = firebase.firestore(); const db = firebase.firestore();
const samplesCollection = db.collection('samples'); const samplesCollection = db.collection(defaults.samplesCollection);
const countsCollection = db.collection('counts'); const countsCollection = db.collection(defaults.countsCollection);
export default function Main() { export default function Main() {
const [appState, setAppState] = useState<BarcodeScannerAppState>(BarcodeScannerAppState.INITIAL); const [appState, setAppState] = useState<BarcodeScannerAppState>(BarcodeScannerAppState.INITIAL);
const [sampleId, setSampleId] = useState<string>(''); const [sampleId, setSampleId] = useState<string>('');
const [barCodeId, setBarCodeId] = useState<string>(''); const [barCodeId, setBarCodeId] = useState<string>('');
const [sampleDate, setSampleDate] = useState<Date>(new Date()); const [sampleDate, setSampleDate] = useState<Date>(new Date());
const [locationStr, setLocationStr] = useState<string>('0000'); const [locationStr, setLocationStr] = useState<string>(defaults.locationId);
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 [lineCounts, setLineCounts] = useState<LineCount[]>([]);
const [cameraType, setCameraType] = useState<CameraType>('back'); const [cameraType, setCameraType] = useState<CameraType>(defaults.cameraType);
const [numCopies, setNumCopies] = useState<number>(0); const [numCopies, setNumCopies] = useState<number>(defaults.numCopies);
const [isConnected, setIsConnected] = useState<boolean>(false); const [isConnected, setIsConnected] = useState<boolean>(false);
const [initials, setInitials] = useState<string>(''); const [initials, setInitials] = useState<string>('');
@ -69,6 +69,7 @@ export default function Main() {
}); });
// Watch for changes to internet connectivity. // Watch for changes to internet connectivity.
// TODO: Set up a timer that periodically syncs data with the database if connected.
NetInfo.addEventListener((state: NetInfoState) => { NetInfo.addEventListener((state: NetInfoState) => {
if (state.type === NetInfoStateType.wifi) { if (state.type === NetInfoStateType.wifi) {
setIsConnected(!!(state.isConnected && state.isInternetReachable)); setIsConnected(!!(state.isConnected && state.isInternetReachable));
@ -100,7 +101,7 @@ export default function Main() {
setAppState(BarcodeScannerAppState.INPUT_LINE_COUNT); setAppState(BarcodeScannerAppState.INPUT_LINE_COUNT);
}; };
const _print = () => setAppState(BarcodeScannerAppState.PRINTING); const _print = () => setAppState(BarcodeScannerAppState.PRINTING);
const _printed = () => setAppState(BarcodeScannerAppState.PRINTED); const _sync = () => setAppState(BarcodeScannerAppState.SYNC);
const _home = () => setAppState(BarcodeScannerAppState.DEFAULT); const _home = () => setAppState(BarcodeScannerAppState.DEFAULT);
const _settings = () => setAppState(BarcodeScannerAppState.SETTINGS); const _settings = () => setAppState(BarcodeScannerAppState.SETTINGS);
@ -125,21 +126,22 @@ export default function Main() {
const handleInitialsInput = (newInitials: string) => { const handleInitialsInput = (newInitials: string) => {
setInitials(newInitials); setInitials(newInitials);
const newSampleId = [barCodeId, newInitials, format(sampleDate, dateFormat), locationStr].join('-'); const newSampleId = [barCodeId, newInitials, format(sampleDate, defaults.dateEncodedFormat), locationStr].join('-');
setSampleId(newSampleId); setSampleId(newSampleId);
setAppState(BarcodeScannerAppState.SCANNED); setAppState(BarcodeScannerAppState.SCANNED);
}; };
const handleLineCountSubmitted = (newCount: number) => { const handleLineCountSubmitted = (newCount: number) => {
const now = new Date(); const now = new Date();
const newId = `${locationStr}-${format(now, dateFormat)}`; const newId = `${locationStr}-${format(now, defaults.dateEncodedFormat)}`;
const newData: LineCount = { const newData: LineCount = {
id: newId, id: newId,
lineCount: newCount, lineCount: newCount,
locationId: locationStr, locationId: locationStr,
createdAt: now, createdAt: now,
}; };
sendDataToFirebase([newData], countsCollection);
AsyncStorage.setItem(newData.id, JSON.stringify(newData)).then(_sync);
} }
const ErrorMessage = (props: ElementProps): ReactElement => { const ErrorMessage = (props: ElementProps): ReactElement => {
@ -170,7 +172,7 @@ export default function Main() {
</View> </View>
} }
function App(props: StateProps): ReactElement { const AppContent = (props: StateProps): ReactElement => {
switch (props.appState) { switch (props.appState) {
case BarcodeScannerAppState.INITIAL: case BarcodeScannerAppState.INITIAL:
return <LoadingMessage/>; return <LoadingMessage/>;
@ -180,7 +182,7 @@ export default function Main() {
<InputIdButton onClicked={_inputIdNumber}/> <InputIdButton onClicked={_inputIdNumber}/>
<InputLineCountButton onClicked={_inputLineCount}/> <InputLineCountButton onClicked={_inputLineCount}/>
</View>; </View>;
case BarcodeScannerAppState.PRINTED: case BarcodeScannerAppState.SYNC:
return <SyncMessage return <SyncMessage
isConnected={isConnected} isConnected={isConnected}
samplesCollection={samplesCollection} samplesCollection={samplesCollection}
@ -192,7 +194,7 @@ export default function Main() {
return <View style={styles.container}> return <View style={styles.container}>
<PrintingMessage <PrintingMessage
numCopies={numCopies} numCopies={numCopies}
onCancel={_printed} onCancel={_sync}
id={sampleId} id={sampleId}
barCodeId={barCodeId} barCodeId={barCodeId}
date={sampleDate} date={sampleDate}
@ -270,7 +272,7 @@ export default function Main() {
<Appbar.Action icon="settings" onPress={_settings}/> <Appbar.Action icon="settings" onPress={_settings}/>
</Appbar.Header> </Appbar.Header>
<SafeAreaView style={styles.safeAreaView}> <SafeAreaView style={styles.safeAreaView}>
<App appState={appState}/> <AppContent appState={appState}/>
</SafeAreaView> </SafeAreaView>
</PaperProvider> </PaperProvider>
); );

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,4 @@
#Fri Sep 11 17:23:10 EDT 2020 #Sun Sep 13 08:50:53 EDT 2020
VERSION_NAME=1.0.7 VERSION_NAME=1.0.10
VERSION_BUILD=16 VERSION_BUILD=19
VERSION_CODE=11 VERSION_CODE=14

View File

@ -5,7 +5,7 @@ import React, {ReactElement, useEffect, useState} from 'react';
import {Text, View} from 'react-native'; import {Text, View} from 'react-native';
import {Button, Title} from 'react-native-paper'; import {Button, Title} from 'react-native-paper';
import QRCode from 'react-native-qrcode-svg'; import QRCode from 'react-native-qrcode-svg';
import {dateDisplayFormat} from '../config/default'; import {defaults} from '../config/default';
import {BarCodeProps, ButtonProps, PrintingProps} from '../models/ElementProps'; import {BarCodeProps, ButtonProps, PrintingProps} from '../models/ElementProps';
import {Sample} from '../models/Sample'; import {Sample} from '../models/Sample';
import {colors, styles} from './Styles'; import {colors, styles} from './Styles';
@ -223,7 +223,7 @@ export const PrintingMessage = (props: PrintingProps): ReactElement => {
export const BarCodeDisplay = (props: BarCodeProps): ReactElement => { export const BarCodeDisplay = (props: BarCodeProps): ReactElement => {
return <View style={styles.printPreview}> return <View style={styles.printPreview}>
<Text style={styles.label}>ID #: {props.id}</Text> <Text style={styles.label}>ID #: {props.id}</Text>
<Text style={styles.label}>Date: {format(props.date, dateDisplayFormat)}</Text> <Text style={styles.label}>Date: {format(props.date, defaults.dateDisplayFormat)}</Text>
<Text style={styles.label}>Location #: {props.location}</Text> <Text style={styles.label}>Location #: {props.location}</Text>
<QRCode value={props.id}/> <QRCode value={props.id}/>
</View>; </View>;

View File

@ -110,7 +110,7 @@ export const InitialsInput = (props: InputInitialsProps): ReactElement => {
}; };
const onSubmit = () => { const onSubmit = () => {
props.onSave(inputStr); props.onSave(inputStr.toLowerCase());
} }
return <View style={styles.settings}> return <View style={styles.settings}>
@ -122,7 +122,7 @@ export const InitialsInput = (props: InputInitialsProps): ReactElement => {
<TextInput <TextInput
label="Initials" label="Initials"
value={inputStr} value={inputStr}
onChangeText={inputStr => setInputStr(inputStr.toLowerCase())} onChangeText={inputStr => setInputStr(inputStr)}
mode="outlined" mode="outlined"
theme={DefaultTheme} theme={DefaultTheme}
/> />

View File

@ -1,9 +1,9 @@
import AsyncStorage from '@react-native-community/async-storage'; import AsyncStorage from '@react-native-community/async-storage';
import {parse} from 'date-fns';
import React, {ReactElement, useEffect, useState} from 'react'; import React, {ReactElement, useEffect, useState} from 'react';
import {View} from 'react-native'; import {View} from 'react-native';
import {Title} from 'react-native-paper'; import {Snackbar, Title} from 'react-native-paper';
import {dateFormat} from '../config/default'; import {defaults} from '../config/default';
import {CollectionMeta} from '../models/Collection';
import {SyncProps} from '../models/ElementProps'; import {SyncProps} from '../models/ElementProps';
import {LineCount} from '../models/LineCount'; import {LineCount} from '../models/LineCount';
import {Sample} from '../models/Sample'; import {Sample} from '../models/Sample';
@ -19,38 +19,70 @@ export const sendDataToFirebase = async (newData: Array<Sample | LineCount>, col
export const SyncMessage = (props: SyncProps): ReactElement => { export const SyncMessage = (props: SyncProps): ReactElement => {
const [syncStatus, setSyncStatus] = useState<string>('Syncing data...'); const [syncStatus, setSyncStatus] = useState<string>('Syncing data...');
const [isError, setIsError] = useState<boolean>(false);
const [errorMessage, setErrorMessage] = useState<string>('');
useEffect(() => { // Display an error message if Firebase sync fails.
// TODO: Alternatively, set up a timer that periodically syncs data with the database. const _handleError = (error: any, dataTypeLabel: string) => {
setIsError(true);
setErrorMessage(error);
setSyncStatus(`Error occurred while syncing ${dataTypeLabel} data.`);
props.onCancel();
};
// Detect when user is online. If online, sync data with Firebase. // Delete locally-cached data from AsyncStorage
if (props.isConnected) { const _clearLocalCache = (keysToRemove: string[], dataTypeLabel: string) => {
// Get the collection subscription AsyncStorage.multiRemove(keysToRemove).then(() => {
const unsubscribe = props.samplesCollection.onSnapshot(q => {}, e => {}); setSyncStatus(`${dataTypeLabel} data synced.`);
// Upload any changes to Firebase
AsyncStorage.getAllKeys().then(keys => {
const newSamples = keys
.filter(s => /^[\d]{9}-[\d]{12}-[\d]{4}$/.test(s))
.map(s => {
const propsArray = s.split('-');
return {
id: s,
barcodeId: propsArray[0],
createdAt: parse(propsArray[1], dateFormat, new Date()),
locationId: propsArray[2],
} as Sample;
});
sendDataToFirebase(newSamples, props.samplesCollection).then(() => {
// TODO: Delete stored keys in AsyncStorage
setSyncStatus('Data synced.');
props.onSync(); props.onSync();
}); });
}
// Upload any new locally-stored items to Firebase, then remove them from local storage.
const _syncCollection = (localStorageKeys: string[], collection: CollectionMeta) => {
const newItemsKeys = localStorageKeys.filter(k => collection.keyRegex.test(k));
AsyncStorage.multiGet(newItemsKeys, (errors, dataTuples) => {
if (dataTuples && (dataTuples.length > 0)) {
const data: Sample[] | LineCount[] = [];
dataTuples.forEach(t => {
if (t[1] !== null) {
const lineCount = JSON.parse(t[1]);
data.push(lineCount);
}
}); });
return () => unsubscribe(); sendDataToFirebase(data, collection.firebaseCollection)
.then(() => _clearLocalCache(newItemsKeys, collection.label))
.catch(error => _handleError(error, collection.label));
}
});
}
useEffect(() => {
// Detect when user is online. If online, sync data with Firebase.
if (props.isConnected) {
const collectionsToSync = [
{
firebaseCollection: props.countsCollection,
keyRegex: defaults.lineCountRegex,
label: 'Line Counts',
unsubscribe: props.countsCollection.onSnapshot(q => {}, e => {}),
},
{
firebaseCollection: props.samplesCollection,
keyRegex: defaults.qrCodeRegex,
label: 'QR Codes',
unsubscribe: props.samplesCollection.onSnapshot(q => {}, e => {}),
},
];
// Upload new data to Firebase
AsyncStorage.getAllKeys().then(keys => {
collectionsToSync.forEach(c => _syncCollection(keys, c));
});
// Unsubscribe from all collections
return () => collectionsToSync.forEach(c => c.unsubscribe());
} else { } else {
// If not online, just go home. // If not online, just go home.
setSyncStatus('Device is not online. Skipping sync...'); setSyncStatus('Device is not online. Skipping sync...');
@ -61,6 +93,11 @@ export const SyncMessage = (props: SyncProps): ReactElement => {
return <View style={styles.container}> return <View style={styles.container}>
<View style={styles.centerMiddle}> <View style={styles.centerMiddle}>
<Title style={styles.heading}>{syncStatus}</Title> <Title style={styles.heading}>{syncStatus}</Title>
<Snackbar
visible={isError}
onDismiss={props.onCancel}
style={styles.error}
>{errorMessage === '' ? 'Something went wrong. Try again.' : errorMessage}</Snackbar>
<CancelButton onClicked={props.onCancel} /> <CancelButton onClicked={props.onCancel} />
</View> </View>
</View>; </View>;

View File

@ -1,12 +1,26 @@
import {AppDefaults} from '../models/Default';
import {CameraType} from '../models/ElementProps';
// Firebase project config from https://console.firebase.google.com > Project Settings > General > Your apps > Web App
export const firebaseConfig = { export const firebaseConfig = {
apiKey: 'api_key_goes_here', apiKey: 'api_key_goes_here',
authDomain: "uva-covid19-testing-kiosk.firebaseapp.com", authDomain: 'uva-covid19-testing-kiosk.firebaseapp.com',
databaseURL: "https://uva-covid19-testing-kiosk.firebaseio.com", databaseURL: 'https://uva-covid19-testing-kiosk.firebaseio.com',
projectId: 'project_id_goes_here', projectId: 'project_id_goes_here',
storageBucket: "uva-covid19-testing-kiosk.appspot.com", storageBucket: 'uva-covid19-testing-kiosk.appspot.com',
messagingSenderId: 'sender_id_goes_here', messagingSenderId: 'sender_id_goes_here',
appId: 'app_id_goes_here' appId: 'app_id_goes_here',
}; };
export const dateFormat = 'yyyyMMddHHmm'; // Default form field and data values
export const dateDisplayFormat = 'MM/dd/yyyy, hh:mm aa'; export const defaults: AppDefaults = {
countsCollection: 'counts', // Name of collection for Line Counts in Firebase.
samplesCollection: 'samples', // Name of collection for Line Counts in Firebase.
dateEncodedFormat: 'yyyyMMddHHmm', // Format for dates when encoded in IDs for database records.
dateDisplayFormat: 'MM/dd/yyyy, hh:mm aa', // Format for dates when displayed to user.
numCopies: 2, // Default number of copies of labels to print. Can be overridden by user setting.
cameraType: 'back' as CameraType, // Which camera to use for capturing bar codes. Can be overridden by user setting.
locationId: '0000', // Default location ID. Can be overridden by user setting.
lineCountRegex: /^[\d]{4}-[\d]{12}$/, // ID format for Line Count records.
qrCodeRegex: /^[\d]{9}-[a-zA-Z]+-[\d]{12}-[\d]{4}$/, // ID format for QR Code records.
}

30
config/example.tsx Normal file
View File

@ -0,0 +1,30 @@
/**
Example configuration file. Make a copy of this file, name it "default.tsx", and place it in the config directory.
Then modify the values below to match the actual Firebase configuration.
*/
import {AppDefaults} from '../models/Default';
import {CameraType} from '../models/ElementProps';
// Firebase project config from https://console.firebase.google.com > Project Settings > General > Your apps > Web App
export const firebaseConfig = {
apiKey: 'api_key_goes_here',
authDomain: 'uva-covid19-testing-kiosk.firebaseapp.com',
databaseURL: 'https://uva-covid19-testing-kiosk.firebaseio.com',
projectId: 'project_id_goes_here',
storageBucket: 'uva-covid19-testing-kiosk.appspot.com',
messagingSenderId: 'sender_id_goes_here',
appId: 'app_id_goes_here',
};
// Default form field and data values
export const defaults: AppDefaults = {
countsCollection: 'counts', // Name of collection for Line Counts in Firebase.
samplesCollection: 'samples', // Name of collection for Line Counts in Firebase.
dateEncodedFormat: 'yyyyMMddHHmm', // Format for dates when encoded in IDs for database records.
dateDisplayFormat: 'MM/dd/yyyy, hh:mm aa', // Format for dates when displayed to user.
numCopies: 2, // Default number of copies of labels to print. Can be overridden by user setting.
cameraType: 'back' as CameraType, // Which camera to use for capturing bar codes. Can be overridden by user setting.
locationId: '0000', // Default location ID. Can be overridden by user setting.
lineCountRegex: /^[\d]{4}-[\d]{12}$/, // ID format for Line Count records.
qrCodeRegex: /^[\d]{9}-[a-zA-Z]+-[\d]{12}-[\d]{4}$/, // ID format for QR Code records.
}

View File

@ -7,7 +7,7 @@ export enum BarcodeScannerAppState {
INPUT_INITIALS = 'INPUT_INITIALS', INPUT_INITIALS = 'INPUT_INITIALS',
INPUT_LINE_COUNT = 'INPUT_LINE_COUNT', INPUT_LINE_COUNT = 'INPUT_LINE_COUNT',
PRINTING = 'PRINTING', PRINTING = 'PRINTING',
PRINTED = 'PRINTED', SYNC = 'SYNC',
ERROR = 'ERROR', ERROR = 'ERROR',
SETTINGS = 'SETTINGS', SETTINGS = 'SETTINGS',
} }

9
models/Collection.tsx Normal file
View File

@ -0,0 +1,9 @@
import * as firebase from 'firebase';
import 'firebase/firestore';
export interface CollectionMeta {
firebaseCollection: firebase.firestore.CollectionReference;
keyRegex: RegExp;
label: string;
unsubscribe: () => void;
}

13
models/Default.tsx Normal file
View File

@ -0,0 +1,13 @@
import {CameraType} from './ElementProps';
export interface AppDefaults {
countsCollection: string;
samplesCollection: string;
dateEncodedFormat: string;
dateDisplayFormat: string;
numCopies: number;
cameraType: CameraType;
locationId: string;
lineCountRegex: RegExp;
qrCodeRegex: RegExp;
}