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:
@@ -2,6 +2,7 @@ type Theme = {
|
||||
chatBot: string;
|
||||
primeFg: string;
|
||||
primeBg: string;
|
||||
secondaryBg: string;
|
||||
messageBorderBg: string;
|
||||
placeholderBg: string;
|
||||
calenderBg: string;
|
||||
@@ -20,6 +21,7 @@ const defaultLight: Theme = {
|
||||
chatBot: "#DE6C20",
|
||||
primeFg: "#3B3329",
|
||||
primeBg: "#FFEEDE",
|
||||
secondaryBg: "#FFFFFF",
|
||||
messageBorderBg: "#FFFFFF",
|
||||
placeholderBg: "#D9D9D9",
|
||||
calenderBg: "#FBD5B2",
|
||||
|
||||
@@ -18,13 +18,49 @@ import React, {
|
||||
useState,
|
||||
} from "react";
|
||||
import { useFocusEffect } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import currentTheme from "../../Themes";
|
||||
import BaseBackground from "../../components/BaseBackground";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { EventService } from "../../services";
|
||||
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 [monthIndex, setMonthIndex] = useState(new Date().getMonth());
|
||||
@@ -149,6 +185,8 @@ const Calendar = () => {
|
||||
changeMonth={changeMonth}
|
||||
monthIndex={monthIndex}
|
||||
currentYear={currentYear}
|
||||
setMonthIndex={setMonthIndex}
|
||||
setYear={setCurrentYear}
|
||||
/>
|
||||
<WeekDaysLine />
|
||||
<CalendarGrid
|
||||
@@ -268,49 +306,74 @@ type MonthSelectorProps = {
|
||||
modalVisible: boolean;
|
||||
onClose: () => void;
|
||||
position: { top: number; left: number; width: number };
|
||||
currentYear: number;
|
||||
currentMonthIndex: number;
|
||||
onSelectMonth: (year: number, monthIndex: number) => void;
|
||||
};
|
||||
|
||||
const MonthSelector = ({
|
||||
modalVisible,
|
||||
onClose,
|
||||
position,
|
||||
currentYear,
|
||||
currentMonthIndex,
|
||||
onSelectMonth,
|
||||
}: 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 listRef = useRef<React.ComponentRef<typeof FlashList<MonthItem>>>(null);
|
||||
const INITIAL_RANGE = 12; // 12 months before and after current
|
||||
|
||||
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);
|
||||
const [monthSelectorData, setMonthSelectorData] = useState<MonthItem[]>(() =>
|
||||
generateMonths(currentYear, currentMonthIndex, INITIAL_RANGE),
|
||||
);
|
||||
|
||||
// Reset data when current month changes
|
||||
useEffect(() => {
|
||||
setMonthSelectorData(
|
||||
generateMonths(currentYear, currentMonthIndex, INITIAL_RANGE),
|
||||
);
|
||||
}, [currentYear, currentMonthIndex]);
|
||||
|
||||
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 (direction === "start") {
|
||||
newMonths.unshift(newMonth);
|
||||
} else {
|
||||
newMonths.push(newMonth);
|
||||
}
|
||||
}
|
||||
}
|
||||
// add new data
|
||||
if (appendToStart) {
|
||||
setMonthSelectorData([...newData, ...monthSelectorData]);
|
||||
} else {
|
||||
setMonthSelectorData([...monthSelectorData, ...newData]);
|
||||
}
|
||||
|
||||
return direction === "start"
|
||||
? [...newMonths, ...prevData]
|
||||
: [...prevData, ...newMonths];
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -321,15 +384,29 @@ const MonthSelector = ({
|
||||
useNativeDriver: false,
|
||||
}).start();
|
||||
} else {
|
||||
// reset on close
|
||||
heightAnim.setValue(0);
|
||||
}
|
||||
}, [modalVisible]);
|
||||
}, [modalVisible, heightAnim]);
|
||||
|
||||
const renderItem = ({ item }: { item: ItemType }) => (
|
||||
<Pressable>
|
||||
<View className="w-full flex justify-center items-center">
|
||||
<Text className="text-3xl">{item.text}</Text>
|
||||
const renderItem = ({ item }: { item: MonthItem }) => (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
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>
|
||||
</Pressable>
|
||||
);
|
||||
@@ -341,30 +418,31 @@ const MonthSelector = ({
|
||||
animationType="none"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<Pressable className="flex-1" onPress={onClose}>
|
||||
<Pressable className="flex-1 rounded-lg" onPress={onClose}>
|
||||
<Animated.View
|
||||
className="absolute bg-white border-2 border-solid rounded-lg overflow-hidden"
|
||||
className="absolute overflow-hidden"
|
||||
style={{
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
width: position.width,
|
||||
height: heightAnim,
|
||||
backgroundColor: currentTheme.primeBg,
|
||||
borderWidth: 2,
|
||||
borderColor: currentTheme.borderPrimary,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<FlashList
|
||||
className="w-full"
|
||||
style={{ borderRadius: 8 }}
|
||||
ref={listRef}
|
||||
keyExtractor={(item) => item.id}
|
||||
data={monthSelectorData}
|
||||
initialScrollIndex={5}
|
||||
initialScrollIndex={INITIAL_RANGE}
|
||||
onEndReachedThreshold={0.5}
|
||||
onEndReached={() =>
|
||||
appendToTestData(monthSelectorData.length, 10, false)
|
||||
}
|
||||
onEndReached={() => appendMonths("end", 12)}
|
||||
onStartReachedThreshold={0.5}
|
||||
onStartReached={() =>
|
||||
appendToTestData(monthSelectorData.length, 10, true)
|
||||
}
|
||||
onStartReached={() => appendMonths("start", 12)}
|
||||
renderItem={renderItem}
|
||||
/>
|
||||
</Animated.View>
|
||||
@@ -377,6 +455,8 @@ type CalendarHeaderProps = {
|
||||
changeMonth: (delta: number) => void;
|
||||
monthIndex: number;
|
||||
currentYear: number;
|
||||
setMonthIndex: (index: number) => void;
|
||||
setYear: (year: number) => void;
|
||||
};
|
||||
|
||||
const CalendarHeader = (props: CalendarHeaderProps) => {
|
||||
@@ -400,54 +480,82 @@ const CalendarHeader = (props: CalendarHeaderProps) => {
|
||||
|
||||
return (
|
||||
<Header className="flex flex-row items-center justify-between">
|
||||
<ChangeMonthButton onPress={prevMonth} title={"<"} />
|
||||
<ChangeMonthButton onPress={prevMonth} icon="chevron-back" />
|
||||
<View
|
||||
ref={containerRef}
|
||||
className="relative flex flex-row items-center justify-around"
|
||||
>
|
||||
<Text className="text-4xl">
|
||||
<Text className="text-4xl px-1">
|
||||
{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"
|
||||
}
|
||||
className="flex justify-center items-center w-12 h-12 border rounded-lg"
|
||||
style={{
|
||||
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}
|
||||
>
|
||||
<Text className="text-4xl">v</Text>
|
||||
<Ionicons
|
||||
name="chevron-down"
|
||||
size={28}
|
||||
color={currentTheme.primeFg}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
<MonthSelector
|
||||
modalVisible={modalVisible}
|
||||
onClose={() => setModalVisible(false)}
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
type ChangeMonthButtonProps = {
|
||||
onPress: () => void;
|
||||
title: string;
|
||||
icon: "chevron-back" | "chevron-forward";
|
||||
};
|
||||
|
||||
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"
|
||||
}
|
||||
className="w-16 h-16 flex items-center justify-center mx-2 rounded-xl border border-solid"
|
||||
style={{
|
||||
backgroundColor: currentTheme.chatBot,
|
||||
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>
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user