diff --git a/admin/src/all.dto.ts b/admin/src/all.dto.ts index 5b490b39..e1c54b7a 100644 --- a/admin/src/all.dto.ts +++ b/admin/src/all.dto.ts @@ -29,6 +29,90 @@ export enum BearSlotDto { ACCESSORY = 'ACCESSORY', } +export enum CampusEventCategoryDto { + SOCIAL = 'SOCIAL', + CULTURAL = 'CULTURAL', + ATHLETIC = 'ATHLETIC', + WELLNESS = 'WELLNESS', + ACADEMIC = 'ACADEMIC', + ARTS = 'ARTS', + CAREER = 'CAREER', + COMMUNITY = 'COMMUNITY', + OTHER = 'OTHER', +} + +export enum EventSourceDto { + API_EVENTS = 'API_EVENTS', + ADMIN_CREATED = 'ADMIN_CREATED', + COMMUNITY_SUBMITTED = 'COMMUNITY_SUBMITTED', +} + +export enum CheckInMethodDto { + LOCATION = 'LOCATION', + QR_CODE = 'QR_CODE', + EITHER = 'EITHER', +} + +export enum CampusEventCategoriesDto { + SOCIAL = 'SOCIAL', + CULTURAL = 'CULTURAL', + ATHLETIC = 'ATHLETIC', + WELLNESS = 'WELLNESS', + ACADEMIC = 'ACADEMIC', + ARTS = 'ARTS', + CAREER = 'CAREER', + COMMUNITY = 'COMMUNITY', + OTHER = 'OTHER', +} + +export enum CampusEventSourceDto { + API_EVENTS = 'API_EVENTS', + ADMIN_CREATED = 'ADMIN_CREATED', + COMMUNITY_SUBMITTED = 'COMMUNITY_SUBMITTED', +} + +export enum CampusEventCheckInMethodDto { + LOCATION = 'LOCATION', + QR_CODE = 'QR_CODE', + EITHER = 'EITHER', +} + +export enum RequestCampusEventsCategoriesDto { + SOCIAL = 'SOCIAL', + CULTURAL = 'CULTURAL', + ATHLETIC = 'ATHLETIC', + WELLNESS = 'WELLNESS', + ACADEMIC = 'ACADEMIC', + ARTS = 'ARTS', + CAREER = 'CAREER', + COMMUNITY = 'COMMUNITY', + OTHER = 'OTHER', +} + +export enum UpsertCampusEventCategoriesDto { + SOCIAL = 'SOCIAL', + CULTURAL = 'CULTURAL', + ATHLETIC = 'ATHLETIC', + WELLNESS = 'WELLNESS', + ACADEMIC = 'ACADEMIC', + ARTS = 'ARTS', + CAREER = 'CAREER', + COMMUNITY = 'COMMUNITY', + OTHER = 'OTHER', +} + +export enum UpsertCampusEventSourceDto { + API_EVENTS = 'API_EVENTS', + ADMIN_CREATED = 'ADMIN_CREATED', + COMMUNITY_SUBMITTED = 'COMMUNITY_SUBMITTED', +} + +export enum UpsertCampusEventCheckInMethodDto { + LOCATION = 'LOCATION', + QR_CODE = 'QR_CODE', + EITHER = 'EITHER', +} + export enum ChallengeLocationDto { ENG_QUAD = 'ENG_QUAD', ARTS_QUAD = 'ARTS_QUAD', @@ -75,9 +159,13 @@ export enum EventCategoryDto { FOOD = 'FOOD', NATURE = 'NATURE', HISTORICAL = 'HISTORICAL', - CAFE = 'CAFE', - DININGHALL = 'DININGHALL', - DORM = 'DORM', + RESIDENTIAL = 'RESIDENTIAL', + LANDMARK = 'LANDMARK', + ARTS = 'ARTS', + ATHLETICS = 'ATHLETICS', + LIBRARY = 'LIBRARY', + ACADEMIC = 'ACADEMIC', + RECREATION = 'RECREATION', } export enum EventTimeLimitationDto { @@ -244,6 +332,119 @@ export interface UpdatePurchaseResultDto { itemId: string; } +export interface AdminBearItemDto { + id: string; + name?: string; + slot?: BearSlotDto; + cost?: number; + assetKey?: string; + mimeType?: string; + zIndex?: number; + isDefault?: boolean; +} + +export interface UpdateBearItemDataDto { + bearItem: AdminBearItemDto; + deleted: boolean; +} + +export interface RequestAllBearItemsDto {} + +export interface CampusEventDto { + id: string; + title: string; + description: string; + imageUrl?: string; + startTime: string; + endTime: string; + allDay: boolean; + locationName: string; + address?: string; + latitude: number; + longitude: number; + categories: CampusEventCategoriesDto[]; + tags: string[]; + source: CampusEventSourceDto; + externalUrl?: string; + organizerName?: string; + registrationUrl?: string; + checkInMethod: CampusEventCheckInMethodDto; + pointsForAttendance: number; + featured: boolean; + attendanceCount: number; + rsvpCount: number; +} + +export interface RequestCampusEventsDto { + page: number; + limit: number; + dateFrom?: string; + dateTo?: string; + categories?: RequestCampusEventsCategoriesDto[]; + search?: string; + featured?: boolean; +} + +export interface CampusEventListDto { + events: CampusEventDto[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export interface RequestCampusEventDetailsDto { + eventId: string; +} + +export interface UpsertCampusEventDto { + id?: string; + title: string; + description: string; + imageUrl?: string; + startTime: string; + endTime: string; + allDay?: boolean; + locationName: string; + address?: string; + latitude: number; + longitude: number; + checkInRadius?: number; + categories: UpsertCampusEventCategoriesDto[]; + tags: string[]; + source: UpsertCampusEventSourceDto; + externalId?: string; + externalUrl?: string; + organizerName?: string; + organizerEmail?: string; + organizerId?: string; + checkInMethod?: UpsertCampusEventCheckInMethodDto; + pointsForAttendance?: number; + featured?: boolean; + registrationUrl?: string; +} + +export interface DeleteCampusEventDto { + eventId: string; +} + +export interface RsvpCampusEventDto { + eventId: string; +} + +export interface UnRsvpCampusEventDto { + eventId: string; +} + +export interface UpdateCampusEventDataDto { + event: CampusEventDto; + deleted: boolean; +} + +export interface CampusEventListResponseDto { + list: CampusEventListDto; +} + export interface CompletedChallengeDto {} export interface ChallengeDto { @@ -259,6 +460,8 @@ export interface ChallengeDto { closeRadiusF?: number; linkedEventId?: string; timerLength?: number; + scheduledStartTime?: string; + scheduledEndTime?: string; } export interface RequestChallengeDataDto { @@ -400,6 +603,7 @@ export interface PrevChallengeDto { extensionsUsed?: number; dateCompleted: string; failed?: boolean; + dateExpired?: boolean; } export interface EventTrackerDto { @@ -707,21 +911,3 @@ export interface JoinOrganizationDto { export interface CompleteOnboardingDto {} export interface ResetOnboardingDto {} - -export interface AdminBearItemDto { - id: string; - name?: string; - slot?: BearSlotDto; - cost?: number; - assetKey?: string; - mimeType?: string; - zIndex?: number | null; - isDefault?: boolean; -} - -export interface UpdateBearItemDataDto { - bearItem: AdminBearItemDto; - deleted: boolean; -} - -export interface RequestAllBearItemsDto {} diff --git a/admin/src/components/ChallengeCardComponents.tsx b/admin/src/components/ChallengeCardComponents.tsx index 3c87cef6..2359ac68 100644 --- a/admin/src/components/ChallengeCardComponents.tsx +++ b/admin/src/components/ChallengeCardComponents.tsx @@ -12,6 +12,7 @@ import { OptionEntryForm, MapEntryForm, CheckboxNumberEntryForm, + CheckboxDateEntryForm, AnswersEntryForm, OptionWithCustomEntryForm, } from './EntryModal'; @@ -223,6 +224,16 @@ export function makeChallengeForm(): EntryForm[] { max: 3600, numberLabel: 'Timer Length (seconds)', }, + { + name: 'Scheduled Start Time', + checked: false, + date: new Date(), + }, + { + name: 'Scheduled End Time', + checked: false, + date: new Date(), + }, ]; } @@ -285,6 +296,20 @@ export function challengeToForm(challenge: ChallengeDto) { max: 3600, numberLabel: 'Timer Length (seconds)', }, + { + name: 'Scheduled Start Time', + checked: !!challenge.scheduledStartTime, + date: challenge.scheduledStartTime + ? new Date(challenge.scheduledStartTime) + : new Date(), + }, + { + name: 'Scheduled End Time', + checked: !!challenge.scheduledEndTime, + date: challenge.scheduledEndTime + ? new Date(challenge.scheduledEndTime) + : new Date(), + }, ]; } @@ -294,6 +319,8 @@ export function challengeFromForm( id: string, ): ChallengeDto { const timerForm = form[8] as CheckboxNumberEntryForm; + const startForm = form[9] as CheckboxDateEntryForm; + const endForm = form[10] as CheckboxDateEntryForm; return { id, name: (form[2] as FreeEntryForm).value, @@ -307,6 +334,10 @@ export function challengeFromForm( closeRadiusF: (form[7] as NumberEntryForm).value, linkedEventId: eventId, timerLength: timerForm.checked ? timerForm.value : undefined, + scheduledStartTime: startForm.checked + ? startForm.date.toISOString() + : undefined, + scheduledEndTime: endForm.checked ? endForm.date.toISOString() : undefined, }; } @@ -412,6 +443,14 @@ export function ChallengeCard(props: { ? `${Math.floor(props.challenge.timerLength / 60)}m ${props.challenge.timerLength % 60}s` : 'None'} +
+ Scheduled:{' '} + + {props.challenge.scheduledStartTime || + props.challenge.scheduledEndTime + ? `${props.challenge.scheduledStartTime ? new Date(props.challenge.scheduledStartTime).toLocaleString() : '—'} to ${props.challenge.scheduledEndTime ? new Date(props.challenge.scheduledEndTime).toLocaleString() : '—'}` + : 'None'} + UP diff --git a/admin/src/components/Challenges.tsx b/admin/src/components/Challenges.tsx index 93166685..a97340ac 100644 --- a/admin/src/components/Challenges.tsx +++ b/admin/src/components/Challenges.tsx @@ -45,9 +45,13 @@ const eventCategoryOptions = [ 'FOOD', 'NATURE', 'HISTORICAL', - 'CAFE', - 'DININGHALL', - 'DORM', + 'RESIDENTIAL', + 'LANDMARK', + 'ARTS', + 'ATHLETICS', + 'LIBRARY', + 'ACADEMIC', + 'RECREATION', ]; // Combined form indices: diff --git a/admin/src/components/EntryModal.tsx b/admin/src/components/EntryModal.tsx index 0c63d374..98ecf35e 100644 --- a/admin/src/components/EntryModal.tsx +++ b/admin/src/components/EntryModal.tsx @@ -60,6 +60,12 @@ export type AnswersEntryForm = { maxAnswers: number; }; +export type CheckboxDateEntryForm = { + name: string; + checked: boolean; + date: Date; +}; + export type OptionWithCustomEntryForm = { name: string; value: number; @@ -76,7 +82,8 @@ export type EntryForm = | DateEntryForm | CheckboxNumberEntryForm | AnswersEntryForm - | OptionWithCustomEntryForm; + | OptionWithCustomEntryForm + | CheckboxDateEntryForm; const EntryBox = styled.div` margin-bottom: 12px; @@ -256,6 +263,46 @@ function CheckboxNumberEntryFormBox(props: { form: CheckboxNumberEntryForm }) { ); } +function CheckboxDateEntryFormBox(props: { form: CheckboxDateEntryForm }) { + const [checked, setChecked] = useState(props.form.checked); + const [val, setVal] = useState(''); + + useEffect(() => { + setChecked(props.form.checked); + setVal(props.form.date.toISOString().slice(0, 16)); + }, [props.form]); + + return ( + <> + + + + {checked && ( + + { + setVal(e.target.value); + props.form.date = new Date(e.target.value); + }} + /> + + )} + + ); +} + const MapBox = styled.div` width: 100%; height: 300px; @@ -270,9 +317,81 @@ const mapContainerStyle = { height: '300px', }; +type FrontendConfigResponse = { + googleMapsApiKey?: string; +}; + function MapEntryFormBox(props: { form: MapEntryForm; allForms?: EntryForm[]; +}) { + const [googleMapsApiKey, setGoogleMapsApiKey] = useState( + process.env.REACT_APP_GOOGLE_MAPS_API_KEY || '', + ); + const [hasLoadedConfig, setHasLoadedConfig] = useState( + Boolean(process.env.REACT_APP_GOOGLE_MAPS_API_KEY), + ); + + useEffect(() => { + if (googleMapsApiKey) { + setHasLoadedConfig(true); + return; + } + + let isCancelled = false; + + fetch('/frontend-config') + .then(async response => { + if (!response.ok) { + throw new Error(`Failed to load config: ${response.status}`); + } + + const config = (await response.json()) as FrontendConfigResponse; + + if (isCancelled) { + return; + } + + setGoogleMapsApiKey(config.googleMapsApiKey || ''); + setHasLoadedConfig(true); + }) + .catch(() => { + if (!isCancelled) { + setHasLoadedConfig(true); + } + }); + + return () => { + isCancelled = true; + }; + }, [googleMapsApiKey]); + + if (!hasLoadedConfig) { + return Loading map configuration...; + } + + if (!googleMapsApiKey) { + return ( + + Google Maps is unavailable. Set `REACT_APP_GOOGLE_MAPS_API_KEY` in the + running server environment. + + ); + } + + return ( + + ); +} + +function LoadedMapEntryFormBox(props: { + form: MapEntryForm; + allForms?: EntryForm[]; + googleMapsApiKey: string; }) { const [lat, setLat] = useState(props.form.latitude); const [lng, setLng] = useState(props.form.longitude); @@ -282,8 +401,8 @@ function MapEntryFormBox(props: { lng: props.form.longitude, }); - const { isLoaded } = useJsApiLoader({ - googleMapsApiKey: process.env.REACT_APP_GOOGLE_MAPS_API_KEY || '', + const { isLoaded, loadError } = useJsApiLoader({ + googleMapsApiKey: props.googleMapsApiKey, }); // Derive radii from form so map updates when Awarding/Close Distance fields change @@ -343,6 +462,15 @@ function MapEntryFormBox(props: { } }; + if (loadError) { + return ( + + Unable to load Google Maps. Verify the API key, referrer restrictions, + and that the Maps JavaScript API is enabled. + + ); + } + if (!isLoaded) return Loading map...; return ( @@ -607,6 +735,8 @@ export function EntryModal(props: { return ; } else if ('characterLimit' in form) { return ; + } else if ('date' in form && 'checked' in form) { + return ; } else if ('checked' in form) { return ; } else if ('min' in form) { diff --git a/admin/src/components/Journeys.tsx b/admin/src/components/Journeys.tsx index 63abbcba..09154e30 100644 --- a/admin/src/components/Journeys.tsx +++ b/admin/src/components/Journeys.tsx @@ -48,9 +48,13 @@ const categoryOptions = [ 'FOOD', 'NATURE', 'HISTORICAL', - 'CAFE', - 'DININGHALL', - 'DORM', + 'RESIDENTIAL', + 'LANDMARK', + 'ARTS', + 'ATHLETICS', + 'LIBRARY', + 'ACADEMIC', + 'RECREATION', ]; // Event form helpers diff --git a/admin/src/components/ServerApi.tsx b/admin/src/components/ServerApi.tsx index d9af8b27..cb57926f 100644 --- a/admin/src/components/ServerApi.tsx +++ b/admin/src/components/ServerApi.tsx @@ -55,6 +55,64 @@ export class ServerApi { return this.send('equipBearItem', data) as Promise; } + requestAllBearItems(data: dto.RequestAllBearItemsDto) { + return this.send('requestAllBearItems', data) as Promise< + number | undefined + >; + } + + updateBearItemData(data: dto.UpdateBearItemDataDto) { + return this.send('updateBearItemData', data) as Promise; + } + + requestCampusEvents(data: dto.RequestCampusEventsDto) { + return this.send('requestCampusEvents', data) as Promise< + number | undefined + >; + } + + requestCampusEventDetails(data: dto.RequestCampusEventDetailsDto) { + return this.send('requestCampusEventDetails', data) as Promise< + string | undefined + >; + } + + requestAllCampusEvents(data: dto.RequestCampusEventsDto) { + return this.send('requestAllCampusEvents', data) as Promise< + number | undefined + >; + } + + createCampusEvent(data: dto.UpsertCampusEventDto) { + return this.send('createCampusEvent', data) as Promise; + } + + updateCampusEvent(data: dto.UpsertCampusEventDto) { + return this.send('updateCampusEvent', data) as Promise; + } + + deleteCampusEvent(data: dto.DeleteCampusEventDto) { + return this.send('deleteCampusEvent', data) as Promise; + } + + rsvpCampusEvent(data: dto.RsvpCampusEventDto) { + return this.send('rsvpCampusEvent', data) as Promise; + } + + unRsvpCampusEvent(data: dto.UnRsvpCampusEventDto) { + return this.send('unRsvpCampusEvent', data) as Promise; + } + + requestAvailableChallenges(data: dto.RequestAvailableChallengesDto) { + return this.send('requestAvailableChallenges', data) as Promise< + any | undefined + >; + } + + setCurrentChallenge(data: dto.SetCurrentChallengeDto) { + return this.send('setCurrentChallenge', data) as Promise; + } + requestChallengeData(data: dto.RequestChallengeDataDto) { return this.send('requestChallengeData', data) as Promise< number | undefined @@ -119,6 +177,14 @@ export class ServerApi { return this.send('updateEventData', data) as Promise; } + triggerEventSync(data: dto.TriggerEventSyncDto) { + return this.send('triggerEventSync', data) as Promise; + } + + requestEventSyncStatus() { + return this.send('requestEventSyncStatus', {}) as Promise; + } + submitFeedback(data: dto.SubmitFeedbackDto) { return this.send('submitFeedback', data) as Promise; } @@ -155,6 +221,14 @@ export class ServerApi { return this.send('updateFcmToken', data) as Promise; } + sendNotification(data: dto.SendNotificationDto) { + return this.send('sendNotification', data) as Promise; + } + + removeFcmToken() { + return this.send('removeFcmToken', {}) as Promise; + } + requestOrganizationData(data: dto.RequestOrganizationDataDto) { return this.send('requestOrganizationData', data) as Promise< number | undefined @@ -259,21 +333,6 @@ export class ServerApi { return this.send('closeAccount', data) as Promise; } - requestAllBearItems(data: dto.RequestAllBearItemsDto) { - return this.send('requestAllBearItems', data) as Promise< - number | undefined - >; - } - - updateBearItemData(data: dto.UpdateBearItemDataDto) { - return this.send('updateBearItemData', data) as Promise; - } - - onUpdateBearItemData(callback: (data: dto.UpdateBearItemDataDto) => void) { - this.socket.removeAllListeners('updateBearItemData'); - this.socket.on('updateBearItemData', data => callback(data)); - } - onUpdateUserData(callback: (data: dto.UpdateUserDataDto) => void) { this.socket.removeAllListeners('updateUserData'); this.socket.on('updateUserData', data => callback(data)); @@ -347,6 +406,11 @@ export class ServerApi { this.socket.on('updateBearItemsData', data => callback(data)); } + onUpdateBearItemData(callback: (data: dto.UpdateBearItemDataDto) => void) { + this.socket.removeAllListeners('updateBearItemData'); + this.socket.on('updateBearItemData', data => callback(data)); + } + onUpdateUserInventoryData( callback: (data: dto.UpdateUserInventoryDataDto) => void, ) { @@ -424,4 +488,16 @@ export class ServerApi { this.socket.removeAllListeners('updateFeedbackData'); this.socket.on('updateFeedbackData', data => callback(data)); } + + onUpdateCampusEventData( + callback: (data: dto.UpdateCampusEventDataDto) => void, + ) { + this.socket.removeAllListeners('updateCampusEventData'); + this.socket.on('updateCampusEventData', data => callback(data)); + } + + onCampusEventList(callback: (data: dto.CampusEventListResponseDto) => void) { + this.socket.removeAllListeners('campusEventList'); + this.socket.on('campusEventList', data => callback(data)); + } } diff --git a/game/android/app/build.gradle b/game/android/app/build.gradle index eae29aae..1fa7ea85 100644 --- a/game/android/app/build.gradle +++ b/game/android/app/build.gradle @@ -43,7 +43,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId 'com.cornellgo.CornellGOApp' - minSdkVersion flutter.minSdkVersion + minSdkVersion 23 targetSdkVersion 35 versionCode flutterVersionCode.toInteger() versionName flutterVersionName diff --git a/game/assets/buildabear/eyes/><.png b/game/assets/buildabear/eyes/greaterthan_lessthan.png similarity index 100% rename from game/assets/buildabear/eyes/><.png rename to game/assets/buildabear/eyes/greaterthan_lessthan.png diff --git a/game/assets/buildabear/eyes/squinty_eyes.png b/game/assets/buildabear/eyes/squinty_eyes.png new file mode 100644 index 00000000..89d10de5 Binary files /dev/null and b/game/assets/buildabear/eyes/squinty_eyes.png differ diff --git a/game/assets/icons/pluscircle_red.svg b/game/assets/icons/pluscircle_red.svg new file mode 100644 index 00000000..d09c6eaf --- /dev/null +++ b/game/assets/icons/pluscircle_red.svg @@ -0,0 +1,4 @@ + + + + diff --git a/game/ios/Podfile.lock b/game/ios/Podfile.lock index 76700fce..cd48c01c 100644 --- a/game/ios/Podfile.lock +++ b/game/ios/Podfile.lock @@ -270,7 +270,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: AppAuth: d4f13a8fe0baf391b2108511793e4b479691fb73 - device_info_plus: 335f3ce08d2e174b9fdc3db3db0f4e3b1f66bd89 + device_info_plus: 71ffc6ab7634ade6267c7a93088ed7e4f74e5896 Firebase: d99ac19b909cd2c548339c2241ecd0d1599ab02e firebase_core: 995454a784ff288be5689b796deb9e9fa3601818 firebase_messaging: f4a41dd102ac18b840eba3f39d67e77922d3f707 diff --git a/game/lib/api/game_client_api.dart b/game/lib/api/game_client_api.dart index 99a14b22..890905c1 100644 --- a/game/lib/api/game_client_api.dart +++ b/game/lib/api/game_client_api.dart @@ -73,6 +73,11 @@ class GameClientApi { Stream get updateBearItemsDataStream => _updateBearItemsDataController.stream; + final _updateBearItemDataController = + StreamController.broadcast(sync: true); + Stream get updateBearItemDataStream => + _updateBearItemDataController.stream; + final _updateUserInventoryDataController = StreamController.broadcast(sync: true); Stream get updateUserInventoryDataStream => @@ -136,6 +141,21 @@ class GameClientApi { Stream get quizProgressStream => _quizProgressController.stream; + final _updateFeedbackDataController = + StreamController.broadcast(sync: true); + Stream get updateFeedbackDataStream => + _updateFeedbackDataController.stream; + + final _updateCampusEventDataController = + StreamController.broadcast(sync: true); + Stream get updateCampusEventDataStream => + _updateCampusEventDataController.stream; + + final _campusEventListController = + StreamController.broadcast(sync: true); + Stream get campusEventListStream => + _campusEventListController.stream; + final _reconnectedController = StreamController.broadcast(sync: true); Stream get reconnectedStream => _reconnectedController.stream; @@ -218,6 +238,11 @@ class GameClientApi { (data) => _updateBearItemsDataController .add(UpdateBearItemsDataDto.fromJson(data))); + sock.on( + "updateBearItemData", + (data) => _updateBearItemDataController + .add(UpdateBearItemDataDto.fromJson(data))); + sock.on( "updateUserInventoryData", (data) => _updateUserInventoryDataController @@ -271,6 +296,21 @@ class GameClientApi { sock.on("quizProgress", (data) => _quizProgressController.add(QuizProgressDto.fromJson(data))); + sock.on( + "updateFeedbackData", + (data) => _updateFeedbackDataController + .add(UpdateFeedbackDataDto.fromJson(data))); + + sock.on( + "updateCampusEventData", + (data) => _updateCampusEventDataController + .add(UpdateCampusEventDataDto.fromJson(data))); + + sock.on( + "campusEventList", + (data) => _campusEventListController + .add(CampusEventListResponseDto.fromJson(data))); + _connectedController.add(true); } diff --git a/game/lib/api/game_client_dto.dart b/game/lib/api/game_client_dto.dart index b355f7df..ae0a9ba7 100644 --- a/game/lib/api/game_client_dto.dart +++ b/game/lib/api/game_client_dto.dart @@ -29,6 +29,90 @@ enum BearSlotDto { ACCESSORY, } +enum CampusEventCategoryDto { + SOCIAL, + CULTURAL, + ATHLETIC, + WELLNESS, + ACADEMIC, + ARTS, + CAREER, + COMMUNITY, + OTHER, +} + +enum EventSourceDto { + API_EVENTS, + ADMIN_CREATED, + COMMUNITY_SUBMITTED, +} + +enum CheckInMethodDto { + LOCATION, + QR_CODE, + EITHER, +} + +enum CampusEventCategoriesDto { + SOCIAL, + CULTURAL, + ATHLETIC, + WELLNESS, + ACADEMIC, + ARTS, + CAREER, + COMMUNITY, + OTHER, +} + +enum CampusEventSourceDto { + API_EVENTS, + ADMIN_CREATED, + COMMUNITY_SUBMITTED, +} + +enum CampusEventCheckInMethodDto { + LOCATION, + QR_CODE, + EITHER, +} + +enum RequestCampusEventsCategoriesDto { + SOCIAL, + CULTURAL, + ATHLETIC, + WELLNESS, + ACADEMIC, + ARTS, + CAREER, + COMMUNITY, + OTHER, +} + +enum UpsertCampusEventCategoriesDto { + SOCIAL, + CULTURAL, + ATHLETIC, + WELLNESS, + ACADEMIC, + ARTS, + CAREER, + COMMUNITY, + OTHER, +} + +enum UpsertCampusEventSourceDto { + API_EVENTS, + ADMIN_CREATED, + COMMUNITY_SUBMITTED, +} + +enum UpsertCampusEventCheckInMethodDto { + LOCATION, + QR_CODE, + EITHER, +} + enum ChallengeLocationDto { ENG_QUAD, ARTS_QUAD, @@ -75,9 +159,13 @@ enum EventCategoryDto { FOOD, NATURE, HISTORICAL, - CAFE, - DININGHALL, - DORM, + RESIDENTIAL, + LANDMARK, + ARTS, + ATHLETICS, + LIBRARY, + ACADEMIC, + RECREATION, } enum EventTimeLimitationDto { @@ -694,172 +782,874 @@ class RequestBearItemsDto { return fields; } - RequestBearItemsDto.fromJson(Map fields) { - slot = fields.containsKey('slot') - ? (BearSlotDto.values.byName(fields['slot'])) + RequestBearItemsDto.fromJson(Map fields) { + slot = fields.containsKey('slot') + ? (BearSlotDto.values.byName(fields['slot'])) + : null; + } + + void partialUpdate(RequestBearItemsDto other) { + slot = other.slot == null ? slot : other.slot; + } + + RequestBearItemsDto({ + this.slot, + }); + + late BearSlotDto? slot; +} + +class RequestUserInventoryDto { + Map toJson() { + Map fields = {}; + return fields; + } + + RequestUserInventoryDto.fromJson(Map fields) {} + + void partialUpdate(RequestUserInventoryDto other) {} + + RequestUserInventoryDto(); +} + +class RequestUserBearLoadoutDto { + Map toJson() { + Map fields = {}; + return fields; + } + + RequestUserBearLoadoutDto.fromJson(Map fields) {} + + void partialUpdate(RequestUserBearLoadoutDto other) {} + + RequestUserBearLoadoutDto(); +} + +class UpdateBearItemsDataDto { + Map toJson() { + Map fields = {}; + fields['items'] = items! + .map>((dynamic val) => val!.toJson()) + .toList(); + return fields; + } + + UpdateBearItemsDataDto.fromJson(Map fields) { + items = fields["items"] + .map((dynamic val) => BearItemDto.fromJson(val)) + .toList(); + } + + void partialUpdate(UpdateBearItemsDataDto other) { + items = other.items; + } + + UpdateBearItemsDataDto({ + required this.items, + }); + + late List items; +} + +class UpdateUserInventoryDataDto { + Map toJson() { + Map fields = {}; + fields['userId'] = userId; + fields['items'] = items! + .map>((dynamic val) => val!.toJson()) + .toList(); + fields['balance'] = balance; + return fields; + } + + UpdateUserInventoryDataDto.fromJson(Map fields) { + userId = fields["userId"]; + items = fields["items"] + .map((dynamic val) => BearItemDto.fromJson(val)) + .toList(); + balance = fields["balance"]; + } + + void partialUpdate(UpdateUserInventoryDataDto other) { + userId = other.userId; + items = other.items; + balance = other.balance; + } + + UpdateUserInventoryDataDto({ + required this.userId, + required this.items, + required this.balance, + }); + + late String userId; + late List items; + late int balance; +} + +class UpdateUserBearLoadoutDataDto { + Map toJson() { + Map fields = {}; + fields['userId'] = userId; + fields['equipped'] = equipped! + .map>((dynamic val) => val!.toJson()) + .toList(); + return fields; + } + + UpdateUserBearLoadoutDataDto.fromJson(Map fields) { + userId = fields["userId"]; + equipped = fields["equipped"] + .map((dynamic val) => EquippedSlotDto.fromJson(val)) + .toList(); + } + + void partialUpdate(UpdateUserBearLoadoutDataDto other) { + userId = other.userId; + equipped = other.equipped; + } + + UpdateUserBearLoadoutDataDto({ + required this.userId, + required this.equipped, + }); + + late String userId; + late List equipped; +} + +class UpdatePurchaseResultDto { + Map toJson() { + Map fields = {}; + fields['success'] = success; + fields['newBalance'] = newBalance; + fields['itemId'] = itemId; + return fields; + } + + UpdatePurchaseResultDto.fromJson(Map fields) { + success = fields["success"]; + newBalance = fields["newBalance"]; + itemId = fields["itemId"]; + } + + void partialUpdate(UpdatePurchaseResultDto other) { + success = other.success; + newBalance = other.newBalance; + itemId = other.itemId; + } + + UpdatePurchaseResultDto({ + required this.success, + required this.newBalance, + required this.itemId, + }); + + late bool success; + late int newBalance; + late String itemId; +} + +class AdminBearItemDto { + Map toJson() { + Map fields = {}; + fields['id'] = id; + if (name != null) { + fields['name'] = name; + } + if (slot != null) { + fields['slot'] = slot!.name; + } + if (cost != null) { + fields['cost'] = cost; + } + if (assetKey != null) { + fields['assetKey'] = assetKey; + } + if (mimeType != null) { + fields['mimeType'] = mimeType; + } + if (zIndex != null) { + fields['zIndex'] = zIndex; + } + if (isDefault != null) { + fields['isDefault'] = isDefault; + } + return fields; + } + + AdminBearItemDto.fromJson(Map fields) { + id = fields["id"]; + name = fields.containsKey('name') ? (fields["name"]) : null; + slot = fields.containsKey('slot') + ? (BearSlotDto.values.byName(fields['slot'])) + : null; + cost = fields.containsKey('cost') ? (fields["cost"]) : null; + assetKey = fields.containsKey('assetKey') ? (fields["assetKey"]) : null; + mimeType = fields.containsKey('mimeType') ? (fields["mimeType"]) : null; + zIndex = fields.containsKey('zIndex') ? (fields["zIndex"]) : null; + isDefault = fields.containsKey('isDefault') ? (fields["isDefault"]) : null; + } + + void partialUpdate(AdminBearItemDto other) { + id = other.id; + name = other.name == null ? name : other.name; + slot = other.slot == null ? slot : other.slot; + cost = other.cost == null ? cost : other.cost; + assetKey = other.assetKey == null ? assetKey : other.assetKey; + mimeType = other.mimeType == null ? mimeType : other.mimeType; + zIndex = other.zIndex == null ? zIndex : other.zIndex; + isDefault = other.isDefault == null ? isDefault : other.isDefault; + } + + AdminBearItemDto({ + required this.id, + this.name, + this.slot, + this.cost, + this.assetKey, + this.mimeType, + this.zIndex, + this.isDefault, + }); + + late String id; + late String? name; + late BearSlotDto? slot; + late int? cost; + late String? assetKey; + late String? mimeType; + late int? zIndex; + late bool? isDefault; +} + +class UpdateBearItemDataDto { + Map toJson() { + Map fields = {}; + fields['bearItem'] = bearItem!.toJson(); + fields['deleted'] = deleted; + return fields; + } + + UpdateBearItemDataDto.fromJson(Map fields) { + bearItem = AdminBearItemDto.fromJson(fields['bearItem']); + deleted = fields["deleted"]; + } + + void partialUpdate(UpdateBearItemDataDto other) { + bearItem = other.bearItem; + deleted = other.deleted; + } + + UpdateBearItemDataDto({ + required this.bearItem, + required this.deleted, + }); + + late AdminBearItemDto bearItem; + late bool deleted; +} + +class RequestAllBearItemsDto { + Map toJson() { + Map fields = {}; + return fields; + } + + RequestAllBearItemsDto.fromJson(Map fields) {} + + void partialUpdate(RequestAllBearItemsDto other) {} + + RequestAllBearItemsDto(); +} + +class CampusEventDto { + Map toJson() { + Map fields = {}; + fields['id'] = id; + fields['title'] = title; + fields['description'] = description; + if (imageUrl != null) { + fields['imageUrl'] = imageUrl; + } + fields['startTime'] = startTime; + fields['endTime'] = endTime; + fields['allDay'] = allDay; + fields['locationName'] = locationName; + if (address != null) { + fields['address'] = address; + } + fields['latitude'] = latitude; + fields['longitude'] = longitude; + fields['categories'] = + categories!.map((dynamic val) => val!.name).toList(); + fields['tags'] = tags; + fields['source'] = source!.name; + if (externalUrl != null) { + fields['externalUrl'] = externalUrl; + } + if (organizerName != null) { + fields['organizerName'] = organizerName; + } + if (registrationUrl != null) { + fields['registrationUrl'] = registrationUrl; + } + fields['checkInMethod'] = checkInMethod!.name; + fields['pointsForAttendance'] = pointsForAttendance; + fields['featured'] = featured; + fields['attendanceCount'] = attendanceCount; + fields['rsvpCount'] = rsvpCount; + return fields; + } + + CampusEventDto.fromJson(Map fields) { + id = fields["id"]; + title = fields["title"]; + description = fields["description"]; + imageUrl = fields.containsKey('imageUrl') ? (fields["imageUrl"]) : null; + startTime = fields["startTime"]; + endTime = fields["endTime"]; + allDay = fields["allDay"]; + locationName = fields["locationName"]; + address = fields.containsKey('address') ? (fields["address"]) : null; + latitude = fields["latitude"]; + longitude = fields["longitude"]; + categories = fields["categories"] + .map( + (dynamic val) => CampusEventCategoriesDto.values.byName(val)) + .toList(); + tags = List.from(fields['tags']); + source = CampusEventSourceDto.values.byName(fields['source']); + externalUrl = + fields.containsKey('externalUrl') ? (fields["externalUrl"]) : null; + organizerName = + fields.containsKey('organizerName') ? (fields["organizerName"]) : null; + registrationUrl = fields.containsKey('registrationUrl') + ? (fields["registrationUrl"]) + : null; + checkInMethod = + CampusEventCheckInMethodDto.values.byName(fields['checkInMethod']); + pointsForAttendance = fields["pointsForAttendance"]; + featured = fields["featured"]; + attendanceCount = fields["attendanceCount"]; + rsvpCount = fields["rsvpCount"]; + } + + void partialUpdate(CampusEventDto other) { + id = other.id; + title = other.title; + description = other.description; + imageUrl = other.imageUrl == null ? imageUrl : other.imageUrl; + startTime = other.startTime; + endTime = other.endTime; + allDay = other.allDay; + locationName = other.locationName; + address = other.address == null ? address : other.address; + latitude = other.latitude; + longitude = other.longitude; + categories = other.categories; + tags = other.tags; + source = other.source; + externalUrl = other.externalUrl == null ? externalUrl : other.externalUrl; + organizerName = + other.organizerName == null ? organizerName : other.organizerName; + registrationUrl = + other.registrationUrl == null ? registrationUrl : other.registrationUrl; + checkInMethod = other.checkInMethod; + pointsForAttendance = other.pointsForAttendance; + featured = other.featured; + attendanceCount = other.attendanceCount; + rsvpCount = other.rsvpCount; + } + + CampusEventDto({ + required this.id, + required this.title, + required this.description, + this.imageUrl, + required this.startTime, + required this.endTime, + required this.allDay, + required this.locationName, + this.address, + required this.latitude, + required this.longitude, + required this.categories, + required this.tags, + required this.source, + this.externalUrl, + this.organizerName, + this.registrationUrl, + required this.checkInMethod, + required this.pointsForAttendance, + required this.featured, + required this.attendanceCount, + required this.rsvpCount, + }); + + late String id; + late String title; + late String description; + late String? imageUrl; + late String startTime; + late String endTime; + late bool allDay; + late String locationName; + late String? address; + late int latitude; + late int longitude; + late List categories; + late List tags; + late CampusEventSourceDto source; + late String? externalUrl; + late String? organizerName; + late String? registrationUrl; + late CampusEventCheckInMethodDto checkInMethod; + late int pointsForAttendance; + late bool featured; + late int attendanceCount; + late int rsvpCount; +} + +class RequestCampusEventsDto { + Map toJson() { + Map fields = {}; + fields['page'] = page; + fields['limit'] = limit; + if (dateFrom != null) { + fields['dateFrom'] = dateFrom; + } + if (dateTo != null) { + fields['dateTo'] = dateTo; + } + if (categories != null) { + fields['categories'] = + categories!.map((dynamic val) => val!.name).toList(); + } + if (search != null) { + fields['search'] = search; + } + if (featured != null) { + fields['featured'] = featured; + } + return fields; + } + + RequestCampusEventsDto.fromJson(Map fields) { + page = fields["page"]; + limit = fields["limit"]; + dateFrom = fields.containsKey('dateFrom') ? (fields["dateFrom"]) : null; + dateTo = fields.containsKey('dateTo') ? (fields["dateTo"]) : null; + categories = fields.containsKey('categories') + ? (fields["categories"] + .map((dynamic val) => + RequestCampusEventsCategoriesDto.values.byName(val)) + .toList()) + : null; + search = fields.containsKey('search') ? (fields["search"]) : null; + featured = fields.containsKey('featured') ? (fields["featured"]) : null; + } + + void partialUpdate(RequestCampusEventsDto other) { + page = other.page; + limit = other.limit; + dateFrom = other.dateFrom == null ? dateFrom : other.dateFrom; + dateTo = other.dateTo == null ? dateTo : other.dateTo; + categories = other.categories == null ? categories : other.categories; + search = other.search == null ? search : other.search; + featured = other.featured == null ? featured : other.featured; + } + + RequestCampusEventsDto({ + required this.page, + required this.limit, + this.dateFrom, + this.dateTo, + this.categories, + this.search, + this.featured, + }); + + late int page; + late int limit; + late String? dateFrom; + late String? dateTo; + late List? categories; + late String? search; + late bool? featured; +} + +class CampusEventListDto { + Map toJson() { + Map fields = {}; + fields['events'] = events! + .map>((dynamic val) => val!.toJson()) + .toList(); + fields['total'] = total; + fields['page'] = page; + fields['limit'] = limit; + fields['totalPages'] = totalPages; + return fields; + } + + CampusEventListDto.fromJson(Map fields) { + events = fields["events"] + .map((dynamic val) => CampusEventDto.fromJson(val)) + .toList(); + total = fields["total"]; + page = fields["page"]; + limit = fields["limit"]; + totalPages = fields["totalPages"]; + } + + void partialUpdate(CampusEventListDto other) { + events = other.events; + total = other.total; + page = other.page; + limit = other.limit; + totalPages = other.totalPages; + } + + CampusEventListDto({ + required this.events, + required this.total, + required this.page, + required this.limit, + required this.totalPages, + }); + + late List events; + late int total; + late int page; + late int limit; + late int totalPages; +} + +class RequestCampusEventDetailsDto { + Map toJson() { + Map fields = {}; + fields['eventId'] = eventId; + return fields; + } + + RequestCampusEventDetailsDto.fromJson(Map fields) { + eventId = fields["eventId"]; + } + + void partialUpdate(RequestCampusEventDetailsDto other) { + eventId = other.eventId; + } + + RequestCampusEventDetailsDto({ + required this.eventId, + }); + + late String eventId; +} + +class UpsertCampusEventDto { + Map toJson() { + Map fields = {}; + if (id != null) { + fields['id'] = id; + } + fields['title'] = title; + fields['description'] = description; + if (imageUrl != null) { + fields['imageUrl'] = imageUrl; + } + fields['startTime'] = startTime; + fields['endTime'] = endTime; + if (allDay != null) { + fields['allDay'] = allDay; + } + fields['locationName'] = locationName; + if (address != null) { + fields['address'] = address; + } + fields['latitude'] = latitude; + fields['longitude'] = longitude; + if (checkInRadius != null) { + fields['checkInRadius'] = checkInRadius; + } + fields['categories'] = + categories!.map((dynamic val) => val!.name).toList(); + fields['tags'] = tags; + fields['source'] = source!.name; + if (externalId != null) { + fields['externalId'] = externalId; + } + if (externalUrl != null) { + fields['externalUrl'] = externalUrl; + } + if (organizerName != null) { + fields['organizerName'] = organizerName; + } + if (organizerEmail != null) { + fields['organizerEmail'] = organizerEmail; + } + if (organizerId != null) { + fields['organizerId'] = organizerId; + } + if (checkInMethod != null) { + fields['checkInMethod'] = checkInMethod!.name; + } + if (pointsForAttendance != null) { + fields['pointsForAttendance'] = pointsForAttendance; + } + if (featured != null) { + fields['featured'] = featured; + } + if (registrationUrl != null) { + fields['registrationUrl'] = registrationUrl; + } + return fields; + } + + UpsertCampusEventDto.fromJson(Map fields) { + id = fields.containsKey('id') ? (fields["id"]) : null; + title = fields["title"]; + description = fields["description"]; + imageUrl = fields.containsKey('imageUrl') ? (fields["imageUrl"]) : null; + startTime = fields["startTime"]; + endTime = fields["endTime"]; + allDay = fields.containsKey('allDay') ? (fields["allDay"]) : null; + locationName = fields["locationName"]; + address = fields.containsKey('address') ? (fields["address"]) : null; + latitude = fields["latitude"]; + longitude = fields["longitude"]; + checkInRadius = + fields.containsKey('checkInRadius') ? (fields["checkInRadius"]) : null; + categories = fields["categories"] + .map( + (dynamic val) => UpsertCampusEventCategoriesDto.values.byName(val)) + .toList(); + tags = List.from(fields['tags']); + source = UpsertCampusEventSourceDto.values.byName(fields['source']); + externalId = + fields.containsKey('externalId') ? (fields["externalId"]) : null; + externalUrl = + fields.containsKey('externalUrl') ? (fields["externalUrl"]) : null; + organizerName = + fields.containsKey('organizerName') ? (fields["organizerName"]) : null; + organizerEmail = fields.containsKey('organizerEmail') + ? (fields["organizerEmail"]) + : null; + organizerId = + fields.containsKey('organizerId') ? (fields["organizerId"]) : null; + checkInMethod = fields.containsKey('checkInMethod') + ? (UpsertCampusEventCheckInMethodDto.values + .byName(fields['checkInMethod'])) + : null; + pointsForAttendance = fields.containsKey('pointsForAttendance') + ? (fields["pointsForAttendance"]) + : null; + featured = fields.containsKey('featured') ? (fields["featured"]) : null; + registrationUrl = fields.containsKey('registrationUrl') + ? (fields["registrationUrl"]) : null; } - void partialUpdate(RequestBearItemsDto other) { - slot = other.slot == null ? slot : other.slot; + void partialUpdate(UpsertCampusEventDto other) { + id = other.id == null ? id : other.id; + title = other.title; + description = other.description; + imageUrl = other.imageUrl == null ? imageUrl : other.imageUrl; + startTime = other.startTime; + endTime = other.endTime; + allDay = other.allDay == null ? allDay : other.allDay; + locationName = other.locationName; + address = other.address == null ? address : other.address; + latitude = other.latitude; + longitude = other.longitude; + checkInRadius = + other.checkInRadius == null ? checkInRadius : other.checkInRadius; + categories = other.categories; + tags = other.tags; + source = other.source; + externalId = other.externalId == null ? externalId : other.externalId; + externalUrl = other.externalUrl == null ? externalUrl : other.externalUrl; + organizerName = + other.organizerName == null ? organizerName : other.organizerName; + organizerEmail = + other.organizerEmail == null ? organizerEmail : other.organizerEmail; + organizerId = other.organizerId == null ? organizerId : other.organizerId; + checkInMethod = + other.checkInMethod == null ? checkInMethod : other.checkInMethod; + pointsForAttendance = other.pointsForAttendance == null + ? pointsForAttendance + : other.pointsForAttendance; + featured = other.featured == null ? featured : other.featured; + registrationUrl = + other.registrationUrl == null ? registrationUrl : other.registrationUrl; } - RequestBearItemsDto({ - this.slot, + UpsertCampusEventDto({ + this.id, + required this.title, + required this.description, + this.imageUrl, + required this.startTime, + required this.endTime, + this.allDay, + required this.locationName, + this.address, + required this.latitude, + required this.longitude, + this.checkInRadius, + required this.categories, + required this.tags, + required this.source, + this.externalId, + this.externalUrl, + this.organizerName, + this.organizerEmail, + this.organizerId, + this.checkInMethod, + this.pointsForAttendance, + this.featured, + this.registrationUrl, }); - late BearSlotDto? slot; + late String? id; + late String title; + late String description; + late String? imageUrl; + late String startTime; + late String endTime; + late bool? allDay; + late String locationName; + late String? address; + late int latitude; + late int longitude; + late int? checkInRadius; + late List categories; + late List tags; + late UpsertCampusEventSourceDto source; + late String? externalId; + late String? externalUrl; + late String? organizerName; + late String? organizerEmail; + late String? organizerId; + late UpsertCampusEventCheckInMethodDto? checkInMethod; + late int? pointsForAttendance; + late bool? featured; + late String? registrationUrl; } -class RequestUserInventoryDto { +class DeleteCampusEventDto { Map toJson() { Map fields = {}; + fields['eventId'] = eventId; return fields; } - RequestUserInventoryDto.fromJson(Map fields) {} - - void partialUpdate(RequestUserInventoryDto other) {} - - RequestUserInventoryDto(); -} - -class RequestUserBearLoadoutDto { - Map toJson() { - Map fields = {}; - return fields; + DeleteCampusEventDto.fromJson(Map fields) { + eventId = fields["eventId"]; } - RequestUserBearLoadoutDto.fromJson(Map fields) {} + void partialUpdate(DeleteCampusEventDto other) { + eventId = other.eventId; + } - void partialUpdate(RequestUserBearLoadoutDto other) {} + DeleteCampusEventDto({ + required this.eventId, + }); - RequestUserBearLoadoutDto(); + late String eventId; } -class UpdateBearItemsDataDto { +class RsvpCampusEventDto { Map toJson() { Map fields = {}; - fields['items'] = items! - .map>((dynamic val) => val!.toJson()) - .toList(); + fields['eventId'] = eventId; return fields; } - UpdateBearItemsDataDto.fromJson(Map fields) { - items = fields["items"] - .map((dynamic val) => BearItemDto.fromJson(val)) - .toList(); + RsvpCampusEventDto.fromJson(Map fields) { + eventId = fields["eventId"]; } - void partialUpdate(UpdateBearItemsDataDto other) { - items = other.items; + void partialUpdate(RsvpCampusEventDto other) { + eventId = other.eventId; } - UpdateBearItemsDataDto({ - required this.items, + RsvpCampusEventDto({ + required this.eventId, }); - late List items; + late String eventId; } -class UpdateUserInventoryDataDto { +class UnRsvpCampusEventDto { Map toJson() { Map fields = {}; - fields['userId'] = userId; - fields['items'] = items! - .map>((dynamic val) => val!.toJson()) - .toList(); - fields['balance'] = balance; + fields['eventId'] = eventId; return fields; } - UpdateUserInventoryDataDto.fromJson(Map fields) { - userId = fields["userId"]; - items = fields["items"] - .map((dynamic val) => BearItemDto.fromJson(val)) - .toList(); - balance = fields["balance"]; + UnRsvpCampusEventDto.fromJson(Map fields) { + eventId = fields["eventId"]; } - void partialUpdate(UpdateUserInventoryDataDto other) { - userId = other.userId; - items = other.items; - balance = other.balance; + void partialUpdate(UnRsvpCampusEventDto other) { + eventId = other.eventId; } - UpdateUserInventoryDataDto({ - required this.userId, - required this.items, - required this.balance, + UnRsvpCampusEventDto({ + required this.eventId, }); - late String userId; - late List items; - late int balance; + late String eventId; } -class UpdateUserBearLoadoutDataDto { +class UpdateCampusEventDataDto { Map toJson() { Map fields = {}; - fields['userId'] = userId; - fields['equipped'] = equipped! - .map>((dynamic val) => val!.toJson()) - .toList(); + fields['event'] = event!.toJson(); + fields['deleted'] = deleted; return fields; } - UpdateUserBearLoadoutDataDto.fromJson(Map fields) { - userId = fields["userId"]; - equipped = fields["equipped"] - .map((dynamic val) => EquippedSlotDto.fromJson(val)) - .toList(); + UpdateCampusEventDataDto.fromJson(Map fields) { + event = CampusEventDto.fromJson(fields['event']); + deleted = fields["deleted"]; } - void partialUpdate(UpdateUserBearLoadoutDataDto other) { - userId = other.userId; - equipped = other.equipped; + void partialUpdate(UpdateCampusEventDataDto other) { + event = other.event; + deleted = other.deleted; } - UpdateUserBearLoadoutDataDto({ - required this.userId, - required this.equipped, + UpdateCampusEventDataDto({ + required this.event, + required this.deleted, }); - late String userId; - late List equipped; + late CampusEventDto event; + late bool deleted; } -class UpdatePurchaseResultDto { +class CampusEventListResponseDto { Map toJson() { Map fields = {}; - fields['success'] = success; - fields['newBalance'] = newBalance; - fields['itemId'] = itemId; + fields['list'] = list!.toJson(); return fields; } - UpdatePurchaseResultDto.fromJson(Map fields) { - success = fields["success"]; - newBalance = fields["newBalance"]; - itemId = fields["itemId"]; + CampusEventListResponseDto.fromJson(Map fields) { + list = CampusEventListDto.fromJson(fields['list']); } - void partialUpdate(UpdatePurchaseResultDto other) { - success = other.success; - newBalance = other.newBalance; - itemId = other.itemId; + void partialUpdate(CampusEventListResponseDto other) { + list = other.list; } - UpdatePurchaseResultDto({ - required this.success, - required this.newBalance, - required this.itemId, + CampusEventListResponseDto({ + required this.list, }); - late bool success; - late int newBalance; - late String itemId; + late CampusEventListDto list; } class CompletedChallengeDto { @@ -912,6 +1702,12 @@ class ChallengeDto { if (timerLength != null) { fields['timerLength'] = timerLength; } + if (scheduledStartTime != null) { + fields['scheduledStartTime'] = scheduledStartTime; + } + if (scheduledEndTime != null) { + fields['scheduledEndTime'] = scheduledEndTime; + } return fields; } @@ -937,6 +1733,12 @@ class ChallengeDto { fields.containsKey('linkedEventId') ? (fields["linkedEventId"]) : null; timerLength = fields.containsKey('timerLength') ? (fields["timerLength"]) : null; + scheduledStartTime = fields.containsKey('scheduledStartTime') + ? (fields["scheduledStartTime"]) + : null; + scheduledEndTime = fields.containsKey('scheduledEndTime') + ? (fields["scheduledEndTime"]) + : null; } void partialUpdate(ChallengeDto other) { @@ -955,6 +1757,12 @@ class ChallengeDto { linkedEventId = other.linkedEventId == null ? linkedEventId : other.linkedEventId; timerLength = other.timerLength == null ? timerLength : other.timerLength; + scheduledStartTime = other.scheduledStartTime == null + ? scheduledStartTime + : other.scheduledStartTime; + scheduledEndTime = other.scheduledEndTime == null + ? scheduledEndTime + : other.scheduledEndTime; } ChallengeDto({ @@ -970,6 +1778,8 @@ class ChallengeDto { this.closeRadiusF, this.linkedEventId, this.timerLength, + this.scheduledStartTime, + this.scheduledEndTime, }); late String id; @@ -984,6 +1794,8 @@ class ChallengeDto { late double? closeRadiusF; late String? linkedEventId; late int? timerLength; + late String? scheduledStartTime; + late String? scheduledEndTime; } class RequestChallengeDataDto { @@ -1798,6 +2610,9 @@ class PrevChallengeDto { if (failed != null) { fields['failed'] = failed; } + if (dateExpired != null) { + fields['dateExpired'] = dateExpired; + } return fields; } @@ -1809,6 +2624,8 @@ class PrevChallengeDto { : null; dateCompleted = fields["dateCompleted"]; failed = fields.containsKey('failed') ? (fields["failed"]) : null; + dateExpired = + fields.containsKey('dateExpired') ? (fields["dateExpired"]) : null; } void partialUpdate(PrevChallengeDto other) { @@ -1818,6 +2635,7 @@ class PrevChallengeDto { other.extensionsUsed == null ? extensionsUsed : other.extensionsUsed; dateCompleted = other.dateCompleted; failed = other.failed == null ? failed : other.failed; + dateExpired = other.dateExpired == null ? dateExpired : other.dateExpired; } PrevChallengeDto({ @@ -1826,6 +2644,7 @@ class PrevChallengeDto { this.extensionsUsed, required this.dateCompleted, this.failed, + this.dateExpired, }); late String challengeId; @@ -1833,6 +2652,7 @@ class PrevChallengeDto { late int? extensionsUsed; late String dateCompleted; late bool? failed; + late bool? dateExpired; } class EventTrackerDto { @@ -2096,6 +2916,105 @@ class SubmitFeedbackDto { late String? challengeId; } +class FeedbackDto { + Map toJson() { + Map fields = {}; + fields['id'] = id; + fields['createdAt'] = createdAt; + fields['category'] = category!.name; + fields['text'] = text; + if (rating != null) { + fields['rating'] = rating; + } + if (challengeId != null) { + fields['challengeId'] = challengeId; + } + fields['userId'] = userId; + if (username != null) { + fields['username'] = username; + } + if (challengeName != null) { + fields['challengeName'] = challengeName; + } + return fields; + } + + FeedbackDto.fromJson(Map fields) { + id = fields["id"]; + createdAt = fields["createdAt"]; + category = FeedbackCategoryDto.values.byName(fields['category']); + text = fields["text"]; + rating = fields.containsKey('rating') ? (fields["rating"]) : null; + challengeId = + fields.containsKey('challengeId') ? (fields["challengeId"]) : null; + userId = fields["userId"]; + username = fields.containsKey('username') ? (fields["username"]) : null; + challengeName = + fields.containsKey('challengeName') ? (fields["challengeName"]) : null; + } + + void partialUpdate(FeedbackDto other) { + id = other.id; + createdAt = other.createdAt; + category = other.category; + text = other.text; + rating = other.rating == null ? rating : other.rating; + challengeId = other.challengeId == null ? challengeId : other.challengeId; + userId = other.userId; + username = other.username == null ? username : other.username; + challengeName = + other.challengeName == null ? challengeName : other.challengeName; + } + + FeedbackDto({ + required this.id, + required this.createdAt, + required this.category, + required this.text, + this.rating, + this.challengeId, + required this.userId, + this.username, + this.challengeName, + }); + + late String id; + late String createdAt; + late FeedbackCategoryDto category; + late String text; + late bool? rating; + late String? challengeId; + late String userId; + late String? username; + late String? challengeName; +} + +class UpdateFeedbackDataDto { + Map toJson() { + Map fields = {}; + fields['feedbacks'] = feedbacks! + .map>((dynamic val) => val!.toJson()) + .toList(); + return fields; + } + + UpdateFeedbackDataDto.fromJson(Map fields) { + feedbacks = fields["feedbacks"] + .map((dynamic val) => FeedbackDto.fromJson(val)) + .toList(); + } + + void partialUpdate(UpdateFeedbackDataDto other) { + feedbacks = other.feedbacks; + } + + UpdateFeedbackDataDto({ + required this.feedbacks, + }); + + late List feedbacks; +} + class JoinGroupDto { Map toJson() { Map fields = {}; diff --git a/game/lib/api/game_server_api.dart b/game/lib/api/game_server_api.dart index ac57d0d8..56ece362 100644 --- a/game/lib/api/game_server_api.dart +++ b/game/lib/api/game_server_api.dart @@ -45,7 +45,12 @@ class GameServerApi { completer.complete(arg); }; - Future.delayed(Duration(seconds: 5)).then((value) => completionFunc(null)); + // Set up timeout - only complete if not already completed + Future.delayed(Duration(seconds: 5)).then((value) { + if (!completer.isCompleted) { + completionFunc(null); + } + }); _refreshEv = ev; _refreshDat = data; @@ -82,6 +87,44 @@ class GameServerApi { Future equipBearItem(EquipBearItemDto dto) async => await _invokeWithRefresh("equipBearItem", dto.toJson()); + Future requestAllBearItems(RequestAllBearItemsDto dto) async => + await _invokeWithRefresh("requestAllBearItems", dto.toJson()); + + Future updateBearItemData(UpdateBearItemDataDto dto) async => + await _invokeWithRefresh("updateBearItemData", dto.toJson()); + + Future requestCampusEvents(RequestCampusEventsDto dto) async => + await _invokeWithRefresh("requestCampusEvents", dto.toJson()); + + Future requestCampusEventDetails( + RequestCampusEventDetailsDto dto) async => + await _invokeWithRefresh("requestCampusEventDetails", dto.toJson()); + + Future requestAllCampusEvents(RequestCampusEventsDto dto) async => + await _invokeWithRefresh("requestAllCampusEvents", dto.toJson()); + + Future createCampusEvent(UpsertCampusEventDto dto) async => + await _invokeWithRefresh("createCampusEvent", dto.toJson()); + + Future updateCampusEvent(UpsertCampusEventDto dto) async => + await _invokeWithRefresh("updateCampusEvent", dto.toJson()); + + Future deleteCampusEvent(DeleteCampusEventDto dto) async => + await _invokeWithRefresh("deleteCampusEvent", dto.toJson()); + + Future rsvpCampusEvent(RsvpCampusEventDto dto) async => + await _invokeWithRefresh("rsvpCampusEvent", dto.toJson()); + + Future unRsvpCampusEvent(UnRsvpCampusEventDto dto) async => + await _invokeWithRefresh("unRsvpCampusEvent", dto.toJson()); + + Future requestAvailableChallenges( + RequestAvailableChallengesDto dto) async => + await _invokeWithRefresh("requestAvailableChallenges", dto.toJson()); + + Future setCurrentChallenge(SetCurrentChallengeDto dto) async => + await _invokeWithRefresh("setCurrentChallenge", dto.toJson()); + Future requestChallengeData(RequestChallengeDataDto dto) async => await _invokeWithRefresh("requestChallengeData", dto.toJson()); @@ -96,12 +139,6 @@ class GameServerApi { Future checkInWithQrCode(QrCodeCheckInDto dto) async => await _invokeWithRefresh("checkInWithQrCode", dto.toJson()); - Future requestAvailableChallenges( - RequestAvailableChallengesDto dto) async => - await _invokeWithRefresh("requestAvailableChallenges", dto.toJson()); - - Future setCurrentChallenge(SetCurrentChallengeDto dto) async => - await _invokeWithRefresh("setCurrentChallenge", dto.toJson()); Future requestEventData(RequestEventDataDto dto) async => await _invokeWithRefresh("requestEventData", dto.toJson()); @@ -125,9 +162,18 @@ class GameServerApi { Future updateEventData(UpdateEventDataDto dto) async => await _invokeWithRefresh("updateEventData", dto.toJson()); + Future triggerEventSync(TriggerEventSyncDto dto) async => + await _invokeWithRefresh("triggerEventSync", dto.toJson()); + + Future requestEventSyncStatus(Map dto) async => + await _invokeWithRefresh("requestEventSyncStatus", dto); + Future submitFeedback(SubmitFeedbackDto dto) async => await _invokeWithRefresh("submitFeedback", dto.toJson()); + Future requestFeedbackData(Map dto) async => + await _invokeWithRefresh("requestFeedbackData", dto); + Future requestGroupData(RequestGroupDataDto dto) async => await _invokeWithRefresh("requestGroupData", dto.toJson()); @@ -149,6 +195,12 @@ class GameServerApi { Future updateFcmToken(UpdatePushTokenDto dto) async => await _invokeWithRefresh("updateFcmToken", dto.toJson()); + Future sendNotification(SendNotificationDto dto) async => + await _invokeWithRefresh("sendNotification", dto.toJson()); + + Future removeFcmToken(Map dto) async => + await _invokeWithRefresh("removeFcmToken", dto); + Future requestOrganizationData(RequestOrganizationDataDto dto) async => await _invokeWithRefresh("requestOrganizationData", dto.toJson()); diff --git a/game/lib/constants/colors.dart b/game/lib/constants/colors.dart index 73800c6d..61ee7dcf 100644 --- a/game/lib/constants/colors.dart +++ b/game/lib/constants/colors.dart @@ -36,6 +36,8 @@ class AppColors { static const Color greenDark = Color(0xFF31B346); // Backgrounds + static const Color white = Color(0xFFFFFFFF); + static const Color transparent = Color(0x00000000); static const Color warmWhite = Color(0xFFFFF8F1); static const Color cream = Color(0xFFF9EDDA); static const Color quizBackground = Color(0xFFF9F5F1); @@ -71,8 +73,12 @@ class AppColors { static const Color black20 = Color(0x33000000); static const Color black25 = Color(0x40000000); static const Color black30 = Color(0x4D000000); + static const Color black50 = Color(0x80000000); static const Color black80 = Color(0xCC000000); + /// White at 50% opacity (e.g. hint text on dark field fills). + static const Color white50 = Color(0x80FFFFFF); + // Extended grays static const Color silverGray = Color(0xFFC6C6C6); static const Color disabledGray = Color(0xFFBABABA); diff --git a/game/lib/journeys/challenge_creation_page.dart b/game/lib/journeys/challenge_creation_page.dart new file mode 100644 index 00000000..b7f3bdbd --- /dev/null +++ b/game/lib/journeys/challenge_creation_page.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:game/constants/constants.dart'; + +/// Next step after journey creation: add challenges (placeholder until wired). +class ChallengeCreationPage extends StatelessWidget { + const ChallengeCreationPage({super.key, this.createdEventId}); + + /// Server id of the `EventBase` with `isJourney == true`, if creation succeeded. + final String? createdEventId; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.warmWhite, + appBar: AppBar( + backgroundColor: AppColors.warmWhite, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: AppColors.darkText), + onPressed: () => Navigator.of(context).pop(), + ), + title: const Text( + 'Add challenges', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 18, + fontWeight: FontWeight.w600, + color: AppColors.darkText, + ), + ), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + Text( + 'Your journey was created. Next, add stops and challenges.', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 16, + height: 1.4, + color: AppColors.darkText.withOpacity(0.85), + ), + ), + if (createdEventId != null && createdEventId!.isNotEmpty) ...[ + const SizedBox(height: 24), + Text( + 'Event ID', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.grayText, + ), + ), + const SizedBox(height: 4), + SelectableText( + createdEventId!, + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 13, + color: AppColors.darkText, + ), + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/game/lib/journeys/custom_journey_creations_page.dart b/game/lib/journeys/custom_journey_creations_page.dart new file mode 100644 index 00000000..809939da --- /dev/null +++ b/game/lib/journeys/custom_journey_creations_page.dart @@ -0,0 +1,547 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_datetime_picker_plus/flutter_datetime_picker_plus.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:game/api/game_api.dart'; +import 'package:game/api/game_client_dto.dart'; +import 'package:game/constants/constants.dart'; +import 'package:game/journeys/challenge_creation_page.dart'; +import 'package:game/model/user_model.dart'; +import 'package:game/utils/utility_functions.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; + +/// Custom journey creation page +/// Fields: Journey Name (text box), Categories (selection pills), Difficulty Level (selection pills), Start Date (DateTime picker), Description +/// The Next button is disabled until all fields are filled out. +/// +/// Once the Next button is clicked, creates an [EventBase] with isJourney:true, and redirects to placeholder challenge_creation_page +/// The user creating the event is required to be a manager of an organization. +class CustomJourneyCreationsPage extends StatefulWidget { + const CustomJourneyCreationsPage({super.key}); + + @override + State createState() => + _CustomJourneyCreationsPageState(); +} + +class _CustomJourneyCreationsPageState + extends State { + static const double _fieldWidth = 345; + + final TextEditingController _nameController = TextEditingController(); + final TextEditingController _descriptionController = TextEditingController(); + + DateTime? _startDateTime; + + String? _selectedCategory; + String? _selectedDifficulty; + + static const List _categories = [ + 'Food', + 'Nature', + 'Historical', + 'Residential', + 'Landmark', + 'Arts', + 'Athletics', + 'Library', + 'Academic', + 'Recreation' + ]; + + static const List _difficulties = ['Easy', 'Medium', 'Hard']; + + /// Default map center (Ithaca) — matches server `defaultEventData`. + static const double _defaultLatitude = 42.44755580740012; + static const double _defaultLongitude = -76.48504614830019; + + bool _isSubmitting = false; + + @override + void initState() { + super.initState(); + _nameController.addListener(_onFormFieldChanged); + _descriptionController.addListener(_onFormFieldChanged); + } + + void _onFormFieldChanged() => setState(() {}); + + // True when all fields are filled out + bool get _canProceed { + return _nameController.text.trim().isNotEmpty && + _selectedCategory != null && + _selectedDifficulty != null && + _startDateTime != null && + _descriptionController.text.trim().isNotEmpty; + } + + EventCategoryDto _eventCategoryDtoFromUi(String label) { + switch (label) { + case 'Food': + return EventCategoryDto.FOOD; + case 'Nature': + return EventCategoryDto.NATURE; + case 'Historical': + return EventCategoryDto.HISTORICAL; + case 'Residential': + return EventCategoryDto.RESIDENTIAL; + case 'Landmark': + return EventCategoryDto.LANDMARK; + case 'Arts': + return EventCategoryDto.ARTS; + case 'Athletics': + return EventCategoryDto.ATHLETICS; + case 'Library': + return EventCategoryDto.LIBRARY; + case 'Academic': + return EventCategoryDto.ACADEMIC; + case 'Recreation': + return EventCategoryDto.RECREATION; + + default: + return EventCategoryDto.FOOD; + } + } + + EventDifficultyDto _mapDifficulty(String d) { + switch (d) { + case 'Easy': + return EventDifficultyDto.Easy; + case 'Medium': + return EventDifficultyDto.Normal; + case 'Hard': + return EventDifficultyDto.Hard; + default: + return EventDifficultyDto.Normal; + } + } + + String _trimDescription(int maxChars) { + final t = _descriptionController.text.trim(); + if (t.length <= maxChars) return t; + return t.substring(0, maxChars); + } + + String _longDescriptionForEvent() { + final start = + DateFormat('MM/dd/yyyy h:mm a', 'en_US').format(_startDateTime!); + final body = _descriptionController.text.trim(); + return 'Scheduled start: $start\nCategory: $_selectedCategory\n\n$body'; + } + + /// Creates `EventBase` with `isJourney: true` on the server, then opens challenge flow. + Future _onTapNext() async { + if (!_canProceed) { + displayToast( + 'All fields must be filled out.', + Status.error, + ); + return; + } + if (_startDateTime!.isBefore(DateTime.now())) { + displayToast( + 'Start date and time cannot be in the past.', + Status.error, + ); + return; + } + if (_isSubmitting) return; + + final api = Provider.of(context, listen: false); + final userModel = Provider.of(context, listen: false); + final userId = userModel.userData?.id; + + if (userId == null) { + displayToast('Sign in to create a journey.', Status.error); + return; + } + + String? managedOrgId; + for (final org in userModel.orgData.values) { + if (org.managers?.contains(userId) ?? false) { + managedOrgId = org.id; + break; + } + } + if (managedOrgId == null) { + displayToast( + 'You must manage an organization to create a journey.', + Status.error, + ); + return; + } + + if (api.serverApi == null) { + displayToast('Not connected to server.', Status.error); + return; + } + + setState(() => _isSubmitting = true); + + final longRaw = _longDescriptionForEvent(); + final longForServer = + longRaw.length > 8192 ? longRaw.substring(0, 8192) : longRaw; + + final eventDto = EventDto( + id: '', + requiredMembers: 1, + name: _nameController.text.trim(), + description: _trimDescription(2048), + longDescription: longForServer, + category: _eventCategoryDtoFromUi(_selectedCategory!), + timeLimitation: EventTimeLimitationDto.PERPETUAL, + endTime: DateTime.utc(2060, 1, 1).toIso8601String(), + initialOrganizationId: managedOrgId, + difficulty: _mapDifficulty(_selectedDifficulty!), + indexable: true, + latitudeF: _defaultLatitude, + longitudeF: _defaultLongitude, + featured: false, + isJourney: true, + ); + + try { + final result = await api.serverApi!.updateEventData( + UpdateEventDataDto( + event: eventDto, + deleted: false, + ), + ); + + if (!mounted) return; + + if (result == null || result.isEmpty) { + displayToast( + 'Could not create journey. Check permissions or try again.', + Status.error, + ); + return; + } + + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ChallengeCreationPage( + createdEventId: result, + ), + ), + ); + } catch (e) { + if (mounted) { + displayToast( + 'Could not create journey. Try again.', + Status.error, + ); + } + } finally { + if (mounted) setState(() => _isSubmitting = false); + } + } + + @override + void dispose() { + _nameController.removeListener(_onFormFieldChanged); + _descriptionController.removeListener(_onFormFieldChanged); + _nameController.dispose(); + _descriptionController.dispose(); + super.dispose(); + } + + InputDecoration _fieldDecoration({String? hint, int? hintMaxLines}) { + const radius = BorderRadius.all(Radius.circular(8)); + const borderSide = BorderSide(color: AppColors.borderGray); + return InputDecoration( + hintText: hint, + hintMaxLines: hintMaxLines, + filled: true, + fillColor: AppColors.white, + contentPadding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12), + border: const OutlineInputBorder( + borderRadius: radius, + borderSide: borderSide, + ), + enabledBorder: const OutlineInputBorder( + borderRadius: radius, + borderSide: borderSide, + ), + focusedBorder: OutlineInputBorder( + borderRadius: radius, + borderSide: BorderSide(color: AppColors.primaryRed, width: 1.5), + ), + hintStyle: const TextStyle( + color: AppColors.grayText, + fontFamily: 'Poppins', + fontSize: 14, + ), + ); + } + + TextStyle get _fieldTextStyle => const TextStyle( + color: AppColors.darkText, + fontFamily: 'Poppins', + fontSize: 14, + ); + + Widget _sectionLabel(String text) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + text, + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.darkText, + ), + ), + ); + } + + Widget _pill({ + required String label, + required bool selected, + required VoidCallback onTap, + }) { + return Material( + color: AppColors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: selected ? AppColors.cream : AppColors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: AppColors.borderGray), + ), + child: Text( + label, + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 14, + color: AppColors.darkText, + ), + ), + ), + ), + ); + } + + /// Date first, then 12-hour time with AM/PM (`showTime12hPicker`). + /// Past calendar days are disabled; combined start must not be before now. + Future _pickStartDate() async { + final now = DateTime.now(); + final startOfToday = DateTime(now.year, now.month, now.day); + final maxTime = DateTime(now.year + 5, 12, 31, 23, 59); + + var initial = _startDateTime ?? now; + if (initial.isBefore(startOfToday)) { + initial = now; + } + + final pickedDate = await DatePicker.showDatePicker( + context, + showTitleActions: true, + minTime: startOfToday, + maxTime: maxTime, + currentTime: initial, + locale: LocaleType.en, + ); + if (!mounted || pickedDate == null) return; + + final timeSeed = DateTime( + pickedDate.year, + pickedDate.month, + pickedDate.day, + initial.hour, + initial.minute, + ); + + final pickedClock = await DatePicker.showTime12hPicker( + context, + showTitleActions: true, + currentTime: timeSeed, + locale: LocaleType.en, + ); + if (!mounted || pickedClock == null) return; + + final combined = DateTime( + pickedDate.year, + pickedDate.month, + pickedDate.day, + pickedClock.hour, + pickedClock.minute, + ); + + if (combined.isBefore(DateTime.now())) { + displayToast( + 'Choose a time in the future for that date.', + Status.error, + ); + return; + } + + setState(() => _startDateTime = combined); + } + + Widget _startDateSelector() { + final hasValue = _startDateTime != null; + final label = hasValue + ? DateFormat('MM/dd/yyyy h:mm a', 'en_US').format(_startDateTime!) + : 'Choose date & time'; + + return Material( + color: AppColors.transparent, + child: InkWell( + onTap: _pickStartDate, + borderRadius: BorderRadius.circular(8), + child: Ink( + decoration: BoxDecoration( + color: AppColors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.borderGray), + ), + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12), + child: Row( + children: [ + Expanded( + child: Text( + label, + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 14, + fontWeight: FontWeight.w400, + color: hasValue ? AppColors.darkText : AppColors.grayText, + ), + ), + ), + Icon( + Icons.calendar_today_outlined, + size: 20, + color: hasValue ? AppColors.darkText : AppColors.grayText, + ), + ], + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.warmWhite, + appBar: AppBar( + backgroundColor: AppColors.warmWhite, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: AppColors.darkText), + onPressed: () => Navigator.of(context).pop(), + ), + ), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Center( + child: SizedBox( + width: _fieldWidth, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionLabel('Journey Name'), + TextField( + controller: _nameController, + style: _fieldTextStyle, + decoration: _fieldDecoration( + hint: 'e.g. study spots', + ), + ), + const SizedBox(height: 24), + _sectionLabel('Category'), + Wrap( + spacing: 8, + runSpacing: 8, + children: _categories.map((c) { + return _pill( + label: c, + selected: _selectedCategory == c, + onTap: () => + setState(() => _selectedCategory = c), + ); + }).toList(), + ), + const SizedBox(height: 24), + _sectionLabel('Difficulty Level'), + Wrap( + spacing: 8, + runSpacing: 8, + children: _difficulties.map((d) { + return _pill( + label: d, + selected: _selectedDifficulty == d, + onTap: () => setState(() => _selectedDifficulty = d), + ); + }).toList(), + ), + const SizedBox(height: 24), + _sectionLabel('Start Date'), + _startDateSelector(), + const SizedBox(height: 24), + _sectionLabel('Description'), + TextField( + controller: _descriptionController, + style: _fieldTextStyle, + maxLines: 5, + decoration: _fieldDecoration( + hint: + 'Add some more details to the locations so that your friends will join!', + hintMaxLines: 5, + ), + ), + const SizedBox(height: 24), + Align( + alignment: Alignment.centerRight, + child: Material( + color: _canProceed && !_isSubmitting + ? AppColors.primaryRed + : AppColors.primaryRed.withOpacity(0.45), + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: _isSubmitting ? null : _onTapNext, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 28, + vertical: 14, + ), + child: _isSubmitting + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.white, + ), + ) + : const Text( + 'Next', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.white, + ), + ), + ), + ), + ), + ), + const SizedBox(height: 24), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/game/lib/journeys/journey_challenge_list_sheet.dart b/game/lib/journeys/journey_challenge_list_sheet.dart index d65fc531..65e3d852 100644 --- a/game/lib/journeys/journey_challenge_list_sheet.dart +++ b/game/lib/journeys/journey_challenge_list_sheet.dart @@ -14,6 +14,7 @@ import 'package:game/navigation_page/bottom_navbar.dart'; import 'package:game/utils/utility_functions.dart'; import 'package:game/widget/cached_image.dart'; import 'package:geolocator/geolocator.dart'; +import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import 'dart:async'; @@ -182,8 +183,29 @@ class _JourneyChallengeListSheetState extends State { final totalChallenges = event?.challenges?.length ?? 0; final remaining = availableChallenges.length; + // Partition available challenges into available now vs upcoming + final now = DateTime.now(); + final availableNow = []; + final upcoming = []; + + for (final challenge in availableChallenges) { + final startStr = challenge.scheduledStartTime; + final endStr = challenge.scheduledEndTime; + final start = startStr != null ? DateTime.tryParse(startStr) : null; + final end = endStr != null ? DateTime.tryParse(endStr) : null; + + if (start != null && now.isBefore(start)) { + upcoming.add(challenge); + } else if (end != null && now.isAfter(end)) { + // Should already be auto-completed by server, skip + continue; + } else { + availableNow.add(challenge); + } + } + // Sort available challenges by distance - final sortedAvailable = List.from(availableChallenges); + final sortedAvailable = List.from(availableNow); sortedAvailable.sort((a, b) { final distA = _distanceTo(a); final distB = _distanceTo(b); @@ -368,6 +390,59 @@ class _JourneyChallengeListSheetState extends State { ), ), ], + // Upcoming section + if (upcoming.isNotEmpty) ...[ + Padding( + padding: + const EdgeInsets.only(top: 12, bottom: 4), + child: Text( + 'Upcoming', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 14, + fontWeight: FontWeight.bold, + color: AppColors.grayText, + ), + ), + ), + ...upcoming.map( + (challenge) => _JourneyChallengeCard( + challenge: challenge, + isCompleted: false, + isUpcoming: true, + walkingTime: + _walkingTime(_distanceTo(challenge)), + onTap: () { + final startStr = + challenge.scheduledStartTime; + final start = startStr != null + ? DateTime.tryParse(startStr) + : null; + final formatted = start != null + ? DateFormat.yMMMd() + .add_jm() + .format(start.toLocal()) + : 'a future date'; + showDialog( + context: context, + builder: (_) => AlertDialog( + title: Text('Not Yet Available'), + content: Text( + 'This challenge is available on $formatted. Come back then!', + ), + actions: [ + TextButton( + onPressed: () => + Navigator.pop(context), + child: Text('OK'), + ), + ], + ), + ); + }, + ), + ), + ], // Completed section if (completedChallenges.isNotEmpty) ...[ Padding( @@ -386,6 +461,8 @@ class _JourneyChallengeListSheetState extends State { ...completedChallenges.map( (challenge) { final prev = prevChallengeMap[challenge.id]; + final isDateExpired = + prev?.dateExpired == true; final totalPts = challenge.points ?? 0; int earned; if (prev?.failed == true) { @@ -406,6 +483,7 @@ class _JourneyChallengeListSheetState extends State { return _JourneyChallengeCard( challenge: challenge, isCompleted: true, + isDateExpired: isDateExpired, walkingTime: _walkingTime(_distanceTo(challenge)), earnedPoints: earned, @@ -429,6 +507,8 @@ class _JourneyChallengeListSheetState extends State { class _JourneyChallengeCard extends StatelessWidget { final ChallengeDto challenge; final bool isCompleted; + final bool isUpcoming; + final bool isDateExpired; final String walkingTime; final int? earnedPoints; final VoidCallback? onTap; @@ -437,133 +517,235 @@ class _JourneyChallengeCard extends StatelessWidget { required this.challenge, required this.isCompleted, required this.walkingTime, + this.isUpcoming = false, + this.isDateExpired = false, this.earnedPoints, this.onTap, }); + bool get _isTodayOnly { + final startStr = challenge.scheduledStartTime; + final endStr = challenge.scheduledEndTime; + if (startStr == null && endStr == null) return false; + final start = startStr != null ? DateTime.tryParse(startStr) : null; + final end = endStr != null ? DateTime.tryParse(endStr) : null; + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final tomorrow = today.add(Duration(days: 1)); + final isAfterStart = start == null || !now.isBefore(start); + final isBeforeEnd = end == null || now.isBefore(end); + final endsToday = end != null && end.isBefore(tomorrow); + return isAfterStart && isBeforeEnd && endsToday; + } + @override Widget build(BuildContext context) { + final isGrayed = isUpcoming || isCompleted; + final cardColor = isGrayed ? AppColors.lightGray : Colors.white; + return GestureDetector( onTap: onTap, - child: Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: AppColors.black10, - offset: Offset(0, 2), - blurRadius: 6, - ), - ], - ), - child: Row( - children: [ - // Left content - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Name + walking time row - Row( - children: [ - Flexible( - child: Text( - challenge.name ?? '', - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontFamily: 'Poppins', - fontSize: 16, - fontWeight: FontWeight.bold, - color: AppColors.darkGrayText, + child: Opacity( + opacity: isGrayed ? 0.6 : 1.0, + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: cardColor, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: AppColors.black10, + offset: Offset(0, 2), + blurRadius: 6, + ), + ], + ), + child: Row( + children: [ + // Left content + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Name + walking time row + badges + Row( + children: [ + Flexible( + child: Text( + challenge.name ?? '', + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.darkGrayText, + ), ), ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Container( - width: 4, - height: 4, - decoration: BoxDecoration( + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Container( + width: 4, + height: 4, + decoration: BoxDecoration( + color: AppColors.darkGrayText, + shape: BoxShape.circle, + ), + ), + ), + if (isUpcoming) ...[ + Icon( + Icons.calendar_today, + size: 16, + color: AppColors.grayText, + ), + SizedBox(width: 4), + Text( + _formatScheduledDate(), + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 12, + fontWeight: FontWeight.bold, + color: AppColors.grayText, + ), + ), + ] else ...[ + Icon( + Icons.directions_walk, + size: 18, color: AppColors.darkGrayText, - shape: BoxShape.circle, ), + SizedBox(width: 2), + Text( + walkingTime, + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 12, + fontWeight: FontWeight.bold, + color: AppColors.darkGrayText, + ), + ), + ], + ], + ), + SizedBox(height: 8), + // Badges row + if (_isTodayOnly || isDateExpired) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + children: [ + if (_isTodayOnly) + Container( + padding: EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppColors.orange, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + 'Today Only', + style: TextStyle( + color: Colors.white, + fontSize: 11, + fontFamily: 'Poppins', + fontWeight: FontWeight.w500, + ), + ), + ), + if (isDateExpired) + Container( + padding: EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppColors.mediumGray, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + 'Expired', + style: TextStyle( + color: Colors.white, + fontSize: 11, + fontFamily: 'Poppins', + fontWeight: FontWeight.w500, + ), + ), + ), + ], ), ), - Icon( - Icons.directions_walk, - size: 18, - color: AppColors.darkGrayText, - ), - SizedBox(width: 2), - Text( - walkingTime, - style: TextStyle( - fontFamily: 'Poppins', - fontSize: 12, - fontWeight: FontWeight.bold, - color: AppColors.darkGrayText, - ), + // Description + Text( + challenge.description ?? '', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 12, + fontWeight: + isCompleted ? FontWeight.bold : FontWeight.normal, + color: AppColors.grayText, ), - ], - ), - SizedBox(height: 8), - // Description - Text( - challenge.description ?? '', - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontFamily: 'Poppins', - fontSize: 12, - fontWeight: - isCompleted ? FontWeight.bold : FontWeight.normal, - color: AppColors.grayText, ), - ), - SizedBox(height: 12), - // Points - Row( - children: [ - SvgPicture.asset( - 'assets/icons/bearcoins.svg', - width: 20, - height: 20, - ), - SizedBox(width: 5), - Text( - isCompleted - ? '${earnedPoints ?? challenge.points ?? 0} PTS / ${challenge.points ?? 0} PTS' - : '${challenge.points ?? 0} PTS', - style: TextStyle( - fontFamily: 'Poppins', - fontSize: 12, - fontWeight: FontWeight.bold, - color: AppColors.gold, + SizedBox(height: 12), + // Points + Row( + children: [ + SvgPicture.asset( + 'assets/icons/bearcoins.svg', + width: 20, + height: 20, ), - ), - ], - ), - ], + SizedBox(width: 5), + Text( + isDateExpired + ? 'Expired — 0 PTS' + : isCompleted + ? '${earnedPoints ?? challenge.points ?? 0} PTS / ${challenge.points ?? 0} PTS' + : '${challenge.points ?? 0} PTS', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 12, + fontWeight: FontWeight.bold, + color: isDateExpired + ? AppColors.mediumGray + : AppColors.gold, + ), + ), + ], + ), + ], + ), ), - ), - SizedBox(width: 8), - // Thumbnail - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: AppCachedImage( - imageUrl: challenge.imageUrl ?? '', - width: 82, - height: 81, + SizedBox(width: 8), + // Thumbnail + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: AppCachedImage( + imageUrl: challenge.imageUrl ?? '', + width: 82, + height: 81, + ), ), - ), - ], + ], + ), ), ), ); } + + String _formatScheduledDate() { + final startStr = challenge.scheduledStartTime; + if (startStr == null) return ''; + final start = DateTime.tryParse(startStr); + if (start == null) return ''; + return DateFormat.MMMd().format(start.toLocal()); + } } /// Launcher scaffold that immediately shows the challenge list bottom sheet. diff --git a/game/lib/journeys/journeys_page.dart b/game/lib/journeys/journeys_page.dart index 6740e891..1aeb375a 100644 --- a/game/lib/journeys/journeys_page.dart +++ b/game/lib/journeys/journeys_page.dart @@ -24,6 +24,9 @@ import 'package:provider/provider.dart'; import 'package:showcaseview/showcaseview.dart'; import 'package:velocity_x/velocity_x.dart'; import 'package:game/constants/constants.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:dotted_border/dotted_border.dart'; +import 'package:game/journeys/custom_journey_creations_page.dart'; /** A Data Transfer Object that holds information about a challenge * cell in the UI */ @@ -288,6 +291,61 @@ class _JourneysPageState extends State { ), Column( children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 12), + child: SizedBox( + width: double.infinity, + height: 168, + child: DottedBorder( + color: AppColors.primaryRed, + strokeWidth: 1.5, + dashPattern: const [5, 4], + padding: EdgeInsets.zero, + borderType: BorderType.Rect, + stackFit: StackFit.expand, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + const CustomJourneyCreationsPage(), + ), + ); + }, + borderRadius: BorderRadius.circular(4), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SvgPicture.asset( + 'assets/icons/pluscircle_red.svg', + width: 56, + height: 56, + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 24, + ), + child: Text( + 'Create your own journey', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.darkText, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), Expanded( child: Consumer6( diff --git a/game/lib/main.dart b/game/lib/main.dart index f391c22a..da6e05fc 100644 --- a/game/lib/main.dart +++ b/game/lib/main.dart @@ -11,9 +11,11 @@ import 'package:game/api/geopoint.dart'; import 'package:game/api/notification_service.dart'; import 'package:game/loading_page/loading_page.dart'; import 'package:game/model/achievement_model.dart'; +import 'package:game/model/campus_event_model.dart'; import 'package:device_preview/device_preview.dart'; import 'package:game/model/onboarding_model.dart'; import 'package:game/model/timer_model.dart'; +import 'package:game/model/feature_flags_model.dart'; import 'package:game/model/quiz_model.dart'; // imports for google maps @@ -39,6 +41,7 @@ const bool USE_DEVICE_PREVIEW = false; final storage = FlutterSecureStorage(); late final String API_URL; late final ApiClient client; +late final FeatureFlagsModel featureFlags; final GlobalKey navigatorKey = GlobalKey(); void main() async { @@ -68,6 +71,10 @@ void main() async { // Initialize API client client = ApiClient(storage, API_URL); + // Load feature flags from server + featureFlags = FeatureFlagsModel(); + await featureFlags.load(API_URL); + // Initialize notification service with callback to send token to server await NotificationService().initialize( onTokenRefresh: (token) { @@ -132,6 +139,7 @@ class MyApp extends StatelessWidget { Widget build(BuildContext context) { return MultiProvider( providers: [ + ChangeNotifierProvider.value(value: featureFlags), ChangeNotifierProvider.value(value: client), ChangeNotifierProvider(create: (_) => UserModel(client), lazy: false), ChangeNotifierProvider( @@ -154,6 +162,10 @@ class MyApp extends StatelessWidget { ), ChangeNotifierProvider(create: (_) => TimerModel(client), lazy: false), ChangeNotifierProvider(create: (_) => QuizModel(client), lazy: false), + ChangeNotifierProvider( + create: (_) => CampusEventModel(client), + lazy: false, + ), ], child: GameWidget( child: MaterialApp( diff --git a/game/lib/model/campus_event_model.dart b/game/lib/model/campus_event_model.dart new file mode 100644 index 00000000..dff3bd27 --- /dev/null +++ b/game/lib/model/campus_event_model.dart @@ -0,0 +1,82 @@ +import 'package:flutter/foundation.dart'; +import 'package:game/api/game_api.dart'; +import 'package:game/api/game_client_dto.dart'; + +class CampusEventModel extends ChangeNotifier { + final Map _eventsById = {}; + CampusEventListDto? _currentList; + final ApiClient _client; + + CampusEventModel(ApiClient client) : _client = client { + client.clientApi.updateCampusEventDataStream.listen((event) { + if (event.deleted) { + _eventsById.remove(event.event.id); + } else { + _eventsById[event.event.id] = event.event; + } + notifyListeners(); + }); + + client.clientApi.campusEventListStream.listen((event) { + _currentList = event.list; + for (final campusEvent in event.list.events) { + _eventsById[campusEvent.id] = campusEvent; + } + notifyListeners(); + }); + + client.clientApi.connectedStream.listen((event) { + _eventsById.clear(); + _currentList = null; + notifyListeners(); + }); + } + + CampusEventDto? getCampusEventById(String id) { + final event = _eventsById[id]; + if (event == null) { + _client.serverApi?.requestCampusEventDetails( + RequestCampusEventDetailsDto(eventId: id), + ); + } + return event; + } + + CampusEventListDto? get currentList => _currentList; + + List get allCachedEvents => _eventsById.values.toList(); + + void requestCampusEvents({ + int page = 1, + int limit = 20, + String? dateFrom, + String? dateTo, + List? categories, + String? search, + bool? featured, + }) { + _client.serverApi?.requestCampusEvents( + RequestCampusEventsDto( + page: page, + limit: limit, + dateFrom: dateFrom, + dateTo: dateTo, + categories: categories, + search: search, + featured: featured, + ), + ); + } + + void rsvpCampusEvent(String campusEventId) { + _client.serverApi?.rsvpCampusEvent( + RsvpCampusEventDto(eventId: campusEventId), + ); + } + + void unRsvpCampusEvent(String campusEventId) { + _client.serverApi?.unRsvpCampusEvent( + UnRsvpCampusEventDto(eventId: campusEventId), + ); + } +} diff --git a/game/lib/model/feature_flags_model.dart b/game/lib/model/feature_flags_model.dart new file mode 100644 index 00000000..e9dfee13 --- /dev/null +++ b/game/lib/model/feature_flags_model.dart @@ -0,0 +1,21 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; + +class FeatureFlagsModel extends ChangeNotifier { + bool enableBuildABear = false; + + Future load(String apiUrl) async { + try { + final response = await http.get(Uri.parse('$apiUrl/feature-flags')); + if (response.statusCode == 200) { + final data = jsonDecode(response.body) as Map; + enableBuildABear = data['enableBuildABear'] ?? false; + notifyListeners(); + } + } catch (_) { + // Default to false — feature stays hidden + } + } +} diff --git a/game/lib/profile/profile_page.dart b/game/lib/profile/profile_page.dart index 2310d1e0..35a25bdb 100644 --- a/game/lib/profile/profile_page.dart +++ b/game/lib/profile/profile_page.dart @@ -10,6 +10,7 @@ import 'package:game/model/achievement_model.dart'; import 'package:game/model/challenge_model.dart'; import 'package:game/model/event_model.dart'; import 'package:game/model/tracker_model.dart'; +import 'package:game/model/feature_flags_model.dart'; import 'package:game/model/user_model.dart'; import 'package:game/achievements/achievement_cell.dart'; import 'package:game/profile/completed_cell.dart'; @@ -293,22 +294,25 @@ class _ProfilePageState extends State { child: Row( mainAxisSize: MainAxisSize.min, children: [ - IconButton( - icon: SvgPicture.asset( - 'assets/icons/clotheshanger.svg', - width: iconSize, - height: iconSize, + if (context + .watch() + .enableBuildABear) + IconButton( + icon: SvgPicture.asset( + 'assets/icons/clotheshanger.svg', + width: iconSize, + height: iconSize, + ), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + const BuildABearPage(), + ), + ); + }, ), - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - const BuildABearPage(), - ), - ); - }, - ), IconButton( icon: Icon(Icons.settings, size: iconSize, color: Colors.black), diff --git a/game/lib/utils/utility_functions.dart b/game/lib/utils/utility_functions.dart index 98f33bc9..14498f68 100644 --- a/game/lib/utils/utility_functions.dart +++ b/game/lib/utils/utility_functions.dart @@ -241,12 +241,16 @@ final Map abbrevLocation = { }; final Map friendlyCategory = { - EventCategoryDto.CAFE: "Cafe", - EventCategoryDto.DININGHALL: "Dining Hall", - EventCategoryDto.DORM: "Dorm", EventCategoryDto.FOOD: "Food", - EventCategoryDto.HISTORICAL: "Historical", EventCategoryDto.NATURE: "Nature", + EventCategoryDto.HISTORICAL: "Historical", + EventCategoryDto.RESIDENTIAL: "Residential", + EventCategoryDto.LANDMARK: "Landmark", + EventCategoryDto.ARTS: "Arts", + EventCategoryDto.ATHLETICS: "Athletics", + EventCategoryDto.LIBRARY: "Library", + EventCategoryDto.ACADEMIC: "Academic", + EventCategoryDto.RECREATION: "Recreation", }; /** diff --git a/game/pubspec.lock b/game/pubspec.lock index d1b1a1d9..f6bd895e 100644 --- a/game/pubspec.lock +++ b/game/pubspec.lock @@ -185,6 +185,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + dotted_border: + dependency: "direct main" + description: + name: dotted_border + sha256: "108837e11848ca776c53b30bc870086f84b62ed6e01c503ed976e8f8c7df9c04" + url: "https://pub.dev" + source: hosted + version: "2.1.0" dropdown_button2: dependency: "direct main" description: @@ -197,10 +205,10 @@ packages: dependency: transitive description: name: fake_async - sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" url: "https://pub.dev" source: hosted - version: "1.3.3" + version: "1.3.2" ffi: dependency: transitive description: @@ -310,6 +318,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + flutter_datetime_picker_plus: + dependency: "direct main" + description: + name: flutter_datetime_picker_plus + sha256: "7d82da02c4e070bb28a9107de119ad195e2319b45c786fecc13482a9ffcc51da" + url: "https://pub.dev" + source: hosted + version: "2.2.0" flutter_launcher_icons: dependency: "direct dev" description: @@ -697,26 +713,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec url: "https://pub.dev" source: hosted - version: "11.0.2" + version: "10.0.8" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 url: "https://pub.dev" source: hosted - version: "3.0.10" + version: "3.0.9" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.1" linkify: dependency: transitive description: @@ -801,10 +817,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.16.0" mgrs_dart: dependency: transitive description: @@ -837,6 +853,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_drawing: + dependency: transitive + description: + name: path_drawing + sha256: bbb1934c0cbb03091af082a6389ca2080345291ef07a5fa6d6e078ba8682f977 + url: "https://pub.dev" + source: hosted + version: "1.0.1" path_parsing: dependency: transitive description: @@ -1270,10 +1294,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.4" timezone: dependency: transitive description: @@ -1406,10 +1430,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.1.4" velocity_x: dependency: "direct main" description: @@ -1491,5 +1515,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.8.0-0 <4.0.0" + dart: ">=3.7.0 <4.0.0" flutter: ">=3.29.0" diff --git a/game/pubspec.yaml b/game/pubspec.yaml index 2dd27d3e..f6101a10 100644 --- a/game/pubspec.yaml +++ b/game/pubspec.yaml @@ -70,6 +70,8 @@ dependencies: device_preview: ^1.2.0 showcaseview: ^5.0.1 flutter_compass: ^0.8.0 + dotted_border: ^2.1.0 + flutter_datetime_picker_plus: ^2.2.0 dev_dependencies: flutter_test: diff --git a/package.json b/package.json index 32e712ef..550da858 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "setup": "node ./scripts/setup.js", "updateapi": "node ./scripts/updateapi.js", "formatall": "node ./scripts/formatall.js", + "bulk-add": "node ./scripts/bulk-add-challenges.js", "compilescripts": "tsc --project scripts", "prepare": "husky" }, diff --git a/scripts/bulk-add-challenges.js b/scripts/bulk-add-challenges.js new file mode 100644 index 00000000..39810c65 --- /dev/null +++ b/scripts/bulk-add-challenges.js @@ -0,0 +1,384 @@ +/** + * Bulk-add challenges from a CSV file or Google Sheet into an existing journey. + * + * Usage: + * node scripts/bulk-add-challenges.js + * + * You will then be prompted for the : + * + * + * + * Alternatively, run in one step: + * node scripts/bulk-add-challenges.js [--force] + * + * Source can be: + * - A local CSV file path + * - A Google Sheets URL, e.g. https://docs.google.com/spreadsheets/d/SHEET_ID/edit... + * Two auth modes: + * a) Set GOOGLE_API_KEY in server/.env — sheet just needs "Anyone with the link" sharing + * b) No API key — sheet must be fully public ("Anyone on the internet") + * + * CSV columns (header row required): + * Name, Description, Latitude, Longitude, Location Description, + * Image URL, Awarding Distance, Close Distance, Points + * + * If the journey already has challenges with completions, the script will + * abort unless --force is passed. + */ + +const { PrismaClient } = require('../server/node_modules/@prisma/client'); +const path = require('path'); +const fs = require('fs'); +const readline = require('readline'); + +function prompt(question) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +require('../server/node_modules/dotenv').config({ + path: path.join(__dirname, '../server/.env'), +}); + +const prisma = new PrismaClient(); + +// CSV parser (no dependencies) + +function parseCsv(text) { + const rows = []; + let current = ''; + let inQuotes = false; + const lines = []; + + // Split into lines respecting quoted newlines + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === '"') { + inQuotes = !inQuotes; + current += ch; + } else if (ch === '\n' && !inQuotes) { + lines.push(current); + current = ''; + } else if (ch === '\r' && !inQuotes) { + // skip \r + } else { + current += ch; + } + } + if (current.length > 0) lines.push(current); + + for (const line of lines) { + const cells = []; + let cell = ''; + let q = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (ch === '"') { + if (q && line[i + 1] === '"') { + cell += '"'; + i++; + } else { + q = !q; + } + } else if (ch === ',' && !q) { + cells.push(cell.trim()); + cell = ''; + } else { + cell += ch; + } + } + cells.push(cell.trim()); + rows.push(cells); + } + + return rows; +} + +// Location mapping + +const LOCATION_MAP = { + 'eng quad': 'ENG_QUAD', + 'engineering quad': 'ENG_QUAD', + 'arts quad': 'ARTS_QUAD', + 'ag quad': 'AG_QUAD', + 'agriculture quad': 'AG_QUAD', + 'central campus': 'CENTRAL_CAMPUS', + 'north campus': 'NORTH_CAMPUS', + 'west campus': 'WEST_CAMPUS', + 'cornell athletics': 'CORNELL_ATHLETICS', + 'athletics': 'CORNELL_ATHLETICS', + 'vet school': 'VET_SCHOOL', + 'collegetown': 'COLLEGETOWN', + 'college town': 'COLLEGETOWN', + 'ithaca commons': 'ITHACA_COMMONS', + 'commons': 'ITHACA_COMMONS', +}; + +function mapLocation(locationDesc) { + if (!locationDesc) return 'ANY'; + const key = locationDesc.toLowerCase().trim(); + return LOCATION_MAP[key] || 'ANY'; +} + +// Validation + +const REQUIRED_FIELDS = [ + 'name', + 'description', + 'latitude', + 'longitude', + 'imageUrl', + 'awardingRadius', + 'closeRadius', + 'points', +]; + +function parseRow(headers, cells) { + const raw = {}; + headers.forEach((h, i) => { + raw[h] = cells[i] || ''; + }); + + // Clean latitude (some cells have trailing commas/spaces) + const latStr = raw['Latitude'].replace(/,/g, '').trim(); + const lngStr = raw['Longitude'].replace(/,/g, '').trim(); + + const challenge = { + name: raw['Name'] || '', + description: raw['Description'] || '', + latitude: parseFloat(latStr), + longitude: parseFloat(lngStr), + imageUrl: raw['Image URL'] || '', + awardingRadius: parseFloat(raw['Awarding Distance']) || 0, + closeRadius: parseFloat(raw['Close Distance']) || 0, + points: parseInt(raw['Points'], 10) || 0, + location: mapLocation(raw['Location Description']), + }; + + const missing = REQUIRED_FIELDS.filter((f) => { + const val = challenge[f]; + if (typeof val === 'string') return !val; + if (typeof val === 'number') return isNaN(val) || val === 0; + return !val; + }); + + return { challenge, missing, rawName: raw['Name'] || '(unnamed)' }; +} + +// Google Sheets fetcher + +function extractSheetId(url) { + const match = url.match(/\/spreadsheets\/d\/([a-zA-Z0-9_-]+)/); + return match ? match[1] : null; +} + +async function fetchGoogleSheet(url) { + const sheetId = extractSheetId(url); + if (!sheetId) { + throw new Error('Could not extract sheet ID from URL: ' + url); + } + + const apiKey = process.env.GOOGLE_API_KEY; + + if (apiKey) { + // Use Sheets API v4 with API key (works with "Anyone with the link" sharing) + const apiUrl = + `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/A:Z?key=${apiKey}`; + console.log('Fetching Google Sheet via Sheets API...'); + const res = await fetch(apiUrl); + if (!res.ok) { + const body = await res.text(); + throw new Error( + `Sheets API error (${res.status}): ${body.substring(0, 200)}`, + ); + } + const json = await res.json(); + const rows = json.values || []; + // Convert to CSV format + return rows + .map((row) => + row.map((cell) => { + const str = String(cell ?? ''); + return str.includes(',') || str.includes('"') || str.includes('\n') + ? '"' + str.replace(/"/g, '""') + '"' + : str; + }).join(','), + ) + .join('\n'); + } + + // Fallback: public CSV export (requires sheet to be fully public) + const csvUrl = `https://docs.google.com/spreadsheets/d/${sheetId}/export?format=csv`; + console.log('Fetching Google Sheet as CSV (public export)...'); + const res = await fetch(csvUrl, { redirect: 'follow' }); + if (!res.ok) { + throw new Error( + `Failed to fetch sheet (${res.status}). Either:\n` + + ` 1. Set GOOGLE_API_KEY in server/.env and share the sheet as "Anyone with the link"\n` + + ` 2. Or make the sheet fully public (File > Share > Anyone on the internet)`, + ); + } + return res.text(); +} + +function isGoogleSheetsUrl(str) { + return str.startsWith('https://docs.google.com/spreadsheets/'); +} + +// Main + +async function main() { + const args = process.argv.slice(2); + const force = args.includes('--force'); + const positional = args.filter((a) => a !== '--force'); + + let journeyId = positional[0]; + let source = positional[1]; + + if (!journeyId) { + journeyId = await prompt('Journey ID: '); + if (!journeyId) { + console.error('Journey ID is required.'); + process.exit(1); + } + } + if (!source) { + source = await prompt('Source (CSV file path or Google Sheets URL): '); + if (!source) { + console.error('Source is required.'); + process.exit(1); + } + } + + // 1. Verify journey exists + const journey = await prisma.eventBase.findUnique({ + where: { id: journeyId }, + include: { + challenges: { + include: { completions: { select: { id: true }, take: 1 } }, + }, + }, + }); + + if (!journey) { + console.error(`Journey not found: ${journeyId}`); + process.exit(1); + } + + console.log(`Journey: "${journey.name}" (${journey.id})`); + console.log(`Existing challenges: ${journey.challenges.length}`); + + // 2. Check for completions if challenges exist + if (journey.challenges.length > 0) { + const hasCompletions = journey.challenges.some( + (c) => c.completions.length > 0, + ); + + if (hasCompletions && !force) { + console.error( + '\nExisting challenges have user completions. ' + + 'Re-adding will DELETE completion records.', + ); + console.error('Pass --force to proceed anyway.'); + process.exit(1); + } + + if (hasCompletions) { + console.log('\n⚠ --force passed: deleting challenges with completions'); + } + + // Delete existing challenges (cascades to PrevChallenge) + const deleted = await prisma.challenge.deleteMany({ + where: { linkedEventId: journeyId }, + }); + console.log(`Deleted ${deleted.count} existing challenges`); + } + + // 3. Read and parse CSV + let csvText; + if (isGoogleSheetsUrl(source)) { + csvText = await fetchGoogleSheet(source); + } else { + csvText = fs.readFileSync(path.resolve(source), 'utf-8'); + } + const rows = parseCsv(csvText); + + if (rows.length < 2) { + console.error('CSV must have a header row and at least one data row.'); + process.exit(1); + } + + const headers = rows[0]; + const dataRows = rows.slice(1); + + // 4. Parse and validate rows + const valid = []; + const skipped = []; + + for (let i = 0; i < dataRows.length; i++) { + const cells = dataRows[i]; + // Skip fully empty rows + if (cells.every((c) => !c)) continue; + + const { challenge, missing, rawName } = parseRow(headers, cells); + + if (missing.length > 0) { + skipped.push({ row: i + 2, name: rawName, missing }); + } else { + valid.push(challenge); + } + } + + if (valid.length === 0) { + console.error('\nNo valid challenges found in CSV.'); + if (skipped.length > 0) printSkipped(skipped); + process.exit(1); + } + + // 5. Insert challenges + console.log(`\nInserting ${valid.length} challenges...`); + + const data = valid.map((c, idx) => ({ + linkedEventId: journeyId, + eventIndex: idx, + name: c.name, + description: c.description, + location: c.location, + points: c.points, + imageUrl: c.imageUrl, + latitude: c.latitude, + longitude: c.longitude, + awardingRadius: c.awardingRadius, + closeRadius: c.closeRadius, + })); + + const result = await prisma.challenge.createMany({ data }); + console.log(`Created ${result.count} challenges`); + + // 6. Print summary + if (skipped.length > 0) printSkipped(skipped); + + console.log('\nDone!'); +} + +function printSkipped(skipped) { + console.log(`\n--- Skipped ${skipped.length} row(s) ---`); + for (const s of skipped) { + console.log(` Row ${s.row}: "${s.name}" — missing: ${s.missing.join(', ')}`); + } +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/scripts/updateapi-lib/apiscanner.js b/scripts/updateapi-lib/apiscanner.js index 167e209e..7103b2c7 100644 --- a/scripts/updateapi-lib/apiscanner.js +++ b/scripts/updateapi-lib/apiscanner.js @@ -1,6 +1,6 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.getApiDefinitions = void 0; +exports.getApiDefinitions = getApiDefinitions; const ts_morph_1 = require("ts-morph"); function getApiDefinitions() { const project = new ts_morph_1.Project({}); @@ -17,6 +17,11 @@ function getApiDefinitions() { for (const prop of clientApiDef.getType().getProperties()) { const ev = prop.getValueDeclarationOrThrow().getChildAtIndex(0).getText(); const dto = prop.getValueDeclarationOrThrow().getChildAtIndex(2).getText(); + // Skip inline object types like { event: CampusEventDto } + if (dto.startsWith("{")) { + console.log(`Client event "${ev}" uses inline object type — skipping. Use a named DTO instead.`); + continue; + } apiDefs.clientEntrypoints.set(ev, dto); } console.log(`Processed ${apiDefs.clientEntrypoints.size} client functions!`); @@ -40,13 +45,23 @@ function getApiDefinitions() { const messageBodyParam = func .getParameters() .find((param) => !!param.getDecorator("MessageBody")); - if (!messageBodyParam) { - console.log(`Function ${ev} has no @MessageBody parameter! Skipping...`); - continue; + let dto = ""; + if (messageBodyParam) { + dto = messageBodyParam.getType().getText(); + } + // Strip intersection types: "FooDto & { id: string; }" → "FooDto" + if (dto.includes("&")) { + const base = dto.split("&")[0].trim(); + console.log(`Function ${ev} uses intersection type, using base type: ${base}`); + dto = base; + } + // Strip utility types: "Omit" → "FooDto" + // ts-morph may resolve to full path like: Omit + const utilityMatch = dto.match(/^(?:Omit|Pick|Partial|Required)<(?:import\([^)]*\)\.)?(\w+)/); + if (utilityMatch) { + console.log(`Function ${ev} uses utility type, using base type: ${utilityMatch[1]}`); + dto = utilityMatch[1]; } - let dto = messageBodyParam - .getType() - .getText(); if (!func.getReturnType().getText().startsWith("Promise")) { console.log(`Function ${ev} does not return a promise/is not async! Skipping...`); continue; @@ -64,10 +79,12 @@ function getApiDefinitions() { : unionTypes[0]; } if (!(ackType.isString() || ackType.isNumber() || ackType.isBoolean())) { - console.log(`Function ${ev} does not return one of number, boolean, or string! Skipping...`); - continue; + // Use "dynamic" for complex return types instead of skipping + apiDefs.serverAcks.set(ev, "dynamic"); + } + else { + apiDefs.serverAcks.set(ev, ackType.getText()); } - apiDefs.serverAcks.set(ev, ackType.getText()); if (dto.includes(".")) { dto = dto.split(".").pop(); } @@ -81,4 +98,3 @@ function getApiDefinitions() { console.log(); return apiDefs; } -exports.getApiDefinitions = getApiDefinitions; diff --git a/scripts/updateapi-lib/apiscanner.ts b/scripts/updateapi-lib/apiscanner.ts index 75d6bdcc..6bed4b14 100644 --- a/scripts/updateapi-lib/apiscanner.ts +++ b/scripts/updateapi-lib/apiscanner.ts @@ -21,6 +21,14 @@ export function getApiDefinitions() { const ev = prop.getValueDeclarationOrThrow().getChildAtIndex(0).getText(); const dto = prop.getValueDeclarationOrThrow().getChildAtIndex(2).getText(); + // Skip inline object types like { event: CampusEventDto } + if (dto.startsWith("{")) { + console.log( + `Client event "${ev}" uses inline object type — skipping. Use a named DTO instead.` + ); + continue; + } + apiDefs.clientEntrypoints.set(ev, dto); } @@ -45,10 +53,35 @@ export function getApiDefinitions() { .asKindOrThrow(SyntaxKind.StringLiteral) .getLiteralValue(); - let dto = func - .getParameterOrThrow((param) => !!param.getDecorator("MessageBody")) - .getType() - .getText(); + const messageBodyParam = func + .getParameters() + .find((param) => !!param.getDecorator("MessageBody")); + + let dto = ""; + if (messageBodyParam) { + dto = messageBodyParam.getType().getText(); + } + + // Strip intersection types: "FooDto & { id: string; }" → "FooDto" + if (dto.includes("&")) { + const base = dto.split("&")[0].trim(); + console.log( + `Function ${ev} uses intersection type, using base type: ${base}` + ); + dto = base; + } + + // Strip utility types: "Omit" → "FooDto" + // ts-morph may resolve to full path like: Omit + const utilityMatch = dto.match( + /^(?:Omit|Pick|Partial|Required)<(?:import\([^)]*\)\.)?(\w+)/ + ); + if (utilityMatch) { + console.log( + `Function ${ev} uses utility type, using base type: ${utilityMatch[1]}` + ); + dto = utilityMatch[1]; + } if (!func.getReturnType().getText().startsWith("Promise")) { console.log( @@ -77,14 +110,12 @@ export function getApiDefinitions() { if ( !(ackType.isString() || ackType.isNumber() || ackType.isBoolean()) ) { - console.log( - `Function ${ev} does not return one of number, boolean, or string! Skipping...` - ); - continue; + // Use "dynamic" for complex return types instead of skipping + apiDefs.serverAcks.set(ev, "dynamic"); + } else { + apiDefs.serverAcks.set(ev, ackType.getText()); } - apiDefs.serverAcks.set(ev, ackType.getText()); - if (dto.includes(".")) { dto = dto.split(".").pop()!; } diff --git a/scripts/updateapi-lib/dartgen.js b/scripts/updateapi-lib/dartgen.js index 8bdebefd..54b92ed8 100644 --- a/scripts/updateapi-lib/dartgen.js +++ b/scripts/updateapi-lib/dartgen.js @@ -1,6 +1,8 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.getDartServerApiFile = exports.getDartClientApiFile = exports.genDartDtoFile = void 0; +exports.genDartDtoFile = genDartDtoFile; +exports.getDartClientApiFile = getDartClientApiFile; +exports.getDartServerApiFile = getDartServerApiFile; function toDartType(tsType, tsName) { switch (tsType) { case "string": @@ -167,7 +169,6 @@ function genDartDtoFile(dtoDefs) { } return dartCode; } -exports.genDartDtoFile = genDartDtoFile; function getDartClientApiFile(apiDefs) { let dartCode = ` // CODE AUTOGENERATED BY npm run updateapi @@ -227,7 +228,6 @@ function getDartClientApiFile(apiDefs) { `; return dartCode; } -exports.getDartClientApiFile = getDartClientApiFile; function getDartServerApiFile(apiDefs) { let dartCode = ` // CODE AUTOGENERATED BY npm run updateapi @@ -277,8 +277,12 @@ function getDartServerApiFile(apiDefs) { completer.complete(arg); }; - Future.delayed(Duration(seconds: 5)) - .then((value) => completionFunc(null)); + // Set up timeout - only complete if not already completed + Future.delayed(Duration(seconds: 5)).then((value) { + if (!completer.isCompleted) { + completionFunc(null); + } + }); _refreshEv = ev; _refreshDat = data; @@ -292,13 +296,21 @@ function getDartServerApiFile(apiDefs) { `; for (const [ev, dto] of apiDefs.serverEntrypoints.entries()) { const ackType = toDartType(apiDefs.serverAcks.get(ev), "x"); - dartCode += ` + if (dto) { + dartCode += ` Future<${ackType}?> ${ev}(${dto} dto) async => await _invokeWithRefresh( "${ev}", dto.toJson()); `; + } + else { + dartCode += ` + Future<${ackType}?> ${ev}(Map dto) async => await _invokeWithRefresh( + "${ev}", dto); + + `; + } } dartCode += "}"; return dartCode; } -exports.getDartServerApiFile = getDartServerApiFile; diff --git a/scripts/updateapi-lib/dartgen.ts b/scripts/updateapi-lib/dartgen.ts index ae2d4ad6..bc715f48 100644 --- a/scripts/updateapi-lib/dartgen.ts +++ b/scripts/updateapi-lib/dartgen.ts @@ -306,11 +306,19 @@ export function getDartServerApiFile(apiDefs: ApiDefs) { for (const [ev, dto] of apiDefs.serverEntrypoints.entries()) { const ackType = toDartType(apiDefs.serverAcks.get(ev)!, "x"); - dartCode += ` + if (dto) { + dartCode += ` Future<${ackType}?> ${ev}(${dto} dto) async => await _invokeWithRefresh( "${ev}", dto.toJson()); `; + } else { + dartCode += ` + Future<${ackType}?> ${ev}(Map dto) async => await _invokeWithRefresh( + "${ev}", dto); + + `; + } } dartCode += "}"; diff --git a/scripts/updateapi-lib/dtoscanner.js b/scripts/updateapi-lib/dtoscanner.js index f3a9f7d2..f073733f 100644 --- a/scripts/updateapi-lib/dtoscanner.js +++ b/scripts/updateapi-lib/dtoscanner.js @@ -1,6 +1,6 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.getDtoDefinitions = void 0; +exports.getDtoDefinitions = getDtoDefinitions; const ts_morph_1 = require("ts-morph"); function getDtoDefinitions() { const project = new ts_morph_1.Project({}); @@ -12,11 +12,40 @@ function getDtoDefinitions() { for (const file of project.getSourceFiles()) { const interfs = file.getInterfaces(); const enums = file.getEnums(); + // Count as const objects that qualify as enums + let constEnumCount = 0; console.log(`${file.getBaseName()}: ${enums.length} enums, ${interfs.length} DTOs`); for (const enum_ of enums) { const vals = enum_.getMembers().map((val) => val.getName()); enumDtos.set(enum_.getName(), vals); } + // Support `as const` objects as enums: + // const FooDto = { A: 'A', B: 'B' } as const; + for (const varStmt of file.getVariableStatements()) { + for (const decl of varStmt.getDeclarations()) { + const init = decl.getInitializer(); + if (init?.getKind() === ts_morph_1.SyntaxKind.AsExpression) { + const inner = init.getChildAtIndex(0); + if (inner.getKind() === ts_morph_1.SyntaxKind.ObjectLiteralExpression) { + const name = decl.getName(); + if (name.endsWith("Dto")) { + const props = inner + .asKindOrThrow(ts_morph_1.SyntaxKind.ObjectLiteralExpression) + .getProperties() + .filter((p) => p.getKind() === ts_morph_1.SyntaxKind.PropertyAssignment) + .map((p) => p.asKindOrThrow(ts_morph_1.SyntaxKind.PropertyAssignment).getName()); + if (props.length > 0) { + enumDtos.set(name, props); + constEnumCount++; + } + } + } + } + } + } + if (constEnumCount > 0) { + console.log(` (also found ${constEnumCount} 'as const' enum${constEnumCount > 1 ? "s" : ""})`); + } for (const interf of interfs) { const props = interf.getProperties(); const baseDto = new Map(); @@ -45,7 +74,7 @@ function getDtoDefinitions() { } else if (propType.isBoolean() || propType.getArrayElementType()?.isBoolean()) { - // num + // bool baseDto.set(propName, [ "boolean", propType.isArray() ? "PRIMITIVE[]" : "PRIMITIVE", @@ -74,19 +103,60 @@ function getDtoDefinitions() { } else if (propType.isUnion() || propType.getArrayElementType()?.isUnion()) { - // enum - const enumName = interfName.replace("Dto", "") + - propName[0].toUpperCase() + - propName.substring(1) + - "Dto"; - enumDtos.set(enumName, propType - .getUnionTypes() - .map((t) => t.getLiteralValue()?.toString() ?? "")); - baseDto.set(propName, [ - enumName, - propType.isArray() ? "ENUM_DTO[]" : "ENUM_DTO", - isOptional, - ]); + const unionTypes = propType.isArray() + ? propType.getArrayElementTypeOrThrow().getUnionTypes() + : propType.getUnionTypes(); + // Filter out null/undefined from the union + const nonNullTypes = unionTypes.filter((t) => !t.isNull() && !t.isUndefined()); + // Collect string literal values + const literalValues = nonNullTypes + .map((t) => t.getLiteralValue()?.toString()) + .filter((v) => v !== undefined && v !== ""); + if (literalValues.length > 0) { + // String literal union → generate enum (existing behavior) + const enumName = interfName.replace("Dto", "") + + propName[0].toUpperCase() + + propName.substring(1) + + "Dto"; + enumDtos.set(enumName, literalValues); + baseDto.set(propName, [ + enumName, + propType.isArray() ? "ENUM_DTO[]" : "ENUM_DTO", + isOptional, + ]); + } + else { + // Union of DTOs/objects (e.g. `FooDto | { id: string }`) + // Use the first named interface/enum type in the union + const namedType = nonNullTypes.find((t) => t.isInterface() || t.isEnum()); + if (namedType) { + let name = namedType.getText(); + if (name.includes(".")) + name = name.split(".").pop(); + const isEnum = namedType.isEnum(); + const fieldType = propType.isArray() + ? isEnum + ? "ENUM_DTO[]" + : "DEPENDENT_DTO[]" + : isEnum + ? "ENUM_DTO" + : "DEPENDENT_DTO"; + baseDto.set(propName, [name, fieldType, isOptional]); + } + else if (nonNullTypes.length === 1 && nonNullTypes[0].isNumber()) { + // number | null → treat as optional number + baseDto.set(propName, ["number", "PRIMITIVE", true]); + } + else if (nonNullTypes.length === 1 && nonNullTypes[0].isString()) { + // string | null → treat as optional string + baseDto.set(propName, ["string", "PRIMITIVE", true]); + } + else if (nonNullTypes.length === 1 && nonNullTypes[0].isBoolean()) { + // boolean | null → treat as optional boolean + baseDto.set(propName, ["boolean", "PRIMITIVE", true]); + } + // else: skip field entirely (can't represent it) + } } } } @@ -94,4 +164,3 @@ function getDtoDefinitions() { console.log(); return { enumDtos, baseDtos }; } -exports.getDtoDefinitions = getDtoDefinitions; diff --git a/scripts/updateapi-lib/dtoscanner.ts b/scripts/updateapi-lib/dtoscanner.ts index c4d40e60..c19cea67 100644 --- a/scripts/updateapi-lib/dtoscanner.ts +++ b/scripts/updateapi-lib/dtoscanner.ts @@ -1,4 +1,4 @@ -import { Project } from "ts-morph"; +import { Project, SyntaxKind } from "ts-morph"; import { BaseDto, DtoDefs, EnumDto, FieldType } from "./types"; export function getDtoDefinitions(): DtoDefs { @@ -16,6 +16,9 @@ export function getDtoDefinitions(): DtoDefs { const interfs = file.getInterfaces(); const enums = file.getEnums(); + // Count as const objects that qualify as enums + let constEnumCount = 0; + console.log( `${file.getBaseName()}: ${enums.length} enums, ${interfs.length} DTOs` ); @@ -25,6 +28,41 @@ export function getDtoDefinitions(): DtoDefs { enumDtos.set(enum_.getName(), vals); } + // Support `as const` objects as enums: + // const FooDto = { A: 'A', B: 'B' } as const; + for (const varStmt of file.getVariableStatements()) { + for (const decl of varStmt.getDeclarations()) { + const init = decl.getInitializer(); + if (init?.getKind() === SyntaxKind.AsExpression) { + const inner = init.getChildAtIndex(0); + if (inner.getKind() === SyntaxKind.ObjectLiteralExpression) { + const name = decl.getName(); + if (name.endsWith("Dto")) { + const props = inner + .asKindOrThrow(SyntaxKind.ObjectLiteralExpression) + .getProperties() + .filter( + (p) => p.getKind() === SyntaxKind.PropertyAssignment + ) + .map((p) => + p.asKindOrThrow(SyntaxKind.PropertyAssignment).getName() + ); + if (props.length > 0) { + enumDtos.set(name, props); + constEnumCount++; + } + } + } + } + } + } + + if (constEnumCount > 0) { + console.log( + ` (also found ${constEnumCount} 'as const' enum${constEnumCount > 1 ? "s" : ""})` + ); + } + for (const interf of interfs) { const props = interf.getProperties(); const baseDto = new Map(); @@ -57,7 +95,7 @@ export function getDtoDefinitions(): DtoDefs { propType.isBoolean() || propType.getArrayElementType()?.isBoolean() ) { - // num + // bool baseDto.set(propName, [ "boolean", propType.isArray() ? "PRIMITIVE[]" : "PRIMITIVE", @@ -93,25 +131,66 @@ export function getDtoDefinitions(): DtoDefs { propType.isUnion() || propType.getArrayElementType()?.isUnion() ) { - // enum - const enumName = - interfName.replace("Dto", "") + - propName[0].toUpperCase() + - propName.substring(1) + - "Dto"; - - enumDtos.set( - enumName, - propType - .getUnionTypes() - .map((t) => t.getLiteralValue()?.toString() ?? "") + const unionTypes = propType.isArray() + ? propType.getArrayElementTypeOrThrow().getUnionTypes() + : propType.getUnionTypes(); + + // Filter out null/undefined from the union + const nonNullTypes = unionTypes.filter( + (t) => !t.isNull() && !t.isUndefined() ); - baseDto.set(propName, [ - enumName, - propType.isArray() ? "ENUM_DTO[]" : "ENUM_DTO", - isOptional, - ]); + // Collect string literal values + const literalValues = nonNullTypes + .map((t) => t.getLiteralValue()?.toString()) + .filter((v): v is string => v !== undefined && v !== ""); + + if (literalValues.length > 0) { + // String literal union → generate enum (existing behavior) + const enumName = + interfName.replace("Dto", "") + + propName[0].toUpperCase() + + propName.substring(1) + + "Dto"; + + enumDtos.set(enumName, literalValues); + baseDto.set(propName, [ + enumName, + propType.isArray() ? "ENUM_DTO[]" : "ENUM_DTO", + isOptional, + ]); + } else { + // Union of DTOs/objects (e.g. `FooDto | { id: string }`) + // Use the first named interface/enum type in the union + const namedType = nonNullTypes.find( + (t) => t.isInterface() || t.isEnum() + ); + if (namedType) { + let name = namedType.getText(); + if (name.includes(".")) name = name.split(".").pop()!; + + const isEnum = namedType.isEnum(); + const fieldType = propType.isArray() + ? isEnum + ? "ENUM_DTO[]" + : "DEPENDENT_DTO[]" + : isEnum + ? "ENUM_DTO" + : "DEPENDENT_DTO"; + + baseDto.set(propName, [name, fieldType, isOptional]); + } else if (nonNullTypes.length === 1 && nonNullTypes[0].isNumber()) { + // number | null → treat as optional number + baseDto.set(propName, ["number", "PRIMITIVE", true]); + } else if (nonNullTypes.length === 1 && nonNullTypes[0].isString()) { + // string | null → treat as optional string + baseDto.set(propName, ["string", "PRIMITIVE", true]); + } else if (nonNullTypes.length === 1 && nonNullTypes[0].isBoolean()) { + // boolean | null → treat as optional boolean + baseDto.set(propName, ["boolean", "PRIMITIVE", true]); + } + // else: skip field entirely (can't represent it) + } } } } diff --git a/scripts/updateapi-lib/tsgen.js b/scripts/updateapi-lib/tsgen.js index 01b624bc..4804976c 100644 --- a/scripts/updateapi-lib/tsgen.js +++ b/scripts/updateapi-lib/tsgen.js @@ -1,6 +1,7 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.getAdminApiFile = exports.genTsDtoFile = void 0; +exports.genTsDtoFile = genTsDtoFile; +exports.getAdminApiFile = getAdminApiFile; function genTsDtoFile(dtoDefs) { let tsCode = ` // CODE AUTOGENERATED BY npm run updateapi @@ -31,7 +32,6 @@ function genTsDtoFile(dtoDefs) { } return tsCode; } -exports.genTsDtoFile = genTsDtoFile; function getAdminApiFile(apiDefs) { let tsCode = ` // CODE AUTOGENERATED BY npm run updateapi @@ -51,13 +51,24 @@ function getAdminApiFile(apiDefs) { `; for (const [ev, dto] of apiDefs.serverEntrypoints.entries()) { - const ackType = apiDefs.serverAcks.get(ev); - tsCode += ` + const rawAckType = apiDefs.serverAcks.get(ev); + const ackType = rawAckType === "dynamic" ? "any" : rawAckType; + if (dto) { + tsCode += ` ${ev}(data: dto.${dto}) { return this.send("${ev}", data) as Promise<${ackType} | undefined>; } `; + } + else { + tsCode += ` + ${ev}() { + return this.send("${ev}", {}) as Promise<${ackType} | undefined>; + } + + `; + } } for (const [ev, dto] of apiDefs.clientEntrypoints.entries()) { const formattedName = ev[0].toUpperCase() + ev.substring(1); @@ -72,4 +83,3 @@ function getAdminApiFile(apiDefs) { tsCode += "}"; return tsCode; } -exports.getAdminApiFile = getAdminApiFile; diff --git a/scripts/updateapi-lib/tsgen.ts b/scripts/updateapi-lib/tsgen.ts index c7abcf23..c7265a33 100644 --- a/scripts/updateapi-lib/tsgen.ts +++ b/scripts/updateapi-lib/tsgen.ts @@ -61,13 +61,23 @@ export function getAdminApiFile(apiDefs: ApiDefs) { `; for (const [ev, dto] of apiDefs.serverEntrypoints.entries()) { - const ackType = apiDefs.serverAcks.get(ev); - tsCode += ` + const rawAckType = apiDefs.serverAcks.get(ev); + const ackType = rawAckType === "dynamic" ? "any" : rawAckType; + if (dto) { + tsCode += ` ${ev}(data: dto.${dto}) { return this.send("${ev}", data) as Promise<${ackType} | undefined>; } `; + } else { + tsCode += ` + ${ev}() { + return this.send("${ev}", {}) as Promise<${ackType} | undefined>; + } + + `; + } } for (const [ev, dto] of apiDefs.clientEntrypoints.entries()) { diff --git a/server/.env.example b/server/.env.example index 9c820335..7aefa19c 100644 --- a/server/.env.example +++ b/server/.env.example @@ -26,3 +26,6 @@ SUPERUSER=admin123@cornell.edu FIREBASE_PROJECT_ID=cornell-go FIREBASE_CLIENT_EMAIL=your-firebase-adminsdk-email@cornell-go.iam.gserviceaccount.com FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nYOUR_PRIVATE_KEY_HERE\n-----END PRIVATE KEY-----\n" + +# Feature Flags +ENABLE_BUILD_A_BEAR=false diff --git a/server/package-lock.json b/server/package-lock.json index 38650695..52ceea1b 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -234,7 +234,6 @@ "version": "7.28.4", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -793,7 +792,6 @@ "node_modules/@casl/ability": { "version": "6.7.3", "license": "MIT", - "peer": true, "dependencies": { "@ucast/mongo2js": "^1.3.0" }, @@ -1742,7 +1740,6 @@ "version": "4.9.5", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -1754,7 +1751,6 @@ "node_modules/@nestjs/common": { "version": "10.4.20", "license": "MIT", - "peer": true, "dependencies": { "file-type": "20.4.1", "iterare": "1.2.1", @@ -1807,7 +1803,6 @@ "version": "10.4.20", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@nuxtjs/opencollective": "0.3.2", "fast-safe-stringify": "2.1.1", @@ -1854,7 +1849,6 @@ "node_modules/@nestjs/platform-express": { "version": "10.4.20", "license": "MIT", - "peer": true, "dependencies": { "body-parser": "1.20.3", "cors": "2.8.5", @@ -1874,7 +1868,6 @@ "node_modules/@nestjs/platform-socket.io": { "version": "10.4.20", "license": "MIT", - "peer": true, "dependencies": { "socket.io": "4.8.1", "tslib": "2.8.1" @@ -2081,7 +2074,6 @@ "node_modules/@nestjs/websockets": { "version": "10.4.20", "license": "MIT", - "peer": true, "dependencies": { "iterare": "1.2.1", "object-hash": "3.0.0", @@ -2176,7 +2168,6 @@ "version": "5.10.2", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=16.13" }, @@ -2579,7 +2570,6 @@ "node_modules/@types/node": { "version": "20.19.11", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2758,7 +2748,6 @@ "version": "4.33.0", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "4.33.0", "@typescript-eslint/types": "4.33.0", @@ -3043,7 +3032,6 @@ "node_modules/acorn": { "version": "8.15.0", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3105,7 +3093,6 @@ "version": "6.12.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -3759,7 +3746,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001733", "electron-to-chromium": "^1.5.199", @@ -4983,7 +4969,6 @@ "version": "7.32.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "7.12.11", "@eslint/eslintrc": "^0.4.3", @@ -5342,7 +5327,6 @@ "node_modules/express": { "version": "4.21.2", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -7172,7 +7156,6 @@ "version": "29.7.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -9185,7 +9168,6 @@ "node_modules/pg": { "version": "8.16.3", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -9435,7 +9417,6 @@ "version": "3.6.2", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -9499,7 +9480,6 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/engines": "5.10.2" }, @@ -9716,8 +9696,7 @@ }, "node_modules/reflect-metadata": { "version": "0.1.14", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", @@ -9929,7 +9908,6 @@ "node_modules/rxjs": { "version": "7.8.2", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -10925,7 +10903,6 @@ "version": "8.17.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -11148,7 +11125,6 @@ "version": "10.9.2", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -11438,7 +11414,6 @@ "node_modules/typescript": { "version": "5.8.3", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11635,7 +11610,6 @@ "version": "5.82.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.3", "@types/estree": "^1.0.0", diff --git a/server/prisma/migrations/20260330025731_add_reminder_sent_to_event_rsvp/migration.sql b/server/prisma/migrations/20260330025731_add_reminder_sent_to_event_rsvp/migration.sql new file mode 100644 index 00000000..14874e8e --- /dev/null +++ b/server/prisma/migrations/20260330025731_add_reminder_sent_to_event_rsvp/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "EventRSVP" ADD COLUMN "reminderSent" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "User" ALTER COLUMN "hasCompletedOnboarding" SET DEFAULT false; diff --git a/server/prisma/migrations/20260414000000_expand_event_categories/migration.sql b/server/prisma/migrations/20260414000000_expand_event_categories/migration.sql new file mode 100644 index 00000000..c31956ff --- /dev/null +++ b/server/prisma/migrations/20260414000000_expand_event_categories/migration.sql @@ -0,0 +1,21 @@ +-- Remap existing categories before altering the enum +UPDATE "EventBase" SET "category" = 'FOOD' WHERE "category" = 'CAFE'; +UPDATE "EventBase" SET "category" = 'FOOD' WHERE "category" = 'DININGHALL'; +UPDATE "EventBase" SET "category" = 'DORM' WHERE "category" = 'DORM'; + +-- Create new enum type with updated values +CREATE TYPE "EventCategoryType_new" AS ENUM ('FOOD', 'NATURE', 'HISTORICAL', 'RESIDENTIAL', 'LANDMARK', 'ARTS', 'ATHLETICS', 'LIBRARY', 'ACADEMIC', 'RECREATION'); + +-- Remap DORM to RESIDENTIAL during the column type change +ALTER TABLE "EventBase" + ALTER COLUMN "category" TYPE "EventCategoryType_new" + USING ( + CASE "category"::text + WHEN 'DORM' THEN 'RESIDENTIAL'::"EventCategoryType_new" + ELSE "category"::text::"EventCategoryType_new" + END + ); + +-- Drop old enum and rename new one +DROP TYPE "EventCategoryType"; +ALTER TYPE "EventCategoryType_new" RENAME TO "EventCategoryType"; diff --git a/server/prisma/migrations/20260414212655_add_scheduled_time_to_challenge/migration.sql b/server/prisma/migrations/20260414212655_add_scheduled_time_to_challenge/migration.sql new file mode 100644 index 00000000..deb3e8e7 --- /dev/null +++ b/server/prisma/migrations/20260414212655_add_scheduled_time_to_challenge/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "Challenge" ADD COLUMN "scheduledEndTime" TIMESTAMP(3), +ADD COLUMN "scheduledStartTime" TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "PrevChallenge" ADD COLUMN "dateExpired" BOOLEAN NOT NULL DEFAULT false; diff --git a/server/prisma/migrations/20260415120000_add_reminder_claimed_at_to_event_rsvp/migration.sql b/server/prisma/migrations/20260415120000_add_reminder_claimed_at_to_event_rsvp/migration.sql new file mode 100644 index 00000000..f6c3f201 --- /dev/null +++ b/server/prisma/migrations/20260415120000_add_reminder_claimed_at_to_event_rsvp/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "EventRSVP" ADD COLUMN "reminderClaimedAt" TIMESTAMP(3); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 9aa6b8d0..d75826e9 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -148,7 +148,9 @@ model Challenge { longitude Float awardingRadius Float closeRadius Float - timerLength Int? + timerLength Int? + scheduledStartTime DateTime? + scheduledEndTime DateTime? completions PrevChallenge[] activeTrackers EventTracker[] QuizQuestion QuizQuestion[] @@ -184,9 +186,13 @@ enum EventCategoryType { FOOD NATURE HISTORICAL - CAFE - DININGHALL - DORM + RESIDENTIAL + LANDMARK + ARTS + ATHLETICS + LIBRARY + ACADEMIC + RECREATION } model EventTracker { @@ -220,6 +226,7 @@ model PrevChallenge { extensionsUsed Int @default(0) timestamp DateTime @default(now()) failed Boolean @default(false) // True if challenge was failed due to timer expiration + dateExpired Boolean @default(false) // True if auto-completed because time window passed } model Organization { @@ -539,7 +546,9 @@ model EventRSVP { campusEventId String campusEvent CampusEvent @relation(fields: [campusEventId], references: [id]) - rsvpAt DateTime @default(now()) + rsvpAt DateTime @default(now()) + reminderSent Boolean @default(false) + reminderClaimedAt DateTime? @@unique([userId, campusEventId]) } diff --git a/server/prisma/seed.ts b/server/prisma/seed.ts index 65205360..0f6b2b3b 100644 --- a/server/prisma/seed.ts +++ b/server/prisma/seed.ts @@ -1899,7 +1899,7 @@ async function main() { name: 'Angled Eyes', slot: 'EYES', cost: 25, - assetKey: 'buildabear/eyes/><', + assetKey: 'buildabear/eyes/squinty_eyes', mimeType: 'image/png', }, ], diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 0550aee9..86d9d02d 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -24,6 +24,8 @@ import { CheckInModule } from './check-in/check-in.module'; import { EventSyncModule } from './event-sync/event-sync.module'; import { ClubSubmissionModule } from './club-submission/club-submission.module'; import { FeedbackModule } from './feedback/feedback.module'; +import { FeatureFlagsController } from './feature-flags/feature-flags.controller'; +import { FrontendConfigController } from './frontend-config/frontend-config.controller'; @Module({ imports: [ @@ -53,7 +55,7 @@ import { FeedbackModule } from './feedback/feedback.module'; ClubSubmissionModule, FeedbackModule, ], - controllers: [], + controllers: [FeatureFlagsController, FrontendConfigController], providers: [], }) export class AppModule {} diff --git a/server/src/campus-event/campus-event.e2e-spec.ts b/server/src/campus-event/campus-event.e2e-spec.ts index 01649fe8..0009a1e3 100644 --- a/server/src/campus-event/campus-event.e2e-spec.ts +++ b/server/src/campus-event/campus-event.e2e-spec.ts @@ -5,6 +5,8 @@ import { ApprovalStatus } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { CampusEventService } from './campus-event.service'; import { ClientService } from '../client/client.service'; +import { RsvpReminderService } from './rsvp-reminder.service'; +import { NotificationService } from '../notification/notification.service'; jest.setTimeout(20000); @@ -220,6 +222,157 @@ describe('CampusEventModule E2E', () => { ); }); + describe('RsvpReminderService', () => { + let reminderService: RsvpReminderService; + let sendToUserMock: jest.SpyInstance; + let reminderEventId: string; + + const LEAD_TIME_MS = 3 * 60 * 60 * 1000; + const REMINDER_WINDOW_BUFFER_MS = 2 * 60 * 1000; + + beforeAll(() => { + reminderService = moduleRef.get(RsvpReminderService); + const notificationService = + moduleRef.get(NotificationService); + sendToUserMock = jest + .spyOn(notificationService, 'sendToUser') + .mockResolvedValue(true); + }); + + afterAll(async () => { + sendToUserMock.mockRestore(); + if (reminderEventId) { + await prisma.eventRSVP + .deleteMany({ where: { campusEventId: reminderEventId } }) + .catch(() => {}); + await campusEventService.deleteEvent(reminderEventId).catch(() => {}); + } + }); + + it('sends reminder for RSVP within the cron window', async () => { + if (!campusEventTableExists || !testUserId) return; + + const startTime = new Date( + Date.now() + LEAD_TIME_MS + REMINDER_WINDOW_BUFFER_MS, + ); + const endTime = new Date(startTime.getTime() + 3600_000); + + const ev = await campusEventService.createEvent({ + title: 'Reminder Test Event', + description: 'Testing RSVP reminders', + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + locationName: 'Duffield Hall', + latitude: 42.4445, + longitude: -76.4827, + categories: ['SOCIAL'], + tags: ['test-reminder'], + source: 'ADMIN_CREATED', + }); + reminderEventId = ev.id; + createdCampusEventIds.push(ev.id); + + await campusEventService.rsvp(testUserId, ev.id); + + sendToUserMock.mockClear(); + await reminderService.handleReminderCron(); + + expect(sendToUserMock).toHaveBeenCalledWith( + testUserId, + expect.stringContaining('Reminder Test Event'), + expect.any(String), + { campusEventId: ev.id }, + ); + + const rsvp = await prisma.eventRSVP.findFirst({ + where: { userId: testUserId, campusEventId: ev.id }, + }); + expect(rsvp?.reminderSent).toBe(true); + }); + + it('does not send duplicate reminders', async () => { + if (!campusEventTableExists || !testUserId || !reminderEventId) return; + + sendToUserMock.mockClear(); + await reminderService.handleReminderCron(); + + expect(sendToUserMock).not.toHaveBeenCalled(); + }); + + it('prevents duplicate sends during concurrent cron runs', async () => { + if (!campusEventTableExists || !testUserId) return; + + const startTime = new Date( + Date.now() + LEAD_TIME_MS + REMINDER_WINDOW_BUFFER_MS, + ); + const endTime = new Date(startTime.getTime() + 3600_000); + const ev = await campusEventService.createEvent({ + title: 'Concurrent Reminder Event', + description: 'Should only send one reminder', + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + locationName: 'Statler Hall', + latitude: 42.4458, + longitude: -76.4821, + categories: ['SOCIAL'], + tags: ['test-concurrent-reminder'], + source: 'ADMIN_CREATED', + }); + createdCampusEventIds.push(ev.id); + + await campusEventService.rsvp(testUserId, ev.id); + + sendToUserMock.mockClear(); + sendToUserMock.mockImplementation( + () => new Promise(resolve => setTimeout(() => resolve(true), 100)), + ); + + await Promise.all([ + reminderService.handleReminderCron(), + reminderService.handleReminderCron(), + ]); + + expect(sendToUserMock).toHaveBeenCalledTimes(1); + + sendToUserMock.mockResolvedValue(true); + }); + + it('does not send reminders for events outside the window', async () => { + if (!campusEventTableExists || !testUserId) return; + + const farFuture = new Date(Date.now() + 24 * 60 * 60 * 1000); + const endTime = new Date(farFuture.getTime() + 3600_000); + + const ev = await campusEventService.createEvent({ + title: 'Far Future Event', + description: 'Should not trigger reminder', + startTime: farFuture.toISOString(), + endTime: endTime.toISOString(), + locationName: 'Olin Library', + latitude: 42.4479, + longitude: -76.4841, + categories: ['SOCIAL'], + tags: ['test-no-reminder'], + source: 'ADMIN_CREATED', + }); + createdCampusEventIds.push(ev.id); + + await campusEventService.rsvp(testUserId, ev.id); + + sendToUserMock.mockClear(); + await reminderService.handleReminderCron(); + + expect(sendToUserMock).not.toHaveBeenCalledWith( + testUserId, + expect.stringContaining('Far Future Event'), + expect.any(String), + expect.any(Object), + ); + + await campusEventService.unRsvp(testUserId, ev.id); + }); + }); + afterAll(async () => { for (const id of createdCampusEventIds) { await campusEventService.deleteEvent(id).catch(() => {}); diff --git a/server/src/campus-event/campus-event.module.ts b/server/src/campus-event/campus-event.module.ts index b40b9ad6..328fcd26 100644 --- a/server/src/campus-event/campus-event.module.ts +++ b/server/src/campus-event/campus-event.module.ts @@ -2,12 +2,14 @@ import { Module } from '@nestjs/common'; import { ClientModule } from '../client/client.module'; import { PrismaModule } from '../prisma/prisma.module'; import { AuthModule } from '../auth/auth.module'; +import { NotificationModule } from '../notification/notification.module'; import { CampusEventService } from './campus-event.service'; import { CampusEventGateway } from './campus-event.gateway'; +import { RsvpReminderService } from './rsvp-reminder.service'; @Module({ - imports: [AuthModule, ClientModule, PrismaModule], - providers: [CampusEventService, CampusEventGateway], + imports: [AuthModule, ClientModule, PrismaModule, NotificationModule], + providers: [CampusEventService, CampusEventGateway, RsvpReminderService], exports: [CampusEventService, CampusEventGateway], }) export class CampusEventModule {} diff --git a/server/src/campus-event/rsvp-reminder.service.ts b/server/src/campus-event/rsvp-reminder.service.ts new file mode 100644 index 00000000..10cf518f --- /dev/null +++ b/server/src/campus-event/rsvp-reminder.service.ts @@ -0,0 +1,114 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { PrismaService } from '../prisma/prisma.service'; +import { NotificationService } from '../notification/notification.service'; + +const REMINDER_LEAD_TIME_MS = 3 * 60 * 60 * 1000; // 3 hours before event +const CRON_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes (matches cron frequency) +const WINDOW_BUFFER_MS = 60 * 1000; // 1 minute jitter tolerance on each end +const CLAIM_TIMEOUT_MS = 10 * 60 * 1000; // Reclaim stale in-flight reminders +const REMINDER_TIME_LOCALE = 'en-US'; +const REMINDER_TIME_ZONE = 'America/New_York'; + +@Injectable() +export class RsvpReminderService { + private readonly logger = new Logger(RsvpReminderService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly notificationService: NotificationService, + ) {} + + @Cron(CronExpression.EVERY_5_MINUTES) + async handleReminderCron() { + const now = Date.now(); + const windowStart = new Date( + now + REMINDER_LEAD_TIME_MS - WINDOW_BUFFER_MS, + ); + const windowEnd = new Date( + now + REMINDER_LEAD_TIME_MS + CRON_INTERVAL_MS + WINDOW_BUFFER_MS, + ); + const staleClaimThreshold = new Date(now - CLAIM_TIMEOUT_MS); + + const rsvps = await this.prisma.eventRSVP.findMany({ + where: { + reminderSent: false, + OR: [ + { reminderClaimedAt: null }, + { reminderClaimedAt: { lt: staleClaimThreshold } }, + ], + campusEvent: { + startTime: { + gte: windowStart, + lt: windowEnd, + }, + }, + }, + include: { + campusEvent: { select: { title: true, startTime: true } }, + }, + }); + + if (rsvps.length === 0) return; + + this.logger.log(`Sending reminders for ${rsvps.length} RSVPs`); + + for (const rsvp of rsvps) { + try { + const claimResult = await this.prisma.eventRSVP.updateMany({ + where: { + id: rsvp.id, + reminderSent: false, + OR: [ + { reminderClaimedAt: null }, + { reminderClaimedAt: { lt: staleClaimThreshold } }, + ], + }, + data: { reminderClaimedAt: new Date() }, + }); + + if (claimResult.count === 0) continue; + + const { title, startTime } = rsvp.campusEvent; + const timeStr = startTime.toLocaleTimeString(REMINDER_TIME_LOCALE, { + hour: '2-digit', + minute: '2-digit', + timeZone: REMINDER_TIME_ZONE, + }); + + await this.notificationService.sendToUser( + rsvp.userId, + `Upcoming Event: ${title}`, + `Starts at ${timeStr} — see you there!`, + { campusEventId: rsvp.campusEventId }, + ); + + await this.prisma.eventRSVP.update({ + where: { id: rsvp.id }, + data: { + reminderSent: true, + reminderClaimedAt: null, + }, + }); + } catch (error) { + this.logger.error( + `Failed to send RSVP reminder for RSVP ${rsvp.id}`, + error instanceof Error ? error.stack : undefined, + ); + try { + await this.prisma.eventRSVP.updateMany({ + where: { id: rsvp.id, reminderSent: false }, + data: { reminderClaimedAt: null }, + }); + } catch (releaseError) { + this.logger.error( + `Failed to release RSVP reminder claim for RSVP ${rsvp.id}`, + releaseError instanceof Error ? releaseError.stack : undefined, + ); + } + } + } + + this.logger.log(`Finished sending ${rsvps.length} reminders`); + } +} diff --git a/server/src/challenge/challenge.dto.ts b/server/src/challenge/challenge.dto.ts index 036d6864..f829e09e 100644 --- a/server/src/challenge/challenge.dto.ts +++ b/server/src/challenge/challenge.dto.ts @@ -28,6 +28,8 @@ export interface ChallengeDto { closeRadiusF?: number; linkedEventId?: string; timerLength?: number; + scheduledStartTime?: string; + scheduledEndTime?: string; } /** DTO for requestChallengeData */ diff --git a/server/src/challenge/challenge.service.ts b/server/src/challenge/challenge.service.ts index dfc3c7dc..3fdbbb85 100644 --- a/server/src/challenge/challenge.service.ts +++ b/server/src/challenge/challenge.service.ts @@ -135,6 +135,69 @@ export class ChallengeService { return nextChal; } + /** + * Auto-complete challenges whose scheduled end time has passed. + * Creates PrevChallenge records with failed=true, dateExpired=true, 0 points. + * Advances curChallengeId if it was pointing to an expired challenge. + */ + private async autoCompleteExpiredScheduledChallenges( + user: User, + evTracker: EventTracker, + ) { + const now = new Date(); + + const expiredChallenges = await this.prisma.challenge.findMany({ + where: { + linkedEventId: evTracker.eventId, + scheduledEndTime: { lt: now }, + completions: { none: { userId: user.id } }, + }, + }); + + if (expiredChallenges.length === 0) return; + + for (const challenge of expiredChallenges) { + await this.prisma.prevChallenge.create({ + data: { + userId: user.id, + challengeId: challenge.id, + trackerId: evTracker.id, + hintsUsed: 0, + failed: true, + dateExpired: true, + }, + }); + } + + // If the current challenge was expired, advance to next available + if ( + evTracker.curChallengeId && + expiredChallenges.some(c => c.id === evTracker.curChallengeId) + ) { + const nextAvailable = await this.prisma.challenge.findFirst({ + where: { + linkedEventId: evTracker.eventId, + completions: { none: { userId: user.id } }, + }, + orderBy: { eventIndex: 'asc' }, + }); + + await this.prisma.eventTracker.update({ + where: { id: evTracker.id }, + data: { + curChallengeId: nextAvailable?.id ?? null, + hintsUsed: 0, + }, + }); + } + + // Emit tracker update so the client sees the new PrevChallenge records + const updatedTracker = await this.prisma.eventTracker.findUniqueOrThrow({ + where: { id: evTracker.id }, + }); + await this.eventService.emitUpdateEventTracker(updatedTracker, user); + } + /** * Get all available (uncompleted) challenges in the user's current journey. * Returns challenges sorted by eventIndex for consistent ordering. @@ -143,6 +206,9 @@ export class ChallengeService { const evTracker = await this.eventService.getCurrentEventTrackerForUser(user); + // Auto-complete any challenges whose scheduled window has passed + await this.autoCompleteExpiredScheduledChallenges(user, evTracker); + return await this.prisma.challenge.findMany({ where: { linkedEventId: evTracker.eventId, @@ -178,6 +244,15 @@ export class ChallengeService { return null; // Challenge not found or belongs to different event } + // Reject if challenge is outside its scheduled time window + const now = new Date(); + if ( + (challenge.scheduledStartTime && now < challenge.scheduledStartTime) || + (challenge.scheduledEndTime && now > challenge.scheduledEndTime) + ) { + return null; + } + const isCompleted = (await this.prisma.prevChallenge.count({ where: { @@ -249,6 +324,20 @@ export class ChallengeService { if (!eventTracker.curChallengeId) return null; + // Reject if current challenge is outside its scheduled time window + const curChal = await this.prisma.challenge.findFirst({ + where: { id: eventTracker.curChallengeId }, + }); + if (curChal) { + const now = new Date(); + if ( + (curChal.scheduledStartTime && now < curChal.scheduledStartTime) || + (curChal.scheduledEndTime && now > curChal.scheduledEndTime) + ) { + return null; + } + } + const alreadyDone = (await this.prisma.prevChallenge.count({ where: { @@ -568,6 +657,8 @@ export class ChallengeService { closeRadiusF: ch.closeRadius, linkedEventId: ch.linkedEventId!, timerLength: ch.timerLength ?? undefined, + scheduledStartTime: ch.scheduledStartTime?.toISOString() ?? undefined, + scheduledEndTime: ch.scheduledEndTime?.toISOString() ?? undefined, }; } @@ -608,6 +699,12 @@ export class ChallengeService { awardingRadius: challenge.awardingRadiusF, closeRadius: challenge.closeRadiusF, timerLength: challenge.timerLength ?? null, + scheduledStartTime: challenge.scheduledStartTime + ? new Date(challenge.scheduledStartTime) + : null, + scheduledEndTime: challenge.scheduledEndTime + ? new Date(challenge.scheduledEndTime) + : null, }; const data = await this.abilityFactory.filterInaccessible( @@ -647,6 +744,12 @@ export class ChallengeService { eventIndex: (maxIndexChallenge._max.eventIndex ?? -1) + 1, linkedEventId: challenge.linkedEventId, timerLength: challenge.timerLength ?? null, + scheduledStartTime: challenge.scheduledStartTime + ? new Date(challenge.scheduledStartTime) + : null, + scheduledEndTime: challenge.scheduledEndTime + ? new Date(challenge.scheduledEndTime) + : null, }; chal = await this.prisma.challenge.create({ diff --git a/server/src/event/event.dto.ts b/server/src/event/event.dto.ts index d3ced511..de928c47 100644 --- a/server/src/event/event.dto.ts +++ b/server/src/event/event.dto.ts @@ -71,9 +71,13 @@ export enum EventCategoryDto { FOOD = 'FOOD', NATURE = 'NATURE', HISTORICAL = 'HISTORICAL', - CAFE = 'CAFE', - DININGHALL = 'DININGHALL', - DORM = 'DORM', + RESIDENTIAL = 'RESIDENTIAL', + LANDMARK = 'LANDMARK', + ARTS = 'ARTS', + ATHLETICS = 'ATHLETICS', + LIBRARY = 'LIBRARY', + ACADEMIC = 'ACADEMIC', + RECREATION = 'RECREATION', } /** @@ -107,6 +111,7 @@ export interface PrevChallengeDto { extensionsUsed?: number; dateCompleted: string; failed?: boolean; // True if challenge was failed due to timer expiration + dateExpired?: boolean; } /** DTO for event tracker in updateEventTrackerData */ diff --git a/server/src/event/event.service.ts b/server/src/event/event.service.ts index 1925fc24..7616f866 100644 --- a/server/src/event/event.service.ts +++ b/server/src/event/event.service.ts @@ -420,6 +420,7 @@ export class EventService { extensionsUsed: pc.extensionsUsed ?? 0, // Default to 0 for backwards compatibility dateCompleted: pc.timestamp.toUTCString(), failed: pc.failed, // True if challenge was failed due to timer expiration + dateExpired: pc.dateExpired, })), }; } diff --git a/server/src/feature-flags/feature-flags.controller.ts b/server/src/feature-flags/feature-flags.controller.ts new file mode 100644 index 00000000..a276440b --- /dev/null +++ b/server/src/feature-flags/feature-flags.controller.ts @@ -0,0 +1,11 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller('feature-flags') +export class FeatureFlagsController { + @Get() + getFlags() { + return { + enableBuildABear: process.env.ENABLE_BUILD_A_BEAR === 'true', + }; + } +} diff --git a/server/src/frontend-config/frontend-config.controller.ts b/server/src/frontend-config/frontend-config.controller.ts new file mode 100644 index 00000000..295f1c76 --- /dev/null +++ b/server/src/frontend-config/frontend-config.controller.ts @@ -0,0 +1,11 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller('frontend-config') +export class FrontendConfigController { + @Get() + getConfig() { + return { + googleMapsApiKey: process.env.REACT_APP_GOOGLE_MAPS_API_KEY ?? '', + }; + } +}