feat: Integrate NativeWind for styling and implement theme management with Zustand

This commit is contained in:
2025-12-26 23:50:57 +01:00
parent 1e355d5d8e
commit 2a3dc9f0e0
45 changed files with 5426 additions and 5835 deletions
+3 -3
View File
@@ -4,7 +4,7 @@ WORKDIR /app
# Install dependencies
COPY package.json package-lock.json ./
RUN npm ci
RUN npm install --legacy-peer-deps
# Expose ports for Expo development server
EXPOSE 9090 8081 19000 19001
@@ -20,7 +20,7 @@ ARG EXPO_PUBLIC_SPOTIFY_CLIENT_ID
COPY package.json package-lock.json ./
RUN npm ci
RUN npm install --legacy-peer-deps
COPY . .
@@ -33,7 +33,7 @@ WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
RUN npm install --omit=dev --legacy-peer-deps
COPY --from=production-builder /app/serve ./serve
+6 -5
View File
@@ -1,11 +1,10 @@
{
"expo": {
"name": "matrix",
"fastRefresh": true,
"slug": "matrix-frontend",
"version": "1.0.1",
"orientation": "portrait",
"icon": "./assets/images/racoon-icon.jpg",
"icon": "./assets/images/racoon-icon.png",
"scheme": "led.matrix",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
@@ -14,7 +13,7 @@
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/images/racoon-icon.jpg",
"foregroundImage": "./assets/images/racoon-icon.png",
"backgroundColor": "#FFFFFF"
},
"package": "de.starappeal.ledmatrix",
@@ -26,7 +25,7 @@
"web": {
"bundler": "metro",
"output": "server",
"favicon": "./assets/images/racoon-icon.jpg"
"favicon": "./assets/images/racoon-icon.png"
},
"plugins": [
[
@@ -45,7 +44,9 @@
"photosPermission": "Allow $(PRODUCT_NAME) to access your photos"
}
],
"expo-font"
"expo-font",
"expo-router",
"expo-web-browser"
],
"experiments": {
"typedRoutes": true
+3 -5
View File
@@ -1,13 +1,11 @@
import {Link, Tabs} from 'expo-router';
import React, {createContext, useContext, useState, useEffect } from "react";
import {Feather} from "@expo/vector-icons";
import {useTheme} from "@/src/context/ThemeProvider";
import {useThemeStore} from "@/src/stores/themeStore";
import AuthenticatedWrapper from "@/src/components/AuthenticatedWrapper";
import { useWindowDimensions } from "react-native";
import {MatrixState} from "@/src/model/User";
import {useAuth} from "@/src/context/AuthProvider";
import {useAuth} from "@/src/stores/authStore";
const tabs = [
{name: 'modes/text', title: 'Text', icon: 'type'},
@@ -51,7 +49,7 @@ const getInitialState = (lastState?: MatrixState | null): MatrixState => {
};
export default function TabLayout() {
const {theme} = useTheme();
const {theme} = useThemeStore();
const {authenticatedUser} = useAuth();
const { width } = useWindowDimensions();
const shouldHideText = (width < 400);
+7 -24
View File
@@ -1,16 +1,16 @@
import ThemedBackground from "@/src/components/themed/ThemedBackground";
import {useAuth} from "@/src/context/AuthProvider";
import {useAuth} from "@/src/stores/authStore";
import ThemedHeader from "@/src/components/themed/ThemedHeader";
import React, {useEffect, useState} from "react";
import Checkbox from 'expo-checkbox';
import {StyleSheet, View} from "react-native";
import {useTheme} from "@/src/context/ThemeProvider";
import {View} from "react-native";
import {useColors} from "@/src/hooks/useColors";
import ThemedParagraph from "@/src/components/themed/ThemedParagraph";
export default function HomeScreen() {
const [idle, setIdle] = useState(false);
const {authenticatedUser} = useAuth();
const {theme} = useTheme();
const {colors} = useColors();
useEffect(() => {
if (authenticatedUser) {
@@ -21,32 +21,15 @@ export default function HomeScreen() {
return (
<ThemedBackground>
<ThemedHeader>Willkommen!</ThemedHeader>
<View style={styles.section}>
<View className="flex-row items-center">
<Checkbox
style={styles.checkbox}
className="m-2"
value={idle}
onValueChange={setIdle}
color={idle ? theme.colors.tertiary : undefined}
color={idle ? colors.secondary : undefined}
/>
<ThemedParagraph>Energiesparmodus</ThemedParagraph>
</View>
</ThemedBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
marginHorizontal: 16,
marginVertical: 32,
},
section: {
flexDirection: 'row',
alignItems: 'center',
},
paragraph: {
fontSize: 15,
},
checkbox: {
margin: 8,
},
});
+27 -99
View File
@@ -5,7 +5,6 @@ import CustomImagePicker from "@/src/components/ImagePicker";
import {ImagePickerSuccessResult} from "expo-image-picker";
import {
View,
StyleSheet,
ActivityIndicator,
FlatList,
Text,
@@ -15,8 +14,8 @@ import {
} from "react-native";
import {S3File, RestService} from "@/src/services/RestService";
import ThemedButton from "@/src/components/themed/ThemedButton";
import {useTheme} from "@/src/context/ThemeProvider";
import {useAuth} from "@/src/context/AuthProvider";
import {useColors} from "@/src/hooks/useColors";
import {useAuth} from "@/src/stores/authStore";
import {MaterialIcons} from '@expo/vector-icons';
export default function ImageScreen() {
@@ -26,9 +25,7 @@ export default function ImageScreen() {
const [showFiles, setShowFiles] = useState(false);
const [loadingFiles, setLoadingFiles] = useState(false);
const [deletingFile, setDeletingFile] = useState<string | null>(null);
const {theme} = useTheme();
const {primary, onSurface, outline, error} = theme.colors;
const {colors} = useColors();
const fetchStoredFiles = async () => {
setLoadingFiles(true);
@@ -113,7 +110,7 @@ export default function ImageScreen() {
}
};
const viewFile = async (objectKey: string, fileName: string, mimeType: string) => {
const viewFile = async (objectKey: string) => {
try {
const response = await new RestService(token).getFileUrl(objectKey);
if (response.ok && response.data.url) {
@@ -155,21 +152,17 @@ export default function ImageScreen() {
setDeletingFile(null);
};
const confirmCancelDelete = () => {
setDeletingFile(null);
};
return (
<ThemedBackground>
<ThemedHeader>
Bildschirm für Bildauswahl
</ThemedHeader>
<View style={styles.container}>
<View className="flex-1 w-full p-4">
{uploading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={primary}/>
<Text style={[styles.loadingText, {color: onSurface}]}>
<View className="flex-1 justify-center items-center">
<ActivityIndicator size="large" color={colors.primary}/>
<Text className="mt-2.5 text-base text-onSurface dark:text-onSurface-dark">
Datei wird hochgeladen...
</Text>
</View>
@@ -181,7 +174,7 @@ export default function ImageScreen() {
/>
)}
<View style={styles.buttonContainer}>
<View className="my-5 items-center">
<ThemedButton
onPress={toggleFilesList}
title={showFiles ? "Dateien ausblenden" : "Gespeicherte Dateien anzeigen"}
@@ -190,39 +183,41 @@ export default function ImageScreen() {
</View>
{showFiles && (
<View style={styles.filesList}>
<View className="flex-1 w-full">
<ThemedHeader>Gespeicherte Dateien</ThemedHeader>
{loadingFiles ? (
<ActivityIndicator size="large" color={primary}/>
<ActivityIndicator size="large" color={colors.primary}/>
) : files.length > 0 ? (
<FlatList
data={files}
keyExtractor={(item) => item.key}
renderItem={({item}) => (
<View style={[styles.fileItem, {borderColor: outline}]}>
<Text style={{color: onSurface, fontWeight: 'bold'}}>
<View
className="p-3 my-2 border rounded-lg relative border-outline dark:border-outline-dark"
>
<Text className="font-bold text-onSurface dark:text-onSurface-dark">
{item.originalName}
</Text>
<Text style={{color: onSurface}}>
<Text className="text-onSurface dark:text-onSurface-dark">
Typ: {item.mimeType}
</Text>
<Text style={{color: onSurface}}>
<Text className="text-onSurface dark:text-onSurface-dark">
Größe: {formatFileSize(item.size)}
</Text>
<Text style={{color: onSurface}}>
<Text className="text-onSurface dark:text-onSurface-dark">
Zuletzt geändert: {formatDate(item.lastModified)}
</Text>
<View style={styles.fileItemButtons}>
<View className="flex-row absolute top-3 right-3">
<TouchableOpacity
style={[styles.fileButton, {backgroundColor: primary}]}
onPress={() => viewFile(item.key, item.originalName, item.mimeType)}
className="w-9 h-9 rounded-full justify-center items-center ml-2 bg-primary"
onPress={() => viewFile(item.key)}
>
<MaterialIcons name="visibility" size={24} color="white"/>
</TouchableOpacity>
<TouchableOpacity
style={[styles.fileButton, {backgroundColor: error}]}
className="w-9 h-9 rounded-full justify-center items-center ml-2 bg-error"
onPress={() => confirmDeleteFile(item.key)}
>
<MaterialIcons name="delete" size={24} color="white"/>
@@ -232,7 +227,7 @@ export default function ImageScreen() {
)}
/>
) : (
<Text style={{color: onSurface, textAlign: 'center'}}>
<Text className="text-center text-onSurface dark:text-onSurface-dark">
Keine Dateien gefunden
</Text>
)}
@@ -245,12 +240,12 @@ export default function ImageScreen() {
visible={!!deletingFile}
onRequestClose={cancelDelete}
>
<View style={styles.modalContainer}>
<View style={styles.modalContent}>
<Text style={styles.modalText}>
<View className="flex-1 justify-center items-center bg-black/50">
<View className="w-4/5 bg-surface dark:bg-surface-dark rounded-lg p-5 items-center">
<Text className="mb-4 text-center text-onSurface dark:text-onSurface-dark">
Sind Sie sicher, dass Sie diese Datei löschen möchten?
</Text>
<View style={styles.modalButtons}>
<View className="flex-row justify-between w-full">
<ThemedButton
onPress={() => {
if (deletingFile) {
@@ -275,70 +270,3 @@ export default function ImageScreen() {
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
width: '100%',
padding: 16,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: 10,
fontSize: 16,
},
buttonContainer: {
marginVertical: 20,
alignItems: 'center',
},
filesList: {
flex: 1,
width: '100%',
},
fileItem: {
padding: 12,
marginVertical: 8,
borderWidth: 1,
borderRadius: 8,
position: 'relative',
},
fileItemButtons: {
flexDirection: 'row',
position: 'absolute',
top: 12,
right: 12,
},
fileButton: {
width: 36,
height: 36,
borderRadius: 18,
justifyContent: 'center',
alignItems: 'center',
marginLeft: 8,
},
modalContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
modalContent: {
width: '80%',
backgroundColor: '#fff',
borderRadius: 8,
padding: 20,
alignItems: 'center',
},
modalText: {
marginBottom: 15,
textAlign: 'center',
},
modalButtons: {
flexDirection: 'row',
justifyContent: 'space-between',
width: '100%',
},
});
+4 -18
View File
@@ -2,7 +2,7 @@ import ThemedBackground from "@/src/components/themed/ThemedBackground";
import ThemedTextInput from "@/src/components/themed/ThemedTextInput";
import ThemedButton from "@/src/components/themed/ThemedButton";
import ColorSelector from "@/src/components/themed/ColorSelector";
import {View, StyleSheet} from "react-native";
import {View} from "react-native";
import ThemedSegmentedButtons from "@/src/components/themed/ThemedSegmentedButtons";
import { MatrixState } from '@/src/model/User';
import {useMatrix} from "@/app/(tabs)/_layout";
@@ -25,8 +25,8 @@ export default function TextScreen() {
return (
<ThemedBackground>
<View style={[styles.contentWrapper]}>
<View style={styles.inputGroup}>
<View className="flex-1 justify-between p-5">
<View className="gap-4">
<ThemedTextInput
label="Text"
value={matrixState.text.text}
@@ -49,7 +49,7 @@ export default function TextScreen() {
/>
</View>
<View style={styles.actionGroup}>
<View className="pt-5">
<ThemedButton
mode="contained"
onPress={handleSendToMatrix}
@@ -60,17 +60,3 @@ export default function TextScreen() {
</ThemedBackground>
);
}
const styles = StyleSheet.create({
contentWrapper: {
flex: 1,
justifyContent: 'space-between',
padding: 20,
},
inputGroup: {
gap: 15,
},
actionGroup: {
paddingTop: 20,
}
});
+3 -11
View File
@@ -6,15 +6,14 @@ import ThemeToggleButton from "@/src/components/ThemeToggleButton";
import SpotifyAuthButton from "@/src/components/SpotifyAuthButton";
import {RestService, Token} from "@/src/services/RestService";
import {useAuth} from "@/src/context/AuthProvider";
import {StyleSheet, View} from "react-native";
import {useAuth} from "@/src/stores/authStore";
import {View} from "react-native";
import ThemedButton from "@/src/components/themed/ThemedButton";
import {useRouter} from "expo-router";
export default function SettingsScreen() {
const {token: jwtToken, authenticatedUser, logout, refreshUser} = useAuth();
const router = useRouter();
console.log("Mashallah", jwtToken);
const handleAuthSuccess = (token: Token) => {
const spotifyConfig = {
@@ -34,7 +33,7 @@ export default function SettingsScreen() {
return (
<ThemedBackground>
<View style={styles.container}>
<View className="w-full gap-3 items-center">
<ThemedHeader>Einen wunderschönen guten Tag, {authenticatedUser?.name}</ThemedHeader>
<ChangePasswordFeature/>
<ThemeToggleButton/>
@@ -64,10 +63,3 @@ export default function SettingsScreen() {
);
}
const styles = StyleSheet.create({
container: {
width: "100%",
gap: 12, // Für Abstand zwischen den Kind-Elementen (ab React Native 0.71)
alignItems: "center", // Zentrierung
},
});
+2 -5
View File
@@ -1,7 +1,6 @@
import React from "react";
import "../global.css";
import {AuthProvider} from "@/src/context/AuthProvider";
import {ThemeProvider} from "@/src/context/ThemeProvider";
import CustomStack from "@/src/core/Stack";
import * as WebBrowser from "expo-web-browser";
@@ -11,9 +10,7 @@ WebBrowser.maybeCompleteAuthSession();
export default function Layout() {
return (
<ThemeProvider>
<AuthProvider>
<CustomStack />
</AuthProvider>
<CustomStack />
</ThemeProvider>
);
}
+1 -1
View File
@@ -8,7 +8,7 @@ import ThemedButton from "../src/components/themed/ThemedButton";
import ThemedTextInput from "../src/components/themed/ThemedTextInput";
import BackButton from "../src/components/BackButton";
import {useAuth} from "@/src/context/AuthProvider";
import {useAuth} from "@/src/stores/authStore";
import {useRouter} from "expo-router";
import ThemeToggleButton from "@/src/components/ThemeToggleButton";
import PasswordInput from "@/src/components/PasswordInput";
Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 KiB

+7 -1
View File
@@ -1,6 +1,12 @@
module.exports = function(api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
presets: [
['babel-preset-expo', { jsxImportSource: 'nativewind' }],
'nativewind/babel',
],
plugins: [
'react-native-reanimated/plugin',
],
};
};
+4
View File
@@ -0,0 +1,4 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+14
View File
@@ -0,0 +1,14 @@
const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require("nativewind/metro");
const config = getDefaultConfig(__dirname);
config.resolver.unstable_enablePackageExports = true
config.resolver.unstable_conditionNames = [
'require',
'react-native',
'default',
]
module.exports = withNativeWind(config, { input: "./global.css" });
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="nativewind/types" />
+4665 -5139
View File
File diff suppressed because it is too large Load Diff
+39 -35
View File
@@ -20,56 +20,60 @@
},
"dependencies": {
"@babel/node": "^7.28.0",
"@expo/metro-config": "^54.0.5",
"@expo/ngrok": "^4.1.3",
"axios": "^1.7.8",
"compression": "^1.7.5",
"expo": "~52.0.18",
"expo-auth-session": "~6.0.3",
"expo-blur": "~14.0.3",
"expo-checkbox": "~4.0.1",
"expo-constants": "~17.0.3",
"expo-font": "~13.0.4",
"expo-haptics": "~14.0.1",
"expo-image": "~2.0.3",
"expo-image-picker": "~16.0.3",
"expo-linking": "~7.0.3",
"expo-router": "~4.0.11",
"expo-secure-store": "~14.0.0",
"expo-splash-screen": "^0.29.18",
"expo-status-bar": "~2.0.1",
"expo-symbols": "^0.2.0",
"expo-system-ui": "^4.0.6",
"expo-updates": "~0.27.4",
"expo-web-browser": "~14.0.1",
"expo": "^53.0.25",
"expo-auth-session": "~6.2.1",
"expo-blur": "~14.1.5",
"expo-checkbox": "~4.1.4",
"expo-constants": "~17.1.8",
"expo-font": "~13.3.2",
"expo-haptics": "~14.1.4",
"expo-image": "~2.4.1",
"expo-image-picker": "~16.1.4",
"expo-linking": "~7.1.7",
"expo-router": "~5.1.10",
"expo-secure-store": "~14.2.4",
"expo-splash-screen": "^0.30.10",
"expo-status-bar": "~2.2.3",
"expo-symbols": "^0.4.5",
"expo-system-ui": "~5.0.11",
"expo-updates": "^0.28.17",
"expo-web-browser": "~14.2.0",
"express": "^4.21.1",
"morgan": "^1.10.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-native": "0.76.9",
"react-native-gesture-handler": "~2.20.2",
"nativewind": "^4.2.1",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-native": "0.79.6",
"react-native-gesture-handler": "~2.24.0",
"react-native-modal": "^13.0.1",
"react-native-paper": "^5.12.5",
"react-native-reanimated": "~3.16.3",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0",
"react-native-reanimated": "~3.17.4",
"react-native-safe-area-context": "5.4.0",
"react-native-screens": "~4.11.1",
"react-native-status-bar-height": "^2.6.0",
"react-native-switch-with-icons": "^3.0.1",
"react-native-web": "~0.19.13",
"react-native-webview": "13.12.5",
"react-native-web": "^0.20.0",
"react-native-webview": "13.13.5",
"react-native-worklets": "0.5.1",
"react-native-worklets-core": "^1.5.0",
"reanimated-color-picker": "^4.1.0",
"sharp": "^0.33.5"
"sharp": "^0.33.5",
"tailwindcss": "^3.4.19",
"zustand": "^5.0.9"
},
"devDependencies": {
"@babel/core": "^7.26.0",
"@types/jest": "^29.5.14",
"@types/react": "~18.3.12",
"@types/react-test-renderer": "^18.3.0",
"eslint-config-expo": "~8.0.1",
"@types/react": "~19.0.10",
"@types/react-test-renderer": "^19.1.0",
"eslint-config-expo": "~9.2.0",
"jest": "^29.7.0",
"jest-expo": "~52.0.2",
"react-test-renderer": "18.3.1",
"jest-expo": "~53.0.13",
"react-test-renderer": "19.1.0",
"ts-node": "^10.9.2",
"typescript": "^5.7.2"
"typescript": "~5.8.3"
}
}
+5 -13
View File
@@ -1,14 +1,14 @@
import React from "react";
import {useAuth} from "@/src/context/AuthProvider";
import {useAuth} from "@/src/stores/authStore";
import NotAuthenticated from "@/src/components/NotAuthenticated";
import { ActivityIndicator, View, StyleSheet } from "react-native";
import { ActivityIndicator, View } from "react-native";
const AuthenticatedWrapper: React.FC<{ children: React.ReactNode }> = ({children}) => {
const {isAuthenticated, loading, authenticatedUser} = useAuth();
const {isAuthenticated, loading, authenticatedUser, isHydrated} = useAuth();
if (loading) {
if (!isHydrated || loading) {
return (
<View style={styles.loaderContainer}>
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" />
</View>
);
@@ -16,17 +16,9 @@ const AuthenticatedWrapper: React.FC<{ children: React.ReactNode }> = ({children
if (!isAuthenticated || !authenticatedUser) {
return <NotAuthenticated />;
// return <Redirect href={"/login"} />;
}
return <>{children}</>;
};
const styles = StyleSheet.create({
loaderContainer: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
});
export default AuthenticatedWrapper;
+16 -19
View File
@@ -1,32 +1,29 @@
import React from "react";
import {Image, StyleSheet, TouchableOpacity} from "react-native";
import {getStatusBarHeight} from "react-native-status-bar-height";
import {useTheme} from "@/src/context/ThemeProvider";
import { Image, TouchableOpacity } from "react-native";
import { getStatusBarHeight } from "react-native-status-bar-height";
import { useColors } from "@/src/hooks/useColors";
type Props = {
goBack: () => void;
}
className?: string;
};
export default function BackButton({ goBack, className }: Props) {
const { colors } = useColors();
const statusBarHeight = getStatusBarHeight();
export default function BackButton({goBack}: Props) {
const {theme} = useTheme();
return (
<TouchableOpacity onPress={goBack} style={styles.container}>
<TouchableOpacity
onPress={goBack}
className={`absolute left-1 ${className || ''}`}
style={{ top: 10 + statusBarHeight }}
>
<Image
style={[styles.image, {tintColor: theme.colors.onBackground}]}
className="w-6 h-6"
style={{ tintColor: colors.onBackground }}
source={require("../../assets/items/back.png")}
/>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
container: {
position: "absolute",
top: 10 + getStatusBarHeight(),
left: 4,
},
image: {
width: 24,
height: 24,
},
});
+17 -30
View File
@@ -1,8 +1,7 @@
import React, {useRef, useState} from "react";
import { useTheme } from "@/src/context/ThemeProvider";
import {StyleSheet, TextInput, View} from "react-native";
import { TextInput, View } from "react-native";
import { ApiResponse, RestService } from "@/src/services/RestService";
import { useAuth } from "@/src/context/AuthProvider";
import { useAuth } from "@/src/stores/authStore";
import PasswordInput from "@/src/components/PasswordInput";
import ThemedButton from "@/src/components/themed/ThemedButton";
import { Text } from "react-native-paper";
@@ -14,7 +13,6 @@ interface ChangePasswordFormProps {
}
export default function ChangePasswordForm({ onSuccess, onCancel }: ChangePasswordFormProps) {
const { theme } = useTheme();
const { token: jwtToken } = useAuth();
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
@@ -43,12 +41,22 @@ export default function ChangePasswordForm({ onSuccess, onCancel }: ChangePasswo
};
return (
<View style={[styles.modalContent, { backgroundColor: theme.colors.surface }]}>
<Text variant="titleMedium" style={{ color: theme.colors.onSurface, fontSize: 18, marginBottom: 10 }}>Passwort ändern</Text>
<View className="p-5 rounded-xl self-center w-full max-w-[400px] bg-surface dark:bg-surface-dark">
<Text
variant="titleMedium"
className="text-lg mb-2.5 text-onSurface dark:text-onSurface-dark"
>
Passwort ändern
</Text>
{apiResponse && apiResponse.data?.message && (
<View style={[styles.apiResponseBox, { backgroundColor: apiResponse.ok ? theme.colors.success : theme.colors.error }]}>
<Text variant="bodyMedium" style={{ color: apiResponse.ok ? theme.colors.onSuccess : theme.colors.onError }}>
<View
className={`my-2 p-3 rounded-lg ${apiResponse.ok ? 'bg-success' : 'bg-error'}`}
>
<Text
variant="bodyMedium"
className="text-white"
>
{apiResponse.data.message}
</Text>
</View>
@@ -75,7 +83,7 @@ export default function ChangePasswordForm({ onSuccess, onCancel }: ChangePasswo
</>
)}
<View style={styles.buttonGroup}>
<View className="flex-row justify-end gap-2.5 mt-4">
{apiResponse?.ok ? (
<ThemedButton mode="contained" onPress={onCancel} title={"Schließen"} style={{flex: 1}} />
) : (
@@ -89,24 +97,3 @@ export default function ChangePasswordForm({ onSuccess, onCancel }: ChangePasswo
);
}
const styles = StyleSheet.create({
modalContent: {
padding: 20,
borderRadius: 12,
alignSelf: 'center',
width: '100%',
maxWidth: 400,
},
apiResponseBox: {
marginBottom: 8,
marginTop: 8,
padding: 12,
borderRadius: 8,
},
buttonGroup: {
flexDirection: "row",
justifyContent: "flex-end",
gap: 10,
marginTop: 16,
},
});
+5 -17
View File
@@ -1,5 +1,5 @@
import React, {useState} from 'react';
import {Button, StyleSheet, View} from 'react-native';
import { Button, View } from 'react-native';
import * as ImagePicker from 'expo-image-picker';
import ImageViewer from "@/src/components/ImageViewer";
import {ImagePickerSuccessResult} from "expo-image-picker/src/ImagePicker.types";
@@ -40,21 +40,9 @@ export default function CustomImagePicker({onSuccess, onFailure, onCanceled}: Pr
};
return (
<View style={styles.container}>
<View className="flex-1 items-center justify-center">
<Button title="Pick an image from camera roll" onPress={pickImage}/>
{image && <ImageViewer imgSource={{uri: image}}/>}
</View>);
};
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
image: {
width: 200,
height: 200,
},
});
</View>
);
}
+9 -11
View File
@@ -1,18 +1,16 @@
import { StyleSheet } from "react-native";
import { Image, type ImageSource } from "expo-image";
type Props = {
imgSource: ImageSource;
imgSource: ImageSource;
className?: string;
};
export default function ImageViewer({ imgSource }: Props) {
return <Image source={imgSource} style={styles.image} />;
export default function ImageViewer({ imgSource, className }: Props) {
return (
<Image
source={imgSource}
className={`w-80 h-[440px] rounded-2xl ${className || ''}`}
/>
);
}
const styles = StyleSheet.create({
image: {
width: 320,
height: 440,
borderRadius: 18,
},
});
+12 -15
View File
@@ -1,19 +1,16 @@
import React from "react";
import { Image, StyleSheet } from "react-native";
import { Image } from "react-native";
export default function Logo() {
return (
<Image
source={require("../../assets/items/logo.png")}
style={styles.image}
/>
);
type Props = {
className?: string;
};
export default function Logo({ className }: Props) {
return (
<Image
source={require("../../assets/items/logo.png")}
className={`w-28 h-28 mb-2 ${className || ''}`}
/>
);
}
const styles = StyleSheet.create({
image: {
width: 110,
height: 110,
marginBottom: 8,
},
});
+3 -7
View File
@@ -1,9 +1,9 @@
import {Image} from "react-native";
import { Image } from "react-native";
import React from "react";
import ThemedBackground from "@/src/components/themed/ThemedBackground";
import ThemedParagraph from "@/src/components/themed/ThemedParagraph";
import ThemedButton from "@/src/components/themed/ThemedButton";
import {useRouter} from "expo-router";
import { useRouter } from "expo-router";
export default function NotAuthenticated() {
const router = useRouter();
@@ -11,11 +11,7 @@ export default function NotAuthenticated() {
<ThemedBackground>
<Image
source={require("@/assets/images/GarfieldCharakter.webp")}
style={{
width: 200,
height: 200,
marginBottom: 12,
}}
className="w-52 h-52 mb-3"
/>
<ThemedParagraph>
You are not authenticated. Please log in to view this content.
+3 -3
View File
@@ -1,14 +1,14 @@
import React from "react";
import {useTheme as useCustomTheme} from "@/src/context/ThemeProvider";
import {useThemeStore} from "@/src/stores/themeStore";
import SwitchWithIcons from "react-native-switch-with-icons";
import {darkTheme, lightTheme} from "@/src/core/theme";
export default function ThemeToggleButton() {
const {theme, toggleTheme} = useCustomTheme();
const {isDark, toggleTheme} = useThemeStore();
return (
<SwitchWithIcons
value={theme.dark}
value={isDark}
onValueChange={toggleTheme}
icon={{true: require("../../assets/items/moon.svg"), false: require("../../assets/items/sun.svg")}}
thumbColor={{true: darkTheme.colors.surfaceVariant, false: lightTheme.colors.surfaceVariant}}
+6 -13
View File
@@ -1,10 +1,9 @@
import React, { useState } from 'react';
import { View, StyleSheet, Dimensions } from 'react-native';
import { View, Dimensions } from 'react-native';
import ColorPicker, { Panel1, Swatches, Preview, HueSlider, ColorFormatsObject } from 'reanimated-color-picker';
import ThemedButton from "@/src/components/themed/ThemedButton";
import { useTheme } from "@/src/context/ThemeProvider";
import ThemedColorPickerButton from "@/src/components/themed/ThemedColorPickerButton";
import CustomModal from './CustomModal'; // Importiere den NEUEN CustomModal
import CustomModal from './CustomModal';
interface ColorSelectorProps {
@@ -13,7 +12,6 @@ interface ColorSelectorProps {
}
export default function ColorSelector({ defaultColor = [255, 255, 255], onSelect }: ColorSelectorProps) {
const { theme } = useTheme();
const [isModalVisible, setIsModalVisible] = useState(false);
@@ -48,7 +46,10 @@ export default function ColorSelector({ defaultColor = [255, 255, 255], onSelect
isVisible={isModalVisible}
onClose={closeModal}
>
<View style={[styles.modalContent, { width: modalWidth, backgroundColor: theme.colors.surface }]}>
<View
className="p-5 rounded-xl self-center items-center bg-surface dark:bg-surface-dark"
style={{ width: modalWidth }}
>
<ColorPicker
style={{ width: '100%' }}
value={pickerHex}
@@ -72,14 +73,6 @@ export default function ColorSelector({ defaultColor = [255, 255, 255], onSelect
);
}
const styles = StyleSheet.create({
modalContent: {
padding: 20,
borderRadius: 12,
alignSelf: 'center',
alignItems: 'center',
},
});
const rgbToHex = ([r, g, b]: [number, number, number]) =>
`#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
+4 -19
View File
@@ -1,5 +1,5 @@
import React from "react";
import {StyleSheet, View} from "react-native";
import { View } from "react-native";
import Modal from "react-native-modal";
export interface CustomModalHandles {
@@ -12,9 +12,10 @@ export interface Props {
onClose: () => void;
children: React.ReactNode;
onModalDidHide?: () => void;
className?: string;
}
const CustomModal = ({ isVisible, onClose, children, onModalDidHide }: Props) => {
const CustomModal = ({ isVisible, onClose, children, onModalDidHide, className }: Props) => {
return (
<Modal
isVisible={isVisible}
@@ -25,7 +26,7 @@ const CustomModal = ({ isVisible, onClose, children, onModalDidHide }: Props) =>
animationOut="zoomOut"
backdropTransitionOutTiming={0}
>
<View style={styles.modalContent}>
<View className={`p-4 rounded-lg ${className || ''}`}>
{children}
</View>
</Modal>
@@ -34,19 +35,3 @@ const CustomModal = ({ isVisible, onClose, children, onModalDidHide }: Props) =>
export default CustomModal;
const styles = StyleSheet.create({
modalContent: {
padding: 16,
borderRadius: 8,
},
apiResponseBox: {
marginBottom: 8,
marginTop: 8,
padding: 8,
},
buttonGroup: {
flexDirection: "row",
justifyContent: "space-between",
marginTop: 16,
},
});
+6 -20
View File
@@ -1,21 +1,20 @@
import React from "react";
import {ImageBackground, KeyboardAvoidingView, Platform, StyleSheet,} from "react-native";
import {useTheme} from "../../context/ThemeProvider";
import { ImageBackground, KeyboardAvoidingView, Platform } from "react-native";
type Props = {
children: React.ReactNode;
}
className?: string;
};
export default function ThemedBackground({children}: Props) {
const {theme} = useTheme();
export default function ThemedBackground({ children, className }: Props) {
return (
<ImageBackground
source={require("../../../assets/items/dot.png")}
resizeMode="repeat"
style={[styles.background, {backgroundColor: theme.colors.background}]}
className="flex-1 w-full bg-background dark:bg-background-dark"
>
<KeyboardAvoidingView
style={styles.container}
className={`flex-1 p-5 w-[90%] max-w-[600px] self-center ${className || ''}`}
behavior={Platform.OS === "ios" ? "padding" : "height"}
>
{children}
@@ -24,16 +23,3 @@ export default function ThemedBackground({children}: Props) {
);
}
const styles = StyleSheet.create({
background: {
flex: 1,
width: "100%",
},
container: {
flex: 1,
padding: 20,
width: "90%",
maxWidth: 600,
alignSelf: "center",
},
});
+11 -22
View File
@@ -1,29 +1,30 @@
import React from "react";
import {StyleSheet} from "react-native";
import { Button as PaperButton } from "react-native-paper";
import {useTheme} from "@/src/context/ThemeProvider";
import {IconSource} from "react-native-paper/src/components/Icon";
import { IconSource } from "react-native-paper/src/components/Icon";
import { useColors } from "@/src/hooks/useColors";
type Props = {
mode: "text" | "outlined" | "contained" | "elevated" | "contained-tonal"
mode: "text" | "outlined" | "contained" | "elevated" | "contained-tonal";
style?: any;
children?: React.ReactNode;
onPress: () => void;
disabled?: boolean;
title: string;
icon?: IconSource;
}
className?: string;
};
export default function ThemedButton({ mode, style, title, icon, className, ...props }: Props) {
const { colors } = useColors();
export default function ThemedButton({mode, style, title, icon, ...props}: Props) {
const {theme} = useTheme();
return (
<PaperButton
className={`my-2.5 py-0.5 ${className || ''}`}
style={[
styles.button,
mode === "outlined" && {backgroundColor: theme.colors.background},
mode === "outlined" && { backgroundColor: colors.background },
style,
]}
labelStyle={styles.text}
labelStyle={{ fontWeight: "bold", fontSize: 15, lineHeight: 26 }}
mode={mode}
icon={icon}
{...props}
@@ -33,16 +34,4 @@ export default function ThemedButton({mode, style, title, icon, ...props}: Props
);
}
const styles =
StyleSheet.create({
button: {
marginVertical: 10,
paddingVertical: 2,
},
text: {
fontWeight: "bold",
fontSize: 15,
lineHeight: 26,
},
});
+14 -24
View File
@@ -1,42 +1,32 @@
import React from 'react';
import { Text, StyleSheet, Pressable } from 'react-native';
import { Text, Pressable } from 'react-native';
import Checkbox from 'expo-checkbox';
import { useTheme } from '@/src/context/ThemeProvider';
import { useColors } from '@/src/hooks/useColors';
type ThemedCheckboxProps = {
label: string;
value: boolean;
onValueChange: (newValue: boolean) => void;
style?: object;
className?: string;
};
const ThemedCheckbox = ({ label, value, onValueChange, style }: ThemedCheckboxProps) => {
const { theme } = useTheme();
const componentStyles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
marginVertical: 12,
},
checkbox: {
marginRight: 8,
},
label: {
fontSize: 14,
color: theme.colors.onSurface,
},
});
const ThemedCheckbox = ({ label, value, onValueChange, className }: ThemedCheckboxProps) => {
const { colors } = useColors();
return (
<Pressable onPress={() => onValueChange(!value)} style={[componentStyles.container, style]}>
<Pressable
onPress={() => onValueChange(!value)}
className={`flex-row items-center my-3 ${className || ''}`}
>
<Checkbox
style={componentStyles.checkbox}
className="mr-2"
value={value}
onValueChange={onValueChange}
color={value ? theme.colors.primary : undefined} // Setzt die Farbe aus dem Theme
color={value ? colors.primary : undefined}
/>
<Text style={componentStyles.label}>{label}</Text>
<Text className="text-sm text-onSurface dark:text-onSurface-dark">
{label}
</Text>
</Pressable>
);
};
@@ -1,57 +1,34 @@
import React from 'react';
import { Pressable, View, Text, StyleSheet, StyleProp, ViewStyle } from 'react-native';
import { useTheme } from '@/src/context/ThemeProvider';
import { Pressable, View, Text, StyleProp, ViewStyle } from 'react-native';
type Props = {
color: [number, number, number];
onPress: () => void;
title?: string;
style?: StyleProp<ViewStyle>;
className?: string;
};
const ThemedColorPickerButton = ({ color, onPress, title = "Farbe wählen", style }: Props) => {
const { theme } = useTheme();
const ThemedColorPickerButton = ({ color, onPress, title = "Farbe wählen", style, className }: Props) => {
const rgbColor = `rgb(${color.join(',')})`;
return (
<Pressable
style={({ pressed }) => [
styles.container,
{
backgroundColor: theme.colors.surface,
borderColor: theme.colors.outline
},
pressed && styles.pressed,
style
]}
className={`flex-row items-center py-3 px-4 rounded-3xl border gap-3
bg-surface dark:bg-surface-dark
border-outline dark:border-outline-dark
${className || ''}`}
style={style}
onPress={onPress}
>
<View style={[styles.swatch, { backgroundColor: rgbColor }]} />
<Text style={{ color: theme.colors.onSurface }}>{title}</Text>
<View
className="w-6 h-6 rounded border border-white/50"
style={{ backgroundColor: rgbColor }}
/>
<Text className="text-onSurface dark:text-onSurface-dark">{title}</Text>
</Pressable>
);
};
export default ThemedColorPickerButton;
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 28,
borderWidth: 1,
gap: 12,
},
swatch: {
width: 24,
height: 24,
borderRadius: 4,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.5)',
},
pressed: {
opacity: 0.7,
},
});
+13 -13
View File
@@ -1,19 +1,19 @@
import React from "react";
import {StyleSheet} from "react-native";
import {Text} from "react-native-paper";
import { Text } from "react-native-paper";
type Props = {
children: React.ReactNode;
className?: string;
};
export default function ThemedHeader({ children, className, ...props }: Props) {
return (
<Text
className={`text-xl font-bold py-3 ${className || ''}`}
{...props}
>
{children}
</Text>
);
}
export default function ThemedHeader(props: Props) {
return <Text style={styles.header} {...props} />;
}
const styles = StyleSheet.create({
header: {
fontSize: 21,
fontWeight: "bold",
paddingVertical: 12,
},
});
+11 -13
View File
@@ -1,21 +1,19 @@
import React from "react";
import {StyleSheet} from "react-native";
import {Text} from "react-native-paper";
import { Text } from "react-native-paper";
type Props = {
children: React.ReactNode;
className?: string;
};
export default function ThemedParagraph(props: Props) {
return <Text style={styles.text} {...props} />;
export default function ThemedParagraph({ children, className, ...props }: Props) {
return (
<Text
className={`text-base leading-6 text-center mb-3 ${className || ''}`}
{...props}
>
{children}
</Text>
);
}
const styles = StyleSheet.create({
text: {
fontSize: 15,
lineHeight: 21,
textAlign: "center",
marginBottom: 12,
},
});
@@ -1,5 +1,5 @@
import React from 'react';
import { View, StyleSheet, StyleProp, ViewStyle } from 'react-native';
import { View, StyleProp, ViewStyle } from 'react-native';
import { SegmentedButtons } from 'react-native-paper';
export type SegmentedButton<T extends string> = {
@@ -13,6 +13,7 @@ type ThemedSegmentedButtonsProps<T extends string> = {
value: T;
onValueChange: (value: T) => void;
style?: StyleProp<ViewStyle>;
className?: string;
} & ({
buttons: SegmentedButton<T>[];
options?: never;
@@ -22,12 +23,13 @@ type ThemedSegmentedButtonsProps<T extends string> = {
});
const ThemedSegmentedButtons = <T extends string>({
value,
onValueChange,
buttons,
options,
style,
}: ThemedSegmentedButtonsProps<T>) => {
value,
onValueChange,
buttons,
options,
style,
className,
}: ThemedSegmentedButtonsProps<T>) => {
const finalButtons = buttons || (options ? (Object.keys(options) as T[]).map(key => ({
value: key,
@@ -39,7 +41,7 @@ const ThemedSegmentedButtons = <T extends string>({
};
return (
<View style={[styles.container, style]}>
<View className={`w-full my-3 ${className || ''}`} style={style}>
<SegmentedButtons
value={value}
onValueChange={handleValueChange}
@@ -49,11 +51,5 @@ const ThemedSegmentedButtons = <T extends string>({
);
};
const styles = StyleSheet.create({
container: {
width: '100%',
marginVertical: 12,
},
});
export default ThemedSegmentedButtons;
+14 -24
View File
@@ -1,30 +1,22 @@
import React, { forwardRef } from "react";
import { StyleSheet, Text, View } from "react-native";
import { Text, View } from "react-native";
import { TextInput as Input, TextInputProps } from "react-native-paper";
import { useTheme } from "@/src/context/ThemeProvider";
export type ThemedTextInputProps = TextInputProps & {
errorText?: string;
description?: string;
error?: boolean;
className?: string;
};
const ThemedTextInput = forwardRef<any, ThemedTextInputProps>(
({ errorText, description, error, ...props }, ref) => {
const { theme } = useTheme();
({ errorText, description, error, className, ...props }, ref) => {
if (error && !errorText) {
console.log("ErrorText is missing! Please provide an errorText prop!");
}
const errorStyle = {
fontSize: 13,
color: theme.colors.error,
paddingTop: 8,
};
return (
<View style={styles.container}>
<View className={`w-full my-3 ${className || ''}`}>
<Input
underlineColor="transparent"
mode="outlined"
@@ -32,23 +24,21 @@ const ThemedTextInput = forwardRef<any, ThemedTextInputProps>(
ref={ref}
/>
{description && !error ? (
<Text style={styles.description}>{description}</Text>
<Text className="text-sm pt-2 text-onSurface dark:text-onSurface-dark">
{description}
</Text>
) : null}
{error && <Text style={errorStyle}>{errorText}</Text>}
{error && (
<Text className="text-sm pt-2 text-error">
{errorText}
</Text>
)}
</View>
);
}
);
ThemedTextInput.displayName = 'ThemedTextInput';
export default ThemedTextInput;
const styles = StyleSheet.create({
container: {
width: "100%",
marginVertical: 12,
},
description: {
fontSize: 13,
paddingTop: 8,
},
});
-130
View File
@@ -1,130 +0,0 @@
import React, {createContext, useContext, useEffect, useState} from "react";
import {getFromStorage, JWT_TOKEN_KEY, removeFromStorage, saveInStorage} from "@/src/utils/secureStorage";
import {RestService} from "@/src/services/RestService";
import {User} from "@/src/model/User";
import {Platform} from "react-native";
type AuthContextType = {
isAuthenticated: boolean | null;
token: string | null;
login: (username: string, password: string, stayLoggedIn?: boolean) => Promise<void>;
logout: () => Promise<void>;
authenticatedUser: User | null;
error: { field: string, message: string } | null;
loading: boolean;
refreshUser: () => Promise<void>;
};
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({children}) => {
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
const [authenticatedUser, setAuthenticatedUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [error, setError] = useState<{ field: string, message: string } | null>(null);
const [loading, setLoading] = useState<boolean>(true);
useEffect(() => {
const checkAuthStatus = async () => {
if (Platform.OS === 'web') {
const user = await saveUser(null);
setIsAuthenticated(!!user);
} else {
const storedToken = await getFromStorage(JWT_TOKEN_KEY);
if (storedToken) {
setToken(storedToken);
const user = await saveUser(storedToken);
setIsAuthenticated(!!user);
} else {
setIsAuthenticated(false);
}
}
setLoading(false);
};
checkAuthStatus();
}, []);
const saveUser = async (token: string | null): Promise<User | null> => {
const response = await new RestService(token).getSelf();
if (!response.ok || !response.data) {
// token ist ungültig
await removeFromStorage(JWT_TOKEN_KEY);
setToken(null);
setIsAuthenticated(false);
setAuthenticatedUser(null);
setError({field: "general", message: "Token is invalid."});
return null;
}
const user = response.data;
setAuthenticatedUser(user);
return user;
}
const login = async (username: string, password: string, stayLoggedIn?: boolean) => {
if (isAuthenticated) {
console.log("Already authenticated");
return;
}
setLoading(true);
setError(null);
const response = await new RestService(null).login(username, password, stayLoggedIn);
if (!response.ok) {
console.error("Login failed:", response.data);
const message = response.data.message!;
setError({field: response.data.details?.field!, message});
setIsAuthenticated(false);
setLoading(false);
return;
}
if (Platform.OS !== 'web') {
const token = response.data.token!;
await saveInStorage(JWT_TOKEN_KEY, token);
setToken(token);
}
// Fehler zurücksetzen
setError(null);
// User laden und ERST DANN isAuthenticated setzen
const user = await saveUser(Platform.OS === 'web' ? null : response.data.token!);
setIsAuthenticated(!!user);
setLoading(false);
};
const logout = async () => {
if (Platform.OS === 'web') {
await new RestService(null).logout();
} else {
await removeFromStorage(JWT_TOKEN_KEY);
}
setToken(null);
setIsAuthenticated(false);
setAuthenticatedUser(null);
};
const refreshUser = async () => {
console.log("refreshUser")
if (Platform.OS === 'web') {
await saveUser(null);
} else {
console.log(token)
if (!token) return;
await saveUser(token);
}
};
return (
<AuthContext.Provider
value={{isAuthenticated, token, login, logout, error, authenticatedUser, loading, refreshUser}}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = (): AuthContextType => {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}
+10 -38
View File
@@ -1,50 +1,22 @@
import React, {createContext, ReactNode, useContext, useEffect, useState} from "react";
import React, {ReactNode, useEffect} from "react";
import {Provider as PaperProvider} from "react-native-paper";
import {getFromStorage, saveInStorage, THEME_KEY} from "@/src/utils/secureStorage";
import {CustomMD3Theme, darkTheme, lightTheme} from "@/src/core/theme";
type ThemeContextType = {
theme: CustomMD3Theme;
toggleTheme: () => void;
};
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
import {useThemeStore} from "@/src/stores/themeStore";
import {useColorScheme} from "nativewind";
export const ThemeProvider = ({children}: { children: ReactNode }) => {
const [theme, setTheme] = useState<CustomMD3Theme>();
const { theme, isDark, isHydrated } = useThemeStore();
const { setColorScheme } = useColorScheme();
useEffect(() => {
getFromStorage(THEME_KEY).then((theme) => {
if (theme === "dark") {
setTheme(darkTheme);
} else {
setTheme(lightTheme);
}
});
}, []);
setColorScheme(isDark ? 'dark' : 'light');
}, [isDark, setColorScheme]);
const toggleTheme = () => {
const newTheme = theme === lightTheme ? darkTheme : lightTheme;
setTheme(newTheme);
saveInStorage(THEME_KEY, newTheme === lightTheme ? "light" : "dark");
}
if (!theme) {
// maybe show a loading spinner here
return <></>;
if (!isHydrated) {
return null;
}
return (
<ThemeContext.Provider value={{theme, toggleTheme}}>
<PaperProvider theme={theme}>{children}</PaperProvider>
</ThemeContext.Provider>
<PaperProvider theme={theme}>{children}</PaperProvider>
);
};
export const useTheme = (): ThemeContextType => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme muss innerhalb eines ThemeProviders verwendet werden");
}
return context;
};
+104
View File
@@ -0,0 +1,104 @@
// Single source of truth for all theme colors
// Imported by: tailwind.config.js, useColors.ts, theme.ts
const colors = {
primary: {
DEFAULT: '#6750A4',
light: '#7F67BE',
dark: '#4F378B',
},
secondary: {
DEFAULT: '#625B71',
light: '#7A7289',
dark: '#4A4458',
},
surface: {
DEFAULT: '#FFFBFE',
dark: '#1C1B1F',
},
background: {
DEFAULT: '#FFFBFE',
dark: '#1C1B1F',
},
error: {
DEFAULT: '#B3261E',
light: '#DC362E',
dark: '#8C1D18',
},
success: {
DEFAULT: '#4CAF50',
light: '#66BB6A',
dark: '#388E3C',
},
onPrimary: {
DEFAULT: '#FFFFFF',
dark: '#1C1B1F',
},
onSecondary: {
DEFAULT: '#FFFFFF',
dark: '#1C1B1F',
},
onSurface: {
DEFAULT: '#1C1B1F',
dark: '#E6E1E5',
},
onBackground: {
DEFAULT: '#1C1B1F',
dark: '#E6E1E5',
},
outline: {
DEFAULT: '#79747E',
dark: '#938F99',
},
};
module.exports = { colors };
// Flat structure for JS access
const lightColors = {
primary: colors.primary.DEFAULT,
primaryLight: colors.primary.light,
primaryDark: colors.primary.dark,
secondary: colors.secondary.DEFAULT,
secondaryLight: colors.secondary.light,
secondaryDark: colors.secondary.dark,
surface: colors.surface.DEFAULT,
background: colors.background.DEFAULT,
error: colors.error.DEFAULT,
errorLight: colors.error.light,
errorDark: colors.error.dark,
success: colors.success.DEFAULT,
successLight: colors.success.light,
successDark: colors.success.dark,
onPrimary: colors.onPrimary.DEFAULT,
onSecondary: colors.onSecondary.DEFAULT,
onSurface: colors.onSurface.DEFAULT,
onBackground: colors.onBackground.DEFAULT,
outline: colors.outline.DEFAULT,
};
const darkColors = {
primary: colors.primary.light,
primaryLight: colors.primary.DEFAULT,
primaryDark: colors.primary.dark,
secondary: colors.secondary.light,
secondaryLight: colors.secondary.DEFAULT,
secondaryDark: colors.secondary.dark,
surface: colors.surface.dark,
background: colors.background.dark,
error: colors.error.light,
errorLight: colors.error.DEFAULT,
errorDark: colors.error.dark,
success: colors.success.light,
successLight: colors.success.DEFAULT,
successDark: colors.success.dark,
onPrimary: colors.onPrimary.dark,
onSecondary: colors.onSecondary.dark,
onSurface: colors.onSurface.dark,
onBackground: colors.onBackground.dark,
outline: colors.outline.dark,
};
module.exports.lightColors = lightColors;
module.exports.darkColors = darkColors;
+26 -6
View File
@@ -1,5 +1,7 @@
import {MD3DarkTheme as DarkTheme, MD3LightTheme as DefaultTheme, MD3Theme} from "react-native-paper";
const { lightColors, darkColors } = require('@/src/core/colors');
export type CustomMD3Theme = MD3Theme & {
colors: {
success: string;
@@ -7,14 +9,22 @@ export type CustomMD3Theme = MD3Theme & {
};
};
export const lightTheme: CustomMD3Theme = {
...DefaultTheme,
colors: {
...DefaultTheme.colors,
primary: DefaultTheme.colors.primary,
success: "#4CAF50", // Standard Erfolgsfarbe
onSuccess: "#FFFFFF", // Schriftfarbe auf Success-Hintergrund
primary: lightColors.primary,
secondary: lightColors.secondary,
background: lightColors.background,
surface: lightColors.surface,
error: lightColors.error,
onPrimary: lightColors.onPrimary,
onSecondary: lightColors.onSecondary,
onBackground: lightColors.onBackground,
onSurface: lightColors.onSurface,
outline: lightColors.outline,
success: lightColors.success,
onSuccess: "#FFFFFF",
},
};
@@ -22,7 +32,17 @@ export const darkTheme: CustomMD3Theme = {
...DarkTheme,
colors: {
...DarkTheme.colors,
success: "#388E3C", // Dunklere Erfolgsfarbe
onSuccess: "#FFFFFF", // Schriftfarbe auf Success-Hintergrund
primary: darkColors.primary,
secondary: darkColors.secondary,
background: darkColors.background,
surface: darkColors.surface,
error: darkColors.error,
onPrimary: darkColors.onPrimary,
onSecondary: darkColors.onSecondary,
onBackground: darkColors.onBackground,
onSurface: darkColors.onSurface,
outline: darkColors.outline,
success: darkColors.success,
onSuccess: "#FFFFFF",
},
};
+22
View File
@@ -0,0 +1,22 @@
import { useColorScheme } from 'nativewind';
const { lightColors, darkColors } = require('@/src/core/colors');
export type ThemeColors = typeof lightColors;
export function useColors() {
const { colorScheme } = useColorScheme();
const isDark = colorScheme === 'dark';
return {
colors: isDark ? darkColors : lightColors,
isDark,
colorScheme,
};
}
export const themeColors = {
light: lightColors,
dark: darkColors,
};
+201
View File
@@ -0,0 +1,201 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { Platform } from 'react-native';
import * as SecureStore from 'expo-secure-store';
import { User } from '@/src/model/User';
import { RestService } from '@/src/services/RestService';
const authStorage = {
getItem: async (name: string): Promise<string | null> => {
if (Platform.OS === 'web') {
return null;
}
return await SecureStore.getItemAsync(name);
},
setItem: async (name: string, value: string): Promise<void> => {
if (Platform.OS === 'web') {
return;
}
await SecureStore.setItemAsync(name, value);
},
removeItem: async (name: string): Promise<void> => {
if (Platform.OS === 'web') {
return;
}
await SecureStore.deleteItemAsync(name);
},
};
export interface AuthError {
field: string;
message: string;
}
interface AuthState {
// State
isAuthenticated: boolean | null;
token: string | null;
authenticatedUser: User | null;
error: AuthError | null;
loading: boolean;
isHydrated: boolean;
// Actions
login: (username: string, password: string, stayLoggedIn?: boolean) => Promise<void>;
logout: () => Promise<void>;
refreshUser: () => Promise<void>;
checkAuthStatus: () => Promise<void>;
setHydrated: () => void;
clearError: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
// Initial State
isAuthenticated: null,
token: null,
authenticatedUser: null,
error: null,
loading: true,
isHydrated: false,
// Actions
setHydrated: () => set({ isHydrated: true }),
clearError: () => set({ error: null }),
checkAuthStatus: async () => {
const state = get();
try {
if (Platform.OS === 'web') {
const user = await fetchUser(null);
set({
isAuthenticated: !!user,
authenticatedUser: user,
loading: false,
});
} else {
const storedToken = state.token;
if (storedToken) {
const user = await fetchUser(storedToken);
set({
isAuthenticated: !!user,
authenticatedUser: user,
loading: false,
});
} else {
set({
isAuthenticated: false,
loading: false,
});
}
}
} catch {
set({
isAuthenticated: false,
token: null,
authenticatedUser: null,
loading: false,
});
}
},
login: async (username: string, password: string, stayLoggedIn?: boolean) => {
const state = get();
if (state.isAuthenticated) {
console.log("Already authenticated");
return;
}
set({ loading: true, error: null });
try {
const response = await new RestService(null).login(username, password, stayLoggedIn);
if (!response.ok) {
console.error("Login failed:", response.data);
const message = response.data.message!;
set({
error: { field: response.data.details?.field!, message },
isAuthenticated: false,
loading: false,
});
return;
}
let token: string | null = null;
if (Platform.OS !== 'web') {
token = response.data.token!;
}
const user = await fetchUser(token);
set({
token,
error: null,
isAuthenticated: !!user,
authenticatedUser: user,
loading: false,
});
} catch {
set({
error: { field: 'general', message: 'Login failed' },
isAuthenticated: false,
loading: false,
});
}
},
logout: async () => {
try {
if (Platform.OS === 'web') {
await new RestService(null).logout();
}
} finally {
set({
token: null,
isAuthenticated: false,
authenticatedUser: null,
error: null,
});
}
},
refreshUser: async () => {
const state = get();
console.log("refreshUser");
const token = Platform.OS === 'web' ? null : state.token;
if (Platform.OS !== 'web' && !token) return;
const user = await fetchUser(token);
if (user) {
set({ authenticatedUser: user });
}
},
}),
{
name: 'auth-storage',
storage: createJSONStorage(() => authStorage),
partialize: (state) => ({ token: state.token }),
onRehydrateStorage: () => (state) => {
if (state) {
state.setHydrated();
state.checkAuthStatus();
}
},
}
)
);
async function fetchUser(token: string | null): Promise<User | null> {
const response = await new RestService(token).getSelf();
if (!response.ok || !response.data) {
return null;
}
return response.data;
}
export const useAuth = useAuthStore;
+4
View File
@@ -0,0 +1,4 @@
export { useThemeStore } from './themeStore';
export { useAuthStore } from './authStore';
export type { AuthError } from './authStore';
+66
View File
@@ -0,0 +1,66 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { Platform } from 'react-native';
import * as SecureStore from 'expo-secure-store';
import { CustomMD3Theme, darkTheme, lightTheme } from '@/src/core/theme';
const zustandStorage = {
getItem: async (name: string): Promise<string | null> => {
if (Platform.OS === 'web') {
return localStorage.getItem(name);
}
return await SecureStore.getItemAsync(name);
},
setItem: async (name: string, value: string): Promise<void> => {
if (Platform.OS === 'web') {
localStorage.setItem(name, value);
} else {
await SecureStore.setItemAsync(name, value);
}
},
removeItem: async (name: string): Promise<void> => {
if (Platform.OS === 'web') {
localStorage.removeItem(name);
} else {
await SecureStore.deleteItemAsync(name);
}
},
};
interface ThemeState {
theme: CustomMD3Theme;
isDark: boolean;
isHydrated: boolean;
toggleTheme: () => void;
setHydrated: () => void;
}
export const useThemeStore = create<ThemeState>()(
persist(
(set, get) => ({
theme: lightTheme,
isDark: false,
isHydrated: false,
toggleTheme: () => {
const newIsDark = !get().isDark;
set({
isDark: newIsDark,
theme: newIsDark ? darkTheme : lightTheme,
});
},
setHydrated: () => set({ isHydrated: true }),
}),
{
name: 'theme-storage',
storage: createJSONStorage(() => zustandStorage),
partialize: (state) => ({ isDark: state.isDark }),
onRehydrateStorage: () => (state) => {
if (state) {
state.theme = state.isDark ? darkTheme : lightTheme;
state.setHydrated();
}
},
}
)
);
+31
View File
@@ -0,0 +1,31 @@
/** @type {import('tailwindcss').Config} */
const { colors } = require('./src/core/colors');
module.exports = {
content: [
"./App.tsx",
"./app/**/*.{js,jsx,ts,tsx}",
"./src/**/*.{js,jsx,ts,tsx}",
],
presets: [require("nativewind/preset")],
darkMode: 'class',
theme: {
extend: {
colors,
fontFamily: {
sans: ["System", "sans-serif"],
},
spacing: {
"safe-top": "env(safe-area-inset-top)",
"safe-bottom": "env(safe-area-inset-bottom)",
},
borderRadius: {
"xl": "12px",
"2xl": "16px",
"3xl": "24px",
},
},
},
plugins: [],
};
+3 -2
View File
@@ -13,6 +13,7 @@
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
"expo-env.d.ts",
"nativewind-env.d.ts"
]
}
}