From c1e717dabf3bcb48ece4318bb4e1e076d5b17933 Mon Sep 17 00:00:00 2001 From: Pranav Maddineedi Date: Thu, 31 Jul 2025 16:59:00 -0700 Subject: [PATCH 01/62] save stuff --- package-lock.json | 144 ++++++++++ package.json | 3 + src/components/CarouselSection.js | 28 +- src/components/FilterPanel.js | 357 ++++++++++++++++++++++++ src/components/TextField.js | 19 +- src/components/TimeSlot.js | 104 +++++++ src/components/TimeSlotList.js | 446 +++--------------------------- src/screens/HomeScreen.js | 83 ++++-- src/screens/SignInScreen.js | 3 +- src/utils/forms/RequestConcert.js | 2 +- 10 files changed, 752 insertions(+), 437 deletions(-) create mode 100644 src/components/FilterPanel.js create mode 100644 src/components/TimeSlot.js diff --git a/package-lock.json b/package-lock.json index 7dd0434..1e6875c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@react-native-async-storage/async-storage": "1.23.1", "@react-native-community/cli-server-api": "^13.6.9", "@react-native-google-signin/google-signin": "^12.2.1", + "@react-native-picker/picker": "^2.11.1", "@react-navigation/native": "^6.1.17", "@react-navigation/native-stack": "^6.10.0", "@robinbobin/react-native-google-drive-api-wrapper": "^1.2.4", @@ -24,6 +25,7 @@ "expo-notifications": "~0.28.19", "expo-status-bar": "~1.12.1", "expo-updates": "~0.25.22", + "fuse.js": "^7.1.0", "react": "18.2.0", "react-native": "0.74.5", "react-native-animated-dots-carousel": "^1.0.2", @@ -31,6 +33,7 @@ "react-native-document-picker": "^9.3.0", "react-native-fs": "^2.20.0", "react-native-gesture-handler": "~2.16.1", + "react-native-multiple-select": "^0.5.12", "react-native-reanimated": "~3.10.1", "react-native-reanimated-carousel": "^3.5.1", "react-native-safe-area-context": "4.10.5", @@ -5384,6 +5387,19 @@ } } }, + "node_modules/@react-native-picker/picker": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@react-native-picker/picker/-/picker-2.11.1.tgz", + "integrity": "sha512-ThklnkK4fV3yynnIIRBkxxjxR4IFbdMNJVF6tlLdOJ/zEFUEFUEdXY0KmH0iYzMwY8W4/InWsLiA7AkpAbnexA==", + "license": "MIT", + "workspaces": [ + "example" + ], + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/@react-native/assets-registry": { "version": "0.74.87", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.74.87.tgz", @@ -7700,6 +7716,28 @@ "node": ">= 0.8" } }, + "node_modules/deprecated-react-native-prop-types": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/deprecated-react-native-prop-types/-/deprecated-react-native-prop-types-5.0.0.tgz", + "integrity": "sha512-cIK8KYiiGVOFsKdPMmm1L3tA/Gl+JopXL6F5+C7x39MyPsQYnP57Im/D6bNUzcborD7fcMwiwZqcBdBXXZucYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@react-native/normalize-colors": "^0.73.0", + "invariant": "^2.2.4", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/deprecated-react-native-prop-types/node_modules/@react-native/normalize-colors": { + "version": "0.73.2", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.73.2.tgz", + "integrity": "sha512-bRBcb2T+I88aG74LMVHaKms2p/T8aQd8+BZ7LuuzXlRfog1bMWWn/C5i0HVuvW4RPtXQYgIlGiXVDy9Ir1So/w==", + "license": "MIT", + "peer": true + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -8960,6 +8998,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuse.js": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz", + "integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -13153,6 +13200,22 @@ "react-native": "*" } }, + "node_modules/react-native-multiple-select": { + "version": "0.5.12", + "resolved": "https://registry.npmjs.org/react-native-multiple-select/-/react-native-multiple-select-0.5.12.tgz", + "integrity": "sha512-lFw0u798/2qHr4TwDdxMtReRtsNOCC2SWPzWHRGKE4XcBiUll0hHhke7iqQg4xJdfo46C/h69f1ZXphDOjZY3A==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "deprecated-react-native-prop-types": ">2.0.0", + "lodash": ">4.17.00", + "react": ">16.6.0", + "react-native": ">0.57.0", + "react-native-vector-icons": ">6.0.0" + } + }, "node_modules/react-native-reanimated": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.10.1.tgz", @@ -13207,6 +13270,87 @@ "react-native": "*" } }, + "node_modules/react-native-vector-icons": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/react-native-vector-icons/-/react-native-vector-icons-10.2.0.tgz", + "integrity": "sha512-n5HGcxUuVaTf9QJPs/W22xQpC2Z9u0nb0KgLPnVltP8vdUvOp6+R26gF55kilP/fV4eL4vsAHUqUjewppJMBOQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "prop-types": "^15.7.2", + "yargs": "^16.1.1" + }, + "bin": { + "fa-upgrade.sh": "bin/fa-upgrade.sh", + "fa5-upgrade": "bin/fa5-upgrade.sh", + "fa6-upgrade": "bin/fa6-upgrade.sh", + "generate-icon": "bin/generate-icon.js" + } + }, + "node_modules/react-native-vector-icons/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "license": "ISC", + "peer": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/react-native-vector-icons/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/react-native-vector-icons/node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/react-native-vector-icons/node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "license": "MIT", + "peer": true, + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/react-native-vector-icons/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "license": "ISC", + "peer": true, + "engines": { + "node": ">=10" + } + }, "node_modules/react-native-webview": { "version": "13.8.6", "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.8.6.tgz", diff --git a/package.json b/package.json index a4ab0e8..fe0d6a8 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@react-native-async-storage/async-storage": "1.23.1", "@react-native-community/cli-server-api": "^13.6.9", "@react-native-google-signin/google-signin": "^12.2.1", + "@react-native-picker/picker": "^2.11.1", "@react-navigation/native": "^6.1.17", "@react-navigation/native-stack": "^6.10.0", "@robinbobin/react-native-google-drive-api-wrapper": "^1.2.4", @@ -28,6 +29,7 @@ "expo-notifications": "~0.28.19", "expo-status-bar": "~1.12.1", "expo-updates": "~0.25.22", + "fuse.js": "^7.1.0", "react": "18.2.0", "react-native": "0.74.5", "react-native-animated-dots-carousel": "^1.0.2", @@ -35,6 +37,7 @@ "react-native-document-picker": "^9.3.0", "react-native-fs": "^2.20.0", "react-native-gesture-handler": "~2.16.1", + "react-native-multiple-select": "^0.5.12", "react-native-reanimated": "~3.10.1", "react-native-reanimated-carousel": "^3.5.1", "react-native-safe-area-context": "4.10.5", diff --git a/src/components/CarouselSection.js b/src/components/CarouselSection.js index 1196776..fdf239b 100644 --- a/src/components/CarouselSection.js +++ b/src/components/CarouselSection.js @@ -7,7 +7,7 @@ * - onRefresh: Callback to reload event data (pull-to-refresh). */ -import { useState } from "react"; +import React, { useState, useCallback } from "react"; import { Dimensions, StyleSheet, View } from "react-native"; import AnimatedDotsCarousel from "react-native-animated-dots-carousel"; import Carousel from "react-native-reanimated-carousel"; @@ -17,14 +17,15 @@ import Heading from "./Heading"; import RefreshButton from "./RefreshButton"; import VolunteerOpportunity from "./VolunteerOpportunity"; -export default function CarouselSection({ navigation, data, onRefresh }) { +function CarouselSection({ navigation, data, onRefresh }) { + console.log("CarouselSection re-rendered at", new Date().toISOString(), "with data length:", data.length); // Current index of active carousel slide for dots indicator const [dotIndex, setDotIndex] = useState(0); /** * Renders one page of the carousel (one row of up to 3 events) */ - const renderItem = ({ item }) => { + const renderItem = useCallback(({ item }) => { return ( {item.map((event, index) => { @@ -52,7 +53,7 @@ export default function CarouselSection({ navigation, data, onRefresh }) { })} ); - }; + }, [navigation]); return ( @@ -68,7 +69,10 @@ export default function CarouselSection({ navigation, data, onRefresh }) { height={280} data={data} renderItem={renderItem} - onSnapToItem={(index) => setDotIndex(index)} + onSnapToItem={useCallback((index) => { + console.log("Carousel snapped to index:", index); + setDotIndex(index); + }, [])} /> {/* Pagination dots below carousel */} @@ -116,3 +120,17 @@ const styles = StyleSheet.create({ justifyContent: "space-between", }, }); + +export default React.memo(CarouselSection, (prevProps, nextProps) => { + console.log("React.memo comparison - data lengths:", prevProps.data.length, "vs", nextProps.data.length); + console.log("React.memo comparison - data same reference?", prevProps.data === nextProps.data); + + // Only re-render if data reference actually changed + if (prevProps.data !== nextProps.data) { + console.log("Data reference changed, allowing re-render"); + return false; // Allow re-render + } + + console.log("Blocking re-render - same data reference"); + return true; // Block re-render +}); diff --git a/src/components/FilterPanel.js b/src/components/FilterPanel.js new file mode 100644 index 0000000..00e85ee --- /dev/null +++ b/src/components/FilterPanel.js @@ -0,0 +1,357 @@ +/** + * FilterPanel.js + * Component for filtering and searching events + * - Key search with prioritized fields (title > description > location > tags > date) + * - Location, tags, and date range hard filters + * - Fuzzy search with relevance sorting + */ + +import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import { StyleSheet, View, TextInput, TouchableOpacity, Text, Pressable } from 'react-native'; +import MultiSelect from 'react-native-multiple-select'; +import Fuse from 'fuse.js'; +import TimeSlot from "./TimeSlot"; +import { formatDate } from "../utils"; + +export default function FilterPanel({ + data, + onFilteredDataChange +}) { + // Filter states + const [keyFilter, setKeyFilter] = useState(""); + const [locationFilter, setLocationFilter] = useState([]); + const [tagsFilter, setTagsFilter] = useState([]); + const [filterSlot, setFilterSlot] = useState({ start: null, end: null }); + const [dateFilterState, setDateFilterState] = useState({ value: "", y: 0, valid: true }); + const [showDatePicker, setShowDatePicker] = useState(false); + + // Applied filter states (used for actual filtering) + const [appliedKeyFilter, setAppliedKeyFilter] = useState(""); + const [appliedLocationFilter, setAppliedLocationFilter] = useState([]); + const [appliedTagsFilter, setAppliedTagsFilter] = useState([]); + const [appliedFilterSlot, setAppliedFilterSlot] = useState({ start: null, end: null }); + + // Options for filters derived from data + const [locations, setLocations] = useState([]); + const [allTags, setAllTags] = useState([]); + + // Deduplicate very similar items using fuzzy matching + const deduplicateSimilar = useCallback((items, threshold = 0.2) => { + if (items.length === 0) return items; + + const deduplicated = []; + const used = new Set(); + + items.forEach(item => { + if (used.has(item)) return; + + const fuse = new Fuse(items, { + threshold: threshold, + minMatchCharLength: 1 + }); + + const similar = fuse.search(item).map(result => result.item); + + // Add the original item + deduplicated.push(item); + + // Mark all similar items as used + similar.forEach(similarItem => used.add(similarItem)); + used.add(item); + }); + + return deduplicated; + }, []); + + // Derive unique locations and tags when data loads + useEffect(() => { + const rawLocs = Array.from(new Set(data.map((e) => e.Location))); + const deduplicatedLocs = deduplicateSimilar(rawLocs, 0.15); // Very strict for locations + setLocations(deduplicatedLocs); + + const tags = new Set(); + data.forEach((e) => { + e.Tags?.split(",").forEach((t) => tags.add(t.trim())); + }); + const rawTags = Array.from(tags).filter((t) => t); + const deduplicatedTags = deduplicateSimilar(rawTags, 0.2); // Slightly more lenient for tags + setAllTags(deduplicatedTags); + }, [data, deduplicateSimilar]); + + // Apply filters function + const applyFilters = useCallback(() => { + setAppliedKeyFilter(keyFilter); + setAppliedLocationFilter(locationFilter); + setAppliedTagsFilter(tagsFilter); + setAppliedFilterSlot(filterSlot); + }, [keyFilter, locationFilter, tagsFilter, filterSlot]); + + // Filtered events (using applied filters) - memoized for performance + const filteredData = useMemo(() => { + console.log("filteredData recalculated at", new Date().toISOString(), "data length:", data.length); + + // Step 1: Apply hard filters first (location, tags, time) - these remove events completely + let hardFilteredEvents = data.filter((e) => { + // Apply location hard filter + if (appliedLocationFilter.length > 0) { + const selectedLocation = appliedLocationFilter[0]; + const locationFuse = new Fuse([e.Location], { + threshold: 0.3, // Stricter threshold for locations + minMatchCharLength: 1 + }); + const locationMatch = locationFuse.search(selectedLocation).length > 0 || e.Location === selectedLocation; + if (!locationMatch) return false; + } + + // Apply date range hard filter + if (appliedFilterSlot.start) { + if (new Date(e.Date) < appliedFilterSlot.start) return false; + } + if (appliedFilterSlot.end) { + if (new Date(e.Date) > appliedFilterSlot.end) return false; + } + + // Apply tags hard filter + if (appliedTagsFilter.length > 0) { + const eventTags = (e.Tags || "").split(",").map((t) => t.trim()); + const anyMode = appliedTagsFilter.includes("__ANY__"); + const selectedTags = appliedTagsFilter.filter((tag) => tag !== "__ANY__"); + + if (selectedTags.length > 0) { + // Use fuzzy matching for tags + const tagFuse = new Fuse(eventTags, { + threshold: 0.3, // Stricter threshold for tags + minMatchCharLength: 1 + }); + + if (anyMode) { + // OR semantics: any of selectedTags should fuzzy match + const hasMatch = selectedTags.some(selectedTag => + tagFuse.search(selectedTag).length > 0 || eventTags.includes(selectedTag) + ); + if (!hasMatch) return false; + } else { + // AND semantics: all selectedTags should fuzzy match + const allMatch = selectedTags.every(selectedTag => + tagFuse.search(selectedTag).length > 0 || eventTags.includes(selectedTag) + ); + if (!allMatch) return false; + } + } + } + return true; + }); + + // Step 2: Apply key filter for sorting/relevance (if specified) + if (appliedKeyFilter) { + // Add searchable date strings to each event for time-based searches + const dataWithSearchableDates = hardFilteredEvents.map(event => ({ + ...event, + searchableDate: formatDate(event.Date), // Full formatted date for searching + searchableMonth: event.Date.toLocaleDateString("en-us", { month: "long" }), // "August" + searchableMonthShort: event.Date.toLocaleDateString("en-us", { month: "short" }), // "Aug" + searchableDay: event.Date.toLocaleDateString("en-us", { weekday: "long" }), // "Monday" + searchableYear: event.Date.getFullYear().toString(), // "2024" + searchableMonthDay: event.Date.toLocaleDateString("en-us", { month: "long", day: "numeric" }), // "August 23" + searchableMonthDayShort: event.Date.toLocaleDateString("en-us", { month: "short", day: "numeric" }) // "Aug 23" + })); + + const fuse = new Fuse(dataWithSearchableDates, { + keys: [ + { name: 'Title', weight: 0.5 }, // Event name (50% - highest priority) + { name: 'Description', weight: 0.25 }, // Event description (25% - second priority) + { name: 'Location', weight: 0.1 }, // Event location (10% - third priority) + { name: 'Tags', weight: 0.1 }, // Event tags (10% - fourth priority) + { name: 'searchableDate', weight: 0.015 }, // Full date string (5% total for all date fields) + { name: 'searchableMonthDay', weight: 0.015 }, // "August 23" + { name: 'searchableMonthDayShort', weight: 0.01 }, // "Aug 23" + { name: 'searchableMonth', weight: 0.005 }, // "August" + { name: 'searchableMonthShort', weight: 0.003 }, // "Aug" + { name: 'searchableDay', weight: 0.001 }, // "Monday" + { name: 'searchableYear', weight: 0.001 } // "2024" + ], + threshold: 0.6, // More lenient threshold for sorting (0.4 was too restrictive) + includeScore: true, + minMatchCharLength: 1 + }); + const results = fuse.search(appliedKeyFilter); + return results.map(result => result.item); + } + + // Step 3: If no key filter, return hard-filtered events as-is + return hardFilteredEvents; + }, [data, appliedKeyFilter, appliedLocationFilter, appliedFilterSlot, appliedTagsFilter]); + + // Update TextField display when filter slot changes + useEffect(() => { + if (filterSlot.start && filterSlot.end) { + const startStr = filterSlot.start.toLocaleDateString() + ' ' + filterSlot.start.toLocaleTimeString(); + const endStr = filterSlot.end.toLocaleDateString() + ' ' + filterSlot.end.toLocaleTimeString(); + setDateFilterState(prev => ({ ...prev, value: `${startStr} - ${endStr}` })); + } else { + setDateFilterState(prev => ({ ...prev, value: "" })); + } + }, [filterSlot]); + + // Notify parent component when filtered data changes + useEffect(() => { + onFilteredDataChange(filteredData); + }, [filteredData, onFilteredDataChange]); + + return ( + + + {/* Location filter via single-select MultiSelect */} + ({ id: loc, name: loc }))} + uniqueKey="id" + single={true} + onSelectedItemsChange={(selectedItems) => { + // toggle off if same item selected again + if (locationFilter.length > 0 && selectedItems[0] === locationFilter[0]) { + setLocationFilter([]); + } else { + setLocationFilter(selectedItems); + } + }} + selectedItems={locationFilter} + selectText="Select Location" + searchInputPlaceholderText=" Search Locations..." + hideSubmitButton={true} + styleMainWrapper={styles.multiSelectWrapper} + styleListContainer={styles.multiSelectList} + styleTextDropdown={styles.multiSelectText} + styleTextDropdownSelected={styles.multiSelectText} + styleDropdownMenuSubsection={styles.multiSelectDropdown} + fixedHeight={true} + /> + {/* Date range filter */} + setShowDatePicker(true)}> + + {dateFilterState.value || "Filter by date & time"} + + + {showDatePicker && ( + { + setFilterSlot(newSlot); + setShowDatePicker(false); + }} + selectRange={true} + autoOpen={true} + /> + )} + {/* Tag filter via multi-select, includes special option for any-match mode */} + ({ id: tag, name: tag }))]} + uniqueKey="id" + onSelectedItemsChange={setTagsFilter} + selectedItems={tagsFilter} + selectText="Select Tags" + searchInputPlaceholderText=" Search Tags..." + tagRemoveIconColor="#CCC" + tagBorderColor="#CCC" + tagTextColor="#CCC" + selectedItemTextColor="#CCC" + selectedItemIconColor="#CCC" + itemTextColor="#000" + displayKey="name" + hideSubmitButton={true} + styleMainWrapper={styles.multiSelectWrapper} + styleListContainer={styles.multiSelectList} + styleTextDropdown={styles.multiSelectText} + styleTextDropdownSelected={styles.multiSelectText} + styleDropdownMenuSubsection={styles.multiSelectDropdown} + fixedHeight={true} + /> + {/* Apply Filters Button */} + + Apply Filters + + + ); +} + +const styles = StyleSheet.create({ + filters: { + marginBottom: 10, + }, + input: { + borderWidth: 1, + borderColor: "#ccc", + borderRadius: 0, + height: 40, + paddingHorizontal: 5, + fontSize: 16, + color: '#666', + backgroundColor: 'white', + marginBottom: 10, + }, + // react-native-multiple-select styles + multiSelectWrapper: { + marginBottom: 0, + }, + multiSelectList: { + maxHeight: 150, + }, + multiSelectText: { + fontSize: 16, + color: '#666', + textAlign: 'left', + paddingTop: 10, + marginTop: 0, + paddingLeft: 5, + lineHeight: 20, + }, + multiSelectDropdown: { + height: 40, + borderRadius: 0, + borderWidth: 1, + borderColor: '#ccc', + backgroundColor: 'white', + paddingHorizontal: 5, + paddingVertical: 10, + justifyContent: 'center', + alignItems: 'center', + }, + dateFilterBox: { + height: 40, + borderRadius: 0, + borderWidth: 1, + borderColor: '#ccc', + backgroundColor: 'white', + justifyContent: 'center', + paddingHorizontal: 5, + marginBottom: 10, + }, + dateFilterText: { + fontSize: 16, + color: '#666', + textAlign: 'left', + }, + applyButton: { + backgroundColor: 'white', + borderRadius: 10, + borderWidth: 1.5, + borderColor: 'black', + height: 40, + alignItems: 'center', + justifyContent: 'center', + alignSelf: 'center', + paddingHorizontal: 40, + marginTop: 5, + marginBottom: 20, + }, + applyButtonText: { + color: 'black', + fontSize: 16, + textAlign: 'center', + }, +}); \ No newline at end of file diff --git a/src/components/TextField.js b/src/components/TextField.js index 0930248..dfae217 100644 --- a/src/components/TextField.js +++ b/src/components/TextField.js @@ -11,7 +11,7 @@ * - extraMargin: whether to add bottom margin after input */ -import { StyleSheet, Text, TextInput, View } from "react-native"; +import { StyleSheet, Text, TextInput, View, Pressable } from "react-native"; import colors from "../constants/colors"; export default function TextField({ @@ -22,6 +22,8 @@ export default function TextField({ state, setState, extraMargin = true, + inputted = true, + onPress = null }) { return ( {/* Text input with validation border color */} - + : onPress ? ( + + + {state.value || "Filter by date & time"} + + + ) : ( + + {title} + + ) + } ); } diff --git a/src/components/TimeSlot.js b/src/components/TimeSlot.js new file mode 100644 index 0000000..1480c4e --- /dev/null +++ b/src/components/TimeSlot.js @@ -0,0 +1,104 @@ +import React, { useState, useEffect } from 'react'; +import { View, Text, Pressable } from 'react-native'; +import DatePicker from 'react-native-date-picker'; +import colors from '../constants/colors'; + + +/** + * TimeSlot: UI component for selecting a single time slot (start/end dates) + * Props: + * - slot: { start: Date|null, end: Date|null } + * - onChange: function(updatedSlot) + * - title?: string (optional label above picker) + */ +export default function TimeSlot({ + slot, + onChange, + title = null, + startPickerMode = 'datetime', + endPickerMode = 'datetime', + selectRange = false, + autoOpen = false, +}) { + const [open, setOpen] = useState(false); + const [mode, setMode] = useState('start'); + const [selectingRange, setSelectingRange] = useState(false); + const [tempStart, setTempStart] = useState(null); + + function label(date, placeholder) { + return date ? date.toDateString() : placeholder; + } + + const valid = slot.start && slot.end ? slot.start < slot.end : true; + + // Formatting helpers + const formatDateTime = (dt) => dt.toLocaleDateString() + ' ' + dt.toLocaleTimeString(); + const formatTimeOnly = (dt) => dt.toLocaleTimeString(); + const combinedLabel = () => { + if (slot.start && slot.end) return `${formatDateTime(slot.start)} - ${formatTimeOnly(slot.end)}`; + return 'Select Time Slot'; + }; + // Auto-open picker for new slots when selectRange is enabled + useEffect(() => { + if (autoOpen && selectRange) { + setSelectingRange(true); + setMode('start'); + setOpen(true); + } + }, [autoOpen, selectRange]); + + return ( + + {title && !selectRange ? {title} : null} + {selectRange ? ( + { setSelectingRange(true); setMode('start'); setOpen(true); }}> + + {combinedLabel()} + + + ) : ( + + { setMode('start'); setOpen(true); }}> + + {label(slot.start, 'Start Date')} + + + { setMode('end'); setOpen(true); }}> + + {label(slot.end, 'End Date')} + + + + )} + { + setOpen(false); + if (selectRange && selectingRange) { + if (mode === 'start') { + setTempStart(date); + setMode('end'); + setOpen(true); + // Don't call onChange until both dates are selected + } else { + const finalSlot = { start: tempStart, end: date }; + onChange(finalSlot); + setSelectingRange(false); + setTempStart(null); + } + } else { + onChange({ ...slot, [mode]: date }); + } + }} + onCancel={() => { + setSelectingRange(false); + setOpen(false); + setTempStart(null); + }} + /> + + ); +} diff --git a/src/components/TimeSlotList.js b/src/components/TimeSlotList.js index 5c1e1c6..3519a9b 100644 --- a/src/components/TimeSlotList.js +++ b/src/components/TimeSlotList.js @@ -2,418 +2,72 @@ * TimeSlotList.js * Manages a dynamic list of time slots with add/remove controls and date pickers. * Exports: - * - TimeSlot: model class for a time slot - * - RemoveButton: UI to remove a slot - * - AddButton: UI to add a new slot * - Default component: renders all slots and a date picker modal */ -import { useState } from "react"; +import React from "react"; import { Pressable, StyleSheet, Text, View } from "react-native"; -import DatePicker from "react-native-date-picker"; - import EvilIcons from "@expo/vector-icons/EvilIcons"; import Ionicons from "@expo/vector-icons/Ionicons"; import colors from "../constants/colors"; +import TimeSlot from "./TimeSlot"; -// Utility: format Date to 'h:mm AM/PM' string -function timeFormatter(date) { - const hour = date.getHours() % 12 == 0 ? 12 : date.getHours() % 12; - const minute = (date.getMinutes() < 10 ? "0" : "") + date.getMinutes(); - const period = date.getHours() >= 12 ? "PM" : "AM"; - return `${hour}:${minute} ${period}`; -} - -// Utility: format Date to 'Day MM/DD/YY h:mm AM/PM' string -function dateFormatter(date) { - const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; - return `${days[date.getDay()]} ${date.getMonth() + 1}/${date.getDate() < 10 ? "0" + date.getDate() : date.getDate()}/${date.getFullYear() - 2000} ${timeFormatter(date)}`; -} - -// Utility: compare two times (hours, minutes) -function timeCompare(hour1, minute1, hour2, minute2) { - if (hour1 < hour2) { - return -1; - } else if (hour1 > hour2) { - return 1; - } else { - if (minute1 < minute2) { - return -1; - } else if (minute1 > minute2) { - return 1; - } else { - return 0; - } - } -} - -/** - * Model for a single time slot with start and end dates. - */ -export class TimeSlot { - constructor(start = new Date(), end = new Date()) { - this.start = start; - this.end = end; - this.valid = true; - } - - validate() { - const start_hour = this.start.getHours(); - const start_minute = this.start.getMinutes(); - const end_hour = this.end.getHours(); - const end_minute = this.end.getMinutes(); - - if (this.start >= this.end) { - this.valid = false; - } else if ( - timeCompare(start_hour, start_minute, 10, 30) < 0 || - timeCompare(start_hour, start_minute, 17, 0) > 0 - ) { - this.valid = false; - } else if (timeCompare(end_hour, end_minute, 18, 0) > 0) { - this.valid = false; - } else { - this.valid = true; - } - - return this.valid; - } - - toString() { - return `${dateFormatter(this.start)} - ${timeFormatter(this.end)}`; - } - - compareTo(other) { - if (this.start < other.start) { - return -1; - } else if (this.start > other.start) { - return 1; - } else { - if (this.end < other.end) { - return -1; - } else if (this.end > other.end) { - return 1; - } else { - return 0; - } - } - } +export default function TimeSlotList({ title, state, setState }) { + const slots = state.value; + const setSlots = (newSlots) => setState((prev) => ({ ...prev, value: newSlots })); - render(state, setState, index, setIndex, setIsOpen, setIsAdded, setIsStart) { - return ( - - - { - setIsOpen(true); - setIsAdded(false); - setIsStart(true); - setIndex(index); + return ( + + + {title} + * + + {slots.map((slot, index) => ( + + { + const newSlots = [...slots]; + newSlots[index] = updatedSlot; + setSlots(newSlots); }} - > - - {dateFormatter(this.start)} - - - - {" "} - -{" "} - + /> { - setIsOpen(true); - setIsAdded(false); - setIsStart(false); - setIndex(index); + const newSlots = slots.filter((_, i) => i !== index); + setSlots(newSlots); }} > - - {timeFormatter(this.end)} - + - - - ); - } -} - -/** - * Button to remove a specific time slot from the list. - */ -function RemoveButton({ state, setState, index }) { - return ( - { - setState((previous) => { - return { - ...previous, - value: previous.value - .slice(0, index) - .concat(previous.value.slice(index + 1, state.value.length)), - }; - }); - }} - > - - - ); -} - -/** - * Button to add a new time slot to the list and open the date picker. - */ -function AddButton({ - state, - setState, - setIndex, - setIsAdded, - setIsOpen, - setIsStart, -}) { - return ( - { - setIsAdded(true); - setIsStart(true); - let length = state.value.length; - setIndex(length); - setState((previous) => { - return { - ...previous, - value: previous.value.concat([new TimeSlot()]), - }; - }); - setIsOpen(true); - }} - > - - Add Time Slot - - ); -} - -/** - * Date and time selection component for start and end times of a time slot. - */ -export function Select({ - state, - setState, - index, - isAdded, - isStart, - setIsStart, - isOpen, - setIsOpen, -}) { - const now = new Date(); - - if (state.value.length <= index) { - return; - } - - let start = state.value[index].start; - let end = state.value[index].end; - - return ( - { - if (isStart) { - setState((previous) => { - let next = previous.value; - next[index].start = date; - return { ...previous, value: next }; - }); - setIsStart(false); - - setIsOpen(false); - - if (isAdded) { - setIsOpen(true); - } - } else { - setState((previous) => { - let next = previous.value; - next[index].end = date; - return { ...previous, value: next }; - }); - setIsOpen(false); - } - }} - onCancel={() => { - if (isAdded && isStart) { - setState((previous) => { - return { - ...previous, - value: previous.value.slice(0, previous.value.length - 1), - }; - }); - } - setIsOpen(false); - }} - /> - ); -} - -/** - * Manages a list of time slots, allowing users to add, remove, and edit slots. - */ -export default function TimeSlotList({ title, state, setState }) { - const [open, setIsOpen] = useState(false); - const [start, setIsStart] = useState(true); - const [added, setIsAdded] = useState(false); - const [index, setIndex] = useState(0); - - return ( - { - const y = event.nativeEvent.layout.y; - setState((prevState) => ({ - ...prevState, - y, - })); - }} - > - - {title} - * - - { + const newSlot = { start: null, end: null }; + setSlots([...slots, newSlot]); + }} > - { - "Each time slot must start between 10:30 am and 5 pm and end before 6 pm." - } - - {state.value.map((slot, index) => - slot == null || (index == state.value.length - 1 && open && added) - ? null - : slot.render( - state, - setState, - index, - setIndex, - setIsOpen, - setIsAdded, - setIsStart, - ), - )} - - {open ? ( -