Initial Commit

This commit is contained in:
2024-09-15 14:36:20 +02:00
parent 05038d512b
commit ec7ef45b96
35 changed files with 22411 additions and 0 deletions

101
common/DatePicker.tsx Normal file
View File

@@ -0,0 +1,101 @@
import React, { useState } from 'react';
import { Platform, View, Modal, Text, StyleSheet } from 'react-native';
import DateTimePicker from '@react-native-community/datetimepicker';
import { useColors } from './colors';
import { format } from 'date-fns';
import { ThemeButton } from './ThemeTypes';
type DatePickerTypes = {
date?: Date,
setDate?: (date: Date) => void,
}
const DatePicker = ({ date, setDate }: DatePickerTypes) => {
const colors = useColors();
const [tempDate, setTempDate] = useState(date);
const [showDatePicker, setShowDatePicker] = useState(false);
const handleCancel = () => {
setShowDatePicker(false);
};
const renderWebContent = () => (
<>
<Modal
visible={showDatePicker}
animationType="slide"
transparent={true}
onRequestClose={handleCancel}
>
<View style={styles.modalOverlay}>
<View style={[styles.modalContent, { backgroundColor: colors.backSecondary }]}>
<input
type="date"
value={format(date, 'yyyy-MM-dd')}
onChange={(e) => setTempDate(new Date(e.target.value))}
style={styles.dateInput}
/>
<View style={styles.buttonContainer}>
<ThemeButton onPress={handleCancel} color={colors.primary} ><Text>Abbrechen</Text></ThemeButton>
<ThemeButton onPress={() => setTempDate(new Date())} color={colors.primary} ><Text>Heute</Text></ThemeButton>
<ThemeButton onPress={() => {setShowDatePicker(false);setDate(tempDate);}} color={colors.primary} ><Text>Speichern</Text></ThemeButton>
</View>
</View>
</View>
</Modal>
</>
);
const renderMobileContent = () => (
showDatePicker && <DateTimePicker style={{ backgroundColor: 'black' }}
value={tempDate}
mode="date"
is24Hour={true}
onChange={(event, selectedDate) => {
if (event.type == 'set') {
setShowDatePicker(false)
setTempDate(selectedDate);
setDate(selectedDate);
} else {
handleCancel();
}
}}
/>
);
return (
<>
<ThemeButton onPress={() => {setTempDate(date);setShowDatePicker(true)}} ><Text>{format(date, 'dd.MM.yyyy')}</Text></ThemeButton>
{Platform.OS === 'web' ? renderWebContent() : renderMobileContent()}
</>
);
};
export default DatePicker;
const styles = StyleSheet.create({
modalOverlay: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
modalContent: {
width: 300,
padding: 20,
borderRadius: 10,
},
dateInput: {
width: '100%',
padding: 10,
fontSize: 16,
marginVertical: 20,
borderColor: '#ccc',
borderWidth: 1,
borderRadius: 5,
},
buttonContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
},
});

14
common/ErrorDisplay.tsx Normal file
View File

@@ -0,0 +1,14 @@
import { Text, View } from 'react-native';
import { ThemeButton, ThemeText } from './ThemeTypes';
const ErrorDisplay = ({message, onRefresh}) => {
return (
<View style={{ marginTop: 30, alignItems: 'center' }}>
<ThemeText style={{fontWeight: 'bold', fontSize: 20}}>Fehler beim Laden der Daten</ThemeText>
<ThemeText style={{ margin: 10 }}>{message}</ThemeText>
<ThemeButton onPress={onRefresh} ><Text>Neu laden</Text></ThemeButton>
</View>
);
}
export default ErrorDisplay;

154
common/FilterModal.tsx Normal file
View File

