193 lines
7.7 KiB
TypeScript
193 lines
7.7 KiB
TypeScript
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
import {
|
|
View, Text, TouchableOpacity,
|
|
ActivityIndicator, LayoutAnimation, useColorScheme,
|
|
} from "react-native";
|
|
import { Ionicons } from "@expo/vector-icons";
|
|
import { Entry, entriesApi } from "../services/api";
|
|
import ExpandableItem from "./ExpandableItem";
|
|
|
|
// ─── Props ─────────────────────────────────────────────────────────────────
|
|
// items removed — now fetched from API using groupName
|
|
|
|
export interface CollapsibleCategoryProps {
|
|
id: string;
|
|
label: string;
|
|
icon: keyof typeof Ionicons.glyphMap;
|
|
color: string;
|
|
darkColor: string;
|
|
groupName: string; // passed to /entries?groupName=
|
|
defaultOpen?: boolean;
|
|
}
|
|
|
|
// ─── Component ─────────────────────────────────────────────────────────────
|
|
|
|
export default function CollapsibleCategory({
|
|
label,
|
|
icon,
|
|
color,
|
|
darkColor,
|
|
groupName,
|
|
defaultOpen = false,
|
|
}: CollapsibleCategoryProps) {
|
|
const dark = useColorScheme() === "dark";
|
|
const accentColor = dark ? darkColor : color;
|
|
|
|
const [open, setOpen] = useState(defaultOpen);
|
|
const [items, setItems] = useState<Entry[]>([]);
|
|
const [page, setPage] = useState(0);
|
|
const [hasMore, setHasMore] = useState(true);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState("");
|
|
|
|
const initialised = useRef(false);
|
|
|
|
const fetchPage = useCallback(async (pageNum: number) => {
|
|
if (loading) return;
|
|
setLoading(true);
|
|
setError("");
|
|
try {
|
|
const data = await entriesApi.getEntries({ groupName, page: pageNum });
|
|
setItems((prev) =>
|
|
pageNum === 0 ? data.content : [...prev, ...data.content]
|
|
);
|
|
setHasMore(!data.last);
|
|
setPage(pageNum);
|
|
} catch {
|
|
setError("Couldn't load entries. Tap to retry.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [groupName, loading]);
|
|
|
|
// Fetch first page the first time the accordion opens
|
|
useEffect(() => {
|
|
if (open && !initialised.current) {
|
|
initialised.current = true;
|
|
fetchPage(0);
|
|
}
|
|
}, [open]);
|
|
|
|
const toggle = () => {
|
|
LayoutAnimation.configureNext({
|
|
duration: 260,
|
|
create: { type: "easeInEaseOut", property: "opacity" },
|
|
update: { type: "spring", springDamping: 0.85 },
|
|
});
|
|
setOpen((v) => !v);
|
|
};
|
|
|
|
return (
|
|
<View className="mb-4">
|
|
{/* ── Header — untouched from your original ── */}
|
|
<TouchableOpacity
|
|
onPress={toggle}
|
|
activeOpacity={0.8}
|
|
className={`
|
|
mx-4 flex-row items-center px-4 py-3.5 rounded-2xl
|
|
${dark
|
|
? "bg-obsidian-50 border border-border-dark"
|
|
: "bg-parchment-100 border border-border-light"
|
|
}
|
|
`}
|
|
>
|
|
<View
|
|
className="w-8 h-8 rounded-xl items-center justify-center mr-3"
|
|
style={{ backgroundColor: accentColor + "25" }}
|
|
>
|
|
<Ionicons name={icon} size={16} color={accentColor} />
|
|
</View>
|
|
|
|
<Text
|
|
className={`flex-1 text-sm font-sans font-bold tracking-wide ${
|
|
dark ? "text-ink-dark" : "text-ink-light"
|
|
}`}
|
|
>
|
|
{label}
|
|
</Text>
|
|
|
|
{/* Count badge — shows loaded count, spins while first load */}
|
|
<View
|
|
className="px-2 py-0.5 rounded-full mr-3"
|
|
style={{ backgroundColor: accentColor + "20" }}
|
|
>
|
|
{loading && items.length === 0 ? (
|
|
<ActivityIndicator size="small" color={accentColor} style={{ width: 24 }} />
|
|
) : (
|
|
<Text
|
|
className="text-xs font-sans font-semibold"
|
|
style={{ color: accentColor }}
|
|
>
|
|
{items.length}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
|
|
<Ionicons
|
|
name={open ? "chevron-up" : "chevron-down"}
|
|
size={16}
|
|
color={dark ? "#706D67" : "#8A8278"}
|
|
/>
|
|
</TouchableOpacity>
|
|
|
|
{/* ── Body ── */}
|
|
{open && (
|
|
<View className="mt-2">
|
|
{/* Error */}
|
|
{error !== "" && (
|
|
<TouchableOpacity
|
|
onPress={() => fetchPage(page)}
|
|
className="mx-4 py-4 items-center"
|
|
>
|
|
<Text className={`text-sm font-sans ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
|
{error}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
|
|
{/* Items — same ExpandableItem you already have */}
|
|
{items.map((item) => (
|
|
<ExpandableItem key={item.id} item={item} />
|
|
))}
|
|
|
|
{/* Load more */}
|
|
{hasMore && items.length > 0 && (
|
|
<TouchableOpacity
|
|
onPress={() => fetchPage(page + 1)}
|
|
disabled={loading}
|
|
className={`mx-4 mt-1 mb-1 py-3 rounded-2xl border items-center ${
|
|
dark
|
|
? "bg-obsidian-50 border-border-dark"
|
|
: "bg-parchment-100 border-border-light"
|
|
}`}
|
|
activeOpacity={0.7}
|
|
>
|
|
{loading ? (
|
|
<ActivityIndicator size="small" color={accentColor} />
|
|
) : (
|
|
<View className="flex-row items-center gap-1.5">
|
|
<Ionicons name="chevron-down" size={13} color={accentColor} />
|
|
<Text
|
|
className="text-xs font-sans font-semibold"
|
|
style={{ color: accentColor }}
|
|
>
|
|
Load more
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</TouchableOpacity>
|
|
)}
|
|
|
|
{/* End of list */}
|
|
{!hasMore && items.length > 0 && (
|
|
<Text className={`text-center text-xs font-sans py-3 ${
|
|
dark ? "text-muted-dark" : "text-muted-light"
|
|
}`}>
|
|
All {items.length} entries loaded
|
|
</Text>
|
|
)}
|
|
</View>
|
|
)}
|
|
</View>
|
|
);
|
|
} |