diff --git a/app/(protected)/(tabs)/settings/_layout.tsx b/app/(protected)/(tabs)/settings/_layout.tsx
index c832755df..df41bdbff 100644
--- a/app/(protected)/(tabs)/settings/_layout.tsx
+++ b/app/(protected)/(tabs)/settings/_layout.tsx
@@ -57,6 +57,14 @@ export default function SettingsLayout() {
headerShown: false,
}}
/>
+
{
- setShowDeleteModal(true);
- };
+ // Email-first signups get a generated `user_…` handle nobody chose or uses.
+ const username = user?.username && !user.username.startsWith('user_') ? user.username : undefined;
- const confirmDelete = async () => {
- setIsDeleting(true);
- try {
- await handleDeleteAccount();
- } catch (_error) {
- Alert.alert('Error', 'Failed to delete account. Please try again.');
- } finally {
- setIsDeleting(false);
- setShowDeleteModal(false);
- }
- };
+ const verification =
+ cardStatus?.kycStatus === KycStatus.APPROVED
+ ? { badge: 'Verified' }
+ : isKycAwaitingDecision(cardStatus)
+ ? { value: 'In review' }
+ : { value: 'Not verified' };
const mobileHeader = (
@@ -61,136 +66,57 @@ export default function Account() {
>
);
- const deleteModal = (
- !isDeleting && setShowDeleteModal(false)}
- >
-
-
-
- Delete Account
- !isDeleting && setShowDeleteModal(false)}>
-
-
-
-
-
- Are you sure you want to delete your account? This action cannot be undone and will:
-
-
-
- • Remove all your data
- • Cancel any active cards
- • Delete your transaction history
- • Remove access to your wallet
-
-
-
- setShowDeleteModal(false)}
- className="mr-2 flex-1 rounded-xl bg-gray-700 py-4"
- disabled={isDeleting}
- >
- Cancel
-
-
-
- {isDeleting ? (
-
- ) : (
- Delete Account
- )}
-
-
-
-
-
- );
-
return (
+ }
scrollable={false}
>
- {/* Top Content */}
- {/* Email Section - shown for all users who have email */}
- {/* {user?.email && (
- <>
- Email
-
- }
- isDesktop={isDesktop}
- hideIconBackground
- titleStyle="font-medium"
- />
-
- >
- )} */}
-
- {/* User Name Section - shown for legacy users or users with custom username */}
- {user?.username && !user.username.startsWith('user_') && (
- <>
- User Name
-
- }
- isDesktop={isDesktop}
- hideIconBackground
- titleStyle="font-medium"
- />
-
- >
- )}
-
- {/* Fallback for users with neither email nor username (should not happen) */}
- {!user?.email && (!user?.username || user.username.startsWith('user_')) && (
- <>
- Account
-
- }
- isDesktop={isDesktop}
- hideIconBackground
- titleStyle="font-medium"
- />
-
- >
- )}
-
- {/* Wallet Address Section */}
- Wallet address
-
- }
- isDesktop={isDesktop}
- hideIconBackground
- titleStyle="font-medium"
- customAction={
+ Profile
+
+ {username ? (
+ }
+ title="Username"
+ subtitle={`@${username}`}
+ accessory={
+
+ }
+ />
+ ) : null}
+ }
+ title="Email"
+ subtitle={user?.email || 'Not set'}
+ onPress={() => router.push('/settings/email' as Href)}
+ accessibilityLabel={user?.email ? 'Change email' : 'Add an email'}
+ />
+ }
+ title="Identity verification"
+ isLoading={isCardStatusLoading}
+ {...verification}
+ />
+
+
+ Wallet
+
+ }
+ title="Wallet address"
+ subtitle={user?.safeAddress ? eclipseAddress(user.safeAddress as Address) : '—'}
+ accessory={
user?.safeAddress ? (
-
+
+
+ This is your self-custodial wallet. Only you can move funds out of it.
+
- {/* Spacer */}
- {/* Delete Account Section - at bottom */}
-
-
-
+
+ }
title="Delete account"
- icon={
-
- }
- isDesktop={isDesktop}
- customAction={}
- titleStyle="text-[#FF7D7D]"
- hideIconBackground
+ tone="danger"
+ onPress={() => setIsDeleteSheetOpen(true)}
/>
-
+
+
+ {DELETE_ACCOUNT_FOOTNOTE}
+
diff --git a/app/(protected)/(tabs)/settings/index.tsx b/app/(protected)/(tabs)/settings/index.tsx
index 545c7e065..acf0517c4 100644
--- a/app/(protected)/(tabs)/settings/index.tsx
+++ b/app/(protected)/(tabs)/settings/index.tsx
@@ -1,44 +1,60 @@
-import React from 'react';
+import React, { ReactNode, useCallback, useState } from 'react';
import { Linking, Platform, Pressable, View } from 'react-native';
-import Svg, { Path } from 'react-native-svg';
import * as Application from 'expo-application';
-import { Image } from 'expo-image';
-import * as IntentLauncher from 'expo-intent-launcher';
-import { Href, router } from 'expo-router';
-import { Bell, UsersRound } from 'lucide-react-native';
-
-import Navbar from '@/components/Navbar';
+import { Href, router, useFocusEffect } from 'expo-router';
+import {
+ Bell,
+ ChevronRight,
+ CircleCheck,
+ CreditCard,
+ FileText,
+ LifeBuoy,
+ LogOut,
+ Pencil,
+ ShieldCheck,
+ Sparkle,
+ Star,
+ UserRound,
+ UsersRound,
+} from 'lucide-react-native';
+
+import { SPEND_MODE_COPY } from '@/components/Card/NewCardDetails/SpendMode/spendModes';
import WhatsNewButton from '@/components/Navbar/WhatsNewButton';
import PageLayout from '@/components/PageLayout';
-import { SettingsCard } from '@/components/Settings';
+import EditAvatarSheet from '@/components/Profile/EditAvatarSheet';
+import ProfileAvatar from '@/components/Profile/ProfileAvatar';
+import { ProfileRow, ProfileRowGroup, ProfileSectionLabel } from '@/components/Profile/ProfileRow';
import TrustpilotReviewCard from '@/components/Trustpilot/TrustpilotReviewCard';
import { BackButton } from '@/components/ui/back-button';
import { Text } from '@/components/ui/text';
import { path } from '@/constants/path';
import { getTierDisplayName } from '@/constants/rewards';
-import { useDimension } from '@/hooks/useDimension';
+import { useCardSpendRegistration } from '@/hooks/useCardSpendRegistration';
+import { useCardStatus } from '@/hooks/useCardStatus';
import useNotificationPermissionStatus from '@/hooks/useNotificationPermissionStatus';
-import { useRewardsUserData } from '@/hooks/useRewards';
+import { useReferralSummary, useRewardsUserData } from '@/hooks/useRewards';
+import { useTotp } from '@/hooks/useTotp';
import useUser from '@/hooks/useUser';
-import { getAsset } from '@/lib/assets';
-import { RewardsTier } from '@/lib/types';
-import { cn, getUserDisplayName } from '@/lib/utils';
+import { KycStatus, RewardsTier } from '@/lib/types';
+import {
+ formatWholeDollars,
+ getUserDisplayName,
+ hasCard,
+ hasCardStatusWithRainApplication,
+ hasPendingCard,
+} from '@/lib/utils';
+import { isKycAwaitingDecision } from '@/lib/utils/kyc/verificationProgress';
+import { useProfileAvatarColorId } from '@/store/useProfileAvatarStore';
import { openSupportDrawer } from '@/store/useSupportDrawerStore';
-const AccountDetailsIcon = getAsset('images/settings_account_details.png');
-const SecurityIcon = getAsset('images/settings_security.png');
-const HelpSupportIcon = getAsset('images/settings_help_and_support.png');
-const LogoutIcon = getAsset('images/settings_logout.png');
+const LEGAL_URL =
+ 'https://support.solid.xyz/en/articles/13184959-legal-privacy-policy-terms-conditions#h_5cf45398ce';
-type MobileSettingsRow = {
- title: string;
- icon: React.ReactNode;
- href?: Href;
- onPress?: () => void;
- /** Right-aligned status text, e.g. the notification permission state. */
- description?: string;
- descriptionClassName?: string;
-};
+const ICON_SIZE = 18;
+const ICON_COLOR = '#FFFFFF';
+
+const formatPoints = (points: number) =>
+ new Intl.NumberFormat('en-us', { notation: 'compact', maximumFractionDigits: 1 }).format(points);
const mobileHeader = (
@@ -47,227 +63,86 @@ const mobileHeader = (
);
-const IconImage = ({
- source,
- width,
- height,
-}: {
- source: ReturnType;
- width: number;
- height: number;
-}) => ;
-
-const ProfileAvatarIcon = () => (
-
-);
-
-const RewardsIcon = () => (
-
-);
-
-const RowChevron = () => (
-
-);
-
-const MembershipBadge = ({ tier }: { tier: RewardsTier }) => {
- return (
-
-
- {getTierDisplayName(tier)} member
-
- );
+const openLegal = () => {
+ if (Platform.OS === 'web') {
+ window.open(LEGAL_URL, '_blank');
+ } else {
+ void Linking.openURL(LEGAL_URL);
+ }
};
-const SettingsRow = ({
- title,
+/** One of the two tiles under the profile: Rewards and Refer & earn. */
+const ProfileTile = ({
icon,
- href,
+ title,
+ subtitle,
+ subtitleClassName = 'text-[#8E8E8E]',
onPress,
- description,
- descriptionClassName,
- showDivider = false,
-}: MobileSettingsRow & { showDivider?: boolean }) => {
- const handlePress = () => {
- if (href) {
- router.push(href);
- return;
- }
-
- onPress?.();
- };
-
- return (
-
-
- {icon}
- {title}
-
- {description ? (
-
- {description}
-
- ) : null}
-
-
- );
-};
-
-const SettingsRowGroup = ({ rows }: { rows: MobileSettingsRow[] }) => {
- return (
-
- {rows.map((row, index) => (
- 0} />
- ))}
+}: {
+ icon: ReactNode;
+ title: string;
+ subtitle?: string;
+ subtitleClassName?: string;
+ onPress: () => void;
+}) => (
+
+
+ {icon}
+
- );
-};
+ {title}
+
+ {subtitle ?? ' '}
+
+
+);
-const MobileSettings = () => {
+/**
+ * Settings → Profile (Figma "Profile").
+ *
+ * Who you are at the top — avatar, name, tier and verification — then the two
+ * things worth coming back for (rewards, referrals), and the account, security
+ * and support rows below. Statements are not in this release.
+ */
+export default function Settings() {
const { user, handleLogout } = useUser();
- const { data: rewardsData } = useRewardsUserData();
- const currentTier = rewardsData?.currentTier ?? RewardsTier.CORE;
- const displayName = getUserDisplayName(user, 18);
- const { status: notificationStatus, request: requestNotificationPermission } =
- useNotificationPermissionStatus();
-
- /**
- * The way back into push notifications.
- *
- * Onboarding asks once, over the wallet screen, and marks itself seen on any
- * dismissal — including a swipe that never reached the OS prompt. After that
- * the sheet is unreachable and nothing else in the app ever asks, so without a
- * row here a cardholder who swiped it away has no route to notifications at
- * all. That is not hypothetical: it is why card-payment pushes go missing, and
- * it bites iOS hardest, since Android below 13 grants the permission at
- * install and registers a token whether or not the sheet was ever seen.
- */
- const handleNotificationsPress = () => {
- // Never asked: the OS prompt is the only thing that can change this, and
- // iOS lists an app under Settings → Notifications only once it has asked,
- // so sending them to Settings would show them a page with nothing on it.
- if (notificationStatus === 'Undetermined') {
- void requestNotificationPermission();
- return;
- }
+ const [isAvatarSheetOpen, setIsAvatarSheetOpen] = useState(false);
+ const avatarColorId = useProfileAvatarColorId(user?.userId);
- // Already answered — only the OS can change that answer now.
- if (Platform.OS === 'android') {
- void IntentLauncher.startActivityAsync(
- IntentLauncher.ActivityAction.APP_NOTIFICATION_SETTINGS,
- { extra: { 'android.provider.extra.APP_PACKAGE': Application.applicationId } },
- );
- return;
- }
-
- void Linking.openSettings();
- };
-
- const notificationRows: MobileSettingsRow[] =
- Platform.OS === 'web'
- ? []
- : [
- {
- title: 'Push Notifications',
- icon: ,
- onPress: handleNotificationsPress,
- description: notificationStatus,
- descriptionClassName:
- notificationStatus === 'Authorized'
- ? 'text-[#94F27F]'
- : notificationStatus === 'Denied'
- ? 'text-[#FFB347]'
- : 'text-[#ACACAC]',
- },
- ];
+ const { data: rewardsData } = useRewardsUserData();
+ const { data: referralSummary } = useReferralSummary();
+ const { data: cardStatus } = useCardStatus();
+ const { canChangeMode, mode } = useCardSpendRegistration();
+ const { isVerified: isTotpOn, isLoading: isTotpLoading, refetch: refetchTotp } = useTotp();
+ const { status: notificationStatus } = useNotificationPermissionStatus();
+
+ // Setting up 2FA happens a screen away, so "Add 2FA" is re-checked on the way back.
+ useFocusEffect(
+ useCallback(() => {
+ void refetchTotp();
+ }, [refetchTotp]),
+ );
- const rowGroups: MobileSettingsRow[][] = [
- [
- {
- title: 'Rewards',
- icon: ,
- href: path.REWARDS,
- },
- {
- title: 'Refer & Earn',
- icon: ,
- href: path.REFERRAL_PROGRAM,
- },
- ],
- [
- {
- title: 'Account details',
- icon: ,
- href: '/settings/account' as Href,
- },
- {
- title: 'Security',
- icon: ,
- href: '/settings/security' as Href,
- },
- ...notificationRows,
- ],
- [
- {
- title: 'Help & Support',
- icon: ,
- onPress: () => openSupportDrawer(),
- },
- ],
- [
- {
- title: 'Sign out',
- icon: ,
- onPress: handleLogout,
- },
- ],
- ];
+ const displayName = getUserDisplayName(user, 18);
+ const currentTier = rewardsData?.currentTier ?? RewardsTier.CORE;
+ const isVerified = cardStatus?.kycStatus === KycStatus.APPROVED;
+ const userHasCard = hasCard(cardStatus);
+ const referrerUsd = referralSummary?.rewards.referrerUsd;
+
+ // The card row names its destination rather than going through the `/card`
+ // shim, which is kept only for old links.
+ const cardHref: Href = userHasCard
+ ? path.CARD_INFO
+ : hasPendingCard(cardStatus) ||
+ isKycAwaitingDecision(cardStatus) ||
+ hasCardStatusWithRainApplication(cardStatus)
+ ? path.CARD_ACTIVATE
+ : path.CARD_COUNTRY_SELECTION;
return (
{
useDesktopBreakpoint
className="bg-[#111111]"
contentClassName="bg-[#111111]"
+ additionalContent={
+
+ }
>
router.push('/settings/account' as Href)}
- className="relative h-[123px] w-[123px] items-center justify-center rounded-full bg-[#1c1c1c] active:opacity-80"
- accessibilityLabel="Edit account details"
+ onPress={() => setIsAvatarSheetOpen(true)}
accessibilityRole="button"
+ accessibilityLabel="Edit avatar"
+ className="active:opacity-80"
>
-
+
+
+
+
-
- {displayName}
-
-
-
+
+ {displayName}
+ {user?.email ? (
+
+ {user.email}
+
+ ) : null}
+
+
+ router.push(path.REWARDS)}
+ accessibilityRole="button"
+ className="h-8 flex-row items-center gap-1.5 rounded-full bg-[#1C1C1C] pl-3 pr-2 active:opacity-70"
+ >
+
+
+ {getTierDisplayName(currentTier)} member
+
+
+
+ {isVerified ? (
+
+
+ Verified
+
+ ) : null}
-
- {rowGroups.map((rows, index) => (
-
- ))}
- {/* Web/desktop only — native asks for a rating through the OS sheet. */}
-
-
-
-
- );
-};
-
-const DesktopSettings = () => {
- const { handleLogout } = useUser();
- const { isDesktop } = useDimension();
- const { status: notificationStatus, request: requestNotificationPermission } =
- useNotificationPermissionStatus();
-
- const notificationStatusColor =
- notificationStatus === 'Authorized'
- ? 'text-[#94F27F]'
- : notificationStatus === 'Denied'
- ? 'text-[#FFB347]'
- : 'text-[#ACACAC]';
-
- const handleLegalPress = () => {
- const url =
- 'https://support.solid.xyz/en/articles/13184959-legal-privacy-policy-terms-conditions#h_5cf45398ce';
- if (Platform.OS === 'web') {
- window.open(url, '_blank');
- } else {
- Linking.openURL(url);
- }
- };
-
- const desktopHeader = (
- <>
-
-
-
-
- Settings
-
+
+ }
+ title="Rewards"
+ subtitle={
+ rewardsData ? `${formatPoints(rewardsData.totalPoints ?? 0)} points` : undefined
+ }
+ onPress={() => router.push(path.REWARDS)}
+ />
+ }
+ title="Refer & earn"
+ subtitle={
+ referrerUsd ? `Get ${formatWholeDollars(referrerUsd)} per friend` : 'Invite friends'
+ }
+ subtitleClassName="text-[#94F27F]"
+ onPress={() => router.push(path.REFERRAL_PROGRAM)}
+ />
-
- >
- );
-
- return (
-
-
-
-
- Account
+
+ }
title="Account details"
- icon={}
- link="/settings/account"
- isDesktop={isDesktop}
- hideIconBackground
+ onPress={() => router.push('/settings/account' as Href)}
/>
-
+ }
+ title="Card"
+ value={userHasCard && canChangeMode ? `${SPEND_MODE_COPY[mode].label} mode` : undefined}
+ onPress={() => router.push(cardHref)}
+ />
+
-
- Security & alerts
+
+ }
title="Security"
- icon={}
- link="/settings/security"
- isDesktop={isDesktop}
- hideIconBackground
+ value={!isTotpLoading && !isTotpOn ? 'Add 2FA' : undefined}
+ valueTone="warning"
+ onPress={() => router.push('/settings/security' as Href)}
/>
-
-
- {Platform.OS !== 'web' && (
-
- }
- onPress={() => {
- // Nobody has asked this user yet, so there is nothing in the OS
- // settings to turn on — iOS does not even list an app under
- // Notifications until it has requested authorization once. The
- // onboarding sheet is shown a single time and is not reachable
- // again, so without this the only way out of `Undetermined` is
- // to reinstall the app. Ask here instead.
- if (notificationStatus === 'Undetermined') {
- void requestNotificationPermission();
- return;
- }
-
- // Already answered: only the OS can change that answer.
- if (Platform.OS === 'android') {
- IntentLauncher.startActivityAsync(
- IntentLauncher.ActivityAction.APP_NOTIFICATION_SETTINGS,
- { extra: { 'android.provider.extra.APP_PACKAGE': Application.applicationId } },
- );
- } else {
- Linking.openSettings();
- }
- }}
- isDesktop={isDesktop}
- hideIconBackground
+ {/* Push only exists in the apps; the web has nothing to switch on. */}
+ {Platform.OS !== 'web' ? (
+ }
+ title="Notifications"
+ value={notificationStatus === 'Authorized' ? 'On' : 'Off'}
+ onPress={() => router.push('/settings/notifications' as Href)}
/>
-
- )}
-
-
- }
+ ) : null}
+
+
+ Support
+
+ }
+ title="Help & support"
onPress={() => openSupportDrawer()}
- isDesktop={isDesktop}
- hideIconBackground
/>
-
-
-
- }
- onPress={handleLogout}
- isDesktop={isDesktop}
- hideIconBackground
+ }
+ title="Legal & privacy"
+ onPress={openLegal}
/>
-
-
-
-
- Legal
-
-
-
- {Platform.OS !== 'web' && (
-
-
- {Application.applicationName || 'Solid'} v
- {Application.nativeApplicationVersion || 'Unknown'} - Build{' '}
- {Application.nativeBuildVersion || 'Unknown'}
-
-
- )}
+
+
+ {/* Web/desktop only — native asks for a rating through the OS sheet. */}
+
+
+
+
+ Sign out
+
+
+ {Platform.OS !== 'web' ? (
+
+ {Application.applicationName || 'Solid'} v
+ {Application.nativeApplicationVersion || 'Unknown'} · Build{' '}
+ {Application.nativeBuildVersion || 'Unknown'}
+
+ ) : null}
);
-};
-
-export default function Settings() {
- // The sidebar's Profile item lands here, and desktop is the same redesigned
- // profile screen, stretched inside the sidebar shell — see `SidebarShell` in
- // `(protected)/_layout.tsx`.
- return ;
}
diff --git a/app/(protected)/(tabs)/settings/notifications.tsx b/app/(protected)/(tabs)/settings/notifications.tsx
new file mode 100644
index 000000000..8ecf9eb55
--- /dev/null
+++ b/app/(protected)/(tabs)/settings/notifications.tsx
@@ -0,0 +1,256 @@
+import { ReactNode } from 'react';
+import { ActivityIndicator, Linking, Platform, Pressable, View } from 'react-native';
+import * as Application from 'expo-application';
+import * as IntentLauncher from 'expo-intent-launcher';
+import {
+ ArrowDownLeft,
+ Bell,
+ ChevronRight,
+ CreditCard,
+ Megaphone,
+ ShieldCheck,
+ Sparkle,
+ Wallet,
+} from 'lucide-react-native';
+
+import Navbar from '@/components/Navbar';
+import PageLayout from '@/components/PageLayout';
+import { ProfileRow, ProfileRowGroup, ProfileSectionLabel } from '@/components/Profile/ProfileRow';
+import ToggleSwitch from '@/components/Profile/ToggleSwitch';
+import { BackButton } from '@/components/ui/back-button';
+import { Text } from '@/components/ui/text';
+import { useDimension } from '@/hooks/useDimension';
+import useNotificationPermissionStatus from '@/hooks/useNotificationPermissionStatus';
+import { useNotificationPreferences } from '@/hooks/useNotificationPreferences';
+import { NotificationPreferences } from '@/lib/api';
+import { cn } from '@/lib/utils';
+
+const ICON_SIZE = 18;
+const ICON_COLOR = '#FFFFFF';
+const SETTINGS_APP = Platform.OS === 'android' ? 'Android Settings' : 'iOS Settings';
+
+type PreferenceRow = {
+ key: keyof NotificationPreferences;
+ icon: ReactNode;
+ title: string;
+ subtitle: string;
+};
+
+const SECTIONS: { title: string; rows: (PreferenceRow | 'payment-approvals')[] }[] = [
+ {
+ title: 'Card',
+ rows: [
+ {
+ key: 'cardPayments',
+ icon: ,
+ title: 'Card payments',
+ subtitle: 'Purchases, refunds and declines',
+ },
+ 'payment-approvals',
+ ],
+ },
+ {
+ title: 'Money',
+ rows: [
+ {
+ key: 'depositsTransfers',
+ icon: ,
+ title: 'Deposits & transfers',
+ subtitle: 'When money arrives or leaves',
+ },
+ {
+ key: 'earn',
+ icon: ,
+ title: 'Earn',
+ subtitle: 'Weekly yield summary',
+ },
+ ],
+ },
+ {
+ title: 'Rewards & news',
+ rows: [
+ {
+ key: 'cashbackRewards',
+ icon: ,
+ title: 'Cashback & rewards',
+ subtitle: 'Cashback earned, tier changes',
+ },
+ {
+ key: 'productNews',
+ icon: ,
+ title: 'Product news & offers',
+ subtitle: 'New features and promotions',
+ },
+ ],
+ },
+];
+
+/**
+ * The way back into push notifications, and what the OS says about them now.
+ *
+ * Onboarding asks once, over the wallet screen, and marks itself seen on any
+ * dismissal — including a swipe that never reached the OS prompt. After that
+ * the sheet is unreachable and nothing else in the app ever asks, so without
+ * this card a cardholder who swiped it away has no route to notifications at
+ * all. That is not hypothetical: it is why card-payment pushes go missing, and
+ * it bites iOS hardest, since Android below 13 grants the permission at install
+ * and registers a token whether or not the sheet was ever seen.
+ */
+const PushPermissionCard = () => {
+ const { status, request } = useNotificationPermissionStatus();
+
+ const handlePress = () => {
+ // Never asked: the OS prompt is the only thing that can change this, and
+ // iOS lists an app under Settings → Notifications only once it has asked,
+ // so sending them to Settings would show them a page with nothing on it.
+ if (status === 'Undetermined') {
+ void request();
+ return;
+ }
+
+ // Already answered — only the OS can change that answer now.
+ if (Platform.OS === 'android') {
+ void IntentLauncher.startActivityAsync(
+ IntentLauncher.ActivityAction.APP_NOTIFICATION_SETTINGS,
+ { extra: { 'android.provider.extra.APP_PACKAGE': Application.applicationId } },
+ );
+ return;
+ }
+
+ void Linking.openSettings();
+ };
+
+ const copy =
+ status === 'Authorized'
+ ? { title: 'Push notifications are on', detail: `Manage permission in ${SETTINGS_APP}` }
+ : status === 'Denied'
+ ? { title: 'Push notifications are off', detail: `Turn them on in ${SETTINGS_APP}` }
+ : { title: 'Turn on push notifications', detail: 'Get alerts for payments and deposits' };
+ const isOn = status === 'Authorized';
+
+ return (
+
+
+
+
+
+ {copy.title}
+ {copy.detail}
+
+
+
+ );
+};
+
+/**
+ * Settings → Notifications (Figma "Notifications").
+ *
+ * The OS permission at the top, then which kinds of push the account wants.
+ * The categories only appear once the backend stores them — until then there
+ * is nothing for a switch to change, so the screen shows the permission alone.
+ */
+export default function Notifications() {
+ const { isDesktop } = useDimension();
+ const { preferences, isLoading, isError, update, isSaveError } = useNotificationPreferences();
+
+ const mobileHeader = (
+
+
+
+ Notifications
+
+
+ );
+
+ const desktopHeader = (
+ <>
+
+
+
+
+ Notifications
+
+
+
+ >
+ );
+
+ return (
+
+
+
+
+ {isLoading ? (
+
+ ) : isError ? (
+
+ Couldn't load your notification choices. Try again later.
+
+ ) : preferences ? (
+ <>
+ {SECTIONS.map(section => (
+
+ {section.title}
+
+ {section.rows.map(row =>
+ row === 'payment-approvals' ? (
+ }
+ title="Payment approvals"
+ subtitle="3D Secure for online payments"
+ value="Always on"
+ />
+ ) : (
+ update({ [row.key]: value })}
+ accessibilityLabel={row.title}
+ />
+ }
+ />
+ ),
+ )}
+
+
+ ))}
+ {isSaveError ? (
+
+ Couldn't save that change. Please try again.
+
+ ) : null}
+ >
+ ) : null}
+
+
+ Security alerts and important account notices are always sent by email.
+
+
+
+ );
+}
diff --git a/components/Navbar/WhatsNewButton.tsx b/components/Navbar/WhatsNewButton.tsx
index 0324acfb9..3e50318cd 100644
--- a/components/Navbar/WhatsNewButton.tsx
+++ b/components/Navbar/WhatsNewButton.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import { Pressable } from 'react-native';
-import { X } from 'lucide-react-native';
+import { Sparkle, X } from 'lucide-react-native';
import { Text } from '@/components/ui/text';
import { useWhatsNew } from '@/hooks/useWhatsNew';
@@ -15,10 +15,11 @@ const WhatsNewButton = ({ className }: { className?: string }) => {
+
What's new?
{
diff --git a/components/Profile/DeleteAccountSheet.tsx b/components/Profile/DeleteAccountSheet.tsx
new file mode 100644
index 000000000..cb90357df
--- /dev/null
+++ b/components/Profile/DeleteAccountSheet.tsx
@@ -0,0 +1,242 @@
+import { useEffect, useMemo, useState } from 'react';
+import { ActivityIndicator, Pressable, TextStyle, View } from 'react-native';
+import { router } from 'expo-router';
+import { Check, CircleAlert, Info, Trash2 } from 'lucide-react-native';
+
+import CardBottomSheet from '@/components/Card/NewCardDetails/SpendMode/CardBottomSheet';
+import {
+ CardSheetBody,
+ sheetBodyInset,
+} from '@/components/Card/NewCardDetails/SpendMode/CardBottomSheet.types';
+import SheetTextInput from '@/components/Card/NewCardDetails/SpendMode/SheetTextInput';
+import { Text } from '@/components/ui/text';
+import { path } from '@/constants/path';
+import { useCardStatus } from '@/hooks/useCardStatus';
+import { useCardTransactions } from '@/hooks/useCardTransactions';
+import { usePortfolio } from '@/hooks/usePortfolio';
+import useUser from '@/hooks/useUser';
+import { cn, hasCard } from '@/lib/utils';
+
+import {
+ DELETE_CONFIRMATION_PHRASE,
+ DeleteAccountCheck,
+ getDeleteAccountChecks,
+ getDeleteSheetSubtitle,
+} from './deleteAccount';
+
+const DANGER = '#FF7D7D';
+const WARNING = '#F2B84B';
+const POSITIVE = '#94F27F';
+const MUTED = '#8E8E8E';
+
+const INPUT_STYLE: TextStyle = {
+ height: 52,
+ width: '100%',
+ borderRadius: 16,
+ borderWidth: 1,
+ borderColor: 'rgba(255,255,255,0.1)',
+ backgroundColor: '#111111',
+ paddingHorizontal: 16,
+ fontSize: 16,
+ color: '#FFFFFF',
+};
+
+const CheckIcon = ({ state }: { state: DeleteAccountCheck['state'] }) => {
+ switch (state) {
+ case 'loading':
+ return ;
+ case 'ok':
+ return ;
+ case 'info':
+ return ;
+ default:
+ return ;
+ }
+};
+
+interface DeleteAccountSheetBodyProps extends CardSheetBody {
+ onClose: () => void;
+}
+
+/**
+ * The sheet's contents, mounted only while it is open: the balance read behind
+ * the checks is the whole portfolio, and Account details has no other use for it.
+ */
+const DeleteAccountSheetBody = ({
+ session,
+ topPadding,
+ presentation,
+ onClose,
+}: DeleteAccountSheetBodyProps) => {
+ const { handleDeleteAccount } = useUser();
+ const portfolio = usePortfolio();
+ const { data: cardStatus, isLoading: isCardStatusLoading } = useCardStatus();
+ const userHasCard = hasCard(cardStatus);
+ const cardTransactions = useCardTransactions({ enabled: userHasCard });
+ const [confirmation, setConfirmation] = useState('');
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ setConfirmation('');
+ setError(null);
+ }, [session]);
+
+ // Payments still settling are recent, so the first page holds any there are.
+ const pendingCardPayments = cardTransactions.isError
+ ? undefined
+ : cardTransactions.data?.pages[0]?.data.filter(
+ transaction => transaction.status?.toLowerCase() === 'pending',
+ ).length;
+
+ const { checks, canDelete } = useMemo(
+ () =>
+ getDeleteAccountChecks({
+ balanceUsd: portfolio.totalAssets,
+ isBalanceLoading: portfolio.isLoading,
+ isBalanceComplete: portfolio.isComplete,
+ hasCard: userHasCard,
+ pendingCardPayments,
+ isCardPaymentsLoading: isCardStatusLoading || (userHasCard && cardTransactions.isLoading),
+ }),
+ [
+ portfolio.totalAssets,
+ portfolio.isLoading,
+ portfolio.isComplete,
+ userHasCard,
+ pendingCardPayments,
+ isCardStatusLoading,
+ cardTransactions.isLoading,
+ ],
+ );
+
+ const isArmed =
+ canDelete && confirmation.trim().toUpperCase() === DELETE_CONFIRMATION_PHRASE && !isDeleting;
+
+ const confirmDelete = async () => {
+ if (!isArmed) return;
+ setIsDeleting(true);
+ setError(null);
+ try {
+ // Signs out and leaves for onboarding on success, so there is nothing to close here.
+ await handleDeleteAccount();
+ } catch {
+ setError("Couldn't delete your account. Please try again.");
+ setIsDeleting(false);
+ }
+ };
+
+ const openAssets = () => {
+ onClose();
+ router.push(path.ASSETS);
+ };
+
+ return (
+
+
+
+
+
+ Delete your Solid account?
+
+
+ {getDeleteSheetSubtitle(userHasCard)}
+
+
+
+ {checks.map(check => (
+
+
+
+
+
+ {check.title}
+ {check.detail ? (
+ {check.detail}
+ ) : null}
+
+ {check.hasAction ? (
+
+ Withdraw
+
+ ) : null}
+
+ ))}
+
+
+
+ Type {DELETE_CONFIRMATION_PHRASE} to confirm
+
+
+ {error ? {error} : null}
+
+
+ {isDeleting ? (
+
+ ) : (
+ Delete account
+ )}
+
+
+ Keep my account
+
+
+ );
+};
+
+interface DeleteAccountSheetProps {
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+/**
+ * Delete account (Figma "Delete your Solid account?").
+ *
+ * Lists what has to happen first — balance withdrawn, card payments settled —
+ * and asks for the word DELETE before the button arms.
+ */
+const DeleteAccountSheet = ({ isOpen, onOpenChange }: DeleteAccountSheetProps) => (
+
+ {body => onOpenChange(false)} />}
+
+);
+
+export default DeleteAccountSheet;
diff --git a/components/Profile/EditAvatarSheet.tsx b/components/Profile/EditAvatarSheet.tsx
new file mode 100644
index 000000000..e369daff2
--- /dev/null
+++ b/components/Profile/EditAvatarSheet.tsx
@@ -0,0 +1,126 @@
+import { useEffect, useState } from 'react';
+import { Pressable, View } from 'react-native';
+
+import CardBottomSheet from '@/components/Card/NewCardDetails/SpendMode/CardBottomSheet';
+import { sheetBodyInset } from '@/components/Card/NewCardDetails/SpendMode/CardBottomSheet.types';
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+import { cn } from '@/lib/utils';
+import { useProfileAvatarStore } from '@/store/useProfileAvatarStore';
+
+import {
+ AVATAR_COLORS,
+ AvatarColorId,
+ DEFAULT_AVATAR_COLOR_ID,
+ getAvatarInitial,
+} from './avatarColors';
+import ProfileAvatar from './ProfileAvatar';
+
+interface EditAvatarSheetProps {
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+ userId: string | undefined;
+ name: string;
+}
+
+const SWATCH_SIZE = 36;
+
+/**
+ * Edit avatar: pick the colour behind the initial.
+ *
+ * Colour only for now. Choosing or taking a photo needs somewhere to keep the
+ * image, and the backend has no avatar storage yet, so those rows are left out
+ * rather than offered and saved to this device alone.
+ */
+const EditAvatarSheet = ({ isOpen, onOpenChange, userId, name }: EditAvatarSheetProps) => {
+ const savedColor = useProfileAvatarStore(state =>
+ userId ? state.colorByUserId[userId] : undefined,
+ );
+ const setColor = useProfileAvatarStore(state => state.setColor);
+ const [draft, setDraft] = useState(savedColor ?? DEFAULT_AVATAR_COLOR_ID);
+
+ // Every visit starts from what is saved, so Cancel really does throw the draft away.
+ useEffect(() => {
+ if (isOpen) setDraft(savedColor ?? DEFAULT_AVATAR_COLOR_ID);
+ }, [isOpen, savedColor]);
+
+ const save = () => {
+ if (userId) setColor(userId, draft);
+ onOpenChange(false);
+ };
+
+ const initial = getAvatarInitial(name);
+
+ return (
+
+ {({ topPadding, presentation }) => (
+
+ Edit avatar
+
+
+
+
+
+
+ Color
+
+ {AVATAR_COLORS.map(color => {
+ const isSelected = color.id === draft;
+ return (
+ setDraft(color.id)}
+ accessibilityRole="radio"
+ accessibilityState={{ selected: isSelected }}
+ accessibilityLabel={`${color.id} avatar`}
+ hitSlop={4}
+ className={cn(
+ 'items-center justify-center rounded-full border-2 web:hover:opacity-80',
+ isSelected ? 'border-white' : 'border-transparent',
+ )}
+ style={{ width: SWATCH_SIZE + 6, height: SWATCH_SIZE + 6 }}
+ >
+
+
+ {initial}
+
+
+
+ );
+ })}
+
+
+
+
+ onOpenChange(false)}
+ accessibilityRole="button"
+ className="mt-2 h-12 w-full items-center justify-center active:opacity-70"
+ >
+ Cancel
+
+
+ )}
+
+ );
+};
+
+export default EditAvatarSheet;
diff --git a/components/Profile/ProfileAvatar.tsx b/components/Profile/ProfileAvatar.tsx
new file mode 100644
index 000000000..2215fcbf2
--- /dev/null
+++ b/components/Profile/ProfileAvatar.tsx
@@ -0,0 +1,32 @@
+import { View } from 'react-native';
+
+import { Text } from '@/components/ui/text';
+
+import { AvatarColorId, getAvatarColor, getAvatarInitial } from './avatarColors';
+
+interface ProfileAvatarProps {
+ name: string | null | undefined;
+ colorId?: AvatarColorId | null;
+ size: number;
+}
+
+/** The account's initial on a tinted disc. */
+const ProfileAvatar = ({ name, colorId, size }: ProfileAvatarProps) => {
+ const color = getAvatarColor(colorId);
+
+ return (
+
+
+ {getAvatarInitial(name)}
+
+
+ );
+};
+
+export default ProfileAvatar;
diff --git a/components/Profile/ProfileRow.tsx b/components/Profile/ProfileRow.tsx
new file mode 100644
index 000000000..ed18341c7
--- /dev/null
+++ b/components/Profile/ProfileRow.tsx
@@ -0,0 +1,126 @@
+import { Children, Fragment, isValidElement, ReactNode } from 'react';
+import { ActivityIndicator, Pressable, View } from 'react-native';
+import { ChevronRight } from 'lucide-react-native';
+
+import { Text } from '@/components/ui/text';
+import { cn } from '@/lib/utils';
+
+export type ProfileRowTone = 'muted' | 'warning' | 'positive';
+
+interface ProfileRowProps {
+ icon: ReactNode;
+ title: string;
+ /** A second line under the title, e.g. the username on Account details. */
+ subtitle?: string;
+ /** Right-aligned status text before the chevron ("Credit mode", "Add 2FA"). */
+ value?: string;
+ valueTone?: ProfileRowTone;
+ /** A pill on the right in place of `value`, e.g. "Verified". */
+ badge?: string;
+ /** Replaces the chevron, e.g. a copy button. */
+ accessory?: ReactNode;
+ /** `danger` draws the destructive row: red title on a red-tinted icon. */
+ tone?: 'default' | 'danger';
+ isLoading?: boolean;
+ onPress?: () => void;
+ accessibilityLabel?: string;
+}
+
+const VALUE_TONE: Record = {
+ muted: 'text-[#8E8E8E]',
+ warning: 'text-[#F2B84B]',
+ positive: 'text-[#94F27F]',
+};
+
+/**
+ * One row of a Profile group: an icon on a disc, the title (and optionally a
+ * line under it), and on the right a status, a pill or a custom accessory, then
+ * a chevron when the row leads somewhere.
+ */
+export const ProfileRow = ({
+ icon,
+ title,
+ subtitle,
+ value,
+ valueTone = 'muted',
+ badge,
+ accessory,
+ tone = 'default',
+ isLoading,
+ onPress,
+ accessibilityLabel,
+}: ProfileRowProps) => {
+ const isDanger = tone === 'danger';
+
+ const content = (
+
+
+ {icon}
+
+
+
+ {title}
+
+ {subtitle ? (
+
+ {subtitle}
+
+ ) : null}
+
+ {isLoading ? (
+
+ ) : badge ? (
+
+ {badge}
+
+ ) : value ? (
+
+ {value}
+
+ ) : null}
+ {accessory ?? (onPress ? : null)}
+
+ );
+
+ if (!onPress) return content;
+
+ return (
+
+ {content}
+
+ );
+};
+
+/** A rounded card of rows with hairlines between them, inset to the titles. */
+export const ProfileRowGroup = ({ children }: { children: ReactNode }) => {
+ // Conditional rows arrive as `false`/`null`; dividers only go between real ones.
+ const rows = Children.toArray(children).filter(isValidElement);
+
+ return (
+
+ {rows.map((row, index) => (
+
+ {index > 0 ? : null}
+ {row}
+
+ ))}
+
+ );
+};
+
+export const ProfileSectionLabel = ({ children }: { children: string }) => (
+ {children}
+);
diff --git a/components/Profile/ToggleSwitch.tsx b/components/Profile/ToggleSwitch.tsx
new file mode 100644
index 000000000..8f9ba2e0b
--- /dev/null
+++ b/components/Profile/ToggleSwitch.tsx
@@ -0,0 +1,71 @@
+import { useEffect } from 'react';
+import { Pressable } from 'react-native';
+import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
+
+const TRACK_WIDTH = 51;
+const TRACK_HEIGHT = 31;
+const THUMB_SIZE = 27;
+const THUMB_INSET = 2;
+const TRAVEL = TRACK_WIDTH - THUMB_SIZE - THUMB_INSET * 2;
+
+interface ToggleSwitchProps {
+ value: boolean;
+ onValueChange: (value: boolean) => void;
+ accessibilityLabel: string;
+ disabled?: boolean;
+}
+
+/**
+ * The Notifications screen's switch: brand-green track with a dark thumb when
+ * on, a grey track with a white thumb when off — the iOS proportions, drawn the
+ * same on every platform.
+ */
+const ToggleSwitch = ({
+ value,
+ onValueChange,
+ accessibilityLabel,
+ disabled,
+}: ToggleSwitchProps) => {
+ const progress = useSharedValue(value ? 1 : 0);
+
+ useEffect(() => {
+ progress.value = withTiming(value ? 1 : 0, { duration: 180 });
+ }, [value, progress]);
+
+ const thumbStyle = useAnimatedStyle(() => ({
+ transform: [{ translateX: progress.value * TRAVEL }],
+ }));
+
+ return (
+ onValueChange(!value)}
+ disabled={disabled}
+ accessibilityRole="switch"
+ accessibilityLabel={accessibilityLabel}
+ accessibilityState={{ checked: value, disabled }}
+ hitSlop={6}
+ style={{
+ width: TRACK_WIDTH,
+ height: TRACK_HEIGHT,
+ borderRadius: TRACK_HEIGHT / 2,
+ padding: THUMB_INSET,
+ backgroundColor: value ? '#94F27F' : '#3A3A3A',
+ opacity: disabled ? 0.5 : 1,
+ }}
+ >
+
+
+ );
+};
+
+export default ToggleSwitch;
diff --git a/components/Profile/__tests__/avatarColors.test.ts b/components/Profile/__tests__/avatarColors.test.ts
new file mode 100644
index 000000000..962a63fa2
--- /dev/null
+++ b/components/Profile/__tests__/avatarColors.test.ts
@@ -0,0 +1,33 @@
+import {
+ AVATAR_COLORS,
+ DEFAULT_AVATAR_COLOR_ID,
+ getAvatarColor,
+ getAvatarInitial,
+} from '@/components/Profile/avatarColors';
+
+describe('getAvatarColor', () => {
+ it('returns the swatch that was picked', () => {
+ expect(getAvatarColor('green').id).toBe('green');
+ });
+
+ it('falls back to the default for nothing picked or a retired swatch', () => {
+ expect(getAvatarColor(undefined).id).toBe(DEFAULT_AVATAR_COLOR_ID);
+ expect(getAvatarColor('teal').id).toBe(DEFAULT_AVATAR_COLOR_ID);
+ });
+
+ it('offers eight distinct swatches', () => {
+ expect(new Set(AVATAR_COLORS.map(color => color.id)).size).toBe(8);
+ });
+});
+
+describe('getAvatarInitial', () => {
+ it('uses the first character, capitalised', () => {
+ expect(getAvatarInitial('smargon4')).toBe('S');
+ expect(getAvatarInitial(' eli@fuse.io')).toBe('E');
+ });
+
+ it('shows a placeholder when there is no name', () => {
+ expect(getAvatarInitial('')).toBe('?');
+ expect(getAvatarInitial(undefined)).toBe('?');
+ });
+});
diff --git a/components/Profile/__tests__/deleteAccount.test.ts b/components/Profile/__tests__/deleteAccount.test.ts
new file mode 100644
index 000000000..b1c719dc2
--- /dev/null
+++ b/components/Profile/__tests__/deleteAccount.test.ts
@@ -0,0 +1,104 @@
+import {
+ DELETE_ACCOUNT_FOOTNOTE,
+ DeleteAccountCheckInput,
+ getDeleteAccountChecks,
+ getDeleteSheetSubtitle,
+} from '@/components/Profile/deleteAccount';
+
+// The real barrel pulls in wagmi, which jest cannot parse; only the formatter is used.
+jest.mock('@/lib/utils', () => ({
+ formatBalanceUSD: (value: number) =>
+ `$${new Intl.NumberFormat('en-us', { minimumFractionDigits: 2 }).format(value)}`,
+}));
+
+const settled: DeleteAccountCheckInput = {
+ balanceUsd: 0,
+ isBalanceLoading: false,
+ isBalanceComplete: true,
+ hasCard: true,
+ pendingCardPayments: 0,
+ isCardPaymentsLoading: false,
+};
+
+const stateOf = (input: DeleteAccountCheckInput, id: string) =>
+ getDeleteAccountChecks(input).checks.find(check => check.id === id)?.state;
+
+describe('getDeleteAccountChecks', () => {
+ it('allows deletion once the balance is gone and nothing is pending', () => {
+ const { checks, canDelete } = getDeleteAccountChecks(settled);
+
+ expect(canDelete).toBe(true);
+ expect(checks.map(check => [check.id, check.state])).toEqual([
+ ['balance', 'ok'],
+ ['card-payments', 'ok'],
+ ['card', 'info'],
+ ]);
+ });
+
+ it('blocks while money is left and says how much', () => {
+ const { checks, canDelete } = getDeleteAccountChecks({ ...settled, balanceUsd: 1284.5 });
+
+ expect(canDelete).toBe(false);
+ expect(checks[0]).toMatchObject({
+ state: 'blocked',
+ title: 'Withdraw your balance',
+ detail: '$1,284.50 is still in your account',
+ hasAction: true,
+ });
+ });
+
+ it('treats dust as nothing left', () => {
+ expect(stateOf({ ...settled, balanceUsd: 0.42 }, 'balance')).toBe('ok');
+ expect(getDeleteAccountChecks({ ...settled, balanceUsd: 0.42 }).canDelete).toBe(true);
+ });
+
+ it('still blocks on a known balance when some of it could not be read', () => {
+ expect(stateOf({ ...settled, balanceUsd: 50, isBalanceComplete: false }, 'balance')).toBe(
+ 'blocked',
+ );
+ });
+
+ it('warns but does not block when the balance cannot be checked', () => {
+ const { checks, canDelete } = getDeleteAccountChecks({ ...settled, balanceUsd: undefined });
+
+ expect(checks[0].state).toBe('unknown');
+ expect(canDelete).toBe(true);
+ });
+
+ it('waits for both checks to load', () => {
+ expect(getDeleteAccountChecks({ ...settled, isBalanceLoading: true }).canDelete).toBe(false);
+ expect(getDeleteAccountChecks({ ...settled, isCardPaymentsLoading: true }).canDelete).toBe(
+ false,
+ );
+ });
+
+ it('blocks while card payments are pending', () => {
+ const { checks, canDelete } = getDeleteAccountChecks({ ...settled, pendingCardPayments: 2 });
+
+ expect(canDelete).toBe(false);
+ expect(checks[1]).toMatchObject({ state: 'blocked', detail: '2 payments still pending' });
+ });
+
+ it('leaves the card checks out for someone without a card', () => {
+ const { checks } = getDeleteAccountChecks({
+ ...settled,
+ hasCard: false,
+ pendingCardPayments: undefined,
+ });
+
+ expect(checks.map(check => check.id)).toEqual(['balance']);
+ });
+});
+
+describe('deletion copy', () => {
+ // Deletion is immediate on the backend today; promising a window would be false.
+ it('does not promise a grace period', () => {
+ expect(getDeleteSheetSubtitle(true)).toBe(
+ "This closes your account and cancels your card. It happens straight away and can't be undone.",
+ );
+ expect(getDeleteSheetSubtitle(false)).toBe(
+ "This closes your account. It happens straight away and can't be undone.",
+ );
+ expect(DELETE_ACCOUNT_FOOTNOTE).not.toMatch(/days/);
+ });
+});
diff --git a/components/Profile/avatarColors.ts b/components/Profile/avatarColors.ts
new file mode 100644
index 000000000..8039fc3be
--- /dev/null
+++ b/components/Profile/avatarColors.ts
@@ -0,0 +1,33 @@
+/**
+ * The swatches the Edit avatar sheet offers, in the order it lays them out.
+ *
+ * Each is a tinted disc with the initial drawn in the full-strength colour, the
+ * same recipe `getColorForTransaction` uses for merchant avatars so a profile
+ * avatar reads as one of the family rather than a new style.
+ */
+export const AVATAR_COLORS = [
+ { id: 'purple', bg: 'rgba(165,127,242,0.25)', text: '#A57FF2' },
+ { id: 'blue', bg: 'rgba(127,200,242,0.25)', text: '#7FC8F2' },
+ { id: 'green', bg: 'rgba(127,242,158,0.25)', text: '#7FF29E' },
+ { id: 'gold', bg: 'rgba(242,194,127,0.25)', text: '#F2C27F' },
+ { id: 'pink', bg: 'rgba(242,127,215,0.25)', text: '#F27FD7' },
+ { id: 'red', bg: 'rgba(242,127,129,0.25)', text: '#F27F81' },
+ { id: 'lime', bg: 'rgba(180,242,127,0.25)', text: '#B4F27F' },
+ { id: 'gray', bg: 'rgba(189,189,189,0.25)', text: '#BDBDBD' },
+] as const;
+
+export type AvatarColorId = (typeof AVATAR_COLORS)[number]['id'];
+export type AvatarColor = (typeof AVATAR_COLORS)[number];
+
+export const DEFAULT_AVATAR_COLOR_ID: AvatarColorId = 'purple';
+
+/** The swatch for `id`, or the default when it is missing or no longer offered. */
+export const getAvatarColor = (id: string | null | undefined): AvatarColor =>
+ AVATAR_COLORS.find(color => color.id === id) ??
+ AVATAR_COLORS.find(color => color.id === DEFAULT_AVATAR_COLOR_ID)!;
+
+/** The letter on the avatar: the name's first character, `?` when there is none. */
+export const getAvatarInitial = (name: string | null | undefined): string => {
+ const first = name?.trim().charAt(0);
+ return first ? first.toUpperCase() : '?';
+};
diff --git a/components/Profile/deleteAccount.ts b/components/Profile/deleteAccount.ts
new file mode 100644
index 000000000..f09bb2bcc
--- /dev/null
+++ b/components/Profile/deleteAccount.ts
@@ -0,0 +1,133 @@
+import { formatBalanceUSD } from '@/lib/utils';
+
+/**
+ * Days between confirming and the account actually closing, or null while
+ * deletion is immediate.
+ *
+ * Today `DELETE /accounts/v1/auths/delete-account` closes the account on the
+ * spot and every later sign-in is refused, so promising a window to change
+ * your mind would be false. Set this once the backend schedules deletion and
+ * lets a sign-in cancel it; the copy on Account details and in the sheet
+ * follows.
+ */
+export const DELETION_GRACE_PERIOD_DAYS: number | null = null;
+
+/** Below this, what is left is dust and does not stand in the way. */
+export const BALANCE_DUST_USD = 1;
+
+/** The phrase typed to arm the delete button. */
+export const DELETE_CONFIRMATION_PHRASE = 'DELETE';
+
+export type DeleteCheckState = 'ok' | 'blocked' | 'unknown' | 'info' | 'loading';
+
+export interface DeleteAccountCheck {
+ id: 'balance' | 'card-payments' | 'card';
+ state: DeleteCheckState;
+ title: string;
+ detail?: string;
+ /** Offer the way to clear the check, e.g. "Withdraw". */
+ hasAction?: boolean;
+}
+
+export interface DeleteAccountCheckInput {
+ /** Everything the account holds, card balance included, in USD. */
+ balanceUsd: number | undefined;
+ isBalanceLoading: boolean;
+ /** False when some balance could not be read, so the figure may be low. */
+ isBalanceComplete: boolean;
+ hasCard: boolean;
+ pendingCardPayments: number | undefined;
+ isCardPaymentsLoading: boolean;
+}
+
+const pluralize = (count: number, noun: string) => `${count} ${noun}${count === 1 ? '' : 's'}`;
+
+/**
+ * What has to be true before the account can go, and whether it is.
+ *
+ * Money left behind and payments still settling block deletion: once the
+ * account is closed nobody can sign in to move the one or reconcile the other.
+ * A check that could not be answered warns rather than blocks — deleting an
+ * account is something a user is entitled to do, and a balance endpoint that is
+ * down must not take that away from them.
+ */
+export const getDeleteAccountChecks = ({
+ balanceUsd,
+ isBalanceLoading,
+ isBalanceComplete,
+ hasCard,
+ pendingCardPayments,
+ isCardPaymentsLoading,
+}: DeleteAccountCheckInput): { checks: DeleteAccountCheck[]; canDelete: boolean } => {
+ const checks: DeleteAccountCheck[] = [];
+
+ if (isBalanceLoading) {
+ checks.push({ id: 'balance', state: 'loading', title: 'Checking your balance' });
+ } else if (balanceUsd !== undefined && balanceUsd >= BALANCE_DUST_USD) {
+ checks.push({
+ id: 'balance',
+ state: 'blocked',
+ title: 'Withdraw your balance',
+ detail: `${formatBalanceUSD(balanceUsd)} is still in your account`,
+ hasAction: true,
+ });
+ } else if (balanceUsd === undefined || !isBalanceComplete) {
+ checks.push({
+ id: 'balance',
+ state: 'unknown',
+ title: 'Withdraw your balance',
+ detail: "We couldn't check every balance. Move out anything left first",
+ hasAction: true,
+ });
+ } else {
+ checks.push({ id: 'balance', state: 'ok', title: 'No balance left in your account' });
+ }
+
+ if (hasCard) {
+ if (isCardPaymentsLoading) {
+ checks.push({ id: 'card-payments', state: 'loading', title: 'Checking card payments' });
+ } else if (pendingCardPayments === undefined) {
+ checks.push({
+ id: 'card-payments',
+ state: 'unknown',
+ title: "Couldn't check card payments",
+ detail: 'Make sure none are still pending',
+ });
+ } else if (pendingCardPayments > 0) {
+ checks.push({
+ id: 'card-payments',
+ state: 'blocked',
+ title: 'Wait for card payments to settle',
+ detail: `${pluralize(pendingCardPayments, 'payment')} still pending`,
+ });
+ } else {
+ checks.push({ id: 'card-payments', state: 'ok', title: 'No pending card payments' });
+ }
+
+ checks.push({
+ id: 'card',
+ state: 'info',
+ title: 'Your card will be cancelled',
+ detail: 'It stops working as soon as your account closes',
+ });
+ }
+
+ const canDelete = checks.every(check => check.state !== 'blocked' && check.state !== 'loading');
+
+ return { checks, canDelete };
+};
+
+/** The line under the sheet's title. */
+export const getDeleteSheetSubtitle = (hasCard: boolean): string => {
+ const closes = hasCard
+ ? 'This closes your account and cancels your card.'
+ : 'This closes your account.';
+ return DELETION_GRACE_PERIOD_DAYS
+ ? `${closes} You have ${DELETION_GRACE_PERIOD_DAYS} days to change your mind.`
+ : `${closes} It happens straight away and can't be undone.`;
+};
+
+/** The note under the Delete account row on Account details. */
+export const DELETE_ACCOUNT_FOOTNOTE = DELETION_GRACE_PERIOD_DAYS
+ ? `You'll need to withdraw your balance first. Deletion completes ${DELETION_GRACE_PERIOD_DAYS} days after you confirm — sign in before then to cancel.`
+ : "You'll need to withdraw your balance first. Deleting your account is immediate and can't be undone.";
diff --git a/hooks/useNotificationPreferences.ts b/hooks/useNotificationPreferences.ts
new file mode 100644
index 000000000..bac46c0fb
--- /dev/null
+++ b/hooks/useNotificationPreferences.ts
@@ -0,0 +1,61 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+
+import {
+ getNotificationPreferences,
+ NotificationPreferences,
+ updateNotificationPreferences,
+} from '@/lib/api';
+import { withRefreshToken } from '@/lib/utils';
+import { useUserStore } from '@/store/useUserStore';
+
+export const NOTIFICATION_PREFERENCES_QUERY_KEY = 'notificationPreferences';
+
+const useSelectedUserId = () =>
+ useUserStore(state => state.users.find(user => user.selected)?.userId);
+
+/**
+ * The account's push preferences, and a setter that applies a change at once
+ * and puts it back if the save fails.
+ *
+ * `preferences` is null when the backend has no preferences endpoint, which
+ * the Notifications screen takes as "leave the categories out".
+ */
+export const useNotificationPreferences = () => {
+ const userId = useSelectedUserId();
+ const queryClient = useQueryClient();
+ const queryKey = [NOTIFICATION_PREFERENCES_QUERY_KEY, userId];
+
+ const query = useQuery({
+ queryKey,
+ queryFn: () => withRefreshToken(() => getNotificationPreferences()),
+ enabled: !!userId,
+ retry: 1,
+ });
+
+ const mutation = useMutation({
+ mutationFn: (changes: Partial) =>
+ withRefreshToken(() => updateNotificationPreferences(changes)),
+ onMutate: async changes => {
+ await queryClient.cancelQueries({ queryKey });
+ const previous = queryClient.getQueryData(queryKey);
+ if (previous) queryClient.setQueryData(queryKey, { ...previous, ...changes });
+ return { previous };
+ },
+ onError: (_error, _changes, context) => {
+ if (context?.previous) queryClient.setQueryData(queryKey, context.previous);
+ },
+ onSuccess: saved => {
+ queryClient.setQueryData(queryKey, saved);
+ },
+ });
+
+ return {
+ preferences: query.data ?? null,
+ isLoading: query.isLoading,
+ isError: query.isError,
+ update: mutation.mutate,
+ isSaveError: mutation.isError,
+ };
+};
+
+export default useNotificationPreferences;
diff --git a/lib/api.ts b/lib/api.ts
index b10f97c23..8c19ff532 100644
--- a/lib/api.ts
+++ b/lib/api.ts
@@ -5068,3 +5068,69 @@ export const fetchSavingsSummary = async (
return response.json();
};
+
+/**
+ * Which kinds of push the account wants, by category.
+ *
+ * 3D Secure approvals, security alerts and account notices are not in here:
+ * those always go out.
+ */
+export interface NotificationPreferences {
+ cardPayments: boolean;
+ depositsTransfers: boolean;
+ earn: boolean;
+ cashbackRewards: boolean;
+ productNews: boolean;
+}
+
+/**
+ * The account's push preferences, or null when this backend does not offer
+ * them yet (404) — the screen then leaves the categories out rather than
+ * showing switches that change nothing.
+ */
+export const getNotificationPreferences = async (): Promise => {
+ const jwt = getJWTToken();
+
+ const response = await fetch(
+ `${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/users/notification-preferences`,
+ {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...getPlatformHeaders(),
+ ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}),
+ },
+ credentials: 'include',
+ },
+ );
+
+ if (response.status === 404) return null;
+ if (!response.ok) throw response;
+
+ return response.json();
+};
+
+/** Change some push preferences. Returns the full set as saved. */
+export const updateNotificationPreferences = async (
+ changes: Partial,
+): Promise => {
+ const jwt = getJWTToken();
+
+ const response = await fetch(
+ `${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/users/notification-preferences`,
+ {
+ method: 'PATCH',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...getPlatformHeaders(),
+ ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}),
+ },
+ credentials: 'include',
+ body: JSON.stringify(changes),
+ },
+ );
+
+ if (!response.ok) throw response;
+
+ return response.json();
+};
diff --git a/store/useProfileAvatarStore.ts b/store/useProfileAvatarStore.ts
new file mode 100644
index 000000000..9422f9a6a
--- /dev/null
+++ b/store/useProfileAvatarStore.ts
@@ -0,0 +1,39 @@
+import { create } from 'zustand';
+import { createJSONStorage, persist } from 'zustand/middleware';
+
+import mmkvStorage from '@/lib/mmvkStorage';
+
+import type { AvatarColorId } from '@/components/Profile/avatarColors';
+
+interface ProfileAvatarState {
+ /** The colour each account picked for its avatar, by user id. */
+ colorByUserId: Record;
+ setColor: (userId: string, color: AvatarColorId) => void;
+}
+
+const PROFILE_AVATAR_STORAGE_KEY = 'profile-avatar-storage';
+
+/**
+ * The avatar colour, kept on this device only.
+ *
+ * The backend has nowhere to store an avatar yet, so the choice does not follow
+ * the account to another device. Keyed by user id so switching accounts on one
+ * device shows each its own colour.
+ */
+export const useProfileAvatarStore = create()(
+ persist(
+ set => ({
+ colorByUserId: {},
+ setColor: (userId, color) =>
+ set(state => ({ colorByUserId: { ...state.colorByUserId, [userId]: color } })),
+ }),
+ {
+ name: PROFILE_AVATAR_STORAGE_KEY,
+ storage: createJSONStorage(() => mmkvStorage(PROFILE_AVATAR_STORAGE_KEY)),
+ },
+ ),
+);
+
+/** The colour `userId` picked, or undefined when they never picked one. */
+export const useProfileAvatarColorId = (userId: string | undefined) =>
+ useProfileAvatarStore(state => (userId ? state.colorByUserId[userId] : undefined));