@@ -0,0 +1,154 @@
import React, { useState, useEffect } from 'react';
import { Modal, View, StyleSheet, Text } from 'react-native';
import { useColors } from '../common/colors';
import { ThemeButton, ThemeText } from '../common/ThemeTypes';
import { Picker } from '@react-native-picker/picker';
import { FontAwesome } from '@expo/vector-icons';
import { Colors } from 'react-native/Libraries/NewAppScreen';
const categories = { class: 'Klasse', room: 'Raum', teacher: 'Lehrer' };
type FilterModalTypes = {
filter?: any,
setFilter?: (date: any) => void,
userInformation: any,
baseData: any,
}
const FilterModal = ({ filter, setFilter, userInformation, baseData }: FilterModalTypes) => {
const colors = useColors();
const [tempFilter, setTempFilter] = useState(filter);
const [showFilterModal, setShowFilterModal] = useState(false);
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
justifyContent: 'flex-end',
alignItems: 'center',
paddingRight: 10,
gap: 10,
},
modalContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
marginTop: 22,
},
modalView: {
margin: 20,
backgroundColor: colors.backSecondary,
borderRadius: 20,
width: '80%',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 5,
borderWidth: 5,
borderColor: colors.backSecondary,
padding: 20,
},
modalTitle: {
fontWeight: 'bold',
fontSize: 20,
},
modalSubtitle: {
fontSize: 16,
marginTop: 20,
marginBottom: 10,
},
picker: {
color: colors.text,
backgroundColor: colors.backPrimary,
},
pickerDisabled: {
color: colors.backPrimary,
backgroundColor: colors.backSecondary,
borderColor: colors.backPrimary,
},
modalButtons: {
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: 20,
},
});
const renderFilterModal = () => (
<Modal
visible={showFilterModal}
transparent={true}
onRequestClose={() => setFilter(tempFilter)}
>
<View style={styles.modalContainer}>
<View style={styles.modalView}>
<ThemeText style={styles.modalSubtitle}>Kriterium</ThemeText>
<Picker
style={styles.picker}
selectedValue={tempFilter.type??""}
onValueChange={(itemValue, itemIndex) => {
setTempFilter(prevState => ({ ...prevState, type: itemValue, value: null }))
}}
>
{Object.keys(baseData).map((item) => (
<Picker.Item key={item} label={categories[item]} value={item} />
))}
</Picker>
{tempFilter.type &&
<View>
<ThemeText style={styles.modalSubtitle}>Suchbegriff</ThemeText>
<Picker
style={styles.picker}
selectedValue={tempFilter.value??""}
onValueChange={(itemValue, itemIndex) => {
setTempFilter(prevState => ({ ...prevState, value: itemValue }))
}}
>
<Picker.Item label="Bitte wählen" value="" />
{baseData[tempFilter.type].map((item) => (
<Picker.Item key={item.id} label={item.name} value={item.id} />
))}
</Picker>
</View>
}
<View>
<ThemeText style={styles.modalSubtitle}>Gruppe</ThemeText>
<Picker
style={tempFilter.type === "class" ? styles.picker : styles.pickerDisabled}
enabled={tempFilter.type === "class"}
selectedValue={tempFilter.group??1}
onValueChange={(itemValue, itemIndex) => {
setTempFilter(prevState => ({ ...prevState, group: itemValue }))
}}
>
<Picker.Item label="1" value="1" />
<Picker.Item label="2" value="2" />
</Picker>
</View>
<View style={styles.modalButtons}>
<ThemeButton onPress={() => { setTempFilter(getDefaultFilter(userInformation)); }}><Text>Zurücksetzen</Text></ThemeButton>
<ThemeButton onPress={() => { setFilter(tempFilter); setShowFilterModal(false) }} disabled={!Boolean(tempFilter.value) || (tempFilter.type == 'class' && !Boolean(tempFilter.value))}><Text>Anwenden</Text></ThemeButton>
</View>
</View>
</View>
</Modal>
);
return (
<>
<ThemeButton onPress={() => { setTempFilter(filter); setShowFilterModal(true) }} ><FontAwesome name="filter" size={24} /></ThemeButton>
{renderFilterModal()}
</>
);
};
const getDefaultFilter = (userInformation) => {
if (!userInformation) return null;
let type = userInformation.usertype == 'pupil' ? 'class' : 'teacher';
let value = userInformation.usertype == 'pupil' ? userInformation.classid : userInformation.id;
return { type: type, value: value, group: String(userInformation.group) };
};
export default FilterModal;

