diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml
index 4122f36a..b064c25e 100644
--- a/app/android/app/src/main/AndroidManifest.xml
+++ b/app/android/app/src/main/AndroidManifest.xml
@@ -22,4 +22,5 @@
+
diff --git a/app/android/gradle.properties b/app/android/gradle.properties
index a3b2fa12..c50a2582 100644
--- a/app/android/gradle.properties
+++ b/app/android/gradle.properties
@@ -42,3 +42,5 @@ newArchEnabled=false
# Use this property to enable or disable the Hermes JS engine.
# If set to false, you will be using JSC instead.
hermesEnabled=true
+
+VisionCamera_enableCodeScanner=true
diff --git a/app/ios/EagleScout/Info.plist b/app/ios/EagleScout/Info.plist
index c919e187..d1356a69 100644
--- a/app/ios/EagleScout/Info.plist
+++ b/app/ios/EagleScout/Info.plist
@@ -50,6 +50,8 @@
+ NSCameraUsageDescription
+ $(PRODUCT_NAME) needs access to your Camera.
NSLocationWhenInUseUsageDescription
UILaunchStoryboardName
diff --git a/app/package-lock.json b/app/package-lock.json
index 03e05d3f..88b5f66f 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -35,6 +35,7 @@
"react-native-haptic-feedback": "^2.2.0",
"react-native-linear-gradient": "^2.8.3",
"react-native-pager-view": "^6.2.1",
+ "react-native-qrcode-styled": "^0.3.1",
"react-native-reanimated": "^3.7.2",
"react-native-safe-area-context": "^4.7.2",
"react-native-screens": "^3.25.0",
@@ -43,6 +44,7 @@
"react-native-svg": "^13.13.0",
"react-native-toast-message": "^2.1.6",
"react-native-url-polyfill": "^2.0.0",
+ "react-native-vision-camera": "^3.9.2",
"rn-emoji-keyboard": "^1.6.1"
},
"devDependencies": {
@@ -6941,6 +6943,11 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
+ "node_modules/dijkstrajs": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
+ "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="
+ },
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@@ -7054,6 +7061,11 @@
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
},
+ "node_modules/encode-utf8": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/encode-utf8/-/encode-utf8-1.0.3.tgz",
+ "integrity": "sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw=="
+ },
"node_modules/encodeurl": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
@@ -13097,6 +13109,14 @@
"node": ">=10.4.0"
}
},
+ "node_modules/pngjs": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
+ "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
"node_modules/point-in-polygon": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/point-in-polygon/-/point-in-polygon-1.1.0.tgz",
@@ -13257,6 +13277,114 @@
}
]
},
+ "node_modules/qrcode": {
+ "version": "1.5.3",
+ "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.3.tgz",
+ "integrity": "sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg==",
+ "dependencies": {
+ "dijkstrajs": "^1.0.1",
+ "encode-utf8": "^1.0.3",
+ "pngjs": "^5.0.0",
+ "yargs": "^15.3.1"
+ },
+ "bin": {
+ "qrcode": "bin/qrcode"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/qrcode/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/qrcode/node_modules/cliui": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
+ "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.0",
+ "wrap-ansi": "^6.2.0"
+ }
+ },
+ "node_modules/qrcode/node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/qrcode/node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
+ },
+ "node_modules/qrcode/node_modules/wrap-ansi": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+ "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/y18n": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+ "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="
+ },
+ "node_modules/qrcode/node_modules/yargs": {
+ "version": "15.4.1",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+ "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
+ "dependencies": {
+ "cliui": "^6.0.0",
+ "decamelize": "^1.2.0",
+ "find-up": "^4.1.0",
+ "get-caller-file": "^2.0.1",
+ "require-directory": "^2.1.1",
+ "require-main-filename": "^2.0.0",
+ "set-blocking": "^2.0.0",
+ "string-width": "^4.2.0",
+ "which-module": "^2.0.0",
+ "y18n": "^4.0.0",
+ "yargs-parser": "^18.1.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/qrcode/node_modules/yargs-parser": {
+ "version": "18.1.3",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+ "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+ "dependencies": {
+ "camelcase": "^5.0.0",
+ "decamelize": "^1.2.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/qs": {
"version": "6.11.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz",
@@ -13608,6 +13736,22 @@
"react-native": "*"
}
},
+ "node_modules/react-native-qrcode-styled": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/react-native-qrcode-styled/-/react-native-qrcode-styled-0.3.1.tgz",
+ "integrity": "sha512-Q4EqbIFV0rpCYcdmWY51+H8Vrc0fvP01hPkiSqPEmjjxhm6mqyAuTMdNHNEddLXZzCVQCJujvj6IrHjdAhKjnA==",
+ "dependencies": {
+ "qrcode": "^1.5.1"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*",
+ "react-native-svg": "*"
+ }
+ },
"node_modules/react-native-reanimated": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.7.2.tgz",
@@ -13723,6 +13867,21 @@
"react-native": "*"
}
},
+ "node_modules/react-native-vision-camera": {
+ "version": "3.9.2",
+ "resolved": "https://registry.npmjs.org/react-native-vision-camera/-/react-native-vision-camera-3.9.2.tgz",
+ "integrity": "sha512-watHRWbeH7CBYq/5sPj2fpZj87V8J5nGdmYO61aYsDLuJ2Pkij7anAzBf8B8oZiyoSUuYpAzX4lIIi+LjWVedA==",
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*",
+ "react-native-worklets-core": "*"
+ },
+ "peerDependenciesMeta": {
+ "react-native-worklets-core": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-native/node_modules/@jest/types": {
"version": "26.6.2",
"resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz",
diff --git a/app/package.json b/app/package.json
index cb9d6fac..742db7e0 100644
--- a/app/package.json
+++ b/app/package.json
@@ -39,6 +39,7 @@
"react-native-haptic-feedback": "^2.2.0",
"react-native-linear-gradient": "^2.8.3",
"react-native-pager-view": "^6.2.1",
+ "react-native-qrcode-styled": "^0.3.1",
"react-native-reanimated": "^3.7.2",
"react-native-safe-area-context": "^4.7.2",
"react-native-screens": "^3.25.0",
@@ -47,6 +48,7 @@
"react-native-svg": "^13.13.0",
"react-native-toast-message": "^2.1.6",
"react-native-url-polyfill": "^2.0.0",
+ "react-native-vision-camera": "^3.9.2",
"rn-emoji-keyboard": "^1.6.1"
},
"devDependencies": {
diff --git a/app/src/App.js b/app/src/App.js
index 7676308d..96436b8b 100644
--- a/app/src/App.js
+++ b/app/src/App.js
@@ -53,6 +53,7 @@ import ChangePassword from './screens/settings-flow/ChangePassword';
import ResetPassword from './screens/login-flow/ResetPassword';
import {MatchBetting} from './screens/match-betting-flow/MatchBetting';
import {MatchBettingNavigator} from './screens/match-betting-flow/MatchBettingNavigator';
+import {QrViewSplitter} from './screens/qr-export-flow/QrViewSplitter';
const CustomLightTheme = {
dark: false,
@@ -453,6 +454,18 @@ const MyStack = ({themePreference, setThemePreference, setOled}) => {
}}
component={MatchBettingNavigator}
/>
+ null,
+ headerShown: false,
+ tabBarShowLabel: false,
+ tabBarStyle: {
+ backgroundColor: colors.background,
+ },
+ }}
+ component={QrViewSplitter}
+ />
>
)}
diff --git a/app/src/FormHelper.js b/app/src/FormHelper.js
index 6e810b92..4525228c 100644
--- a/app/src/FormHelper.js
+++ b/app/src/FormHelper.js
@@ -37,7 +37,9 @@ class FormHelper {
*/
static async saveFormOffline(dataToSubmit) {
- dataToSubmit.createdAt = new Date();
+ if (!dataToSubmit.createdAt) {
+ dataToSubmit.createdAt = new Date();
+ }
await AsyncStorage.setItem(
'form-' + dataToSubmit.createdAt.getUTCMilliseconds(),
diff --git a/app/src/components/camera/QrScannerModal.tsx b/app/src/components/camera/QrScannerModal.tsx
new file mode 100644
index 00000000..d77bc3b3
--- /dev/null
+++ b/app/src/components/camera/QrScannerModal.tsx
@@ -0,0 +1,44 @@
+import {useEffect} from 'react';
+import {Text} from 'react-native-svg';
+import {
+ Camera,
+ Code,
+ useCameraDevice,
+ useCameraPermission,
+ useCodeScanner,
+} from 'react-native-vision-camera';
+import {Modal, StyleSheet} from 'react-native';
+
+export const QrScannerModal = ({
+ onCodeScanned,
+}: {
+ onCodeScanned: (codes: Code[]) => void;
+}) => {
+ const {hasPermission, requestPermission} = useCameraPermission();
+ const device = useCameraDevice('back');
+ const codeScanner = useCodeScanner({
+ codeTypes: ['qr'],
+ onCodeScanned,
+ });
+ useEffect(() => {
+ if (!hasPermission) {
+ requestPermission();
+ }
+ }, [hasPermission, requestPermission]);
+ if (!device || !codeScanner) {
+ return null;
+ }
+ if (!hasPermission) {
+ return Requesting camera permission...;
+ }
+ return (
+
+
+
+ );
+};
diff --git a/app/src/screens/data-flow/DataHome.tsx b/app/src/screens/data-flow/DataHome.tsx
index f487b2b3..9ae921ec 100644
--- a/app/src/screens/data-flow/DataHome.tsx
+++ b/app/src/screens/data-flow/DataHome.tsx
@@ -271,6 +271,25 @@ const DataHome = ({navigation}) => {
)}
/>
+ {
+ navigation.navigate('QrView', {
+ type: 'import',
+ });
+ }}
+ caretVisible={false}
+ disabled={internetStatus !== InternetStatus.CONNECTED}
+ icon={() => (
+
+ )}
+ />
{
diff --git a/app/src/screens/qr-export-flow/QrExport.tsx b/app/src/screens/qr-export-flow/QrExport.tsx
new file mode 100644
index 00000000..11e07ff6
--- /dev/null
+++ b/app/src/screens/qr-export-flow/QrExport.tsx
@@ -0,0 +1,134 @@
+import React, {useEffect, useState} from 'react';
+import {SafeAreaView, Text, View} from 'react-native';
+import QRCodeStyled from 'react-native-qrcode-styled';
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import StandardButton from '../../components/StandardButton';
+import {useTheme} from '@react-navigation/native';
+
+interface OfflineReport {
+ form: any[];
+ formId: string;
+ data: any[];
+ matchNumber: number;
+ teamNumber: number;
+ competitionId: string;
+ competitionName: string;
+ createdAt: string;
+}
+
+const Card = ({children}: {children: React.ReactNode}) => (
+
+ {children}
+
+);
+
+export const QrExport = () => {
+ const [offlineReports, setOfflineReports] = useState([]);
+ const [currentIndex, setCurrentIndex] = useState(0);
+
+ const {colors} = useTheme();
+
+ const getOfflineReports = async () => {
+ const keys = await AsyncStorage.getAllKeys();
+ const offReports = keys.filter(key => key.includes('form-'));
+
+ const offlineReportData = await AsyncStorage.multiGet(offReports);
+ setOfflineReports(
+ offlineReportData
+ .map(
+ report =>
+ report != null &&
+ (JSON.parse(report[1] as string) as OfflineReport),
+ )
+ .filter(Boolean) as OfflineReport[],
+ );
+ };
+
+ useEffect(() => {
+ getOfflineReports();
+ }, []);
+
+ useEffect(() => {
+ if (offlineReports.length === 0) {
+ return;
+ }
+ const formKey = new Date(
+ offlineReports[currentIndex].createdAt,
+ ).getUTCMilliseconds();
+ console.log('deleting', `form-${formKey}`);
+ AsyncStorage.removeItem(`form-${formKey}`);
+ AsyncStorage.setItem(
+ `imported-${formKey}`,
+ JSON.stringify(offlineReports[currentIndex]),
+ );
+ }, [currentIndex, offlineReports]);
+
+ if (offlineReports.length === 0) {
+ return (
+
+ No offline reports found
+
+ );
+ }
+
+ return (
+
+
+
+ Team: {offlineReports[currentIndex].teamNumber}
+
+
+ Match: {offlineReports[currentIndex].matchNumber}
+
+
+
+ {currentIndex < offlineReports.length - 1 && (
+ {
+ setCurrentIndex(currentIndex + 1);
+ }}
+ />
+ )}
+
+ Note: You will not be able to revisit this QR code after you press next
+ or leave this page
+
+
+ );
+};
diff --git a/app/src/screens/qr-export-flow/QrImport.tsx b/app/src/screens/qr-export-flow/QrImport.tsx
new file mode 100644
index 00000000..cfee48ef
--- /dev/null
+++ b/app/src/screens/qr-export-flow/QrImport.tsx
@@ -0,0 +1,121 @@
+import {useTheme} from '@react-navigation/native';
+import React, {useEffect, useState} from 'react';
+import {SafeAreaView, Text, View} from 'react-native';
+import {Code} from 'react-native-vision-camera';
+import {QrScannerModal} from '../../components/camera/QrScannerModal';
+import StandardButton from '../../components/StandardButton';
+import FormHelper from '../../FormHelper';
+
+export const QrImport = () => {
+ const {colors} = useTheme();
+ const [step, setStep] = useState('scan');
+ const [currentData, setCurrentData] = useState<{
+ competitionId: number;
+ matchNumber: number;
+ teamNumber: number;
+ data: any[];
+ createdAt: string;
+ }>();
+ const [storedComp, setStoredComp] = useState<{
+ id: number;
+ name: string;
+ formStructure: any[];
+ }>();
+ useEffect(() => {
+ (async () => {
+ const comp = await FormHelper.readAsyncStorage(
+ FormHelper.ASYNCSTORAGE_COMPETITION_KEY,
+ );
+ if (comp != null) {
+ const parsedComp = JSON.parse(comp);
+ if (parsedComp.id !== currentData?.competitionId) {
+ return;
+ }
+ setStoredComp(JSON.parse(comp));
+ console.log(comp);
+ }
+ })();
+ }, [currentData]);
+ const onCodeScanned = (codes: Code[]) => {
+ console.log(codes);
+ if (codes.length === 0 || !codes[0].value) {
+ return;
+ }
+ setCurrentData(JSON.parse(codes[0].value));
+ setStep('confirm');
+ };
+ if (!storedComp) {
+ return (
+
+
+ No Competition Active
+
+
+ );
+ }
+ if (step === 'confirm') {
+ return (
+
+
+ Confirm Import
+
+
+ Competition: {currentData?.competitionId}
+
+
+ Match: {currentData?.matchNumber}
+
+
+ Team: {currentData?.teamNumber}
+
+
+ {
+ setCurrentData(undefined);
+ setStep('scan');
+ }}
+ width="40%"
+ />
+ {
+ await FormHelper.saveFormOffline({
+ data: currentData?.data,
+ competitionId: currentData?.competitionId,
+ matchNumber: currentData?.matchNumber,
+ teamNumber: currentData?.teamNumber,
+ createdAt: currentData?.createdAt,
+ competitionName: storedComp.name,
+ formId: storedComp.id,
+ formStructure: storedComp.formStructure,
+ });
+ setStep('scan');
+ }}
+ width="40%"
+ />
+
+
+ );
+ }
+ return ;
+};
diff --git a/app/src/screens/qr-export-flow/QrViewSplitter.tsx b/app/src/screens/qr-export-flow/QrViewSplitter.tsx
new file mode 100644
index 00000000..5168feab
--- /dev/null
+++ b/app/src/screens/qr-export-flow/QrViewSplitter.tsx
@@ -0,0 +1,8 @@
+import React from 'react';
+import {QrExport} from './QrExport';
+import {QrImport} from './QrImport';
+
+export const QrViewSplitter = ({route}: {route: any}) => {
+ const {type} = route.params;
+ return type === 'export' ? : ;
+};
diff --git a/app/src/screens/settings-flow/SubmittedForms.js b/app/src/screens/settings-flow/SubmittedForms.js
index bb5ccac6..0b24275e 100644
--- a/app/src/screens/settings-flow/SubmittedForms.js
+++ b/app/src/screens/settings-flow/SubmittedForms.js
@@ -15,7 +15,7 @@ import {
import {useEffect, useState} from 'react';
import ReportList from '../../components/ReportList';
import AsyncStorage from '@react-native-async-storage/async-storage';
-import {useTheme} from '@react-navigation/native';
+import {useNavigation, useTheme} from '@react-navigation/native';
import SegmentedOption from '../../components/pickers/SegmentedOption';
import DBManager from '../../DBManager';
import React from 'react-native';
@@ -23,10 +23,12 @@ import StandardButton from '../../components/StandardButton';
import Toast from 'react-native-toast-message';
import ScoutReportsDB from '../../database/ScoutReports';
import CompetitionsDB from '../../database/Competitions';
+import FormHelper from '../../FormHelper';
const DEBUG = false;
function SubmittedForms() {
+ const navigation = useNavigation();
const [reports, setReports] = useState([]);
const [offlineReports, setOfflineReports] = useState([]);
const {colors} = useTheme();
@@ -208,6 +210,15 @@ function SubmittedForms() {
});
}}
/>
+ {
+ navigation.navigate('QrView', {
+ type: 'export',
+ });
+ }}
+ />
)}