diff --git a/Api/MeetingsApi.ts b/Api/MeetingsApi.ts new file mode 100644 index 0000000..a996ae0 --- /dev/null +++ b/Api/MeetingsApi.ts @@ -0,0 +1,91 @@ +import { AxiosInstance } from 'axios'; + +export type TeamInfo> = { + id: number; + name: string; + self_serviceable: boolean; + visible: boolean; + visible_on_kiosk: boolean; + attendable: boolean; + description: string; + mailing_list_name: string | null; + slack_channel_id: string; + slack_channel_name: string; + slack_private_channel_id: string; + google_group: string; + created_at: string; + updated_at: string; + deleted_at: string | null; +} & T; + +export type EventInfo> = { + id: number; + name: string; + allow_anonymous_rsvp: boolean; + location: string; + start_time: string; + end_time: string; + created_at: string; + updated_at: string; + deleted_at: string | null; +} & T; + +export type AttendanceInfo> = { + attendable_type: string; + attendable_id: number; + gtid: number; + source: string; +} & T; + +export type AttendanceResponse = { + attendance: { + attendee?: { + name: string; + }; + }; +}; + +export async function getTeamInfo(api: AxiosInstance): Promise { + try { + const teams = await api.get('/api/v1/teams'); + //TODO: Incorporate Sentry + const teamInfos: TeamInfo[] = teams.data.teams; + return teamInfos; + } catch (error) { + //TODO: incorporate logging + } + return null; +} + +export async function getEventInfo(api: AxiosInstance): Promise { + try { + const events = await api.get('/api/v1/events'); + //TODO: Incorporate Sentry + const eventInfos: EventInfo[] = events.data.events; + return eventInfos; + } catch (error) { + //TODO: incorporate logging + } + return null; +} + +export async function postAttendance( + api: AxiosInstance, + props: AttendanceInfo, +): Promise<{ success: true; data: AttendanceResponse } | { success: false; error: string }> { + try { + const response = await api.post('/api/v1/attendance?include=attendee', props); + + return { + success: true, + data: response.data, + }; + } catch (error) { + //TODO: incorporate logging + console.error(error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } +} diff --git a/Api/Models/Attendance.ts b/Api/Models/Attendance.ts new file mode 100644 index 0000000..c0a1936 --- /dev/null +++ b/Api/Models/Attendance.ts @@ -0,0 +1,10 @@ +export enum NfcSource { + NFC = 'Nfc', + KEYBOARD = 'Keyboard', +} + +export enum AttendableType { + TEAM = 'team', + EVENT = 'event', + NONE = 'none', +} diff --git a/AppEnvironment.tsx b/AppEnvironment.tsx index 5be8a69..ded22b9 100644 --- a/AppEnvironment.tsx +++ b/AppEnvironment.tsx @@ -16,6 +16,11 @@ export const APP_ENVIRONMENTS: AppEnvironmentList = { production: true, baseUrl: 'https://my.robojackets.org', }, + test: { + name: 'Test', + production: false, + baseUrl: 'https://apiary-test.robojackets.org', + }, }; type EnvironmentContextType = { diff --git a/Attendance/AttendableSelect.tsx b/Attendance/AttendableSelect.tsx new file mode 100644 index 0000000..b43dfa4 --- /dev/null +++ b/Attendance/AttendableSelect.tsx @@ -0,0 +1,84 @@ +import React, { useEffect, useState } from 'react'; +import { ScrollView } from 'react-native'; +import { useApi } from '../Api/ApiContextProvider'; +import { EventInfo, getEventInfo, getTeamInfo, TeamInfo } from '../Api/MeetingsApi'; +import { AttendableType } from '../Api/Models/Attendance'; +import LoadingScreen from '../Components/LoadingScreen'; +import MenuHeader from '../Components/MenuHeader'; +import MenuLink from '../Components/MenuLink'; +import RoundedButton from '../Components/RoundedButton'; +import TapABuzzCard from './TapABuzzCard'; + +type AttendanceProps = { + attendanceType: AttendableType; + setAttendanceType: (state: AttendableType) => void; +}; + +function AttendableSelect({ attendanceType, setAttendanceType }: AttendanceProps) { + const api = useApi(); + const [attendables, setAttendables] = useState( + undefined, + ); + const [attendable, setAttendable] = useState(undefined); + + async function onRefreshAttendables(forceRefresh: boolean = false) { + if (forceRefresh) { + if (attendanceType === AttendableType.TEAM) { + setAttendables(await getTeamInfo(api)); + } else { + setAttendables(await getEventInfo(api)); + } + } + } + + useEffect(() => { + onRefreshAttendables(true); + }, []); + + function AttendableList() { + if (attendables) { + const attendableList = []; + for (const attendable of attendables) { + attendableList.push( + { + setAttendable(attendable); + }} + >, + ); + } + return attendableList; + } else { + return ; + } + } + + return ( + <> + {attendable ? ( + + ) : ( + + { + setAttendanceType(AttendableType.NONE); + }} + /> + + + + )} + + ); +} + +export default AttendableSelect; diff --git a/Attendance/AttendanceScreen.tsx b/Attendance/AttendanceScreen.tsx index d2b2c67..f7d7c3b 100644 --- a/Attendance/AttendanceScreen.tsx +++ b/Attendance/AttendanceScreen.tsx @@ -1,13 +1,103 @@ -import React from 'react'; -import { Text, View } from 'react-native'; +import React, { useEffect, useState } from 'react'; +import { SafeAreaView, StyleSheet } from 'react-native'; +import { useApi } from '../Api/ApiContextProvider'; +import { AttendableType } from '../Api/Models/Attendance'; +import { Permission } from '../Api/Models/Permission'; +import { getUserInfo, UserInfo } from '../Api/UserApi'; +import InsufficientPermissions from '../Auth/InsufficientPermissions'; +import LoadingScreen from '../Components/LoadingScreen'; +import MenuHeader from '../Components/MenuHeader'; +import MenuLink from '../Components/MenuLink'; +import { useTheme } from '../Themes/ThemeContextProvider'; +import AttendableSelect from './AttendableSelect'; +const requiredPermissions: Permission[] = [Permission.CREATE_ATTENDANCE, Permission.READ_USERS]; + +/* +Architecture: +Attendance Screen > AttendableSelect > TapABuzzCard > BuzzCardPrompt +*/ function AttendanceScreen() { + const api = useApi(); + const [user, setUser] = useState(undefined); + const [missingPermissions, setMissingPermissions] = useState(undefined); + const { currentTheme } = useTheme(); + + async function onRefreshUser(forceRefresh: boolean = false) { + if (!user || forceRefresh) { + setUser(await getUserInfo(api)); + } + } + + function getPermissions() { + let missingPermissions: Permission[] = []; + if (user && user.allPermissions) { + const permissions = user.allPermissions; + missingPermissions = requiredPermissions.filter((item) => !permissions.includes(item)); + } + setMissingPermissions(missingPermissions); + } + + useEffect(() => { + onRefreshUser(true); + getPermissions(); + }, []); + + function AttendableSelectionScreen() { + const [attendanceType, setAttendanceType] = useState(AttendableType.NONE); + + return ( + + {attendanceType === AttendableType.NONE ? ( + <> + + { + setAttendanceType(AttendableType.TEAM); + }} + > + { + setAttendanceType(AttendableType.EVENT); + }} + > + + ) : ( + + )} + + ); + } + return ( - // eslint-disable-next-line react-native/no-inline-styles - - Attendance Screen - + <> + {user && missingPermissions ? ( + missingPermissions.length != 0 ? ( + {}} + > + ) : ( + + ) + ) : ( + + )} + ); } +const styles = StyleSheet.create({ + container: { flex: 1, padding: 10 }, +}); + export default AttendanceScreen; diff --git a/Attendance/BuzzCardPrompt.tsx b/Attendance/BuzzCardPrompt.tsx new file mode 100644 index 0000000..5b18df2 --- /dev/null +++ b/Attendance/BuzzCardPrompt.tsx @@ -0,0 +1,347 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Modal, NativeModules, Platform, StyleSheet, Text, TextInput, View } from 'react-native'; +import NfcManager, { NfcTech } from 'react-native-nfc-manager'; +import { useApi } from '../Api/ApiContextProvider'; +import { EventInfo, postAttendance, TeamInfo } from '../Api/MeetingsApi'; +import { AttendableType, NfcSource } from '../Api/Models/Attendance'; +import { ActionPrompt } from '../Components/ActionPrompt'; +import RoundedButton from '../Components/RoundedButton'; +import ThemedText from '../Components/ThemedText'; +import { useTheme } from '../Themes/ThemeContextProvider'; +import { LastAttendeeProps } from './TapABuzzCard'; + +const selectApp = [0x90, 0x5a, 0x00, 0x00, 0x03, 0xcd, 0xbb, 0xbb, 0x00]; +const readFile = [0x90, 0xbd, 0x00, 0x00, 0x07, 0x01, 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00]; + +// Names taken from Android App +export type BuzzCardState = + | 'TagLost' + | 'NotABuzzCard' + | 'InvalidBuzzCardData' + | 'UnknownNfcError' + | 'Ready' + | 'Processing' + | 'BadInternet'; + +interface BuzzCardPromptProps { + attendanceType: AttendableType; + attendable: TeamInfo | EventInfo; + setTotalAttendees: React.Dispatch>; // accepts either number or function + setLastAttendee: (state: LastAttendeeProps) => void; +} + +const BuzzCardPrompt: React.FC = ({ + attendanceType, + attendable, + setTotalAttendees, + setLastAttendee, +}) => { + const api = useApi(); + const { currentTheme } = useTheme(); + const [buzzCardState, setBuzzCardState] = useState('Ready'); + const [enterGTIDManually, setEnterGTIDManually] = useState(false); + const lastGtidRef = useRef(null); // keep track of last gtid to prevent duplicates + + useEffect(() => { + beginScan(); + }, []); + + /** + * Scanning function that is called to process buzzcard taps + */ + const beginScan = async () => { + if (Platform.OS === 'ios') { + const { BuzzCardReader } = NativeModules; + BuzzCardReader.sendCommand(selectApp, readFile, handleNfcResult); + } else if (Platform.OS === 'android') { + try { + await NfcManager.start(); + await NfcManager.requestTechnology(NfcTech.IsoDep); + await NfcManager.getTag(); + await NfcManager.transceive(selectApp); + const result = await NfcManager.transceive(readFile); + handleNfcResult(null, result); + } catch (error: unknown) { + handleNfcResult(error instanceof Error ? error : new Error(String(error)), null); + } finally { + NfcManager.cancelTechnologyRequest(); + } + } else { + console.error('Not a valid platform for NFC'); + handleNfcResult(null, null); + } + }; + + /** + * Callback function for scan modal + * @param error + * @param result Scanned GTID in char code format or null + * @returns none + */ + const handleNfcResult = async (error: Error | null, result: number[] | null) => { + if (error) { + const errorMsg = error.message || ''; + if (errorMsg.includes('Wrong CLA')) { + setBuzzCardState('NotABuzzCard'); + } else if (errorMsg.includes('Tag was lost') || errorMsg.includes('Incomplete response')) { + setBuzzCardState('TagLost'); + } else { + setBuzzCardState('UnknownNfcError'); + } + return; + } + + if (!result) { + // User cancelled + setBuzzCardState('Ready'); + beginScan(); + return; + } + + const buzzString = String.fromCharCode(...result); + const gtid = buzzString.substring(0, 9); + await processGtid(gtid, false); + }; + + /** + * Validates GTID format to use in POST request + * @param gtid String GTID to be formatted into number + * @param manual True if GTID was entered via modal, false if entered via NFC scan + * @returns none + */ + const processGtid = async (gtid: string, manual: boolean) => { + setEnterGTIDManually(false); // closes the modal, not related to manual param + // Validate GTID format + if (!/^90[0-9]{7}$/.test(gtid)) { + setBuzzCardState('InvalidBuzzCardData'); + return; + } + + // Try to parse as integer (catches NumberFormatException equivalent) + const gtidNumber = parseInt(gtid, 10); + if (isNaN(gtidNumber)) { + setBuzzCardState('InvalidBuzzCardData'); + return; + } + + if (manual) { + await onBuzzCardTap(gtidNumber, NfcSource.KEYBOARD); + } else { + await onBuzzCardTap(gtidNumber, NfcSource.NFC); + } + + setBuzzCardState('Ready'); + if (!manual) beginScan(); + }; + + /** + * Makes post request via Axios given GTID and NFC Source + * @param gtid Number GTID + * @param source Keyboard or NFC + * @returns none + */ + const onBuzzCardTap = async (gtid: number, source: NfcSource) => { + setBuzzCardState('Processing'); + + const props = { + attendable_type: attendanceType, + attendable_id: attendable.id, + gtid: gtid, + source: `MyRoboJackets ${Platform.OS === 'ios' ? 'iOS' : 'Android'} - ${source}`, + }; + + try { + const res = await postAttendance(api, props); + if (!res.success) { + setBuzzCardState('BadInternet'); + return; + } + + if (lastGtidRef.current !== gtid) { + setTotalAttendees((prev) => prev + 1); + lastGtidRef.current = gtid; + } + const attendeeName = res.data.attendance.attendee?.name ?? 'Non-member'; + setLastAttendee({ + id: gtid, + name: attendeeName, + }); + } catch (error) { + setBuzzCardState('BadInternet'); + } + }; + + /** + * GTID Modal + * @returns none + */ + const EnterGTIDForm = () => { + const [gtid, setGtid] = useState(''); + + return ( + + + + + Type the entire 9-digit GTID, starting with 90. + + setGtid(newGtid)} + > + + + processGtid(gtid, true)} + title="Enter" + color={currentTheme.primary as string} + textColor="#000000" + /> + + + + setEnterGTIDManually(false)} title="Close" /> + + + + + + ); + }; + + return ( + + {enterGTIDManually && } + + {buzzCardState === 'Ready' ? ( + + ) : buzzCardState === 'TagLost' ? ( + + ) : buzzCardState === 'NotABuzzCard' ? ( + + ) : buzzCardState === 'InvalidBuzzCardData' ? ( + + ) : buzzCardState === 'BadInternet' ? ( + + ) : buzzCardState === 'UnknownNfcError' ? ( + + ) : ( + // buzzCardState === 'Processing' + + )} + + {Platform.OS === 'ios' && ( // only relevant to ios, after modal closed to display error + + { + beginScan(); + }} + title="Scan Card" + color={currentTheme.primary as string} + textColor="#000000" + /> + + )} + + { + setEnterGTIDManually(true); + }} + title="Enter GTID manually" + /> + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + alignSelf: 'center', + marginBottom: 'auto', + marginTop: 'auto', + }, + modalButton: { + alignSelf: 'center', + marginHorizontal: 5, + }, + modalButtonContainer: { + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'center', + marginVertical: 20, + }, + modalInput: { + borderColor: 'gray', + borderWidth: 1, + padding: 10, + }, + modalText: { + fontSize: 20, + marginVertical: 20, + textAlign: 'center', + }, + modalView: { + backgroundColor: 'white', + borderRadius: 20, + elevation: 5, + margin: 20, + padding: 20, + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.25, + shadowRadius: 4, + width: '75%', + }, + viewContainer: { + alignItems: 'center', + flex: 1, + justifyContent: 'center', + margin: 10, + }, +}); + +export default BuzzCardPrompt; diff --git a/Attendance/TapABuzzCard.tsx b/Attendance/TapABuzzCard.tsx new file mode 100644 index 0000000..801c427 --- /dev/null +++ b/Attendance/TapABuzzCard.tsx @@ -0,0 +1,97 @@ +import React, { useState } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { EventInfo, TeamInfo } from '../Api/MeetingsApi'; +import { AttendableType } from '../Api/Models/Attendance'; +import MenuHeader from '../Components/MenuHeader'; +import RoundedButton from '../Components/RoundedButton'; +import ThemedText from '../Components/ThemedText'; +import BuzzCardPrompt from './BuzzCardPrompt'; + +type AttendanceProps = { + attendanceType: AttendableType; + setAttendanceType: (state: AttendableType) => void; + attendable: TeamInfo | EventInfo; + setAttendable: (state: TeamInfo | EventInfo | undefined) => void; +}; + +export type LastAttendeeProps = { + name: string; + id: number; +}; + +const AttendanceGoals: { [key: number]: string } = { + 5: "🔥 5 attendees recorded. You're on a roll!", + 10: "👑 10 attendees. You're awesome!", + 25: "🎸 25 attendees! You're a rockstar!", + 42: '4️⃣2️⃣ The meaning of life.', + 50: '🎉 50 attendees! Is this GI?', + 100: '💯 100 ATTENDEES! Go give yourself a prize!', +}; + +function TapABuzzCard({ + attendanceType, + setAttendanceType, + attendable, + setAttendable, +}: AttendanceProps) { + const [totalAttendees, setTotalAttendees] = useState(0); + const [lastAttendee, setLastAttendee] = useState(null); + + function TopPanel() { + return ( + + { + setAttendable(undefined); + setAttendanceType(AttendableType.NONE); + }} + /> + + + Last attendee: {lastAttendee?.name || 'None'} + + + ); + } + + return ( + <> + + + + + Total attendees: {totalAttendees} + + {totalAttendees in AttendanceGoals && ( + + {AttendanceGoals[totalAttendees]} + + )} + + + ); +} + +const styles = StyleSheet.create({ + bottomPanel: { + alignSelf: 'flex-end', + marginTop: 'auto', + paddingHorizontal: 10, + }, + bottomPanelText: { + fontSize: 20, + textAlign: 'right', + }, + topPanelText: { + fontSize: 20, + paddingHorizontal: 10, + }, +}); + +export default TapABuzzCard; diff --git a/Auth/AuthenticationScreen.tsx b/Auth/AuthenticationScreen.tsx index b052029..d7d1f03 100644 --- a/Auth/AuthenticationScreen.tsx +++ b/Auth/AuthenticationScreen.tsx @@ -3,14 +3,17 @@ import { Alert, StyleSheet, Text, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useAppEnvironment } from '../AppEnvironment'; import RoundedButton from '../Components/RoundedButton'; +import ThemedText from '../Components/ThemedText'; import TransparentButton from '../Components/TransparentButton'; import RoboBuzzSvg from '../icons/ic_robobuzz_white_outline.svg'; +import { useTheme } from '../Themes/ThemeContextProvider'; import { AuthContext } from './AuthContextProvider'; import * as Authentication from './Authentication'; import { authError } from './Authentication'; import EnvironmentSelect from './EnvironmentSelect'; function AuthenticationScreen() { + const { currentTheme } = useTheme(); const auth = useContext(AuthContext); const { environment } = useAppEnvironment(); const [envChangeVisible, setEnvChangeVisible] = useState(false); @@ -39,23 +42,25 @@ function AuthenticationScreen() { }, [auth?.authenticated]); return ( - + setEnvChangeVisible(true)} /> - - Server: {environment.name} ({environment.baseUrl}) - + + + Server: {environment.name} ({environment.baseUrl}) + + setEnvChangeVisible(false)} /> ); } const styles = StyleSheet.create({ - container: { backgroundColor: '#fff', flex: 1 }, + container: { flex: 1 }, lower: { alignItems: 'center', flexShrink: 1, padding: 10 }, upper: { alignItems: 'center', flex: 1, justifyContent: 'space-around' }, }); diff --git a/Auth/EnvironmentSelect.tsx b/Auth/EnvironmentSelect.tsx index 9513016..c291abd 100644 --- a/Auth/EnvironmentSelect.tsx +++ b/Auth/EnvironmentSelect.tsx @@ -3,6 +3,8 @@ import { StyleSheet, Text, TextInput, View } from 'react-native'; import { Modal, RadioButton } from 'react-native-paper'; import { APP_ENVIRONMENTS, useAppEnvironment } from '../AppEnvironment'; import RoundedButton from '../Components/RoundedButton'; +import ThemedText from '../Components/ThemedText'; +import { useTheme } from '../Themes/ThemeContextProvider'; type EnvironmentSelectProps = { visible: boolean; @@ -10,6 +12,7 @@ type EnvironmentSelectProps = { }; export default function EnvironmentSelect({ visible, onDismiss }: EnvironmentSelectProps) { + const { currentTheme } = useTheme(); const { environment, setEnvironment } = useAppEnvironment(); const [selectedEnv, setSelectedEnv] = useState(environment.name.toLowerCase()); const [customUrl, setCustomUrl] = useState(''); @@ -37,16 +40,32 @@ export default function EnvironmentSelect({ visible, onDismiss }: EnvironmentSel } return ( - - Change Server + + + Change Server + - Production + + Production + + + + + + Test + - Other + + Other + @@ -60,7 +79,7 @@ export default function EnvironmentSelect({ visible, onDismiss }: EnvironmentSel /> )} - {error && {error}} + {error && {error}} void; - missingPermissions: string[]; - requiredPermissions: string[]; + missingPermissions: Permission[]; + requiredPermissions: Permission[]; }; function InsufficientPermissions({ @@ -44,7 +46,7 @@ function InsufficientPermissions({ style={styles.icon} /> )} - {permission} + {permission} )); }; @@ -52,9 +54,11 @@ function InsufficientPermissions({ const PermissionDetailsDialog = () => { return ( - - - Required Permissions + + + + Required Permissions + @@ -67,7 +71,7 @@ function InsufficientPermissions({ }; return ( - + {detailsVisible && } - {featureName} permissions required + + {featureName} permissions required +