224
common/ScheduleBuilder.tsx Normal file
View File

@@ -0,0 +1,224 @@
import React, { useCallback, useState } from 'react';
import { FlatList, Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { ThemeButton, ThemeText } from './ThemeTypes';
import { useColors } from './colors';
import { format, getDay } from 'date-fns';
const dayNames = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];
const ScheduleBuilder = ({ hours, days, type }) => {
const colors = useColors();
const [modalVisible, setModalVisible] = useState(false);
const [selected, setSelected] = useState(null);
const openModal = useCallback((day, hour) => {
setSelected({ day: day, hour: hour });
setModalVisible(true);
}, []);
const closeModal = useCallback(() => {
setModalVisible(false);
setSelected(null);
}, []);
const styles = StyleSheet.create({
wrapper: {
alignItems: 'baseline',
},
container: {
flexDirection: 'row',
display: 'flex',
alignItems: 'flex-start',
},
leftColumn: {
width: 80,
paddingTop: 38,
borderRightWidth: 1,
borderColor: '#ccc',
},
timeSlot: {
height: 60,
justifyContent: 'center',
alignItems: 'center',
borderBottomWidth: 1,
borderColor: '#ccc',
},
scheduleContent: {},
dayColumn: {
width: 150,
borderRightWidth: 1,
borderColor: '#ccc',
},
dayHeader: {
height: 38,
},
dayTitle: {
fontWeight: 'bold',
textAlign: 'center',
},
dayDate: {
textAlign: 'center',
},
eventSlot: {
height: 60,
justifyContent: 'center',
borderBottomWidth: 1,
borderColor: '#ccc',
paddingHorizontal: 10,
},
modalOverlay: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.5)',
},
modalContent: {
backgroundColor: colors.backPrimary,
padding: 20,
borderRadius: 10,
alignItems: 'center',
width: '80%',
maxWidth: 500,
},
modalTitle: {
fontSize: 20,
fontWeight: 'bold',
marginBottom: 20,
},
closeButton: {
marginTop: 20,
width: '100%',
},
lessonContainer: {
marginBottom: 10,
borderWidth: 2,
borderRadius: 5,
padding: 10,
width: '100%',
},
lessonDetailsContainer: {
flexDirection: 'row',
},
lessonTime: {
fontWeight: 'bold',
paddingRight: 6,
marginRight: 6,
justifyContent: 'center',
alignItems: 'center',
borderColor: '#aaa',
borderRightWidth: 1,
},
lessonDetails: {
flex: 1,
justifyContent: 'center',
},
});
return (
<View style={styles.wrapper}>
<View style={styles.container}>
<View style={styles.leftColumn}>
<FlatList
scrollEnabled={false}
data={hours}
keyExtractor={(item) => item.index}
renderItem={({ item }) => (
<View style={styles.timeSlot}>
<ThemeText>{item.start}</ThemeText>
<ThemeText>{item.end}</ThemeText>
</View>
)}
/>
</View>
{days.map((dayData) => (
<View key={dayData.id} style={styles.scheduleContent}>
<View style={[styles.dayColumn]}>
<View style={[styles.dayHeader]}>
<ThemeText style={styles.dayTitle}>{dayNames[getDay(dayData.date)]}</ThemeText>
<ThemeText style={styles.dayDate}>{format(dayData.date, 'dd.MM.yyyy')}</ThemeText>
</View>
{hours.map((time) => {
let lesson = dayData.lessons.find(
(e) => e.from <= time.index && e.until >= time.index && e.standin
);
if (!lesson) {
lesson = dayData.lessons.find(
(e) => e.from <= time.index && e.until >= time.index
);
}
return (
<TouchableOpacity
style={[
styles.eventSlot,
{ backgroundColor: lesson?.standin ? colors.accent : colors.backPrimary }
]}
key={time.index}
onPress={() => openModal(dayData.id, time.index)}
>
<ThemeText numberOfLines={3}>
{lesson ? `${lesson.subject}\n${type != 'class' ? lesson.class + (lesson.group != 0 ? '-' + lesson.group : '') : ''}${type != 'room' ? ' ' + lesson.room : ''}${type != 'teacher' ? ' ' + lesson.teacher : ''}${lesson.message ? '\n' + lesson.message : ''}` : ''}
</ThemeText>
</TouchableOpacity>
);
})}
</View>
</View>
))}
</View>
{modalVisible && selected.hour !== null && (
<Modal
transparent={true}
visible={modalVisible}
onRequestClose={closeModal}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<ThemeText style={styles.modalTitle}>
Stunde {selected.hour}: {hours[selected.hour-1].start} - {hours[selected.hour-1].end}
</ThemeText>
{days.find((day) => day.id === selected.day).lessons
.filter((lesson) => lesson.from <= selected.hour && lesson.until >= selected.hour)
.map((lesson, index) => (
<View key={index} style={[styles.lessonContainer, { borderColor: lesson?.standin ? colors.accent : '#ccc' }]}>
{lesson.standin && (
<ThemeText style={{ marginBottom: 6 }}>Vertretung</ThemeText>
)}
<View style={styles.lessonDetailsContainer}>
<View style={styles.lessonTime}>
<ThemeText>{lesson.from}</ThemeText>
<ThemeText></ThemeText>
<ThemeText>{lesson.until}</ThemeText>
</View>
<View style={styles.lessonDetails}>
<ThemeText>Fach: {lesson.subject}</ThemeText>
{type !== 'class' && (
<>
<ThemeText>Klasse: {lesson.class}</ThemeText>
<ThemeText>Gruppe: {lesson.group === '0' ? 'Beide' : lesson.group}</ThemeText>
</>
)}
{type !== 'teacher' && (
<ThemeText>Lehrer: {lesson.teacher}</ThemeText>
)}
{type !== 'room' && (
<ThemeText>Raum: {lesson.room}</ThemeText>
)}
{lesson.message && (
<ThemeText>Notiz: {lesson.message}</ThemeText>
)}
</View>
</View>
</View>
))}
<ThemeButton onPress={closeModal} style={styles.closeButton}>
<Text>Schließen</Text>
</ThemeButton>
</View>
</View>
</Modal>
)}
</View>
);
};
export default ScheduleBuilder;

