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', + }); + }} + /> )}