diff --git a/App.js b/App.js index 80ff524..038c685 100644 --- a/App.js +++ b/App.js @@ -1,94 +1,48 @@ /** * App.js * The main entry point of the Audacity Sign Up app. - * - Initializes user authentication state from AsyncStorage - * - Displays a loading indicator until auth status is resolved - * - Sets up React Navigation stack with screens for Sign In, Home, Account, and volunteer flows + * - Sets up React Navigation stack with screens for Home and volunteer flows */ import "@expo/metro-runtime"; -import AsyncStorage from "@react-native-async-storage/async-storage"; import { NavigationContainer } from "@react-navigation/native"; import { createNativeStackNavigator } from "@react-navigation/native-stack"; import { StatusBar } from "expo-status-bar"; -import React, { useEffect, useState } from "react"; -import { ActivityIndicator, View } from "react-native"; +import React from "react"; +import { View } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; -import AccountScreen from "./src/screens/AccountScreen"; import EmbeddedFormScreen from "./src/screens/EmbeddedFormScreen"; import EndScreen from "./src/screens/EndScreen"; import HomeScreen from "./src/screens/HomeScreen"; -import SignInScreen from "./src/screens/SignInScreen"; import VolunteerFormScreen from "./src/screens/VolunteerFormScreen"; import VolunteerOpportunityScreen from "./src/screens/VolunteerOpportunityScreen"; import HomeHeader from "./src/components/HomeHeader"; import NoInternetBanner from "./src/components/NoInternetBanner"; import colors from "./src/constants/colors"; -import { alertError, navigationRef } from "./src/utils"; +import { navigationRef } from "./src/utils"; const Stack = createNativeStackNavigator(); export default function App() { - // Local state: loading indicator and logged-in status - const [loading, setLoading] = useState(true); - const [isLoggedIn, setIsLoggedIn] = useState(false); - - useEffect(() => { - // On mount, retrieve user info to determine if already signed in - (async () => { - try { - const userString = await AsyncStorage.getItem("user"); - setIsLoggedIn(userString != null); - } catch (error) { - // Alert on any errors retrieving user data - alertError(`While getting user in App: ${error}`); - } finally { - // Hide loading spinner after check - setLoading(false); - } - })(); - }, []); - - // Show a full-screen spinner while initializing app state - if (loading) { - return ; - } - - // Once ready, render the navigation container with all screens return ( - {/* Light-content status bar to match header styling */} - {/* Sign In screen for authentication */} - - {/* Main Home screen with custom header */} }} /> - {/* Account management screen */} - - {/* Detailed volunteer opportunity view */} - {/* Form submission screen for events */} - {/* Embedded Google Forms web view */} - {/* End screen showing submission success/failure */} =0.59" } }, - "node_modules/@react-native-google-signin/google-signin": { - "version": "12.2.1", - "resolved": "https://registry.npmjs.org/@react-native-google-signin/google-signin/-/google-signin-12.2.1.tgz", - "integrity": "sha512-qt2Cb+bN2NS8BBT06M3UbLVS1gaSYTenNh4uZtPNmyJC23xZAynLkuhvsC6gmeEnxcj0wLVUn1Es+LSHw0OhVQ==", - "license": "MIT", - "peerDependencies": { - "expo": ">=50.0.0", - "react": "*", - "react-dom": "*", - "react-native": "*" - }, - "peerDependenciesMeta": { - "expo": { - "optional": true - }, - "react-dom": { - "optional": true - } - } - }, "node_modules/@react-native-picker/picker": { "version": "2.11.1", "resolved": "https://registry.npmjs.org/@react-native-picker/picker/-/picker-2.11.1.tgz", @@ -5934,16 +5912,6 @@ } } }, - "node_modules/expo-apple-authentication": { - "version": "8.0.8", - "resolved": "https://registry.npmjs.org/expo-apple-authentication/-/expo-apple-authentication-8.0.8.tgz", - "integrity": "sha512-TwCHWXYR1kS0zaeV7QZKLWYluxsvqL31LFJubzK30njZqeWoWO89HZ8nZVaeXbFV1LrArKsze4BmMb+94wS0AQ==", - "license": "MIT", - "peerDependencies": { - "expo": "*", - "react-native": "*" - } - }, "node_modules/expo-application": { "version": "7.0.8", "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-7.0.8.tgz", @@ -12070,6 +12038,15 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/validator": { + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index 172fd1e..24fc6bb 100644 --- a/package.json +++ b/package.json @@ -15,17 +15,14 @@ "dependencies": { "@emailjs/react-native": "^5.1.0", "@expo/metro-runtime": "~6.1.2", - "@react-native-async-storage/async-storage": "2.2.0", "@react-native-community/cli-server-api": "^13.6.9", "@react-native-community/netinfo": "^11.4.1", - "@react-native-google-signin/google-signin": "^12.2.1", "@react-native-picker/picker": "2.11.1", "@react-navigation/core": "^7.12.4", "@react-navigation/native": "^6.1.17", "@react-navigation/native-stack": "^6.10.0", "@robinbobin/react-native-google-drive-api-wrapper": "^1.2.4", "expo": "^54.0.10", - "expo-apple-authentication": "~8.0.8", "expo-checkbox": "~5.0.8", "expo-constants": "~18.0.12", "expo-dev-client": "~6.0.20", @@ -53,7 +50,8 @@ "react-native-screens": "~4.16.0", "react-native-super-grid": "^6.0.1", "react-native-webview": "13.15.0", - "react-native-worklets": "0.5.1" + "react-native-worklets": "0.5.1", + "validator": "^13.15.35" }, "devDependencies": { "@babel/core": "^7.20.0", diff --git a/src/components/CarouselSection.js b/src/components/CarouselSection.js index 6cc1116..1b04cd4 100644 --- a/src/components/CarouselSection.js +++ b/src/components/CarouselSection.js @@ -47,7 +47,6 @@ function CarouselSection({ navigation, data, onRefresh }) { .filter((str) => str.length > 0) } formURL={event["Form Link"] ?? null} - isSubmitted={event.isSubmitted} max={event.Max} signedUp={event["Signed Up"]} key={index} diff --git a/src/components/HomeHeader.js b/src/components/HomeHeader.js index d526786..c7d90a6 100644 --- a/src/components/HomeHeader.js +++ b/src/components/HomeHeader.js @@ -1,54 +1,23 @@ /** * HomeHeader.js - * Custom app header with gradient background, title, and user profile action. - * Props: - * - navigation: React Navigation prop for screen navigation - * - route: React Navigation route prop to detect focus changes + * Custom app header with gradient background and title */ -import FontAwesome from "@expo/vector-icons/FontAwesome"; import { LinearGradient } from "expo-linear-gradient"; -import { useEffect, useState } from "react"; -import { Image, Pressable, SafeAreaView, StyleSheet, Text } from "react-native"; +import { SafeAreaView, StyleSheet, Text } from "react-native"; import colors from "../constants/colors"; -import { getUser } from "../utils"; - -export default function HomeHeader({ navigation, route }) { - // Local state for current user info - const [user, setUser] = useState(null); - - // Fetch user from AsyncStorage whenever screen focus changes - useEffect(() => { - getUser(true).then(setUser); - }, [route]); +export default function HomeHeader() { return ( - {/* App title */} Audacity Sign Up - {/* Profile icon or user photo navigates to Account screen */} - navigation.navigate("Account")}> - {user?.photo ? ( - - ) : ( - - )} - ); @@ -70,9 +39,4 @@ const styles = StyleSheet.create({ color: colors.white, fontSize: 23, }, - profile: { - width: 40, - height: 40, - borderRadius: 100, - }, }); diff --git a/src/components/Profile.js b/src/components/Profile.js deleted file mode 100644 index 7a61afd..0000000 --- a/src/components/Profile.js +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Profile.js - * Displays logged-in user's name, email, and profile image or icon. - * Retrieves user info from AsyncStorage on mount. - */ -import FontAwesome from "@expo/vector-icons/FontAwesome"; -import { useEffect, useState } from "react"; -import { Image, StyleSheet, Text, View } from "react-native"; - -import { getUser } from "../utils"; - -export default function Profile() { - // Local user state - const [user, setUser] = useState(null); - - // Load user data on mount - useEffect(() => { - getUser().then(setUser); - }, []); - - // Default display values while loading or empty - let name = user?.name ?? "Loading"; - if (name === "") name = "Anonymous Apple User"; - let email = user?.email ?? "Loading"; - if (email === "") email = "apple.com"; - - return ( - - {/* User photo if available, else default icon */} - {user?.photo ? ( - - ) : ( - - )} - {/* Name and email text */} - - - {name} - - - {email} - - - - ); -} - -const styles = StyleSheet.create({ - container: { - flexDirection: "row", - alignItems: "center", - backgroundColor: "#f5f5f5", // Light grey background color - padding: 15, - borderRadius: 10, - borderColor: "#e0e0e0", - borderWidth: 1, - margin: 10, - }, - image: { - width: 60, - height: 60, - borderRadius: 100, - marginRight: 10, - }, - name: { - fontSize: 16, - fontWeight: "bold", - }, - email: { - fontSize: 14, - color: "#555", - }, -}); diff --git a/src/components/UploadButton.js b/src/components/UploadButton.js index a744e24..2bfc511 100644 --- a/src/components/UploadButton.js +++ b/src/components/UploadButton.js @@ -1,20 +1,19 @@ /** * UploadButton.js * Provides a PDF upload button using Google Drive API: - * - Selects a PDF file - * - Uploads to Google Drive + * - Selects a PDF file from the device + * - Exchanges a stored OAuth refresh token for a short-lived access token + * - Uploads to Google Drive with a timestamped unique filename * - Sets sharing to public * - Updates form state with Drive URL * Props: * - title: label text for the upload field * - state: { value: url|string, y: number, valid: boolean } * - setState: setter to update component state - * - navigation: React Navigation prop for redirection * - required: whether a file is mandatory */ import Feather from "@expo/vector-icons/Feather"; -import AsyncStorage from "@react-native-async-storage/async-storage"; import { GDrive, MimeTypes, @@ -27,209 +26,180 @@ import * as fs from "react-native-fs"; import colors from "../constants/colors"; import { alertError } from "../utils"; -// Helper: launch document picker for PDF selection -const selectFile = async () => { +const CLIENT_ID = process.env.EXPO_PUBLIC_GOOGLE_CLIENT_ID ?? ""; +const CLIENT_SECRET = process.env.EXPO_PUBLIC_GOOGLE_CLIENT_SECRET ?? ""; +const REFRESH_TOKEN = process.env.EXPO_PUBLIC_GOOGLE_REFRESH_TOKEN ?? ""; + +function uniqueFileName(original) { + const now = new Date(); + const pad = (n) => String(n).padStart(2, "0"); + const stamp = + `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` + + `_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; + const base = ( + original.endsWith(".pdf") ? original.slice(0, -4) : original + ).replace(/[^a-zA-Z0-9_-]/g, "_"); + return `${stamp}_${base}.pdf`; +} + +async function getAccessToken() { try { - const file = await DocumentPicker.pickSingle({ - type: [DocumentPicker.types.pdf], + const res = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + refresh_token: REFRESH_TOKEN, + grant_type: "refresh_token", + }).toString(), }); - return file; - } catch (error) { - if (DocumentPicker.isCancel(error)) { + if (!res.ok) { + Alert.alert("Unable to upload file. Please try again."); return null; } - alertError(`In selectFile: ${error}`); + const json = await res.json(); + if (!json.access_token) { + Alert.alert("Unable to upload file. Please try again."); + return null; + } + return json.access_token; + } catch (error) { + Alert.alert("Unable to upload file. Please try again."); + return null; } -}; +} -// Helper: retrieve stored Google access token for API calls -async function getAccessToken() { +const selectFile = async () => { try { - const accessToken = await AsyncStorage.getItem("access-token"); - return accessToken; + return await DocumentPicker.pickSingle({ + type: [DocumentPicker.types.pdf], + }); } catch (error) { - alertError(`In getAccessToken: ${error}`); + if (DocumentPicker.isCancel(error)) return null; + alertError(`In selectFile: ${error}`); } -} +}; export default function UploadButton({ title, state, setState, - navigation, required = false, }) { - // Local UI state for showing selected file name const [fileName, setFileName] = useState(null); return ( { - // Capture Y position for scroll-to-error behavior const y = event.nativeEvent.layout.y; setState((prev) => ({ ...prev, y })); }} > - {/* Field title and required marker */} {title} {required && *} - {/* Display chosen file name */} {fileName && ( {fileName} )} - {/* Upload button triggers file selection and upload flow */} { - // Retrieve access token - const accessToken = await getAccessToken(); - if (!accessToken) { - // Prompt user to re-login on failure + const file = await selectFile(); + if (file == null) { + setFileName(null); + setState((prev) => ({ ...prev, value: null })); + return; + } + + if (file.size > 104857600) { Alert.alert( - "File upload is unavailable", - "Please log in with a Google account.", - [ - { - text: "Go to Profile", - onPress: () => navigation.navigate("Account"), - }, - { text: "Cancel", style: "cancel" }, - ], + "File size too large", + `${file.name} exceeds the 100 MB limit. Please compress it and try again.`, ); + setFileName("Upload too large"); + setState((prev) => ({ ...prev, value: null })); return; } - // Indicate uploading state - setState((prev) => ({ ...prev, value: "Uploading" })); - const googleDrive = new GDrive(); - googleDrive.accessToken = accessToken; - googleDrive.fetchTimeout = 20000; // 20 seconds + setFileName("Uploading..."); + setState((prev) => ({ ...prev, value: "Uploading" })); - const file = await selectFile(); - if (file == null) { - // Cancel - setFileName(null); - setState((prevState) => ({ ...prevState, value: null })); + const accessToken = await getAccessToken(); + if (!accessToken) { + Alert.alert( + "File upload is unavailable", + "Could not authenticate for file upload. Please check your connection and try again.", + ); + setFileName("Upload failed"); + setState((prev) => ({ ...prev, value: null })); return; } let fileData; try { fileData = await fs.readFile(file.uri, "base64"); - } catch (error) { - setFileName("Upload failed"); - setState((prevState) => ({ ...prevState, value: null })); - - const fileName = file.name?.endsWith(".pdf") - ? file.name.slice(0, file.name.length - 4) + } catch { + const base = file.name?.endsWith(".pdf") + ? file.name.slice(0, -4) : file.name; - Alert.alert( "Invalid file", - `Please make sure your file (${fileName}) is a single PDF that can be accessed by the app.`, + `Could not read ${base}. Make sure it is a single accessible PDF.`, ); + setFileName("Upload failed"); + setState((prev) => ({ ...prev, value: null })); return; } - let id = ""; + + const googleDrive = new GDrive(); + googleDrive.accessToken = accessToken; + googleDrive.fetchTimeout = 20000; try { - setFileName("Uploading..."); - const googleFile = await googleDrive.files + const uploaded = await googleDrive.files .newMultipartUploader() .setIsBase64(true) .setData(fileData, MimeTypes.PDF) .setRequestBody({ parents: ["root"], - name: file.name, + name: uniqueFileName(file.name), }) .execute(); - id = googleFile.id; + await googleDrive.permissions.create( - id, + uploaded.id, {}, - { - role: "reader", - type: "anyone", - }, + { role: "reader", type: "anyone" }, ); + + setFileName(file.name); + setState((prev) => ({ + ...prev, + value: `https://drive.google.com/open?id=${uploaded.id}`, + })); } catch (error) { - if (error.name == "AbortError") { + if (error.name === "AbortError") { Alert.alert( - "File upload aborted", - "Your file could not be uploaded due to a connection error. Please check your Internet connection and try again.", - ); - } else if (error.__response?.status == 401) { - Alert.alert( - "Invalid Session", - "Your authentication session is malformed, most likely because your device has old data that is not compatible with the current version of the app. To fix this issue, please go to your profile, clear your data, and log in again with your Google account. We apologize for the inconvenience.", - [ - { - text: "Go to Profile", - onPress: () => { - navigation.navigate("Account"); - }, - }, - { - text: "Cancel", - isPreferred: true, - }, - ], - ); - } else if (error.__response?.status == 403) { - Alert.alert( - "Permission Denied", - 'Audacity Sign Up does not have permission to share files associated with your Google account.\n\nTo enable this feature, please go to your profile, clear your data, and reauthenticate your Google account. When prompted to select what the app can access, tap on the checkbox to "see, edit, create, and delete Google files in this app."', - [ - { - text: "Go to Profile", - onPress: () => { - navigation.navigate("Account"); - }, - }, - { - text: "Cancel", - isPreferred: true, - }, - ], + "Upload timed out", + "Check your internet connection and try again.", ); } else { alertError("Error uploading file: " + error); } setFileName("Upload failed"); - setState((prevState) => ({ ...prevState, value: null })); - return; - } - - if (file.size > 104857600) { - // 100 MB - Alert.alert( - "File size too large", - `Your file ${file.name} has a size of ${file.size} bytes. Please compress your file and upload a smaller version.`, - ); - setFileName("Upload too large"); - setState((prevState) => ({ ...prevState, value: null })); - return; + setState((prev) => ({ ...prev, value: null })); } - - setFileName(file.name); - - setState((prevState) => ({ - ...prevState, - value: `https://drive.google.com/open?id=${id}`, - })); }} > - {/* Upload icon and label */} Upload - {/* PDF-only notice and validation info */} PDF only - 100MB Limit @@ -252,14 +222,12 @@ const styles = StyleSheet.create({ fontWeight: "700", paddingBottom: 10, }, - otherInfo: { alignSelf: "center", textAlign: "center", marginVertical: 5, fontSize: 14, }, - upload: { flexDirection: "row", justifyContent: "center", @@ -270,7 +238,6 @@ const styles = StyleSheet.create({ paddingVertical: 10, paddingHorizontal: 15, }, - uploadText: { fontSize: 18, paddingLeft: "3%", diff --git a/src/components/VolunteerOpportunity.js b/src/components/VolunteerOpportunity.js index 2f500b5..8162180 100644 --- a/src/components/VolunteerOpportunity.js +++ b/src/components/VolunteerOpportunity.js @@ -11,7 +11,6 @@ * - description: event description text * - tags: array of tag strings * - formURL: URL string for sign-up form - * - isSubmitted: boolean indicating if the user has signed up * - max: maximum number of volunteers for the event * - signedUp: current number of signed up volunteers */ @@ -33,7 +32,6 @@ export default function VolunteerOpportunity({ description, tags, formURL, - isSubmitted, max, signedUp, }) { @@ -51,7 +49,6 @@ export default function VolunteerOpportunity({ description, tags, formURL, - isSubmitted, max, signedUp, }) @@ -95,12 +92,12 @@ export default function VolunteerOpportunity({ - {/* Right-side indicator: checkmark if submitted, else chevron */} + {/* Right-side indicator chevron */} ); diff --git a/src/constants/formIDs.js b/src/constants/formIDs.js index 07b18da..aac2d16 100644 --- a/src/constants/formIDs.js +++ b/src/constants/formIDs.js @@ -5,6 +5,7 @@ export default { date: "785137847", fullName: "194705108", city: "171289053", + email: "2039184033", phoneNumber: "1427621981", age: "2098203720", musicPiece: "762590043", @@ -26,6 +27,7 @@ export default { date: "785137847", fullName: "194705108", city: "171289053", + email: "1215232098", phoneNumber: "1427621981", age: "2098203720", musicPiece: "762590043", @@ -41,6 +43,7 @@ export default { }, "Request a Concert": { id: "1FAIpQLScBBGfjQ397ol1n6HLFIwAtjLxjc4Sw9mDWWYlVHXdbQ3Hl5g", + email: "722836817", phoneNumber: "1828463866", organization: "1450595390", eventInfo: "411385951", @@ -59,6 +62,7 @@ export default { "Audacity Dance Club": { id: "1FAIpQLSeeehgsi7DOXDSlX-X0p_lnCnXHrvIIPtoNsexRIs1xhcPZXA", fullName: "1028625525", + email: "1183890095", phoneNumber: "503217771", favoritePieces: "183639428", age: "529415268", diff --git a/src/screens/AccountScreen.js b/src/screens/AccountScreen.js deleted file mode 100644 index e927258..0000000 --- a/src/screens/AccountScreen.js +++ /dev/null @@ -1,123 +0,0 @@ -/** - * AccountScreen.js - * Allows the user to clear all app data or log out of their account. - * - Presents Profile component - * - Provides buttons to clear local storage or log out - */ - -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { GoogleSignin } from "@react-native-google-signin/google-signin"; -import Constants from "expo-constants"; -import { Alert, StyleSheet, View } from "react-native"; - -import FullWidthButton from "../components/FullWidthButton.js"; -import Profile from "../components/Profile.js"; - -import colors from "../constants/colors"; -import { alertError } from "../utils/index.js"; - -export default function AccountScreen({ navigation }) { - return ( - - {/* User profile summary */} - - - {/* Clear all local data and sign out */} - { - Alert.alert( - "Are you sure you want to clear all data on this device?", - `You will be logged out and prompted to sign in if you proceed. To clear data that you have submitted to Audacity Sign Up in previous forms, please contact the IT Team at ${Constants.expoConfig.extra.email}.`, - [ - { - text: "Cancel", - onPress: () => {}, - isPreferred: true, - }, - { - text: "Continue", - onPress: async () => { - if (GoogleSignin.getCurrentUser() != null) { - try { - await GoogleSignin.revokeAccess(); - await GoogleSignin.signOut(); - } catch (error) { - alertError( - `Unable to log out and remove Google account while clearing data: ${error}`, - ); - } - } - try { - await AsyncStorage.clear(); - } catch (error) { - alertError(`Unable to clear data: ${error}`); - } - navigation.navigate("Sign In"); - }, - }, - ], - ); - }} - > - Clear Data - - - {/* Log out of Google or local user */} - { - Alert.alert("Are you sure you want to log out?", null, [ - { - text: "Cancel", - onPress: () => {}, - isPreferred: true, - }, - { - text: "Continue", - onPress: async () => { - if (GoogleSignin.getCurrentUser() != null) { - try { - await GoogleSignin.signOut(); - } catch (error) { - alertError(`Unable to log out of Google: ${error}`); - } - } - try { - await AsyncStorage.removeItem("user"); - await AsyncStorage.removeItem("access-token"); - } catch (error) { - alertError(`Unable to remove user data: ${error}`); - } - navigation.navigate("Sign In"); - }, - }, - ]); - }} - > - Log Out - - - ); -} - -const styles = StyleSheet.create({ - container: { - height: "100%", - }, - clearDataButton: { - borderColor: colors.danger, - borderWidth: 1, - }, - clearDataText: { - color: colors.danger, - }, - logOutButton: { - backgroundColor: colors.danger, - }, - logOutText: { - color: colors.white, - }, -}); diff --git a/src/screens/EmbeddedFormScreen.js b/src/screens/EmbeddedFormScreen.js index f3268e1..4b0d12b 100644 --- a/src/screens/EmbeddedFormScreen.js +++ b/src/screens/EmbeddedFormScreen.js @@ -1,6 +1,6 @@ /** * EmbeddedFormScreen.js - * Displays a Google Forms URL within a WebView. + * Displays an external form URL in a full-screen WebView. * Props: * - route.params.formURL: the URL string of the form to embed */ diff --git a/src/screens/HomeScreen.js b/src/screens/HomeScreen.js index 2547caa..fc8b4d0 100644 --- a/src/screens/HomeScreen.js +++ b/src/screens/HomeScreen.js @@ -2,27 +2,18 @@ * HomeScreen.js * Main screen showing upcoming events in a carousel, plus extra resources. * - Fetches data from Google Sheets via PublicGoogleSheetsParser - * - Filters events by date range and submission status + * - Filters events to the next two months * - Manages push notifications for each event */ import React, { useEffect, useState, useMemo, useCallback } from "react"; import { StyleSheet, FlatList } from "react-native"; -import AsyncStorage from "@react-native-async-storage/async-storage"; import FilterPanel from "../components/FilterPanel"; import CarouselSection from "../components/CarouselSection"; import Heading from "../components/Heading"; import OtherOpportunities from "../components/OtherOpportunities"; import Websites from "../components/Websites"; -// ...existing imports... -import { - alertError, - formatDate, - getUser, - hashForm, - request, - strToDate, -} from "../utils"; +import { alertError, request, strToDate } from "../utils"; import PublicGoogleSheetsParser from "../utils/PublicGoogleSheetsParser"; import { initNotificationHandling, @@ -30,13 +21,6 @@ import { cancelAllScheduled, } from "../utils/notifications"; -/** - * HomeScreen: displays upcoming events in a carousel, other opportunities, and websites. - * - Fetches event data from Google Sheets - * - Filters events by date range - * - Manages notification scheduling for each event - */ -// Suppress warning about nested VirtualizedLists export default function HomeScreen({ navigation, route }) { // State: array of event objects const [data, setData] = useState([]); @@ -82,8 +66,8 @@ export default function HomeScreen({ navigation, route }) { }, []); /** - * Fetch events from Google Sheets, filter by date and submission status, - * then update state for display and scheduling. + * Fetch events from Google Sheets, filter to the next two months, + * then update state for display and notification scheduling. * @returns {Promise} number of events loaded or null on failure */ const onRefresh = useCallback(async () => { @@ -97,24 +81,12 @@ export default function HomeScreen({ navigation, route }) { }, ); - const submittedForms = []; - try { - const storedForms = await AsyncStorage.getItem("submittedForms"); - if (storedForms != null) { - submittedForms.push(...JSON.parse(storedForms)); - } - } catch (error) { - alertError(`In onRefresh: ${error}`); - } - // Fetch raw data with retry logic via request() const unparsedData = await request(() => parser.parse()); if (unparsedData == null) { return null; } const newData = []; - const user = await getUser(); - const currentDate = new Date(); const twoMonthsLater = new Date(); @@ -124,8 +96,6 @@ export default function HomeScreen({ navigation, route }) { // - Provide default Title/Location // - Convert date string to Date // - Exclude past events and events beyond two months ahead - // - Mark isSubmitted based on stored hashes - // - Collect valid events for (let i = 0; i < unparsedData.length; i++) { const opportunity = unparsedData[i]; @@ -145,13 +115,6 @@ export default function HomeScreen({ navigation, route }) { if (event_midnight < currentDate || opportunity.Date > twoMonthsLater) { continue; } - const hash = hashForm( - user.id, - opportunity.Title, - opportunity.Location, - formatDate(opportunity.Date), - ); - opportunity.isSubmitted = submittedForms.includes(hash); newData.push(opportunity); } diff --git a/src/screens/SignInScreen.js b/src/screens/SignInScreen.js deleted file mode 100644 index 95544bd..0000000 --- a/src/screens/SignInScreen.js +++ /dev/null @@ -1,195 +0,0 @@ -/** - * SignInScreen.js - * Handles user authentication via Google and Apple sign-in. - * - Configures GoogleSignin on load - * - Provides buttons for Google and Apple login - * - Stores user info and access token in AsyncStorage - * - Custom Apple sign-in button (icon size adjustable) - */ - -import { - Image, - Platform, - Pressable, - SafeAreaView, - StyleSheet, - Text, - View, -} from "react-native"; - -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { - GoogleSignin, - isErrorWithCode, - statusCodes, -} from "@react-native-google-signin/google-signin"; -import * as AppleAuth from "expo-apple-authentication"; - -import { alertError } from "../utils"; - -// Initialize Google Sign-In configuration -GoogleSignin.configure({ - webClientId: - process.env.EXPO_PUBLIC_GOOGLE_OAUTH_WEB_ID ?? - alertError("Undefined EXPO_PUBLIC_GOOGLE_OAUTH_WEB_ID env variable"), - iosClientId: - process.env.EXPO_PUBLIC_GOOGLE_OAUTH_IOS_ID ?? - alertError("Undefined EXPO_PUBLIC_GOOGLE_OAUTH_IOS_ID env variable"), - scopes: [ - "https://www.googleapis.com/auth/userinfo.profile", - "https://www.googleapis.com/auth/userinfo.email", - "https://www.googleapis.com/auth/drive.file", - "openid", - ], - offlineAccess: true, -}); - -export default function SignInScreen({ navigation }) { - // Apple sign-in logic - const handleAppleSignIn = async () => { - try { - const isAvailable = await AppleAuth.isAvailableAsync(); - if (!isAvailable) { - alertError("Apple Sign-In is not available on this device."); - return; - } - // Perform Apple sign-in and request full name and email scopes - const credential = await AppleAuth.signInAsync({ - requestedScopes: [ - AppleAuth.AppleAuthenticationScope.FULL_NAME, - AppleAuth.AppleAuthenticationScope.EMAIL, - ], - }); - // Extract user info - const { user, email, fullName } = credential; - // Apple only provides fullName and email the first time - await AsyncStorage.setItem( - "user", - JSON.stringify({ - id: user, - name: fullName - ? [fullName.givenName, fullName.familyName] - .filter(Boolean) - .join(" ") - : "", - email: email ?? "", - }), - ); - await AsyncStorage.removeItem("access-token"); - navigation.navigate("Home", { forceRerender: true }); - } catch (error) { - if (error.code !== "ERR_REQUEST_CANCELED") { - alertError(`While signing in with Apple: (${error.code}) ${error}`); - } - } - }; - - const handleGoogleSignIn = async () => { - try { - await GoogleSignin.hasPlayServices(); - const userInfo = await GoogleSignin.signIn(); - await AsyncStorage.setItem("user", JSON.stringify(userInfo.user)); - await AsyncStorage.setItem( - "access-token", - (await GoogleSignin.getTokens()).accessToken, - ); - navigation.navigate("Home", { forceRerender: true }); - } catch (error) { - if (isErrorWithCode(error)) { - if (error.code === statusCodes.SIGN_IN_CANCELLED) { - return; - } - alertError(`While signing in with Google: (${error.code}) ${error}`); - } else { - alertError(`While signing in with Google: (no error code) ${error}`); - } - } - }; - - return ( - - - {/* Introductory text */} - - Thank you for choosing to help make our volunteer opportunities and - concerts across the Bay Area a success! To begin, please sign in. - - {/* Google Sign-In button */} - - - - {" "} - Sign in with Google - - - - {/* Custom Apple Sign-In button */} - {Platform.OS === "ios" ? ( - - - - {" "} - Sign in with Apple - - - ) : null} - - - ); -} - -const styles = StyleSheet.create({ - container: { - backgroundColor: "#fff", - padding: "1%", - height: "100%", - }, - body: { - alignItems: "center", - justifyContent: "center", - }, - paragraph: { - fontSize: 18, - padding: 20, - }, - OAuth: { - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - width: 270, - height: 50, - borderRadius: 5, - margin: 5, - }, - OAuthBackground: { - backgroundColor: "#000", - }, - OAuthLogo: { - width: 30, - height: 30, - resizeMode: "contain", - }, - OAuthText: { - color: "#fff", - fontSize: 18, - fontWeight: "600", - }, - loading: { - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - fontSize: 20, - }, -}); diff --git a/src/screens/VolunteerFormScreen.js b/src/screens/VolunteerFormScreen.js index ff9f180..39423c6 100644 --- a/src/screens/VolunteerFormScreen.js +++ b/src/screens/VolunteerFormScreen.js @@ -28,6 +28,7 @@ import { alertError, sendErrorEmail, openInMaps } from "../utils"; import DanceClub from "../utils/forms/DanceClub"; import LibraryMusicHour from "../utils/forms/LibraryMusicHour"; import MusicByTheTracks from "../utils/forms/MusicByTheTracks"; +import RequestConcert from "../utils/forms/RequestConcert"; import colors from "../constants/colors"; import formIDs from "../constants/formIDs"; @@ -37,6 +38,11 @@ function getForm(title, date, location, navigation, scrollRef) { // Define form options with their exact names and constructors const formOptions = [ + { + name: "REQUEST A CONCERT", + constructor: RequestConcert, + aliases: ["Request a Concert", "Request Concert"], + }, { name: "LIBRARY MUSIC HOUR", constructor: LibraryMusicHour, @@ -183,7 +189,6 @@ export default function VolunteerFormScreen({ navigation, route }) { invalidResponses = 1; // Assume invalid if error occurs } if (invalidResponses > 0) { - // Show error alert with count of invalid questions const questionText = invalidResponses === 1 ? "question" : "questions"; const hasText = invalidResponses === 1 ? "has" : "have"; @@ -192,17 +197,11 @@ export default function VolunteerFormScreen({ navigation, route }) { `${invalidResponses} ${questionText} ${hasText} invalid or missing responses. Please fix all responses highlighted in red to submit this form.`, [{ text: "OK" }], ); - // Don't show "Submitting..." if validation fails - // Do not call form.submit() here; error alert is already shown } else { - // Only show "Submitting..." if validation passes setButtonText("Submitting..."); await form.submit(); setButtonText("Submit"); } - setButtonText("Submitting..."); - await form.submit(); - setButtonText("Submit"); }} > {buttonText} diff --git a/src/screens/VolunteerOpportunityScreen.js b/src/screens/VolunteerOpportunityScreen.js index ea96823..a43ca75 100644 --- a/src/screens/VolunteerOpportunityScreen.js +++ b/src/screens/VolunteerOpportunityScreen.js @@ -42,7 +42,6 @@ export default function VolunteerOpportunityScreen({ route, navigation }) { description, tags, formURL, - isSubmitted, max, signedUp, } = route.params; @@ -161,14 +160,9 @@ export default function VolunteerOpportunityScreen({ route, navigation }) { - {isSubmitted && ( - - Warning: You have already submitted this form. - - )} - remainingSpots <= 0 || isSubmitted + remainingSpots <= 0 ? null : navigation.navigate( formURL == null ? "Sign Up Form" : "Google Forms", @@ -182,9 +176,7 @@ export default function VolunteerOpportunityScreen({ route, navigation }) { ) } > - - Sign Up - + Sign Up {/* Show remaining spots if applicable */} {!isNaN(remainingSpots) && (remainingSpots <= 0 ? ( @@ -261,10 +253,6 @@ const styles = StyleSheet.create({ flexWrap: "wrap", gap: 5, }, - alreadySubmitted: { - color: colors.danger, - marginBottom: 10, - }, lowerRight: { flex: 1, justifyContent: "flex-end", diff --git a/src/utils/forms/DanceClub.js b/src/utils/forms/DanceClub.js index a3103e6..e344af8 100644 --- a/src/utils/forms/DanceClub.js +++ b/src/utils/forms/DanceClub.js @@ -5,15 +5,13 @@ * - Fields include full name, contact, favorite pieces, age group, styles, and consents */ -import { useEffect } from "react"; - import { Question, emptyQuestionState, - getUser, - isAtLeast, isExactly, isNotEmpty, + isValidEmail, + isValidPhoneNumber, } from ".."; import Form from "./Form"; @@ -35,15 +33,9 @@ export default class DanceClub extends Form { super("Audacity Dance Club", date, location, navigation, scrollRef); // State hooks for each question - this.fullName = emptyQuestionState(); // Performer name - useEffect(() => { - // Prefill name if available - (async () => { - const user = await getUser(); - this.fullName[1]((prev) => ({ ...prev, value: user?.name })); - })(); - }, []); - this.phoneNumber = emptyQuestionState(); // Contact number + this.fullName = emptyQuestionState(); + this.email = emptyQuestionState(); + this.phoneNumber = emptyQuestionState(); this.favoritePieces = emptyQuestionState(["", "", "", ""]); // Top 4 music pieces this.age = emptyQuestionState(); // Age selection this.favoriteDanceStyles = emptyQuestionState([]); // Styles selection @@ -70,6 +62,20 @@ export default class DanceClub extends Form { validate: (value) => value.trim().split(" ").length >= 2, }), + new Question({ + name: "email", + component: ( + + ), + validate: isValidEmail, + }), + new Question({ name: "phoneNumber", component: ( @@ -82,7 +88,7 @@ export default class DanceClub extends Form { setState={this.phoneNumber[1]} /> ), - validate: (value) => isAtLeast(value, 10), + validate: isValidPhoneNumber, }), new Question({ diff --git a/src/utils/forms/Form.js b/src/utils/forms/Form.js index 1eb098c..7b7119a 100644 --- a/src/utils/forms/Form.js +++ b/src/utils/forms/Form.js @@ -6,10 +6,8 @@ * - Form: abstract class to define questions, validation, and submission flow */ -import AsyncStorage from "@react-native-async-storage/async-storage"; - import { Alert } from "react-native"; -import { alertError, getUser, hashForm, request } from ".."; +import { alertError, request } from ".."; import formIDs from "../../constants/formIDs"; /** @@ -139,7 +137,7 @@ export default class Form { } /** - * Perform validation, build form data, submit to Google Forms, track submissions, and navigate outcome. + * Validate, build form data, submit to Google Forms, and navigate to outcome screen. */ async submit() { const invalidResponses = this.validate(); @@ -184,22 +182,6 @@ export default class Form { return; } - const user = await getUser(); - const hash = hashForm(user.id, this.title, this.location, this.date); - - try { - const submittedForms = await AsyncStorage.getItem("submittedForms"); - if (submittedForms == null) { - await AsyncStorage.setItem("submittedForms", JSON.stringify([hash])); - } else { - const newForms = JSON.parse(submittedForms); - newForms.push(hash); - await AsyncStorage.setItem("submittedForms", JSON.stringify(newForms)); - } - } catch (error) { - alertError(`Unable to get/save submittedForms: ${error}`); - } - this.navigation.navigate("End", { isSuccess: true }); } } diff --git a/src/utils/forms/LibraryMusicHour.js b/src/utils/forms/LibraryMusicHour.js index a1a9279..ec67e65 100644 --- a/src/utils/forms/LibraryMusicHour.js +++ b/src/utils/forms/LibraryMusicHour.js @@ -5,14 +5,14 @@ * Fields include performer info, music selection, performance type, and permissions. */ -import { useEffect, useState } from "react"; +import { useState } from "react"; import { Question, emptyQuestionState, - getUser, - isAtLeast, isNotEmpty, + isValidEmail, + isValidPhoneNumber, } from ".."; import Form from "./Form"; @@ -33,15 +33,8 @@ export default class LibraryMusicHour extends Form { super("Library Music Hour", date, location, navigation, scrollRef); // Question states: [stateValue, setState] hooks for each input - this.fullName = emptyQuestionState(); // Performer's name - - // Prefill fullName from stored user on mount - useEffect(() => { - (async () => { - const user = await getUser(); - this.fullName[1]((prev) => ({ ...prev, value: user?.name })); - })(); - }, []); + this.fullName = emptyQuestionState(); + this.email = emptyQuestionState(); // Other fields this.city = emptyQuestionState(); // City of residence @@ -106,6 +99,20 @@ export default class LibraryMusicHour extends Form { validate: isNotEmpty, }), + new Question({ + name: "email", + component: ( + + ), + validate: isValidEmail, + }), + new Question({ name: "phoneNumber", component: ( @@ -118,7 +125,7 @@ export default class LibraryMusicHour extends Form { setState={this.phoneNumber[1]} /> ), - validate: (value) => isAtLeast(value, 10), + validate: isValidPhoneNumber, }), new Question({ @@ -305,7 +312,6 @@ export default class LibraryMusicHour extends Form { key="pianoAccompaniment" state={this.pianoAccompaniment[0]} setState={this.pianoAccompaniment[1]} - navigation={this.navigation} /> ), validate: () => this.pianoAccompaniment[0].value != "Uploading", @@ -320,7 +326,6 @@ export default class LibraryMusicHour extends Form { key="ensembleProfile" state={this.ensembleProfile[0]} setState={this.ensembleProfile[1]} - navigation={this.navigation} required={true} /> ), diff --git a/src/utils/forms/RequestConcert.js b/src/utils/forms/RequestConcert.js index 11b1e32..16a6deb 100644 --- a/src/utils/forms/RequestConcert.js +++ b/src/utils/forms/RequestConcert.js @@ -5,7 +5,13 @@ * - Fields include contact info, organization details, event logistics, resources, and time slots. */ -import { Question, emptyQuestionState, isAtLeast, isNotEmpty } from ".."; +import { + Question, + emptyQuestionState, + isNotEmpty, + isValidEmail, + isValidPhoneNumber, +} from ".."; import Form from "./Form"; import CheckBoxQuery from "../../components/CheckBoxQuery"; @@ -26,7 +32,8 @@ export default class RequestConcert extends Form { super("Request a Concert", date, location, navigation, scrollRef); // State hooks for each question field - this.phoneNumber = emptyQuestionState(); // Contact phone + this.email = emptyQuestionState(); + this.phoneNumber = emptyQuestionState(); this.organization = emptyQuestionState(); // Org name/description this.eventInfo = emptyQuestionState(); // Event details this.venue = emptyQuestionState(); // Venue info @@ -48,7 +55,20 @@ export default class RequestConcert extends Form { */ questions() { return [ - // Phone number field + new Question({ + name: "email", + component: ( + + ), + validate: isValidEmail, + }), + new Question({ name: "phoneNumber", component: ( @@ -61,7 +81,7 @@ export default class RequestConcert extends Form { setState={this.phoneNumber[1]} /> ), - validate: (value) => isAtLeast(value, 10), + validate: isValidPhoneNumber, }), // Organization name and description new Question({ diff --git a/src/utils/index.js b/src/utils/index.js index 328810c..7e84f61 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -1,20 +1,19 @@ /** * index.js * Shared utility functions: - * - alertError: standardized error alert - * - openURL / maybeOpenURL: external link handling - * - getUser: retrieve cached user from AsyncStorage - * - request: retry wrapper for network calls - * - strToDate / formatDate: date parsing and formatting + * - alertError: standardized error alert + EmailJS error report + * - openURL / maybeOpenURL: external link handling with app store fallback + * - request: retry wrapper with exponential backoff for network calls + * - strToDate / formatDate: Google Sheets date parsing and formatting * - Question: form question helper class * - emptyQuestionState: hook for question state - * - validation helpers: isAtLeast, isNotEmpty, isExactly - * - hashForm: deterministic event hash + * - isAtLeast, isNotEmpty, isExactly: basic validation predicates + * - isValidEmail, isValidPhoneNumber: validator.js-backed field validators * - openInMaps: launch maps app for a location */ -import AsyncStorage from "@react-native-async-storage/async-storage"; import Constants from "expo-constants"; +import { isEmail, isMobilePhone } from "validator"; import { createNavigationContainerRef } from "@react-navigation/native"; import { useState } from "react"; import { Alert, Linking, Platform } from "react-native"; @@ -58,11 +57,14 @@ export async function sendErrorEmail(error) { } } -/** - * Log error and show user-friendly alert with diagnostic info. - * @param {string} error - error message or object to display - * @returns {null} - */ +const normalizeValidationInput = (value) => (value ?? "").trim(); +const normalizePhoneValidationInput = (value) => + normalizeValidationInput(value).replace(/[^\d+]/g, ""); +export const isValidEmail = (value) => isEmail(normalizeValidationInput(value)); +export const isValidPhoneNumber = (value) => + isMobilePhone(normalizePhoneValidationInput(value), "any", { + strictMode: false, + }); /** * Open a URL in the default browser. @@ -101,26 +103,6 @@ export function maybeOpenURL(url, appName, appStoreID, playStoreID) { }); } -/** - * Retrieve the stored user object from AsyncStorage. - * @param {boolean} [isEmptySafe=false] - * @returns {Promise} - */ -export async function getUser(isEmptySafe = false) { - try { - const userString = await AsyncStorage.getItem("user"); - if (userString === null) { - if (!isEmptySafe) { - alertError("Empty getUser"); - } - return; - } - return JSON.parse(userString); - } catch (error) { - alertError("Unexpected error in getUser: " + error); - } -} - /** * Utility delay function for retry backoff. */ @@ -234,17 +216,6 @@ export const isNotEmpty = (value) => isAtLeast(value, 1); export const isExactly = (value, len) => !isAtLeast(value, len + 1) && isAtLeast(value, len); -/** - * Hash event details deterministically for submission tracking. - * @param {string} userID - * @param {string} title - * @param {string} location - * @param {string} date - */ -export function hashForm(userID, title, location, date) { - return `${userID}&&&${title}&&&${location}&&&${date}`; -} - /** * Launch maps application or fallback to web URL for a location. * @param {string} location diff --git a/src/utils/notifications.js b/src/utils/notifications.js index 3c6d369..bc3ea11 100644 --- a/src/utils/notifications.js +++ b/src/utils/notifications.js @@ -89,7 +89,7 @@ export async function scheduleEventNotifications(event) { /** * Cancel all scheduled notifications throughout the app. - * Useful for clearing events on logout or data refresh. + * Called before rescheduling on data refresh to avoid duplicates. */ export function cancelAllScheduled() { return Notifications.cancelAllScheduledNotificationsAsync();