137
common/ThemeTypes.tsx Normal file
View File

@@ -0,0 +1,137 @@
import React, { useState } from 'react';
import { TouchableOpacity, Text, GestureResponderEvent, ColorValue, Switch, TextInput, View, StyleSheet } from 'react-native';
import { useColors } from './colors';
import { MaterialCommunityIcons } from '@expo/vector-icons';
type ThemeTextType = {
style?: Object,
children?: any,
numberOfLines?: number,
}
export const ThemeText = ({ style, children, numberOfLines }: ThemeTextType) => {
const colors = useColors();
return (
<Text style={[style, { color: colors.text }]} numberOfLines={numberOfLines}>{children}</Text>
);
};
type ThemeButtonType = {
style?: Object,
children?: React.ReactNode,
onPress?: (event: GestureResponderEvent) => void,
color?: ColorValue,
disabled?: boolean,
}
export const ThemeButton = ({ style, children, onPress, color, disabled }: ThemeButtonType) => {
const colors = useColors();
return (
<TouchableOpacity
style={[
style,
{
backgroundColor: color ? color : colors.primary,
padding: 8,
height: 40,
borderRadius: 5,
opacity: disabled ? 0.5 : 1,
alignItems: 'center',
justifyContent: 'center',
}
]}
onPress={disabled ? undefined : onPress}
disabled={disabled}
>
{children}
</TouchableOpacity>
);
};
type ThemeSwitchType = {
style?: Object,
value?: boolean,
disabled?: boolean,
onValueChange?: (value: boolean) => void | Promise<void>,
}
export const ThemeSwitch = ({ style, value, disabled, onValueChange }: ThemeSwitchType) => {
const colors = useColors();
return (
<Switch
style={style}
trackColor={{
false: colors.backSecondary,
true: colors.backSecondary,
}}
thumbColor={disabled ? colors.textAccent : value ? colors.primary : colors.text}
// @ts-expect-error - Für Web, TS kennt activeThumbColor nicht
activeThumbColor={colors.primary}
onValueChange={onValueChange}
value={value}
disabled={disabled}
/>
);
};
type ThemeTextInputType = {
style?: Object,
value?: string,
disabled?: boolean,
onValueChange?: (value: string) => void | Promise<void>,
secureTextEntry?: boolean,
placeholder?: string,
}
export const ThemeTextInput = ({ style, value, disabled, onValueChange, secureTextEntry, placeholder }: ThemeTextInputType) => {
const colors = useColors();
const [showPassword, setShowPassword] = useState(!secureTextEntry);
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 8,
paddingRight: 14,
paddingLeft: 8,
borderColor: disabled ? colors.textDisabled : colors.text,
borderWidth: 1,
},
input: {
flex: 1,
paddingVertical: 10,
paddingRight: 10,
// @ts-expect-error - Für Web, TS kennt outlineStyle nicht
outlineStyle: 'none',
backgroundColor: colors.backPrimary,
color: disabled ? colors.textDisabled : colors.text,
},
icon: {
marginLeft: 10,
},
});
return (
<View style={styles.container}>
<TextInput
style={styles.input}
editable={!disabled}
value={value}
onChangeText={onValueChange}
placeholder={placeholder}
placeholderTextColor={colors.textAccent}
secureTextEntry={!showPassword}
autoCapitalize='none'
/>
{
secureTextEntry && <MaterialCommunityIcons
name={showPassword ? 'eye-off' : 'eye'}
size={24}
color="#aaa"
style={styles.icon}
onPress={() => setShowPassword(!showPassword)}
/>
}
</View>
);
};

