feat: implement functional MonthSelector with infinite scroll
- Add MonthSelector dropdown with dynamic month loading - Replace text buttons with Ionicons (chevron-back/forward/down) - Add shadows and themed styling to navigation buttons - Add secondaryBg color to theme for alternating list items - Update CLAUDE.md documentation
This commit is contained in:
@@ -295,7 +295,8 @@ MONGODB_URI=mongodb://root:mongoose@localhost:27017/calchat?authSource=admin
|
|||||||
**Frontend:**
|
**Frontend:**
|
||||||
- Tab navigation (Chat, Calendar) implemented with basic UI
|
- Tab navigation (Chat, Calendar) implemented with basic UI
|
||||||
- Calendar screen fully functional:
|
- Calendar screen fully functional:
|
||||||
- Month navigation with grid display
|
- Month navigation with grid display and Ionicons (chevron-back/forward)
|
||||||
|
- MonthSelector dropdown with infinite scroll (dynamically loads months)
|
||||||
- Events loaded from API via EventService.getByDateRange()
|
- Events loaded from API via EventService.getByDateRange()
|
||||||
- Orange dot indicator for days with events
|
- Orange dot indicator for days with events
|
||||||
- Tap-to-open modal overlay showing EventCards for selected day
|
- Tap-to-open modal overlay showing EventCards for selected day
|
||||||
@@ -309,7 +310,7 @@ MONGODB_URI=mongodb://root:mongoose@localhost:27017/calchat?authSource=admin
|
|||||||
- `EventCardBase`: Shared base component with event layout (header, date/time/recurring icons, description) - used by both EventCard and ProposedEventCard
|
- `EventCardBase`: Shared base component with event layout (header, date/time/recurring icons, description) - used by both EventCard and ProposedEventCard
|
||||||
- `EventCard`: Uses EventCardBase + edit/delete buttons for calendar display
|
- `EventCard`: Uses EventCardBase + edit/delete buttons for calendar display
|
||||||
- `ProposedEventCard`: Uses EventCardBase + confirm/reject buttons for chat proposals (supports create/update/delete actions)
|
- `ProposedEventCard`: Uses EventCardBase + confirm/reject buttons for chat proposals (supports create/update/delete actions)
|
||||||
- `Themes.tsx`: Centralized color definitions including textPrimary, borderPrimary, eventIndicator
|
- `Themes.tsx`: Centralized color definitions including textPrimary, borderPrimary, eventIndicator, secondaryBg
|
||||||
- `EventsStore`: Zustand store with setEvents(), addEvent(), updateEvent(), deleteEvent() - stores ExpandedEvent[]
|
- `EventsStore`: Zustand store with setEvents(), addEvent(), updateEvent(), deleteEvent() - stores ExpandedEvent[]
|
||||||
- `ChatStore`: Zustand store with addMessage(), updateMessage(), clearMessages() - persists messages across tab switches
|
- `ChatStore`: Zustand store with addMessage(), updateMessage(), clearMessages() - persists messages across tab switches
|
||||||
- Auth screens (Login, Register), Event Detail, and Note screens exist as skeletons
|
- Auth screens (Login, Register), Event Detail, and Note screens exist as skeletons
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ type Theme = {
|
|||||||
chatBot: string;
|
chatBot: string;
|
||||||
primeFg: string;
|
primeFg: string;
|
||||||
primeBg: string;
|
primeBg: string;
|
||||||
|
secondaryBg: string;
|
||||||
messageBorderBg: string;
|
messageBorderBg: string;
|
||||||
placeholderBg: string;
|
placeholderBg: string;
|
||||||
calenderBg: string;
|
calenderBg: string;
|
||||||
@@ -20,6 +21,7 @@ const defaultLight: Theme = {
|
|||||||
chatBot: "#DE6C20",
|
chatBot: "#DE6C20",
|
||||||
primeFg: "#3B3329",
|
primeFg: "#3B3329",
|
||||||
primeBg: "#FFEEDE",
|
primeBg: "#FFEEDE",
|
||||||
|
secondaryBg: "#FFFFFF",
|
||||||
messageBorderBg: "#FFFFFF",
|
messageBorderBg: "#FFFFFF",
|
||||||
placeholderBg: "#D9D9D9",
|
placeholderBg: "#D9D9D9",
|
||||||
calenderBg: "#FBD5B2",
|
calenderBg: "#FBD5B2",
|
||||||
|
|||||||
@@ -18,13 +18,49 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useFocusEffect } from "expo-router";
|
import { useFocusEffect } from "expo-router";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import currentTheme from "../../Themes";
|
import currentTheme from "../../Themes";
|
||||||
import BaseBackground from "../../components/BaseBackground";
|
import BaseBackground from "../../components/BaseBackground";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { EventService } from "../../services";
|
import { EventService } from "../../services";
|
||||||
import { useEventsStore } from "../../stores";
|
import { useEventsStore } from "../../stores";
|
||||||
|
|
||||||
// TODO: month selection dropdown menu
|
// MonthSelector types and helpers
|
||||||
|
type MonthItem = {
|
||||||
|
id: string; // Format: "YYYY-MM"
|
||||||
|
year: number;
|
||||||
|
monthIndex: number; // 0-11
|
||||||
|
label: string; // e.g. "January 2024"
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateMonths = (
|
||||||
|
centerYear: number,
|
||||||
|
centerMonth: number,
|
||||||
|
range: number,
|
||||||
|
): MonthItem[] => {
|
||||||
|
const months: MonthItem[] = [];
|
||||||
|
for (let offset = -range; offset <= range; offset++) {
|
||||||
|
let year = centerYear;
|
||||||
|
let month = centerMonth + offset;
|
||||||
|
|
||||||
|
while (month < 0) {
|
||||||
|
month += 12;
|
||||||
|
year--;
|
||||||
|
}
|
||||||
|
while (month > 11) {
|
||||||
|
month -= 12;
|
||||||
|
year++;
|
||||||
|
}
|
||||||
|
|
||||||
|
months.push({
|
||||||
|
id: `${year}-${String(month + 1).padStart(2, "0")}`,
|
||||||
|
year,
|
||||||
|
monthIndex: month,
|
||||||
|
label: `${MONTHS[month]} ${year}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return months;
|
||||||
|
};
|
||||||
|
|
||||||
const Calendar = () => {
|
const Calendar = () => {
|
||||||
const [monthIndex, setMonthIndex] = useState(new Date().getMonth());
|
const [monthIndex, setMonthIndex] = useState(new Date().getMonth());
|
||||||
@@ -149,6 +185,8 @@ const Calendar = () => {
|
|||||||
changeMonth={changeMonth}
|
changeMonth={changeMonth}
|
||||||
monthIndex={monthIndex}
|
monthIndex={monthIndex}
|
||||||
currentYear={currentYear}
|
currentYear={currentYear}
|
||||||
|
setMonthIndex={setMonthIndex}
|
||||||
|
setYear={setCurrentYear}
|
||||||
/>
|
/>
|
||||||
<WeekDaysLine />
|
<WeekDaysLine />
|
||||||
<CalendarGrid
|
<CalendarGrid
|
||||||
@@ -268,49 +306,74 @@ type MonthSelectorProps = {
|
|||||||
modalVisible: boolean;
|
modalVisible: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
position: { top: number; left: number; width: number };
|
position: { top: number; left: number; width: number };
|
||||||
|
currentYear: number;
|
||||||
|
currentMonthIndex: number;
|
||||||
|
onSelectMonth: (year: number, monthIndex: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MonthSelector = ({
|
const MonthSelector = ({
|
||||||
modalVisible,
|
modalVisible,
|
||||||
onClose,
|
onClose,
|
||||||
position,
|
position,
|
||||||
|
currentYear,
|
||||||
|
currentMonthIndex,
|
||||||
|
onSelectMonth,
|
||||||
}: MonthSelectorProps) => {
|
}: MonthSelectorProps) => {
|
||||||
const heightAnim = useRef(new Animated.Value(0)).current;
|
const heightAnim = useRef(new Animated.Value(0)).current;
|
||||||
type ItemType = { id: string; text: string };
|
const listRef = useRef<React.ComponentRef<typeof FlashList<MonthItem>>>(null);
|
||||||
const listRef = useRef<React.ComponentRef<typeof FlashList<ItemType>>>(null);
|
const INITIAL_RANGE = 12; // 12 months before and after current
|
||||||
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 = (
|
const [monthSelectorData, setMonthSelectorData] = useState<MonthItem[]>(() =>
|
||||||
startIndex: number,
|
generateMonths(currentYear, currentMonthIndex, INITIAL_RANGE),
|
||||||
numberOfEntries: number,
|
);
|
||||||
appendToStart: boolean,
|
|
||||||
) => {
|
// Reset data when current month changes
|
||||||
// create new data
|
useEffect(() => {
|
||||||
const newData = [];
|
setMonthSelectorData(
|
||||||
for (let i = 0; i < numberOfEntries; i++) {
|
generateMonths(currentYear, currentMonthIndex, INITIAL_RANGE),
|
||||||
const newIndex = startIndex + i + 1;
|
);
|
||||||
const newEntry = {
|
}, [currentYear, currentMonthIndex]);
|
||||||
id: newIndex + "",
|
|
||||||
text: `number ${newIndex}`,
|
const appendMonths = (direction: "start" | "end", count: number) => {
|
||||||
|
setMonthSelectorData((prevData) => {
|
||||||
|
if (prevData.length === 0) return prevData;
|
||||||
|
|
||||||
|
const newMonths: MonthItem[] = [];
|
||||||
|
const referenceMonth =
|
||||||
|
direction === "start" ? prevData[0] : prevData[prevData.length - 1];
|
||||||
|
|
||||||
|
for (let i = 1; i <= count; i++) {
|
||||||
|
const offset = direction === "start" ? -i : i;
|
||||||
|
let year = referenceMonth.year;
|
||||||
|
let month = referenceMonth.monthIndex + offset;
|
||||||
|
|
||||||
|
while (month < 0) {
|
||||||
|
month += 12;
|
||||||
|
year--;
|
||||||
|
}
|
||||||
|
while (month > 11) {
|
||||||
|
month -= 12;
|
||||||
|
year++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newMonth: MonthItem = {
|
||||||
|
id: `${year}-${String(month + 1).padStart(2, "0")}`,
|
||||||
|
year,
|
||||||
|
monthIndex: month,
|
||||||
|
label: `${MONTHS[month]} ${year}`,
|
||||||
};
|
};
|
||||||
if (appendToStart) {
|
|
||||||
newData.unshift(newEntry);
|
if (direction === "start") {
|
||||||
|
newMonths.unshift(newMonth);
|
||||||
} else {
|
} else {
|
||||||
newData.push(newEntry);
|
newMonths.push(newMonth);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// add new data
|
|
||||||
if (appendToStart) {
|
return direction === "start"
|
||||||
setMonthSelectorData([...newData, ...monthSelectorData]);
|
? [...newMonths, ...prevData]
|
||||||
} else {
|
: [...prevData, ...newMonths];
|
||||||
setMonthSelectorData([...monthSelectorData, ...newData]);
|
});
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -321,15 +384,29 @@ const MonthSelector = ({
|
|||||||
useNativeDriver: false,
|
useNativeDriver: false,
|
||||||
}).start();
|
}).start();
|
||||||
} else {
|
} else {
|
||||||
// reset on close
|
|
||||||
heightAnim.setValue(0);
|
heightAnim.setValue(0);
|
||||||
}
|
}
|
||||||
}, [modalVisible]);
|
}, [modalVisible, heightAnim]);
|
||||||
|
|
||||||
const renderItem = ({ item }: { item: ItemType }) => (
|
const renderItem = ({ item }: { item: MonthItem }) => (
|
||||||
<Pressable>
|
<Pressable
|
||||||
<View className="w-full flex justify-center items-center">
|
onPress={() => {
|
||||||
<Text className="text-3xl">{item.text}</Text>
|
onSelectMonth(item.year, item.monthIndex);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className="w-full flex justify-center items-center py-2"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
item.monthIndex % 2 === 0
|
||||||
|
? currentTheme.primeBg
|
||||||
|
: currentTheme.secondaryBg,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="text-xl" style={{ color: currentTheme.primeFg }}>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
@@ -341,30 +418,31 @@ const MonthSelector = ({
|
|||||||
animationType="none"
|
animationType="none"
|
||||||
onRequestClose={onClose}
|
onRequestClose={onClose}
|
||||||
>
|
>
|
||||||
<Pressable className="flex-1" onPress={onClose}>
|
<Pressable className="flex-1 rounded-lg" onPress={onClose}>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
className="absolute bg-white border-2 border-solid rounded-lg overflow-hidden"
|
className="absolute overflow-hidden"
|
||||||
style={{
|
style={{
|
||||||
top: position.top,
|
top: position.top,
|
||||||
left: position.left,
|
left: position.left,
|
||||||
width: position.width,
|
width: position.width,
|
||||||
height: heightAnim,
|
height: heightAnim,
|
||||||
|
backgroundColor: currentTheme.primeBg,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: currentTheme.borderPrimary,
|
||||||
|
borderRadius: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FlashList
|
<FlashList
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
style={{ borderRadius: 8 }}
|
||||||
ref={listRef}
|
ref={listRef}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
data={monthSelectorData}
|
data={monthSelectorData}
|
||||||
initialScrollIndex={5}
|
initialScrollIndex={INITIAL_RANGE}
|
||||||
onEndReachedThreshold={0.5}
|
onEndReachedThreshold={0.5}
|
||||||
onEndReached={() =>
|
onEndReached={() => appendMonths("end", 12)}
|
||||||
appendToTestData(monthSelectorData.length, 10, false)
|
|
||||||
}
|
|
||||||
onStartReachedThreshold={0.5}
|
onStartReachedThreshold={0.5}
|
||||||
onStartReached={() =>
|
onStartReached={() => appendMonths("start", 12)}
|
||||||
appendToTestData(monthSelectorData.length, 10, true)
|
|
||||||
}
|
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -377,6 +455,8 @@ type CalendarHeaderProps = {
|
|||||||
changeMonth: (delta: number) => void;
|
changeMonth: (delta: number) => void;
|
||||||
monthIndex: number;
|
monthIndex: number;
|
||||||
currentYear: number;
|
currentYear: number;
|
||||||
|
setMonthIndex: (index: number) => void;
|
||||||
|
setYear: (year: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CalendarHeader = (props: CalendarHeaderProps) => {
|
const CalendarHeader = (props: CalendarHeaderProps) => {
|
||||||
@@ -400,54 +480,82 @@ const CalendarHeader = (props: CalendarHeaderProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Header className="flex flex-row items-center justify-between">
|
<Header className="flex flex-row items-center justify-between">
|
||||||
<ChangeMonthButton onPress={prevMonth} title={"<"} />
|
<ChangeMonthButton onPress={prevMonth} icon="chevron-back" />
|
||||||
<View
|
<View
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="relative flex flex-row items-center justify-around"
|
className="relative flex flex-row items-center justify-around"
|
||||||
>
|
>
|
||||||
<Text className="text-4xl">
|
<Text className="text-4xl px-1">
|
||||||
{MONTHS[props.monthIndex]} {props.currentYear}
|
{MONTHS[props.monthIndex]} {props.currentYear}
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
className={
|
className="flex justify-center items-center w-12 h-12 border rounded-lg"
|
||||||
"flex justify-center items-center bg-white w-12 h-12 p-2 " +
|
|
||||||
"border border-solid rounded-full ml-2"
|
|
||||||
}
|
|
||||||
style={{
|
style={{
|
||||||
borderColor: currentTheme.primeFg,
|
borderColor: currentTheme.primeFg,
|
||||||
|
backgroundColor: currentTheme.chatBot,
|
||||||
|
// iOS shadow
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 3 },
|
||||||
|
shadowOpacity: 0.35,
|
||||||
|
shadowRadius: 5,
|
||||||
|
// Android shadow
|
||||||
|
elevation: 6,
|
||||||
}}
|
}}
|
||||||
onPress={measureAndOpen}
|
onPress={measureAndOpen}
|
||||||
>
|
>
|
||||||
<Text className="text-4xl">v</Text>
|
<Ionicons
|
||||||
|
name="chevron-down"
|
||||||
|
size={28}
|
||||||
|
color={currentTheme.primeFg}
|
||||||
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
<MonthSelector
|
<MonthSelector
|
||||||
modalVisible={modalVisible}
|
modalVisible={modalVisible}
|
||||||
onClose={() => setModalVisible(false)}
|
onClose={() => setModalVisible(false)}
|
||||||
position={dropdownPosition}
|
position={dropdownPosition}
|
||||||
|
currentYear={props.currentYear}
|
||||||
|
currentMonthIndex={props.monthIndex}
|
||||||
|
onSelectMonth={(year, month) => {
|
||||||
|
props.setYear(year);
|
||||||
|
props.setMonthIndex(month);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<ChangeMonthButton onPress={nextMonth} title={">"} />
|
<ChangeMonthButton onPress={nextMonth} icon="chevron-forward" />
|
||||||
</Header>
|
</Header>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type ChangeMonthButtonProps = {
|
type ChangeMonthButtonProps = {
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
title: string;
|
icon: "chevron-back" | "chevron-forward";
|
||||||
};
|
};
|
||||||
|
|
||||||
const ChangeMonthButton = (props: ChangeMonthButtonProps) => (
|
const ChangeMonthButton = (props: ChangeMonthButtonProps) => (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={props.onPress}
|
onPress={props.onPress}
|
||||||
className={
|
className="w-16 h-16 flex items-center justify-center mx-2 rounded-xl border border-solid"
|
||||||
"w-16 h-16 bg-white rounded-full flex items-center " +
|
|
||||||
"justify-center border border-solid border-1 mx-2"
|
|
||||||
}
|
|
||||||
style={{
|
style={{
|
||||||
|
backgroundColor: currentTheme.chatBot,
|
||||||
borderColor: currentTheme.primeFg,
|
borderColor: currentTheme.primeFg,
|
||||||
|
// iOS shadow
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 8,
|
||||||
|
// Android shadow
|
||||||
|
elevation: 6,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className="text-4xl">{props.title}</Text>
|
<Ionicons
|
||||||
|
name={props.icon}
|
||||||
|
size={48}
|
||||||
|
color={currentTheme.primeFg}
|
||||||
|
style={{
|
||||||
|
marginLeft: props.icon === "chevron-forward" ? 4 : 0,
|
||||||
|
marginRight: props.icon === "chevron-back" ? 4 : 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user