diff --git a/src/api/authenticated-api.ts b/src/api/authenticated-api.ts index e782d89a..93ce407e 100644 --- a/src/api/authenticated-api.ts +++ b/src/api/authenticated-api.ts @@ -224,7 +224,7 @@ export class AuthenticatedAPIClient extends AnonymousAPIClient { return res } - async getPersonalLecturers(): Promise { + async getPersonalLecturers(): Promise { const res = await this.requestCached(KEY_GET_PERSONAL_LECTURERS, { service: 'thiapp', method: 'stpllecturers', @@ -238,7 +238,7 @@ export class AuthenticatedAPIClient extends AnonymousAPIClient { * @param {string} from Single character indicating where to start listing the lecturers * @param {string} to Single character indicating where to end listing the lecturers */ - async getLecturers(from: string, to: string): Promise { + async getLecturers(from: string, to: string): Promise { const key = `${KEY_GET_LECTURERS}-${from}-${to}` const res = await this.requestCached(key, { service: 'thiapp', diff --git a/src/app/(pages)/lecturer.tsx b/src/app/(pages)/lecturer.tsx new file mode 100644 index 00000000..a527d86e --- /dev/null +++ b/src/app/(pages)/lecturer.tsx @@ -0,0 +1,145 @@ +import FormList from '@/components/Elements/Universal/FormList' +import { type Colors } from '@/stores/colors' +import { type FormListSections } from '@/stores/types/components' +import { type NormalizedLecturer } from '@/utils/lecturers-utils' +import { useTheme } from '@react-navigation/native' +import { router, useLocalSearchParams } from 'expo-router' +import { StatusBar } from 'expo-status-bar' +import React from 'react' +import { Linking, ScrollView, StyleSheet, Text, View } from 'react-native' + +export default function LecturerDetail(): JSX.Element { + const colors = useTheme().colors as Colors + const { lecturerEntry } = useLocalSearchParams<{ lecturerEntry: string }>() + const lecturer: NormalizedLecturer | undefined = + lecturerEntry != null ? JSON.parse(lecturerEntry) : undefined + + const sections: FormListSections[] = [ + { + header: 'Details', + items: [ + { + title: 'Name', + value: `${lecturer?.vorname ?? ''} ${lecturer?.name ?? ''}`, + disabled: true, + }, + { + title: 'Title', + value: lecturer?.titel, + disabled: true, + }, + { + title: 'Organization', + value: lecturer?.organisation, + disabled: true, + }, + + { + title: 'Function', + value: lecturer?.funktion, + disabled: true, + }, + ], + }, + { + header: 'Contact', + items: [ + { + title: 'Room', + value: lecturer?.room_short, + disabled: lecturer?.room_short == null, + iconColor: colors.primary, + onPress: () => { + router.push('(tabs)/map') + router.setParams({ + q: lecturer?.room_short ?? '', + h: 'true', + }) + }, + }, + { + title: 'E-Mail', + value: lecturer?.email, + disabled: lecturer?.email == null, + iconColor: colors.primary, + onPress: () => { + void Linking.openURL(`mailto:${lecturer?.email ?? ''}`) + }, + }, + { + title: 'Phone', + value: lecturer?.tel_dienst, + disabled: lecturer?.tel_dienst == null, + iconColor: colors.primary, + onPress: () => { + void Linking.openURL( + `tel:${ + lecturer?.tel_dienst?.replace(/\s+/g, '') ?? '' + }` + ) + }, + }, + { + title: 'Office Hours', + value: lecturer?.sprechstunde, + disabled: true, + }, + { + title: 'Exam Insigths', + value: lecturer?.einsichtnahme, + disabled: true, + }, + ], + }, + ] + + return ( + + + ) +} + +const styles = StyleSheet.create({ + titleContainer: { + alignSelf: 'center', + width: '92%', + marginTop: 20, + paddingHorizontal: 5, + paddingVertical: 10, + borderRadius: 8, + alignItems: 'center', + }, + titleText: { + fontSize: 18, + textAlign: 'center', + }, + notesContainer: { + alignSelf: 'center', + width: '92%', + marginTop: 20, + marginBottom: 40, + }, + notesText: { + textAlign: 'justify', + fontSize: 13, + }, +}) diff --git a/src/app/(pages)/lecturers.tsx b/src/app/(pages)/lecturers.tsx new file mode 100644 index 00000000..e09af737 --- /dev/null +++ b/src/app/(pages)/lecturers.tsx @@ -0,0 +1,226 @@ +import API from '@/api/authenticated-api' +import { + NoSessionError, + UnavailableSessionError, +} from '@/api/thi-session-handler' +import LecturerRow from '@/components/Elements/Pages/LecturerRow' +import Divider from '@/components/Elements/Universal/Divider' +import { type Colors } from '@/stores/colors' +import { + type NormalizedLecturer, + normalizeLecturers, +} from '@/utils/lecturers-utils' +import { useTheme } from '@react-navigation/native' +import { useGlobalSearchParams, useRouter } from 'expo-router' +import React, { useEffect, useState } from 'react' +import { + ActivityIndicator, + RefreshControl, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native' + +export default function LecturersCard(): JSX.Element { + enum LoadingState { + LOADING, + LOADED, + ERROR, + REFRESHING, + } + + const [personalLecturers, setPersonalLecturers] = useState< + NormalizedLecturer[] + >([]) + const [filteredLecturers, setFilteredLecturers] = useState< + NormalizedLecturer[] + >([]) + const [didFetch, setDidFetch] = useState(false) + const [error, setError] = useState(null) + const [loadingState, setLoadingState] = useState( + LoadingState.LOADING + ) + const { q } = useGlobalSearchParams<{ q: string }>() + const [allLecturers, setAllLecturers] = useState([]) + const colors = useTheme().colors as Colors + const router = useRouter() + async function load(): Promise { + try { + const rawData = await API.getPersonalLecturers() + const data = normalizeLecturers(rawData) + setPersonalLecturers(data) + setLoadingState(LoadingState.LOADED) + } catch (e) { + if ( + e instanceof NoSessionError || + e instanceof UnavailableSessionError + ) { + router.replace('(user)/login') + } else { + setLoadingState(LoadingState.ERROR) + setError(e as Error) + } + } + } + useEffect(() => { + void load() + }, []) + + const onRefresh: () => void = () => { + void load() + setLoadingState(LoadingState.LOADED) + } + + useEffect(() => { + async function load(): Promise { + setLoadingState(LoadingState.LOADING) + if (q == null) { + setFilteredLecturers(personalLecturers) + setLoadingState(LoadingState.LOADED) + return + } + + if (allLecturers.length === 0) { + if (didFetch) { + return + } + + setDidFetch(true) + setFilteredLecturers([]) + try { + const rawData = await API.getLecturers('0', 'z') + const data = normalizeLecturers(rawData) + setAllLecturers(data) + setLoadingState(LoadingState.LOADED) + return + } catch (e) { + if (e instanceof NoSessionError) { + router.replace('(user)/login') + } else { + setError(e as Error) + } + setLoadingState(LoadingState.ERROR) + return + } + } + + const normalizedSearch = q.toLowerCase().trim() + const checkField = (value: string | null): boolean => + value?.toString().toLowerCase().includes(normalizedSearch) ?? + false + const filtered = allLecturers + .filter( + (x) => + checkField(x.name) || + checkField(x.vorname) || + checkField(x.email) || + checkField(x.tel_dienst) || + checkField(x.raum) + ) + .slice(0, 20) + + setFilteredLecturers(filtered) + setLoadingState(LoadingState.LOADED) + } + void load() + }, [didFetch, q, personalLecturers, allLecturers]) + + return ( + + ) : undefined + } + > + {loadingState === LoadingState.LOADING && ( + + + + )} + {loadingState === LoadingState.ERROR && ( + + + {error?.message} + + + An error occurred while loading the data.{'\n'}Pull down + to refresh. + + + )} + + {loadingState === LoadingState.LOADED && ( + + + {q != null ? 'Suchergebnisse' : 'Persönliche Dozenten'} + + + {filteredLecturers?.map((event, index) => ( + + + {index !== personalLecturers.length - 1 && ( + + )} + + ))} + + + )} + + ) +} + +const styles = StyleSheet.create({ + loadedContainer: { + alignSelf: 'center', + width: '95%', + marginTop: 14, + marginBottom: 24, + }, + loadedRows: { + borderRadius: 8, + }, + errorMessage: { + paddingTop: 100, + fontWeight: '600', + fontSize: 16, + textAlign: 'center', + }, + errorInfo: { + fontSize: 14, + textAlign: 'center', + marginTop: 10, + }, + loadingContainer: { + paddingTop: 40, + justifyContent: 'center', + alignItems: 'center', + }, + sectionHeader: { + fontSize: 13, + + fontWeight: 'normal', + textTransform: 'uppercase', + marginBottom: 4, + }, +}) diff --git a/src/app/(tabs)/_layout.tsx b/src/app/(tabs)/_layout.tsx index 0c3caa2e..0b781d89 100644 --- a/src/app/(tabs)/_layout.tsx +++ b/src/app/(tabs)/_layout.tsx @@ -19,7 +19,7 @@ export default function HomeLayout(): JSX.Element { tabBarLabelStyle: { marginBottom: 2, }, - lazy: true, + lazy: false, }} > ), diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index be9b69c3..36d8dd70 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -189,6 +189,49 @@ export default function RootLayout(): JSX.Element { }), }} /> + { + router.setParams({ + q: event.nativeEvent.text, + }) + }, + shouldShowHintSearchIcon: false, + }, + }} + /> + diff --git a/src/components/Elements/Pages/LecturerRow.tsx b/src/components/Elements/Pages/LecturerRow.tsx new file mode 100644 index 00000000..6bfe31d0 --- /dev/null +++ b/src/components/Elements/Pages/LecturerRow.tsx @@ -0,0 +1,98 @@ +import { type Colors } from '@/stores/colors' +import { type NormalizedLecturer } from '@/utils/lecturers-utils' +import { router } from 'expo-router' +import React from 'react' +import { Text, View } from 'react-native' + +import RowEntry from '../Universal/RowEntry' + +const LecturerRow = ({ + colors, + item, +}: { + colors: Colors + + item: NormalizedLecturer +}): JSX.Element => { + const onPressRoom = (): void => { + router.push('(tabs)/map') + router.setParams({ + q: item.room_short, + h: 'true', + }) + } + const onPressRow = (): void => { + router.push({ + pathname: '(pages)/lecturer', + params: { lecturerEntry: JSON.stringify(item) }, + }) + } + return ( + + + {item.funktion} + + + {item.organisation} + + + } + rightChildren={ + <> + + {item.raum !== null && item.raum !== '' && ( + + + {'Room: '} + + + {item.room_short} + + + )} + + + } + onPress={onPressRow} + /> + ) +} + +export default LecturerRow diff --git a/src/components/Elements/Universal/FormList.tsx b/src/components/Elements/Universal/FormList.tsx index 301c7c55..91287286 100644 --- a/src/components/Elements/Universal/FormList.tsx +++ b/src/components/Elements/Universal/FormList.tsx @@ -79,6 +79,7 @@ const FormList: React.FC = ({ sections }) => { - AllCards.find((y) => y.key === x) - ) + const entries = JSON.parse(personalDashboard) + .map((x: string) => AllCards.find((y) => y.key === x)) + .filter((x: Record) => x != null) setShownDashboardEntries(entries) if (personalDashboardHidden != null) { diff --git a/src/stores/types/thi-api.ts b/src/stores/types/thi-api.ts index 80a661e5..16218d85 100644 --- a/src/stores/types/thi-api.ts +++ b/src/stores/types/thi-api.ts @@ -63,15 +63,15 @@ export interface Jobs { export interface Lecturers { id: string name: string - vorname: null | string - titel: null | string - raum: null | string + vorname: string + titel: string + raum: string email: string tel_dienst: string - sprechstunde: null | string - einsichtnahme: null | string + sprechstunde: string + einsichtnahme: string ist_intern: IstIntern - organisation: Organisation | null + organisation: Organisation funktion: Funktion } diff --git a/src/utils/lecturers-utils.ts b/src/utils/lecturers-utils.ts new file mode 100644 index 00000000..28fc085f --- /dev/null +++ b/src/utils/lecturers-utils.ts @@ -0,0 +1,32 @@ +import { type Lecturers } from '@customTypes/thi-api' + +/** + * Normalizes lecturer data. + * This removes invalid entries and converts phone numbers to a standardized format. + * @param {object[]} entries + * @returns {object[]} + */ +export function normalizeLecturers(entries: Lecturers[]): NormalizedLecturer[] { + return entries + .filter((x) => !(x.vorname == null)) // remove dummy entries + .map((x) => ({ + ...x, + // try to reformat phone numbers to DIN 5008 International + tel_dienst: x.tel_dienst + .trim() + .replace(/\(0\)/g, '') // remove (0) in +49 (0) 8441 + .replace(/(\d|\/|^)(\s|-|\/|\(|\))+(?=\d|\/)/g, '$1') // remove spaces, -, / and braces in numbers + .replace(/^-?(\d{3,5})$/, '+49 841 9348$1') // add prefix for suffix-only numbers + .replace(/^9348/, '+49 841 9348') // add missing +49 841 prefix to THI numbers + .replace(/^49/, '+49') // fix international format + .replace(/^((\+?\s*49)|0)\s*841\s*/, '+49 841 '), + room_short: ((x.raum ?? '').match(/[A-Z]\s*\d+/g) ?? [ + '', + ])[0].replace(/\s+/g, ''), + })) + .sort((a, b) => a.name.localeCompare(b.name)) +} + +export interface NormalizedLecturer extends Lecturers { + room_short: string +}