89
common/Toast.tsx Normal file
View File

@@ -0,0 +1,89 @@
import * as React from "react";
import { Animated, StyleSheet, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ToastType, useToast } from "./ToastProvider";
const fadeDuration = 300;
const tabBarHeight = 60;
export const Toast: React.FC = () => {
const insets = useSafeAreaInsets();
const { toastConfig, hideToast } = useToast();
const opacity = React.useRef(new Animated.Value(0)).current;
const fadeIn = React.useCallback(() => {
Animated.timing(opacity, {
toValue: 1,
duration: fadeDuration,
useNativeDriver: true,
}).start();
}, [opacity]);
const fadeOut = React.useCallback(() => {
Animated.timing(opacity, {
toValue: 0,
duration: fadeDuration,
useNativeDriver: true,
}).start(() => {
hideToast();
});
}, [opacity, hideToast]);
React.useEffect(() => {
if (!toastConfig) {
return;
}
fadeIn();
if (toastConfig.duration > 0) {
const timer = setTimeout(fadeOut, toastConfig.duration);
return () => clearTimeout(timer);
}
}, [toastConfig, fadeIn, fadeOut]);
if (!toastConfig) {
return null;
}
const { type, message } = toastConfig;
const toastTypeColors: { [key in ToastType]: string } = {
[ToastType.Info]: "#17a2b8",
[ToastType.Error]: "#dc3545",
[ToastType.Success]: "#28a745",
[ToastType.Warn]: "#ffc107",
};
let backgroundColor = toastTypeColors[type];
return (
<Animated.View
style={[
styles.container,
{ bottom: insets.bottom + tabBarHeight, opacity },
]}
>
<View style={[styles.toast, { backgroundColor }]}>
<Text style={styles.message}>{message}</Text>
</View>
</Animated.View>
);
};
const styles = StyleSheet.create({
container: {
alignSelf: "center",
position: "absolute",
marginHorizontal: 20,
maxWidth: 480,
},
toast: {
borderRadius: 6,
padding: 16,
},
message: {
fontSize: 16,
textAlign: "center",
color: '#fff',
},
});

