implement frontend skeleton with tab navigation and service layer
- Add tab-based navigation (Chat, Calendar) using Expo-Router - Create auth screens (login, register) as skeletons - Add dynamic routes for event detail and note editing - Implement service layer (ApiClient, AuthService, EventService, ChatService) - Add Zustand stores (AuthStore, EventsStore) for state management - Create EventCard and EventConfirmDialog components - Update CLAUDE.md with new frontend architecture documentation - Add Zustand and FlashList to technology stack
This commit is contained in:
308
apps/client/src/app/(tabs)/calendar.tsx
Normal file
308
apps/client/src/app/(tabs)/calendar.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import { Animated, Modal, Pressable, Text, View } from "react-native";
|
||||
import { DAYS, MONTHS, Month } from "../../Constants";
|
||||
import Header from "../../components/Header";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import currentTheme from "../../Themes";
|
||||
import BaseBackground from "../../components/BaseBackground";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
|
||||
// TODO: month selection dropdown menu
|
||||
|
||||
const Calendar = () => {
|
||||
const [monthIndex, setMonthIndex] = useState(0);
|
||||
const [currentYear, setCurrentYear] = useState(new Date().getFullYear());
|
||||
|
||||
const changeMonth = (delta: number) => {
|
||||
setMonthIndex((prev) => {
|
||||
const newIndex = prev + delta;
|
||||
if (newIndex > 11) {
|
||||
setCurrentYear((y) => y + 1);
|
||||
return 0;
|
||||
}
|
||||
if (newIndex < 0) {
|
||||
setCurrentYear((y) => y - 1);
|
||||
return 11;
|
||||
}
|
||||
return newIndex;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseBackground>
|
||||
<CalendarHeader
|
||||
changeMonth={changeMonth}
|
||||
monthIndex={monthIndex}
|
||||
currentYear={currentYear}
|
||||
/>
|
||||
<WeekDaysLine />
|
||||
<CalendarGrid month={MONTHS[monthIndex]} year={currentYear} />
|
||||
</BaseBackground>
|
||||
);
|
||||
};
|
||||
|
||||
type MonthSelectorProps = {
|
||||
modalVisible: boolean;
|
||||
onClose: () => void;
|
||||
position: { top: number; left: number; width: number };
|
||||
};
|
||||
|
||||
const MonthSelector = ({
|
||||
modalVisible,
|
||||
onClose,
|
||||
position,
|
||||
}: MonthSelectorProps) => {
|
||||
const heightAnim = useRef(new Animated.Value(0)).current;
|
||||
type ItemType = { id: string; text: string };
|
||||
const listRef = useRef<React.ComponentRef<typeof FlashList<ItemType>>>(null);
|
||||
const [monthSelectorData, setMonthSelectorData] = useState(() => {
|
||||
const initial = [];
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
initial.push({ id: i.toString(), text: `number ${i}` });
|
||||
}
|
||||
return initial;
|
||||
});
|
||||
|
||||
const appendToTestData = (
|
||||
startIndex: number,
|
||||
numberOfEntries: number,
|
||||
appendToStart: boolean,
|
||||
) => {
|
||||
// create new data
|
||||
const newData = [];
|
||||
for (let i = 0; i < numberOfEntries; i++) {
|
||||
const newIndex = startIndex + i + 1;
|
||||
const newEntry = {
|
||||
id: newIndex + "",
|
||||
text: `number ${newIndex}`,
|
||||
};
|
||||
if (appendToStart) {
|
||||
newData.unshift(newEntry);
|
||||
} else {
|
||||
newData.push(newEntry);
|
||||
}
|
||||
}
|
||||
// add new data
|
||||
if (appendToStart) {
|
||||
setMonthSelectorData([...newData, ...monthSelectorData]);
|
||||
} else {
|
||||
setMonthSelectorData([...monthSelectorData, ...newData]);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (modalVisible) {
|
||||
Animated.timing(heightAnim, {
|
||||
toValue: 200,
|
||||
duration: 200,
|
||||
useNativeDriver: false,
|
||||
}).start();
|
||||
} else {
|
||||
// reset on close
|
||||
heightAnim.setValue(0);
|
||||
}
|
||||
}, [modalVisible]);
|
||||
|
||||
const renderItem = ({ item }: { item: ItemType }) => (
|
||||
<Pressable>
|
||||
<View className="w-full flex justify-center items-center">
|
||||
<Text className="text-3xl">{item.text}</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={modalVisible}
|
||||
transparent={true}
|
||||
animationType="none"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<Pressable className="flex-1" onPress={onClose}>
|
||||
<Animated.View
|
||||
className="absolute bg-white border-2 border-solid rounded-lg overflow-hidden"
|
||||
style={{
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
width: position.width,
|
||||
height: heightAnim,
|
||||
}}
|
||||
>
|
||||
<FlashList
|
||||
className="w-full"
|
||||
ref={listRef}
|
||||
keyExtractor={(item) => item.id}
|
||||
data={monthSelectorData}
|
||||
initialScrollIndex={5}
|
||||
onEndReachedThreshold={0.5}
|
||||
onEndReached={() =>
|
||||
appendToTestData(monthSelectorData.length, 10, false)
|
||||
}
|
||||
onStartReachedThreshold={0.5}
|
||||
onStartReached={() =>
|
||||
appendToTestData(monthSelectorData.length, 10, true)
|
||||
}
|
||||
renderItem={renderItem}
|
||||
/>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
type CalendarHeaderProps = {
|
||||
changeMonth: (delta: number) => void;
|
||||
monthIndex: number;
|
||||
currentYear: number;
|
||||
};
|
||||
|
||||
const CalendarHeader = (props: CalendarHeaderProps) => {
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [dropdownPosition, setDropdownPosition] = useState({
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 0,
|
||||
});
|
||||
const containerRef = useRef<View>(null);
|
||||
|
||||
const prevMonth = () => props.changeMonth(-1);
|
||||
const nextMonth = () => props.changeMonth(1);
|
||||
|
||||
const measureAndOpen = () => {
|
||||
containerRef.current?.measureInWindow((x, y, width, height) => {
|
||||
setDropdownPosition({ top: y + height, left: x, width });
|
||||
setModalVisible(true);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Header className="flex flex-row items-center justify-between">
|
||||
<ChangeMonthButton onPress={prevMonth} title={"<"} />
|
||||
<View
|
||||
ref={containerRef}
|
||||
className="relative flex flex-row items-center justify-around"
|
||||
>
|
||||
<Text className="text-4xl">
|
||||
{MONTHS[props.monthIndex]} {props.currentYear}
|
||||
</Text>
|
||||
<Pressable
|
||||
className={
|
||||
"flex justify-center items-center bg-white w-12 h-12 p-2 " +
|
||||
"border border-solid rounded-full ml-2"
|
||||
}
|
||||
style={{
|
||||
borderColor: currentTheme.primeFg,
|
||||
}}
|
||||
onPress={measureAndOpen}
|
||||
>
|
||||
<Text className="text-4xl">v</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<MonthSelector
|
||||
modalVisible={modalVisible}
|
||||
onClose={() => setModalVisible(false)}
|
||||
position={dropdownPosition}
|
||||
/>
|
||||
<ChangeMonthButton onPress={nextMonth} title={">"} />
|
||||
</Header>
|
||||
);
|
||||
};
|
||||
|
||||
type ChangeMonthButtonProps = {
|
||||
onPress: () => void;
|
||||
title: string;
|
||||
};
|
||||
|
||||
const ChangeMonthButton = (props: ChangeMonthButtonProps) => (
|
||||
<Pressable
|
||||
onPress={props.onPress}
|
||||
className={
|
||||
"w-16 h-16 bg-white rounded-full flex items-center " +
|
||||
"justify-center border border-solid border-1 mx-2"
|
||||
}
|
||||
style={{
|
||||
borderColor: currentTheme.primeFg,
|
||||
}}
|
||||
>
|
||||
<Text className="text-4xl">{props.title}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
const WeekDaysLine = () => (
|
||||
<View className="flex flex-row items-center justify-around px-2 gap-2">
|
||||
{/* TODO: px and gap need fine tuning to perfectly align with the grid */}
|
||||
{DAYS.map((day, i) => (
|
||||
<Text key={i}>{day.substring(0, 2).toUpperCase()}</Text>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
|
||||
type CalendarGridProps = {
|
||||
month: Month;
|
||||
year: number;
|
||||
};
|
||||
|
||||
const CalendarGrid = (props: CalendarGridProps) => {
|
||||
const { baseDate, dateOffset } = useMemo(() => {
|
||||
const monthIndex = MONTHS.indexOf(props.month);
|
||||
const base = new Date(props.year, monthIndex, 1);
|
||||
const offset = base.getDay() === 0 ? 6 : base.getDay() - 1;
|
||||
return { baseDate: base, dateOffset: offset };
|
||||
}, [props.month, props.year]);
|
||||
|
||||
// TODO: create array beforehand in a useMemo
|
||||
const createDateFromOffset = (offset: number): Date => {
|
||||
const date = new Date(baseDate);
|
||||
date.setDate(date.getDate() + offset);
|
||||
return date;
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
className="h-full flex-1 flex-col flex-wrap gap-2 p-2"
|
||||
style={{
|
||||
backgroundColor: currentTheme.calenderBg,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<View
|
||||
key={i}
|
||||
className="w-full flex-1 flex-row justify-around items-center gap-2"
|
||||
>
|
||||
{Array.from({ length: 7 }).map((_, j) => (
|
||||
<SingleDay
|
||||
key={j}
|
||||
date={createDateFromOffset(i * 7 + j - dateOffset)}
|
||||
month={props.month}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
type SingleDayProps = {
|
||||
date: Date;
|
||||
month: Month;
|
||||
};
|
||||
|
||||
const SingleDay = (props: SingleDayProps) => {
|
||||
const isSameMonth = MONTHS[props.date.getMonth()] === props.month;
|
||||
|
||||
return (
|
||||
<View
|
||||
className="h-full flex-1 aspect-auto rounded-xl items-center"
|
||||
style={{
|
||||
backgroundColor: currentTheme.primeBg,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className={`text-xl ` + (isSameMonth ? "text-black" : "text-black/50")}
|
||||
>
|
||||
{props.date.getDate()}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default Calendar;
|
||||
Reference in New Issue
Block a user