feat: add theme system with light/dark mode support
- Add ThemeStore (Zustand) for reactive theme switching - Add Themes.tsx with THEMES object (defaultLight, defaultDark) - Add Settings screen with theme switcher and logout button - Add BaseButton component for reusable themed buttons - Migrate all components from static currentTheme to useThemeStore() - Add shadowColor to theme (iOS only, Android uses elevation) - All text elements now use theme colors (textPrimary, textSecondary, etc.) - Update tab navigation to include Settings tab - Move logout from Header to Settings screen
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { Pressable, Text, ActivityIndicator } from "react-native";
|
||||
import currentTheme from "../Themes";
|
||||
import { useThemeStore } from "../stores/ThemeStore";
|
||||
|
||||
interface AuthButtonProps {
|
||||
title: string;
|
||||
@@ -8,6 +8,7 @@ interface AuthButtonProps {
|
||||
}
|
||||
|
||||
const AuthButton = ({ title, onPress, isLoading = false }: AuthButtonProps) => {
|
||||
const { theme } = useThemeStore();
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
@@ -15,9 +16,9 @@ const AuthButton = ({ title, onPress, isLoading = false }: AuthButtonProps) => {
|
||||
className="w-full rounded-lg p-4 mb-4 border-4"
|
||||
style={{
|
||||
backgroundColor: isLoading
|
||||
? currentTheme.disabledButton
|
||||
: currentTheme.chatBot,
|
||||
shadowColor: "#000",
|
||||
? theme.disabledButton
|
||||
: theme.chatBot,
|
||||
shadowColor: theme.shadowColor,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 3.84,
|
||||
@@ -25,11 +26,11 @@ const AuthButton = ({ title, onPress, isLoading = false }: AuthButtonProps) => {
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color={currentTheme.buttonText} />
|
||||
<ActivityIndicator color={theme.buttonText} />
|
||||
) : (
|
||||
<Text
|
||||
className="text-center font-semibold text-lg"
|
||||
style={{ color: currentTheme.buttonText }}
|
||||
style={{ color: theme.buttonText }}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { View } from "react-native";
|
||||
import currentTheme from "../Themes";
|
||||
import { useThemeStore } from "../stores/ThemeStore";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type BaseBackgroundProps = {
|
||||
@@ -8,11 +8,12 @@ type BaseBackgroundProps = {
|
||||
};
|
||||
|
||||
const BaseBackground = (props: BaseBackgroundProps) => {
|
||||
const { theme } = useThemeStore();
|
||||
return (
|
||||
<View
|
||||
className={`h-full ${props.className}`}
|
||||
style={{
|
||||
backgroundColor: currentTheme.primeBg,
|
||||
backgroundColor: theme.primeBg,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
|
||||
39
apps/client/src/components/BaseButton.tsx
Normal file
39
apps/client/src/components/BaseButton.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Pressable, Text } from "react-native";
|
||||
import { useThemeStore } from "../stores/ThemeStore";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type BaseButtonProps = {
|
||||
children?: ReactNode;
|
||||
onPress: () => void;
|
||||
solid?: boolean;
|
||||
};
|
||||
|
||||
const BaseButton = ({children, onPress, solid = false}: BaseButtonProps) => {
|
||||
const { theme } = useThemeStore();
|
||||
return (
|
||||
<Pressable
|
||||
className="w-11/12 rounded-lg p-4 mb-4 border-4"
|
||||
onPress={onPress}
|
||||
style={{
|
||||
borderColor: theme.borderPrimary,
|
||||
backgroundColor: solid
|
||||
? theme.chatBot
|
||||
: theme.primeBg,
|
||||
shadowColor: theme.shadowColor,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className="text-center font-semibold text-lg"
|
||||
style={{ color: theme.buttonText }}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
export default BaseButton;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { View, ViewStyle } from "react-native";
|
||||
import colors from "../Themes";
|
||||
import { useThemeStore } from "../stores/ThemeStore";
|
||||
|
||||
type BubbleSide = "left" | "right";
|
||||
|
||||
@@ -11,7 +11,8 @@ type ChatBubbleProps = {
|
||||
};
|
||||
|
||||
export function ChatBubble({ side, children, className = "", style }: ChatBubbleProps) {
|
||||
const borderColor = side === "left" ? colors.chatBot : colors.primeFg;
|
||||
const { theme } = useThemeStore();
|
||||
const borderColor = side === "left" ? theme.chatBot : theme.primeFg;
|
||||
const sideClass =
|
||||
side === "left"
|
||||
? "self-start ml-2 rounded-bl-sm"
|
||||
@@ -19,8 +20,8 @@ export function ChatBubble({ side, children, className = "", style }: ChatBubble
|
||||
|
||||
return (
|
||||
<View
|
||||
className={`bg-white border-2 border-solid rounded-xl my-2 ${sideClass} ${className}`}
|
||||
style={[{ borderColor, elevation: 8 }, style]}
|
||||
className={`border-2 border-solid rounded-xl my-2 ${sideClass} ${className}`}
|
||||
style={[{ borderColor, elevation: 8, backgroundColor: theme.secondaryBg }, style]}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { View, Pressable } from "react-native";
|
||||
import { ExpandedEvent } from "@calchat/shared";
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import currentTheme from "../Themes";
|
||||
import { useThemeStore } from "../stores/ThemeStore";
|
||||
import { EventCardBase } from "./EventCardBase";
|
||||
|
||||
type EventCardProps = {
|
||||
@@ -11,6 +11,7 @@ type EventCardProps = {
|
||||
};
|
||||
|
||||
export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => {
|
||||
const { theme } = useThemeStore();
|
||||
return (
|
||||
<View className="mb-3">
|
||||
<EventCardBase
|
||||
@@ -27,23 +28,23 @@ export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => {
|
||||
className="w-10 h-10 rounded-full items-center justify-center"
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: currentTheme.borderPrimary,
|
||||
borderColor: theme.borderPrimary,
|
||||
}}
|
||||
>
|
||||
<Feather name="edit-2" size={18} color={currentTheme.textPrimary} />
|
||||
<Feather name="edit-2" size={18} color={theme.textPrimary} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={onDelete}
|
||||
className="w-10 h-10 rounded-full items-center justify-center"
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: currentTheme.borderPrimary,
|
||||
borderColor: theme.borderPrimary,
|
||||
}}
|
||||
>
|
||||
<Feather
|
||||
name="trash-2"
|
||||
size={18}
|
||||
color={currentTheme.textPrimary}
|
||||
color={theme.textPrimary}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { View, Text } from "react-native";
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { ReactNode } from "react";
|
||||
import currentTheme from "../Themes";
|
||||
import { useThemeStore } from "../stores/ThemeStore";
|
||||
|
||||
type EventCardBaseProps = {
|
||||
className?: string;
|
||||
@@ -60,34 +60,35 @@ export const EventCardBase = ({
|
||||
isRecurring,
|
||||
children,
|
||||
}: EventCardBaseProps) => {
|
||||
const { theme } = useThemeStore();
|
||||
return (
|
||||
<View
|
||||
className={`rounded-xl overflow-hidden ${className}`}
|
||||
style={{ borderWidth: 2, borderColor: currentTheme.borderPrimary }}
|
||||
style={{ borderWidth: 2, borderColor: theme.borderPrimary }}
|
||||
>
|
||||
{/* Header with title */}
|
||||
<View
|
||||
className="px-3 py-2"
|
||||
style={{
|
||||
backgroundColor: currentTheme.chatBot,
|
||||
backgroundColor: theme.chatBot,
|
||||
borderBottomWidth: 2,
|
||||
borderBottomColor: currentTheme.borderPrimary,
|
||||
borderBottomColor: theme.borderPrimary,
|
||||
}}
|
||||
>
|
||||
<Text className="font-bold text-base">{title}</Text>
|
||||
<Text className="font-bold text-base" style={{ color: theme.textPrimary }}>{title}</Text>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<View className="px-3 py-2 bg-white">
|
||||
<View className="px-3 py-2" style={{ backgroundColor: theme.secondaryBg }}>
|
||||
{/* Date */}
|
||||
<View className="flex-row items-center mb-1">
|
||||
<Feather
|
||||
name="calendar"
|
||||
size={16}
|
||||
color={currentTheme.textPrimary}
|
||||
color={theme.textPrimary}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text style={{ color: currentTheme.textPrimary }}>
|
||||
<Text style={{ color: theme.textPrimary }}>
|
||||
{formatDate(startTime)}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -97,10 +98,10 @@ export const EventCardBase = ({
|
||||
<Feather
|
||||
name="clock"
|
||||
size={16}
|
||||
color={currentTheme.textPrimary}
|
||||
color={theme.textPrimary}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text style={{ color: currentTheme.textPrimary }}>
|
||||
<Text style={{ color: theme.textPrimary }}>
|
||||
{formatTime(startTime)} - {formatTime(endTime)} (
|
||||
{formatDuration(startTime, endTime)})
|
||||
</Text>
|
||||
@@ -112,10 +113,10 @@ export const EventCardBase = ({
|
||||
<Feather
|
||||
name="repeat"
|
||||
size={16}
|
||||
color={currentTheme.textPrimary}
|
||||
color={theme.textPrimary}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text style={{ color: currentTheme.textPrimary }}>
|
||||
<Text style={{ color: theme.textPrimary }}>
|
||||
Wiederkehrend
|
||||
</Text>
|
||||
</View>
|
||||
@@ -124,7 +125,7 @@ export const EventCardBase = ({
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<Text
|
||||
style={{ color: currentTheme.textPrimary }}
|
||||
style={{ color: theme.textPrimary }}
|
||||
className="text-sm mt-1"
|
||||
>
|
||||
{description}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { View, Text, Modal, Pressable } from "react-native";
|
||||
import { CreateEventDTO } from "@calchat/shared";
|
||||
import { useThemeStore } from "../stores/ThemeStore";
|
||||
|
||||
type EventConfirmDialogProps = {
|
||||
visible: boolean;
|
||||
@@ -16,6 +17,8 @@ const EventConfirmDialog = ({
|
||||
onReject: _onReject,
|
||||
onClose: _onClose,
|
||||
}: EventConfirmDialogProps) => {
|
||||
const { theme } = useThemeStore();
|
||||
|
||||
// TODO: Display proposed event details (title, time, description)
|
||||
// TODO: Confirm button calls onConfirm and closes dialog
|
||||
// TODO: Reject button calls onReject and closes dialog
|
||||
@@ -26,7 +29,7 @@ const EventConfirmDialog = ({
|
||||
<Modal visible={false} transparent animationType="fade">
|
||||
<View>
|
||||
<Pressable>
|
||||
<Text>EventConfirmDialog - Not Implemented</Text>
|
||||
<Text style={{ color: theme.textPrimary }}>EventConfirmDialog - Not Implemented</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
@@ -1,42 +1,28 @@
|
||||
import { View, Pressable } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { router } from "expo-router";
|
||||
import currentTheme from "../Themes";
|
||||
import { View } from "react-native";
|
||||
import { useThemeStore } from "../stores/ThemeStore";
|
||||
import { ReactNode } from "react";
|
||||
import { AuthService } from "../services";
|
||||
|
||||
type HeaderProps = {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await AuthService.logout();
|
||||
router.replace("/login");
|
||||
};
|
||||
|
||||
const Header = (props: HeaderProps) => {
|
||||
const { theme } = useThemeStore();
|
||||
return (
|
||||
<View>
|
||||
<View
|
||||
className={`w-full h-32 pt-10 pb-4 ${props.className}`}
|
||||
style={{
|
||||
backgroundColor: currentTheme.chatBot,
|
||||
backgroundColor: theme.chatBot,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
<Pressable
|
||||
onPress={handleLogout}
|
||||
className="absolute left-1 bottom-0 p-2"
|
||||
hitSlop={8}
|
||||
>
|
||||
<Ionicons name="log-out-outline" size={24} color={currentTheme.primeFg} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<View
|
||||
className="h-2 bg-black"
|
||||
style={{
|
||||
shadowColor: "#000",
|
||||
shadowColor: theme.shadowColor,
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 5,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { View, Text, Pressable } from "react-native";
|
||||
import { ProposedEventChange } from "@calchat/shared";
|
||||
import currentTheme from "../Themes";
|
||||
import { useThemeStore } from "../stores/ThemeStore";
|
||||
import { EventCardBase } from "./EventCardBase";
|
||||
|
||||
type ProposedEventCardProps = {
|
||||
@@ -19,42 +19,45 @@ const ConfirmRejectButtons = ({
|
||||
respondedAction?: "confirm" | "reject";
|
||||
onConfirm: () => void;
|
||||
onReject: () => void;
|
||||
}) => (
|
||||
<View className="flex-row mt-3 gap-2">
|
||||
<Pressable
|
||||
onPress={onConfirm}
|
||||
disabled={isDisabled}
|
||||
className="flex-1 py-2 rounded-lg items-center"
|
||||
style={{
|
||||
backgroundColor: isDisabled
|
||||
? currentTheme.disabledButton
|
||||
: currentTheme.confirmButton,
|
||||
borderWidth: respondedAction === "confirm" ? 2 : 0,
|
||||
borderColor: currentTheme.confirmButton,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: currentTheme.buttonText }} className="font-medium">
|
||||
Annehmen
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={onReject}
|
||||
disabled={isDisabled}
|
||||
className="flex-1 py-2 rounded-lg items-center"
|
||||
style={{
|
||||
backgroundColor: isDisabled
|
||||
? currentTheme.disabledButton
|
||||
: currentTheme.rejectButton,
|
||||
borderWidth: respondedAction === "reject" ? 2 : 0,
|
||||
borderColor: currentTheme.rejectButton,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: currentTheme.buttonText }} className="font-medium">
|
||||
Ablehnen
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}) => {
|
||||
const { theme } = useThemeStore();
|
||||
return (
|
||||
<View className="flex-row mt-3 gap-2">
|
||||
<Pressable
|
||||
onPress={onConfirm}
|
||||
disabled={isDisabled}
|
||||
className="flex-1 py-2 rounded-lg items-center"
|
||||
style={{
|
||||
backgroundColor: isDisabled
|
||||
? theme.disabledButton
|
||||
: theme.confirmButton,
|
||||
borderWidth: respondedAction === "confirm" ? 2 : 0,
|
||||
borderColor: theme.confirmButton,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: theme.buttonText }} className="font-medium">
|
||||
Annehmen
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={onReject}
|
||||
disabled={isDisabled}
|
||||
className="flex-1 py-2 rounded-lg items-center"
|
||||
style={{
|
||||
backgroundColor: isDisabled
|
||||
? theme.disabledButton
|
||||
: theme.rejectButton,
|
||||
borderWidth: respondedAction === "reject" ? 2 : 0,
|
||||
borderColor: theme.rejectButton,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: theme.buttonText }} className="font-medium">
|
||||
Ablehnen
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProposedEventCard = ({
|
||||
proposedChange,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Text } from "react-native";
|
||||
import colors from "../Themes";
|
||||
import { useThemeStore } from "../stores/ThemeStore";
|
||||
import { ChatBubble } from "./ChatBubble";
|
||||
|
||||
const DOTS = [".", "..", "..."];
|
||||
const INTERVAL_MS = 400;
|
||||
|
||||
export default function TypingIndicator() {
|
||||
const { theme } = useThemeStore();
|
||||
const [dotIndex, setDotIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -21,7 +22,7 @@ export default function TypingIndicator() {
|
||||
<ChatBubble side="left" className="px-4 py-2">
|
||||
<Text
|
||||
className="text-lg font-bold tracking-widest"
|
||||
style={{ color: colors.textMuted }}
|
||||
style={{ color: theme.textMuted }}
|
||||
>
|
||||
{DOTS[dotIndex]}
|
||||
</Text>
|
||||
|
||||
Reference in New Issue
Block a user