48
common/ToastProvider.tsx Normal file
View File

@@ -0,0 +1,48 @@
import React, { createContext, useContext, useState } from 'react';
// Defines the three kinds of message that are displayed
export enum ToastType {
Info = "INFO",
Error = "ERROR",
Success = "SUCCESS",
Warn = "WARN",
}
// Defines the parameters required to display the toast
type ToastConfigType = { type: ToastType; message: string; duration: number };
// The toast context exposes this object throughout the app
type ToastContextType = {
toastConfig: ToastConfigType | null;
showToast: (type: ToastType, message: string, duration?: number) => void;
hideToast: () => void;
};
// Creates the toast context
export const ToastContext = createContext<ToastContextType | null>(null);
export const ToastProvider = ({ children }) => {
// Calls setToastConfig in order to control the toast
// toastConfig is null by default so the toast is hidden
const [toastConfig, setToastConfig] = useState<ToastConfigType>();
function showToast(type: ToastType, message: string, duration = 0) {
// Calls setToastConfig to show the toast
setToastConfig({ type, message, duration });
}
function hideToast() {
// Sets toast config to null in order to hide the toast
setToastConfig(null);
}
return (
<ToastContext.Provider value={{ toastConfig, showToast, hideToast }}>
{children}
</ToastContext.Provider>
);
};
export function useToast() {
return useContext(ToastContext)!;
}

71
common/colors.tsx Normal file
View File

@@ -0,0 +1,71 @@
import { useContext } from 'react';
import { useColorScheme } from 'react-native';
import { SettingsContext } from '../SettingsProvider';
import { Appearance } from 'react-native';
export const useColors = () => {
let colorTheme = useColorScheme(); // Device color scheme (light or dark)
const { settings } = useContext(SettingsContext); // Accessing user settings
if (settings && !settings.systemTheme) {
colorTheme = settings.darkTheme ? 'dark' : 'light'; // Use user-defined setting if not following system
}
// Return the appropriate color scheme based on the color theme
return colorTheme === 'dark' ? darkModeColors : lightModeColors;
};
export const useDayColors = () => {
let colorTheme = useColorScheme(); // Device color scheme (light or dark)
const { settings } = useContext(SettingsContext); // Accessing user settings
if (settings && !settings.systemTheme) {
colorTheme = settings.darkTheme ? 'dark' : 'light'; // Use user-defined setting if not following system
}
// Return the appropriate color scheme based on the color theme
return colorTheme === 'dark' ? darkModeDayColors : lightModeDayColors;
};
const lightModeColors = {
theme: 'light',
primary: '#4CAF50',
secondary: '#8AE44E',
accent: '#e600c7',
backPrimary: '#f2f2f2',
backSecondary: '#E3E3E3',
text: '#040404',
textDisabled: '#b7b7b7',
textAccent: '#9a9a9a',
};
const darkModeColors = {
theme: 'dark',
primary: '#4CAF50',
secondary: '#8AE44E',
accent: '#80006f',
backPrimary: '#3D3D3D',
backSecondary: '#2A2A2A',
text: '#F1F1F1',
textDisabled: '#808080',
textAccent: '#9c9c9c',
};
const lightModeDayColors = {
schoolday: lightModeColors.backPrimary,
weekend: '#8bb6f8',
holiday: '#4c6ba8',
bridgeday: '#c5dbff',
vacation: '#c5dbff',
examday: '#FFD700'
};
const darkModeDayColors = {
schoolday: darkModeColors.backPrimary,
weekend: '#1a439c',
holiday: '#4080ff',
bridgeday: '#3f63b2',
vacation: '#3f63b2',
examday: '#808000'
};