Amba-Agent-App/app/(root)/(tabs)/listrecipient.tsx
2026-01-16 00:22:35 +03:00

605 lines
20 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useCallback, useMemo } from "react";
import {
View,
Text,
TouchableOpacity,
Image,
Platform,
Pressable,
} from "react-native";
import { FlashList } from "@shopify/flash-list";
import { Button } from "~/components/ui/button";
import {
LucideChevronRight,
LucideUser,
LucidePlus,
LucideSlidersHorizontal,
} from "lucide-react-native";
import { ROUTES } from "~/lib/routes";
import { router } from "expo-router";
import ContactModal from "~/components/ui/contactModal";
import { Recipient } from "~/lib/services/recipientService";
import { Contact, useContactsStore, useRecipientsStore } from "~/lib/stores";
import TopBar from "~/components/ui/topBar";
import { Input } from "~/components/ui/input";
import ScreenWrapper from "~/components/ui/ScreenWrapper";
import ModalToast from "~/components/ui/toast";
import { useTranslation } from "react-i18next";
import Skeleton from "~/components/ui/skeleton";
// Contacts are only available on native platforms
const isContactsSupported = Platform.OS !== "web";
// Helper function to create initials - moved outside component
const getInitials = (name: string) => {
return name
.split(" ")
.map((word) => word.charAt(0).toUpperCase())
.slice(0, 2)
.join("");
};
// Individual Contact Card Component - Memoized for performance
const ContactCard = React.memo(
({ contact, onPress }: { contact: Contact; onPress: () => void }) => {
const { t } = useTranslation();
const primaryPhone = useMemo(
() =>
contact.phoneNumbers?.find((phone) => phone.isPrimary) ||
contact.phoneNumbers?.[0],
[contact.phoneNumbers]
);
const initials = useMemo(() => getInitials(contact.name), [contact.name]);
return (
<TouchableOpacity
className="flex flex-row justify-between w-full items-center py-4 bg-green-50 rounded-md px-3"
onPress={onPress}
style={{ minHeight: 74 }} // Add explicit minHeight
>
<View className="flex flex-row space-x-3 items-center flex-1">
<View
className="rounded-full bg-secondary w-12 h-12 flex items-center justify-center"
style={{ width: 48, height: 48 }}
>
{contact.imageAvailable && contact.image ? (
<Image
source={{ uri: contact.image.uri }}
className="w-12 h-12 rounded-full"
style={{ width: 48, height: 48 }}
resizeMode="cover"
/>
) : (
<Text className="text-white font-dmsans-medium text-lg">
{initials}
</Text>
)}
</View>
<View className="w-2" />
<View className="flex space-y-1 flex-1">
<Text className="font-dmsans text-secondary" numberOfLines={1}>
{contact.name}
</Text>
<Text
className="font-dmsans-medium text-primary text-sm"
numberOfLines={1}
>
{primaryPhone?.number || t("listrecipient.contactNoPhone")}
</Text>
</View>
</View>
<View className="flex space-y-1">
<Button className="rounded-md">
<LucideChevronRight color="#FFB84D" size={20} />
</Button>
</View>
</TouchableOpacity>
);
},
(prevProps, nextProps) => {
// Custom comparison function for better performance
return (
prevProps.contact.id === nextProps.contact.id &&
prevProps.contact.name === nextProps.contact.name &&
prevProps.contact.imageAvailable === nextProps.contact.imageAvailable &&
prevProps.contact.image?.uri === nextProps.contact.image?.uri &&
prevProps.contact.phoneNumbers?.length ===
nextProps.contact.phoneNumbers?.length
);
}
);
// Individual Saved Recipient (Client) Card Component - Memoized for performance
const SavedRecipientCard = React.memo(
({
recipient,
onPress,
clientType,
accountsLabel,
nextPaymentLabel,
}: {
recipient: Recipient;
onPress: () => void;
clientType: "Individual" | "Business";
accountsLabel: string;
nextPaymentLabel?: string;
}) => {
const initials = useMemo(
() => getInitials(recipient.fullName),
[recipient.fullName]
);
return (
<TouchableOpacity
className="flex flex-row justify-between w-full items-center py-4 bg-[#F1FCF5] mb-3 rounded-md px-3"
onPress={onPress}
style={{ minHeight: 80 }}
>
<View className="flex flex-row space-x-3 items-center flex-1">
<View className="rounded-full bg-primary w-12 h-12 flex items-center justify-center">
<Text className="text-white font-dmsans-medium text-lg">
{initials}
</Text>
</View>
<View className="w-2" />
<View className="flex space-y-1 flex-1">
<View className="flex flex-row items-center justify-between">
<Text className="font-dmsans text-secondary" numberOfLines={1}>
{recipient.fullName}
</Text>
<View className="px-2 py-[2px] rounded-md bg-[#FFB668] ml-2">
<Text className="text-[11px] font-dmsans-medium text-white">
{clientType}
</Text>
</View>
</View>
<Text
className="font-dmsans-medium text-primary text-xs"
numberOfLines={1}
>
{accountsLabel}
</Text>
{nextPaymentLabel && (
<Text
className="font-dmsans text-gray-500 text-[11px]"
numberOfLines={1}
>
{nextPaymentLabel}
</Text>
)}
</View>
</View>
<View className="flex space-y-1 ml-4">
<LucideChevronRight color="#FFB84D" size={20} />
</View>
</TouchableOpacity>
);
},
(prevProps, nextProps) => {
// Custom comparison function for better performance
return (
prevProps.recipient.id === nextProps.recipient.id &&
prevProps.recipient.fullName === nextProps.recipient.fullName &&
prevProps.recipient.phoneNumber === nextProps.recipient.phoneNumber
);
}
);
export default function ListRecip() {
const { contacts, loading, error, hasPermission, requestPermission } =
useContactsStore();
const {
recipients,
loading: recipientsLoading,
error: recipientsError,
} = useRecipientsStore();
const { t } = useTranslation();
const [selectedContact, setSelectedContact] = useState<Contact | null>(null);
const [modalVisible, setModalVisible] = useState(false);
const [searchQuery, setSearchQuery] = React.useState("");
const [toastVisible, setToastVisible] = React.useState(false);
const [toastTitle, setToastTitle] = React.useState("");
const [toastDescription, setToastDescription] = React.useState<
string | undefined
>(undefined);
const [toastVariant, setToastVariant] = React.useState<
"success" | "error" | "warning" | "info"
>("info");
const toastTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(
null
);
const showToast = (
title: string,
description?: string,
variant: "success" | "error" | "warning" | "info" = "info"
) => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
setToastTitle(title);
setToastDescription(description);
setToastVariant(variant);
setToastVisible(true);
toastTimeoutRef.current = setTimeout(() => {
setToastVisible(false);
toastTimeoutRef.current = null;
}, 2500);
};
React.useEffect(() => {
return () => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
};
}, []);
React.useEffect(() => {
if (error) {
showToast(
t("listrecipient.toastErrorTitle"),
t("listrecipient.toastContactsError"),
"error"
);
} else if (recipientsError) {
showToast(
t("listrecipient.toastErrorTitle"),
t("listrecipient.toastRecipientsError"),
"error"
);
}
}, [error, recipientsError, t]);
const handleContactPress = useCallback((contact: Contact) => {
setSelectedContact(contact);
setModalVisible(true);
}, []);
const handleCloseModal = useCallback(() => {
setModalVisible(false);
setSelectedContact(null);
}, []);
const handleSendMoney = useCallback(
(contact: Contact) => {
// TODO: Navigate to send money screen with selected contact/recipient
if (__DEV__) {
console.log(
"Send money to:",
contact.name,
"Phone:",
contact.phoneNumbers?.[0]?.number
);
}
// Check if this is a saved recipient or device contact
const isSavedRecipient = recipients.some(
(recipient) => recipient.id === contact.id
);
if (isSavedRecipient) {
// router.push(`/sendorrequestmoney?recipientId=${contact.id}`);
if (__DEV__) {
console.log("This is a saved recipient");
}
} else {
// router.push(`/sendorrequestmoney?contactId=${contact.id}`);
if (__DEV__) {
console.log("This is a device contact");
}
}
},
[recipients]
);
const handleRecipientPress = useCallback((recipient: Recipient) => {
router.push({
pathname: ROUTES.RECIPIENT_DETAIL,
params: { recipientId: recipient.id },
});
}, []);
// Memoized renderItem function for FlashList performance
const renderContactItem = useCallback(
({ item }: { item: Contact }) => (
<ContactCard contact={item} onPress={() => handleContactPress(item)} />
),
[handleContactPress]
);
// Memoized renderItem for saved recipients
const renderRecipientItem = useCallback(
({ item, index }: { item: Recipient; index: number }) => {
// Simple UI-only client typing and schedule data
const lowerName = item.fullName.toLowerCase();
const isBusiness =
lowerName.includes("ltd") ||
lowerName.includes("plc") ||
lowerName.includes("inc") ||
lowerName.includes("company");
const clientType: "Individual" | "Business" = isBusiness
? "Business"
: "Individual";
const accountsCount = (index % 3) + 1; // 13 accounts, UI-only
const accountsLabel =
accountsCount === 1 ? "1 account" : `${accountsCount} accounts`;
// Give every 2nd/3rd client a dummy schedule label
let nextPaymentLabel: string | undefined;
if (index % 3 === 1) {
nextPaymentLabel = "Next payment: Every Monday · 10:00";
} else if (index % 3 === 2) {
nextPaymentLabel = "Next payment: Monthly, 1st · 09:00";
}
return (
<SavedRecipientCard
recipient={item}
onPress={() => handleRecipientPress(item)}
clientType={clientType}
accountsLabel={accountsLabel}
nextPaymentLabel={nextPaymentLabel}
/>
);
},
[handleRecipientPress]
);
// Memoized ItemSeparator component
const ItemSeparator = useCallback(() => <View className="h-2" />, []);
// Memoized keyExtractor
const keyExtractor = useCallback((item: Contact) => item.id, []);
// Memoized keyExtractor for recipients
const recipientKeyExtractor = useCallback((item: Recipient) => item.id, []);
const normalizedSearch = searchQuery.trim().toLowerCase();
const filteredContacts = useMemo(() => {
if (!normalizedSearch) return contacts;
return contacts.filter((contact) => {
const name = contact.name?.toLowerCase() ?? "";
const phones = (contact.phoneNumbers ?? [])
.map((p) => p.number)
.join(" ")
.toLowerCase();
return (
name.includes(normalizedSearch) || phones.includes(normalizedSearch)
);
});
}, [contacts, normalizedSearch]);
const filteredRecipients = useMemo(() => {
if (!normalizedSearch) return recipients;
return recipients.filter((recipient) => {
const name = recipient.fullName.toLowerCase();
const phone = recipient.phoneNumber.toLowerCase();
return (
name.includes(normalizedSearch) || phone.includes(normalizedSearch)
);
});
}, [recipients, normalizedSearch]);
const renderContent = () => {
if (loading) {
return (
<View className="space-y-3 py-2 w-full">
{Array.from({ length: 8 }).map((_, index) => (
<View
key={index}
className="flex flex-row items-center w-full py-2"
>
<Skeleton width={48} height={48} radius={24} />
<View style={{ width: 12 }} />
<View className="flex-1">
<Skeleton width="70%" height={14} radius={4} />
<View style={{ height: 8 }} />
<Skeleton width="40%" height={12} radius={4} />
</View>
</View>
))}
</View>
);
}
if (error) {
return (
<View className="flex items-center justify-center py-10">
<Text className="text-red-500 font-dmsans">
{t("listrecipient.contactsErrorTitle")}
</Text>
<Text className="text-gray-400 font-dmsans-medium text-sm mt-1 text-center px-4">
{error}
</Text>
</View>
);
}
if (!hasPermission) {
return (
<View className="flex items-center justify-center py-10">
<LucideUser color="#D1D5DB" size={48} />
<Text className="text-gray-500 font-dmsans mt-4">
{t("listrecipient.contactsPermissionTitle")}
</Text>
<Text className="text-gray-400 font-dmsans-medium text-sm mt-1 text-center px-4">
{t("listrecipient.contactsPermissionSubtitle")}
</Text>
<Button
className="bg-primary rounded-md mt-4 px-6"
onPress={requestPermission}
>
<Text className="text-white font-dmsans-medium">
{t("listrecipient.contactsPermissionButton")}
</Text>
</Button>
</View>
);
}
if (contacts.length === 0) {
return (
<View className="flex items-center justify-center py-10">
<LucideUser color="#D1D5DB" size={48} />
<Text className="text-gray-500 font-dmsans mt-4">
{t("listrecipient.contactsEmptyTitle")}
</Text>
<Text className="text-gray-400 font-dmsans-medium text-sm mt-1 text-center px-4">
{t("listrecipient.contactsEmptySubtitle")}
</Text>
</View>
);
}
return (
<FlashList
data={filteredContacts}
keyExtractor={keyExtractor}
renderItem={renderContactItem}
// @ts-expect-error - estimatedItemSize is valid prop but types may be incomplete
estimatedItemSize={82} // Increased: 74 (item) + 8 (separator) = 82
ItemSeparatorComponent={ItemSeparator}
contentContainerStyle={{ paddingBottom: 16 }}
drawDistance={500} // Pre-render items 500px outside viewport
/>
);
};
return (
<ScreenWrapper edges={["bottom"]}>
<TopBar />
<View className="flex flex-col space-y-1 px-5 py-5 items-start">
<Text className="text-xl font-dmsans text-primary">
{t("listrecipient.title")}
</Text>
</View>
<View className="flex-1 flex flex-col items-left px-5 w-full">
<View className="w-full mb-4">
<Input
value={searchQuery}
onChangeText={setSearchQuery}
placeholderText={t("listrecipient.searchPlaceholder")}
containerClassName="w-full"
borderClassName="border-[#D9DBE9] bg-white"
placeholderColor="#7E7E7E"
textClassName="text-[#000] text-sm"
rightIcon={<LucideSlidersHorizontal color="#9CA3AF" size={18} />}
/>
</View>
{/* Add Recipient Button */}
<View className="flex flex-row items-center space-x-2 mb-6 w-full">
<Button
className="flex flex-row items-center space-x-2 bg-primary rounded-md p-3 w-full"
onPress={() => router.push(ROUTES.ADD_RECIPIENT)}
>
<LucidePlus className="w-[20px] h-[20px]" color="#FFB84D" />
<View className="w-2" />
<Text className="text-white text-base font-dmsans-medium">
{t("listrecipient.addButton")}
</Text>
</Button>
</View>
{/* Saved Recipients List */}
<View className="flex flex-col w-full mb-8">
<Text className="text-lg font-dmsans-medium text-gray-800 mb-4">
{t("listrecipient.savedRecipientsTitle", {
count: recipients.length,
})}
</Text>
{recipientsLoading ? (
<View className="space-y-3 py-2 w-full">
{Array.from({ length: 4 }).map((_, index) => (
<View
key={index}
className="flex flex-row items-center w-full py-2"
>
<Skeleton width={48} height={48} radius={24} />
<View style={{ width: 12 }} />
<View className="flex-1">
<Skeleton width="65%" height={14} radius={4} />
<View style={{ height: 8 }} />
<Skeleton width="35%" height={12} radius={4} />
</View>
</View>
))}
</View>
) : recipientsError ? (
<View className="flex items-center justify-center py-4">
<Text className="text-red-500 font-dmsans">
{t("listrecipient.savedRecipientsError")}
</Text>
</View>
) : recipients.length === 0 ? (
<View className="flex items-center justify-center py-4">
<Text className="text-gray-500 font-dmsans">
{t("listrecipient.savedRecipientsEmpty")}
</Text>
</View>
) : (
<View className="space-y-2">
{filteredRecipients.map((recipient, index) => {
const lowerName = recipient.fullName.toLowerCase();
const isBusiness =
lowerName.includes("ltd") ||
lowerName.includes("plc") ||
lowerName.includes("inc") ||
lowerName.includes("company");
const clientType: "Individual" | "Business" = isBusiness
? "Business"
: "Individual";
const accountsCount = (index % 3) + 1;
const accountsLabel =
accountsCount === 1
? "1 account"
: `${accountsCount} accounts`;
let nextPaymentLabel: string | undefined;
if (index % 3 === 1) {
nextPaymentLabel = "Next payment: Every Monday · 10:00";
} else if (index % 3 === 2) {
nextPaymentLabel = "Next payment: Monthly, 1st · 09:00";
}
return (
<SavedRecipientCard
key={recipient.id}
recipient={recipient}
onPress={() => handleRecipientPress(recipient)}
clientType={clientType}
accountsLabel={accountsLabel}
nextPaymentLabel={nextPaymentLabel}
/>
);
})}
</View>
)}
</View>
{/* Contacts list hidden for Agent app */}
<View className="h-20" />
</View>
{/* Contact Modal */}
<ContactModal
visible={modalVisible}
contact={selectedContact}
onClose={handleCloseModal}
/>
<ModalToast
visible={toastVisible}
title={toastTitle}
description={toastDescription}
variant={toastVariant}
/>
</ScreenWrapper>
);
}