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

897 lines
29 KiB
TypeScript

import React, { useState, useEffect, useRef } from "react";
import {
ScrollView,
View,
Keyboard,
TouchableOpacity,
Image,
Alert,
ActivityIndicator,
} from "react-native";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Text } from "~/components/ui/text";
import { router } from "expo-router";
import { useAuthWithProfile } from "~/lib/hooks/useAuthWithProfile";
import { ROUTES } from "~/lib/routes";
import { profileUpdateSchema, validate } from "~/lib/utils/validationSchemas";
import BackButton from "~/components/ui/backButton";
import ScreenWrapper from "~/components/ui/ScreenWrapper";
import ModalToast from "~/components/ui/toast";
import { useTranslation } from "react-i18next";
import Dropdown, { DropdownOption } from "~/components/ui/dropdown";
import { useLangStore } from "~/lib/stores";
import { Copy, Plus, ChevronDown } from "lucide-react-native";
import * as Clipboard from "expo-clipboard";
import * as ImagePicker from "expo-image-picker";
import { Icons } from "~/assets/icons";
import { AuthService } from "~/lib/services/authServices";
import { uploadProfileImage } from "~/lib/services/profileImageService";
import BottomSheet from "~/components/ui/bottomSheet";
type ProfileLinkedAccount = {
id: string;
bankId: string;
bankName: string;
accountNumber: string;
isDefault: boolean;
};
const PROFILE_BANK_OPTIONS: { id: string; name: string }[] = [
{ id: "cbe", name: "Commercial Bank of Ethiopia" },
{ id: "dashen", name: "Dashen Bank" },
{ id: "abay", name: "Abay Bank" },
{ id: "awash", name: "Awash Bank" },
{ id: "hibret", name: "Hibret Bank" },
{ id: "telebirr", name: "Ethio Telecom (Telebirr)" },
{ id: "safaricom", name: "Safaricom M-PESA" },
];
export default function EditProfile() {
const { t } = useTranslation();
const language = useLangStore((state) => state.language);
const setLanguage = useLangStore((state) => state.setLanguage);
const { user, profile, wallet, profileLoading, profileError } =
useAuthWithProfile();
const [updateLoading, setUpdateLoading] = useState(false);
const [updateError, setUpdateError] = useState<string | null>(null);
const [profileImage, setProfileImage] = useState<string | null>(null);
const [imagePicking, setImagePicking] = useState(false);
// Editable state for form fields
const [editedProfile, setEditedProfile] = useState({
fullName: "",
phoneNumber: "",
email: "",
address: "",
});
const [profileAccounts, setProfileAccounts] = useState<
ProfileLinkedAccount[]
>([]);
const [isSelectAccountSheetVisible, setIsSelectAccountSheetVisible] =
useState(false);
const [isAddingAccount, setIsAddingAccount] = useState(false);
const [selectedBank, setSelectedBank] = useState<string | null>(null);
const [accountNumberInput, setAccountNumberInput] = useState("");
const [savingAccount, setSavingAccount] = useState(false);
const [pendingDefaultAccountId, setPendingDefaultAccountId] = useState<
string | null
>(null);
const [toastVisible, setToastVisible] = useState(false);
const [toastTitle, setToastTitle] = useState("");
const [toastDescription, setToastDescription] = useState<string | undefined>(
undefined
);
const [toastVariant, setToastVariant] = useState<
"success" | "error" | "warning" | "info"
>("info");
const toastTimeoutRef = 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);
};
useEffect(() => {
return () => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
};
}, []);
// Update local state when profile loads
useEffect(() => {
if (profile) {
setEditedProfile({
fullName: profile.fullName || "",
phoneNumber: profile.phoneNumber || "",
email: profile.email || "",
address: profile.address || "",
});
// Only sync image from profile if user hasn't picked a local file
setProfileImage((prev) => {
if (prev && prev.startsWith("file:")) {
return prev;
}
return profile.photoUrl || null;
});
const linkedFromProfile: any = (profile as any).linkedAccounts;
if (Array.isArray(linkedFromProfile)) {
const mapped: ProfileLinkedAccount[] = linkedFromProfile.map(
(acc: any) => ({
id: String(acc.id),
bankId: String(acc.bankId || ""),
bankName: String(acc.bankName || ""),
accountNumber: String(acc.accountNumber || ""),
isDefault: !!acc.isDefault,
})
);
setProfileAccounts(mapped);
} else {
setProfileAccounts([]);
}
}
}, [profile]);
const handleSelectProfileImage = async () => {
try {
setImagePicking(true);
const permissionResult =
await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permissionResult.granted) {
Alert.alert(
"Permission Required",
"Please allow access to your photo library to select a profile picture."
);
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
aspect: [1, 1],
quality: 0.8,
});
if (!result.canceled && result.assets[0]) {
const localUri = result.assets[0].uri;
console.log("[EditProfile] Image selected locally", { localUri });
// Only update local preview here. Actual upload happens on Save.
setProfileImage(localUri);
}
} catch (e) {
console.error("[EditProfile] Error while selecting profile image", e);
showToast("Error", "Failed to select image", "error");
} finally {
setImagePicking(false);
}
};
const handleUpdateProfile = async () => {
Keyboard.dismiss();
if (!user?.uid) {
showToast(
t("profile.toastErrorTitle"),
t("profile.toastUserNotFound"),
"error"
);
return;
}
// Prepare data for validation based on signup type
const validationData: any = {
fullName: editedProfile.fullName,
address: editedProfile.address || undefined,
};
// Only validate phone number if user didn't sign up with phone
if (profile?.signupType !== "phone") {
validationData.phoneNumber = editedProfile.phoneNumber || undefined;
}
// Only validate email if user signed up with phone
if (profile?.signupType === "phone") {
validationData.email = editedProfile.email || undefined;
}
// Validate form using valibot
const validationResult = validate(profileUpdateSchema, validationData);
if (!validationResult.success) {
showToast("Error", validationResult.error, "warning");
return;
}
try {
setUpdateLoading(true);
setUpdateError(null);
// If the user picked a new local image (file://), upload it first
let finalPhotoUrl: string | undefined = profile?.photoUrl || undefined;
if (
profileImage &&
profileImage !== profile?.photoUrl &&
profileImage.startsWith("file:")
) {
try {
console.log("[EditProfile] Uploading profile image on save", {
uid: user.uid,
localUri: profileImage,
});
const uploadedUrl = await uploadProfileImage(user.uid, profileImage);
console.log(
"[EditProfile] Profile image uploaded on save, url:",
uploadedUrl
);
finalPhotoUrl = uploadedUrl;
setProfileImage(uploadedUrl);
} catch (e) {
console.error(
"[EditProfile] Failed to upload profile image on save",
e
);
throw new Error(
e instanceof Error ? e.message : "Failed to upload profile image"
);
}
}
// Prepare update data based on signup type
const updateData: any = {
fullName: validationResult.data.fullName,
address: validationResult.data.address || undefined,
};
if (finalPhotoUrl) {
updateData.photoUrl = finalPhotoUrl;
}
// Only allow phone number update if user didn't sign up with phone
if (
profile?.signupType !== "phone" &&
validationResult.data.phoneNumber
) {
updateData.phoneNumber = validationResult.data.phoneNumber;
}
// Only allow email update if user signed up with phone
if (profile?.signupType === "phone" && validationResult.data.email) {
updateData.email = validationResult.data.email;
}
const result = await AuthService.updateUserProfile(user.uid, updateData);
if (!result.success) {
throw new Error(result.error || "Failed to update profile");
}
showToast(
t("profile.toastProfileUpdatedTitle"),
t("profile.toastProfileUpdatedDescription"),
"success"
);
// Navigate back to profile after successful update
setTimeout(() => {
router.back();
}, 1000);
} catch (error) {
setUpdateError(
error instanceof Error ? error.message : "Failed to update profile"
);
showToast(
t("profile.toastErrorTitle"),
t("profile.toastUpdateErrorDescription"),
"error"
);
} finally {
setUpdateLoading(false);
}
};
const handleCancel = () => {
router.back();
};
const handleCopyUsername = async () => {
if (
!usernameDisplay ||
usernameDisplay === t("profile.usernamePlaceholder")
) {
showToast("Error", "No username to copy", "error");
return;
}
await Clipboard.setStringAsync(usernameDisplay);
showToast("Copied", "Username copied to clipboard", "success");
};
const handleStartAddAccount = () => {
if (profileAccounts.length >= 5) {
showToast("Limit reached", "You can link up to 5 accounts.", "warning");
return;
}
setIsSelectAccountSheetVisible(false);
setSelectedBank(null);
setAccountNumberInput("");
setIsAddingAccount(true);
};
const handleSelectBank = (bankId: string) => {
setSelectedBank(bankId);
};
const handleSaveAccount = async () => {
if (!selectedBank || !accountNumberInput.trim()) {
return;
}
if (profileAccounts.length >= 5) {
showToast("Limit reached", "You can link up to 5 accounts.", "warning");
return;
}
const bank = PROFILE_BANK_OPTIONS.find((b) => b.id === selectedBank);
if (!bank) return;
const newAccount: ProfileLinkedAccount = {
id: `${selectedBank}-${Date.now()}`,
bankId: selectedBank,
bankName: bank.name,
accountNumber: accountNumberInput.trim(),
isDefault: profileAccounts.length === 0,
};
const updatedAccounts = [...profileAccounts, newAccount];
setProfileAccounts(updatedAccounts);
if (!user?.uid) {
showToast(
t("profile.toastErrorTitle"),
t("profile.toastUserNotFound"),
"error"
);
return;
}
try {
setSavingAccount(true);
const result = await AuthService.updateUserProfile(user.uid, {
linkedAccounts: updatedAccounts,
});
if (!result.success) {
throw new Error(result.error || "Failed to save account");
}
} catch (error) {
showToast(
t("profile.toastErrorTitle"),
error instanceof Error ? error.message : "Failed to save account",
"error"
);
} finally {
setSavingAccount(false);
}
setAccountNumberInput("");
setSelectedBank(null);
setIsAddingAccount(false);
};
const handleConfirmDefaultAccount = async () => {
if (!user?.uid) {
showToast(
t("profile.toastErrorTitle"),
t("profile.toastUserNotFound"),
"error"
);
return;
}
if (!pendingDefaultAccountId) {
setIsSelectAccountSheetVisible(false);
return;
}
const updatedAccounts = profileAccounts.map((account) => ({
...account,
isDefault: account.id === pendingDefaultAccountId,
}));
setProfileAccounts(updatedAccounts);
try {
const result = await AuthService.updateUserProfile(user.uid, {
linkedAccounts: updatedAccounts,
});
if (!result.success) {
throw new Error(result.error || "Failed to update default account");
}
} catch (error) {
showToast(
t("profile.toastErrorTitle"),
error instanceof Error
? error.message
: "Failed to update default account",
"error"
);
} finally {
setIsSelectAccountSheetVisible(false);
setPendingDefaultAccountId(null);
}
};
// Show update error
useEffect(() => {
if (updateError) {
showToast(t("profile.toastUpdateErrorTitle"), updateError, "error");
}
}, [updateError]);
const languageOptions: DropdownOption[] = [
{ value: "en", label: t("profile.languageOptionEnglish") },
{ value: "am", label: t("profile.languageOptionAmharic") },
{ value: "fr", label: t("profile.languageOptionFrench") },
{ value: "ti", label: t("profile.languageOptionTigrinya") },
{ value: "om", label: t("profile.languageOptionOromo") },
];
const isTelecomWallet =
selectedBank === "telebirr" || selectedBank === "safaricom";
const accountLabel = isTelecomWallet ? "Phone Number" : "Account Number";
const accountPlaceholder = isTelecomWallet
? "Enter phone number"
: "Enter account number";
const defaultAccount =
profileAccounts.find((account) => account.isDefault) || null;
const usernameDisplay = profile?.fullName
? `@${profile.fullName.split(" ")[0]}`
: user?.email
? `@${user.email.split("@")[0]}`
: t("profile.usernamePlaceholder");
return (
<ScreenWrapper edges={[]}>
<BackButton />
<ScrollView
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
showsVerticalScrollIndicator={false}
>
<View className="flex h-[100%] justify-between px-5 space-y-3 w-full ">
<View className="py-8">
<Text className="text-xl font-bold font-dmsans">Edit Profile</Text>
</View>
<View className="flex flex-col space-y-3 pt-5 ">
<View className="items-center mb-4">
<TouchableOpacity
onPress={handleSelectProfileImage}
activeOpacity={0.8}
className="relative"
>
<View className="w-24 h-24 rounded-full bg-[#C8E6C9] items-center justify-center overflow-hidden relative">
{profileImage ? (
<Image
source={{ uri: profileImage }}
className="w-24 h-24 rounded-full"
resizeMode="cover"
/>
) : (
<Image
source={Icons.avatar}
style={{ width: 84, height: 84, resizeMode: "contain" }}
/>
)}
{(updateLoading || imagePicking) && (
<View className="absolute inset-0 items-center justify-center bg-black/20">
<ActivityIndicator color="#ffffff" />
</View>
)}
</View>
<View className="absolute bottom-0 -right-2 w-8 h-8 rounded-full bg-primary items-center justify-center border-2 border-white">
<Plus size={16} color="#fff" strokeWidth={3} />
</View>
</TouchableOpacity>
</View>
<View className="flex flex-col space-y-1 py-2 items-left">
<Text className="text-base font-dmsans text-gray-400">
{profileLoading ? t("profile.loadingProfile") : ""}
</Text>
{profileError && (
<Text className="text-sm font-dmsans text-red-500">
{t("profile.errorWithMessage", { error: profileError })}
</Text>
)}
</View>
<Label className="text-base font-dmsans-medium">
{t("profile.fullNameLabel")}
</Label>
<View className="h-2" />
<Input
placeholder={t("profile.fullNamePlaceholder")}
value={editedProfile.fullName}
onChangeText={(text) =>
setEditedProfile((prev) => ({ ...prev, fullName: text }))
}
containerClassName="w-full"
borderClassName="border-[#D9DBE9] bg-white"
placeholderColor="#7E7E7E"
textClassName="text-[#000] text-sm"
/>
<View className="h-2" />
<Label className="text-base font-dmsans-medium">
{t("profile.addressLabel")}
</Label>
<View className="h-2" />
<Input
placeholder={t("profile.addressPlaceholder")}
value={editedProfile.address}
onChangeText={(text) =>
setEditedProfile((prev) => ({ ...prev, address: text }))
}
containerClassName="w-full"
borderClassName="border-[#D9DBE9] bg-white"
placeholderColor="#7E7E7E"
textClassName="text-[#000] text-sm"
/>
<View className="h-2" />
<Label className="text-base font-dmsans-medium">
{t("profile.phoneLabel")}
</Label>
<View className="h-2" />
<Input
placeholder={t("profile.phonePlaceholder")}
value={editedProfile.phoneNumber}
onChangeText={(text) =>
setEditedProfile((prev) => ({ ...prev, phoneNumber: text }))
}
editable={profile?.signupType !== "phone"}
containerClassName="w-full"
borderClassName="border-[#D9DBE9] bg-white"
placeholderColor="#7E7E7E"
textClassName="text-[#000] text-sm"
keyboardType="phone-pad"
/>
<View className="h-2" />
<Label className="text-base font-dmsans-medium">
{t("profile.emailLabel")}
</Label>
<View className="h-2" />
<Input
placeholder={t("profile.emailPlaceholder")}
value={editedProfile.email}
onChangeText={(text) =>
setEditedProfile((prev) => ({ ...prev, email: text }))
}
editable={profile?.signupType === "phone"}
containerClassName="w-full"
borderClassName="border-[#D9DBE9] bg-white"
placeholderColor="#7E7E7E"
textClassName="text-[#000] text-sm"
/>
<View className="h-4" />
{/* Language Dropdown */}
<Label className="text-base font-dmsans-medium">
{t("profile.languageLabel")}
</Label>
<View className="h-2" />
<Dropdown
value={language}
options={languageOptions}
onSelect={(value) => setLanguage(value as any)}
placeholder={t("profile.languagePlaceholder")}
/>
<View className="h-4" />
{/* Account Number (read-only) */}
<Label className="text-base font-dmsans-medium">
{t("profile.accountNumberLabel")}
</Label>
<View className="h-2" />
<View className="space-y-1">
<TouchableOpacity
activeOpacity={0.8}
onPress={() => {
setPendingDefaultAccountId(defaultAccount?.id || null);
setIsSelectAccountSheetVisible(true);
}}
className={`flex-row items-center justify-between ${
defaultAccount ? "py-1 px-4" : "py-3 px-4"
} rounded-md border border-[#D9DBE9]`}
>
<View className="flex-1 mr-3">
<Text className="text-base font-dmsans text-gray-800">
{defaultAccount
? defaultAccount.bankName
: "Choose account"}
</Text>
{defaultAccount && (
<Text className="text-sm font-dmsans text-gray-500 ">
{defaultAccount.accountNumber}
</Text>
)}
</View>
<ChevronDown size={18} color="#9CA3AF" />
</TouchableOpacity>
{/* {profileAccounts.length > 0 && (
<Text className="text-sm pt-1 font-dmsans-medium text-gray-500">
Tap to change default account or add another.
</Text>
)} */}
</View>
<View className="h-4" />
{/* Username (read-only) with copy icon */}
<Label className="text-base font-dmsans-medium">
{t("profile.usernameLabel")}
</Label>
<View className="h-2" />
<View className="relative">
<Input
value={usernameDisplay}
editable={false}
containerClassName="w-full"
borderClassName="border-[#D9DBE9] bg-white"
placeholderColor="#7E7E7E"
textClassName="text-[#7E7E7E] text-sm pr-12"
/>
<TouchableOpacity
onPress={handleCopyUsername}
className="absolute right-3 top-1/2 -translate-y-1/2"
style={{ transform: [{ translateY: -12 }] }}
activeOpacity={0.6}
>
<Copy size={20} color="#9CA3AF" />
</TouchableOpacity>
</View>
</View>
</View>
</ScrollView>
<View className="flex flex-col pt-5 px-5 pb-6">
<View className="flex-row mb-4">
<View className="flex-1 mr-2">
<Button
className="bg-primary rounded-3xl"
onPress={handleUpdateProfile}
disabled={updateLoading}
>
<Text className="font-dmsans">
{updateLoading
? t("profile.savingButton")
: t("profile.saveButton")}
</Text>
</Button>
</View>
<View className="flex-1 ml-2">
<Button
className="bg-gray-500 rounded-3xl"
onPress={handleCancel}
disabled={updateLoading}
>
<Text className="font-dmsans text-white">
{t("profile.cancelButton")}
</Text>
</Button>
</View>
</View>
</View>
<BottomSheet
visible={isSelectAccountSheetVisible}
onClose={() => setIsSelectAccountSheetVisible(false)}
maxHeightRatio={0.9}
>
<View className="mb-4">
<Text className="text-xl font-dmsans-bold text-primary text-center">
Choose Account
</Text>
</View>
{profileAccounts.length === 0 ? (
<View className="flex-1 items-center justify-center mb-6 px-5">
<Text className="text-base font-dmsans text-gray-600 text-center">
You have not added any accounts yet.
</Text>
</View>
) : (
<View className="mb-4 space-y-2">
{profileAccounts.map((account) => (
<TouchableOpacity
key={account.id}
activeOpacity={0.8}
onPress={() => {
setPendingDefaultAccountId(account.id);
}}
className={`flex-row items-center justify-between py-1 px-4 rounded-md mb-2 border ${
(
pendingDefaultAccountId
? pendingDefaultAccountId === account.id
: account.isDefault
)
? "border-primary bg-primary/5"
: "border-[#D9DBE9] bg-white"
}`}
>
<View className="flex-1 mr-3">
<Text className="text-base font-dmsans text-gray-800">
{account.bankName}
</Text>
<Text className="text-sm font-dmsans text-gray-500">
{account.accountNumber}
</Text>
</View>
{account.isDefault && (
<View className="px-3 py-1 rounded-full bg-primary">
<Text className="text-xs font-dmsans-medium text-white">
Default
</Text>
</View>
)}
</TouchableOpacity>
))}
</View>
)}
{profileAccounts.length > 0 && (
<Button
className="bg-primary rounded-3xl w-full mt-2"
onPress={handleConfirmDefaultAccount}
disabled={
!pendingDefaultAccountId ||
pendingDefaultAccountId === defaultAccount?.id
}
>
<Text className="font-dmsans text-white">Save Default</Text>
</Button>
)}
<Button
className="bg-primary rounded-3xl w-full mt-2"
onPress={handleStartAddAccount}
disabled={profileAccounts.length >= 5}
>
<Text className="font-dmsans text-white">Add Account</Text>
</Button>
{profileAccounts.length >= 5 && (
<Text className="mt-2 text-xs font-dmsans text-gray-500 text-center">
You can link up to 5 accounts.
</Text>
)}
</BottomSheet>
<BottomSheet
visible={isAddingAccount}
onClose={() => setIsAddingAccount(false)}
maxHeightRatio={0.9}
>
<View className="mb-4">
<Text className="text-xl font-dmsans-bold text-primary text-center">
Add Account
</Text>
</View>
<View className="mb-4">
<Text className="text-base font-dmsans text-black mb-2">Bank</Text>
<View className="flex-row flex-wrap justify-between">
{PROFILE_BANK_OPTIONS.map((bank) => {
const isSelected = selectedBank === bank.id;
const initials = bank.name
.split(" ")
.map((part) => part[0])
.join("")
.toUpperCase()
.slice(0, 2);
return (
<TouchableOpacity
key={bank.id}
activeOpacity={0.8}
onPress={() => handleSelectBank(bank.id)}
className={`items-center justify-between px-3 py-4 mb-3 rounded-2xl border ${
isSelected
? "border-primary bg-primary/5"
: "border-gray-200 bg-white"
}`}
style={{ width: "30%" }}
>
<View className="w-10 h-10 mb-2 rounded-full bg-primary/10 items-center justify-center">
<Text className="text-primary font-dmsans-bold text-sm">
{initials}
</Text>
</View>
<Text
className="text-center text-xs font-dmsans text-gray-800"
numberOfLines={2}
>
{bank.name}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
<View className="mb-4">
<Text className="text-base font-dmsans text-black mb-2">
{accountLabel}
</Text>
<Input
placeholder={accountPlaceholder}
value={accountNumberInput}
onChangeText={(text) =>
setAccountNumberInput(text.replace(/[^0-9]/g, ""))
}
containerClassName="w-full mb-4"
borderClassName="border-[#E5E7EB] bg-white rounded-[4px]"
placeholderColor="#9CA3AF"
textClassName="text-[#111827] text-sm"
keyboardType="number-pad"
/>
</View>
<Button
className="bg-primary rounded-3xl w-full"
onPress={handleSaveAccount}
disabled={
!selectedBank ||
!accountNumberInput.trim() ||
profileAccounts.length >= 5 ||
savingAccount
}
>
{savingAccount ? (
<ActivityIndicator color="#ffffff" />
) : (
<Text className="font-dmsans text-white">Save Account</Text>
)}
</Button>
</BottomSheet>
<ModalToast
visible={toastVisible}
title={toastTitle}
description={toastDescription}
variant={toastVariant}
/>
</ScreenWrapper>
);
}