
Экраны профиля и настроек из сгруппированных строк.
npx astra-rn add profile-settings-v3pnpm dlx astra-rn add profile-settings-v3yarn dlx astra-rn add profile-settings-v3bunx --bun astra-rn add profile-settings-v3npm install @hugeicons/core-free-icons @hugeicons/react-native @shopify/react-native-skia expo-asset expo-linear-gradient react-native-reanimated react-native-safe-area-context react-native-svgpnpm add @hugeicons/core-free-icons @hugeicons/react-native @shopify/react-native-skia expo-asset expo-linear-gradient react-native-reanimated react-native-safe-area-context react-native-svgyarn add @hugeicons/core-free-icons @hugeicons/react-native @shopify/react-native-skia expo-asset expo-linear-gradient react-native-reanimated react-native-safe-area-context react-native-svgbun add @hugeicons/core-free-icons @hugeicons/react-native @shopify/react-native-skia expo-asset expo-linear-gradient react-native-reanimated react-native-safe-area-context react-native-svgimport {
ArrowLeft01Icon,
ArrowRight01Icon,
Search01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react-native";
import { LinearGradient } from "expo-linear-gradient";
import React, { memo, useCallback, useState } from "react";
import {
Image,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Svg, {
Defs,
LinearGradient as SvgGradient,
Path,
Stop,
} from "react-native-svg";
import { SquircleView } from "@/shared/components/base/squircle-view";
import { Switch } from "@/shared/components/primitives/switch";
import {
AVATAR_SIZE,
CARD_BORDER_WIDTH,
CARD_CORNER_SMOOTHING,
CARD_GAP,
CARD_RADIUS,
COLORS,
CONTENT_BOTTOM_PADDING,
CONTENT_HORIZONTAL_PADDING,
CONTENT_TOP_PADDING,
DEFAULT_PROFILE,
DEFAULT_PROMO,
DEFAULT_SECTIONS,
DEFAULT_TITLE,
FONT_FAMILY,
HEADER_BUTTON_SIZE,
HEADER_HEIGHT,
LABEL_GAP,
PROFILE_PADDING,
PROMO_COLORS,
PROMO_GAP,
PROMO_ICON_SIZE,
PROMO_PADDING,
PROMO_RADIUS,
ROW_HEIGHT,
ROW_HORIZONTAL_PADDING,
ROW_ICON_GAP,
ROW_ICON_SIZE,
SECTION_GAP,
SEPARATOR_INSET,
SPARKLE_COLORS,
} from "./const";
import type {
IGlyph,
IProfileSettingsV3,
ISettingsRow,
ISettingsRowItem,
} from "./types";
const SparkleGlyph: React.FC<IGlyph> = ({
size = PROMO_ICON_SIZE,
colors = SPARKLE_COLORS,
}: IGlyph) => (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Defs>
<SvgGradient id="sparkle" x1="0" y1="0" x2="1" y2="2">
<Stop offset="0" stopColor={colors[0]} />
<Stop offset="1" stopColor={colors[1]} />
<Stop offset="1" stopColor={colors[2]} />
</SvgGradient>
</Defs>
<Path
d="M12 1.5C12.7 7.1 16.9 11.3 22.5 12C16.9 12.7 12.7 16.9 12 22.5C11.3 16.9 7.1 12.7 1.5 12C7.1 11.3 11.3 7.1 12 1.5Z"
fill="url(#sparkle)"
/>
</Svg>
);
const SettingsRowItem: React.FC<ISettingsRowItem> = memo(
({
id,
title,
icon,
accessory = "chevron",
value,
isLast,
checked = false,
onPress,
onToggle,
}: ISettingsRowItem) => {
const [isPressed, setIsPressed] = useState<boolean>(false);
const handlePressIn = useCallback((): void => setIsPressed(true), []);
const handlePressOut = useCallback((): void => setIsPressed(false), []);
const handlePress = useCallback((): void => onPress?.(id), [id, onPress]);
const handleCheckedChange = useCallback(
(next: boolean): void => onToggle?.(id, next),
[id, onToggle],
);
const isSwitch = accessory === "switch";
return (
<Pressable
accessibilityRole={isSwitch ? "switch" : "button"}
accessibilityLabel={title}
accessibilityState={isSwitch ? { checked } : undefined}
disabled={isSwitch}
onPress={handlePress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
style={[styles.row, isPressed ? styles.rowPressed : null]}
>
<View style={styles.rowIcon}>
<HugeiconsIcon icon={icon} size={ROW_ICON_SIZE} color={COLORS.icon} />
</View>
<Text style={styles.rowTitle} numberOfLines={1}>
{title}
</Text>
{accessory === "value" && value ? (
<Text style={styles.rowValue} numberOfLines={1}>
{value}
</Text>
) : null}
{isSwitch ? (
<Switch.Root
size="sm"
checked={checked}
trackColor={COLORS.switchTrack}
onCheckedChange={handleCheckedChange}
>
<Switch.Track>
<Switch.Thumb />
</Switch.Track>
</Switch.Root>
) : accessory === "none" ? null : (
<HugeiconsIcon
icon={ArrowRight01Icon}
size={18}
color={COLORS.accessory}
/>
)}
{isLast ? null : <View style={styles.separator} />}
</Pressable>
);
},
);
const buildToggleState = (
sections: IProfileSettingsV3["sections"],
): Record<string, boolean> => {
const state: Record<string, boolean> = {};
sections?.forEach((section) =>
section.rows.forEach((row: ISettingsRow) => {
if (row.accessory === "switch")
state[row.id] = row.defaultChecked ?? false;
}),
);
return state;
};
const ProfileSettingsV3: React.FC<IProfileSettingsV3> = ({
title = DEFAULT_TITLE,
profile = DEFAULT_PROFILE,
promo = DEFAULT_PROMO,
sections = DEFAULT_SECTIONS,
hideBack = false,
hideSearch = false,
style,
onBackPress,
onSearchPress,
onProfilePress,
onPromoPress,
onRowPress,
onRowToggle,
}: IProfileSettingsV3) => {
const [toggles, setToggles] = useState<Record<string, boolean>>(() =>
buildToggleState(sections),
);
const [isProfilePressed, setIsProfilePressed] = useState<boolean>(false);
const handleProfilePressIn = useCallback(
(): void => setIsProfilePressed(true),
[],
);
const handleProfilePressOut = useCallback(
(): void => setIsProfilePressed(false),
[],
);
const handleRowToggle = useCallback(
(id: string, checked: boolean): void => {
setToggles((prev) => ({ ...prev, [id]: checked }));
onRowToggle?.(id, checked);
},
[onRowToggle],
);
const insets = useSafeAreaInsets();
return (
<View style={[styles.container, style]}>
<View style={[styles.header, { paddingTop: insets.top }]}>
{hideBack ? (
<View style={styles.headerSpacer} />
) : (
<Pressable
accessibilityRole="button"
accessibilityLabel="Go back"
onPress={onBackPress}
style={({ pressed }) => [
styles.headerButton,
pressed ? styles.headerButtonPressed : null,
]}
>
<HugeiconsIcon
icon={ArrowLeft01Icon}
size={20}
color={COLORS.headerButtonIcon}
/>
</Pressable>
)}
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
{hideSearch ? (
<View style={styles.headerSpacer} />
) : (
<Pressable
accessibilityRole="button"
accessibilityLabel="Search"
onPress={onSearchPress}
style={({ pressed }) => [
styles.headerButton,
pressed ? styles.headerButtonPressed : null,
]}
>
<HugeiconsIcon
icon={Search01Icon}
size={20}
color={COLORS.headerButtonIcon}
/>
</Pressable>
)}
</View>
<ScrollView
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
<Pressable
accessibilityRole="button"
accessibilityLabel={profile.name}
onPress={onProfilePress}
onPressIn={handleProfilePressIn}
onPressOut={handleProfilePressOut}
>
<SquircleView
cornerRadius={CARD_RADIUS}
cornerSmoothing={CARD_CORNER_SMOOTHING}
backgroundColor={
isProfilePressed ? COLORS.cardPressed : COLORS.card
}
borderColor={COLORS.cardBorder}
borderWidth={CARD_BORDER_WIDTH}
>
<View style={styles.profile}>
{profile.avatar ? (
<Image
source={profile.avatar}
style={styles.avatar}
resizeMode="cover"
accessibilityIgnoresInvertColors
/>
) : (
<View style={[styles.avatar, styles.avatarPlaceholder]} />
)}
<View style={styles.profileText}>
<Text style={styles.name} numberOfLines={1}>
{profile.name}
</Text>
{profile.email ? (
<Text style={styles.email} numberOfLines={1}>
{profile.email}
</Text>
) : null}
</View>
<HugeiconsIcon
icon={ArrowRight01Icon}
size={18}
color={COLORS.accessory}
/>
</View>
</SquircleView>
</Pressable>
{promo ? (
<LinearGradient
colors={promo.colors ?? PROMO_COLORS}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.promo}
>
<SparkleGlyph />
<View style={styles.promoText}>
<Text style={styles.promoTitle} numberOfLines={1}>
{promo.title}
</Text>
{promo.subtitle ? (
<Text style={styles.promoSubtitle} numberOfLines={3}>
{promo.subtitle}
</Text>
) : null}
</View>
{promo.actionLabel ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={promo.actionLabel}
onPress={onPromoPress}
style={({ pressed }) => [
styles.promoAction,
pressed ? styles.promoActionPressed : null,
]}
>
<Text style={styles.promoActionLabel} numberOfLines={1}>
{promo.actionLabel}
</Text>
<HugeiconsIcon
icon={ArrowRight01Icon}
size={14}
color={COLORS.promoActionLabel}
/>
</Pressable>
) : null}
</LinearGradient>
) : null}
{sections.map((section) => (
<View key={section.id} style={styles.section}>
{section.label ? (
<Text style={styles.label}>{section.label.toUpperCase()}</Text>
) : null}
<SquircleView
cornerRadius={CARD_RADIUS}
cornerSmoothing={CARD_CORNER_SMOOTHING}
backgroundColor={COLORS.card}
borderColor={COLORS.cardBorder}
borderWidth={CARD_BORDER_WIDTH}
>
{section.rows.map((row, index) => (
<SettingsRowItem
key={row.id}
{...row}
isLast={index === section.rows.length - 1}
checked={toggles[row.id]}
onPress={onRowPress}
onToggle={handleRowToggle}
/>
))}
</SquircleView>
</View>
))}
</ScrollView>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.screen,
},
header: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: CONTENT_HORIZONTAL_PADDING,
},
headerButton: {
alignItems: "center",
justifyContent: "center",
width: HEADER_BUTTON_SIZE,
height: HEADER_BUTTON_SIZE,
borderRadius: HEADER_BUTTON_SIZE / 2,
backgroundColor: COLORS.headerButton,
},
headerButtonPressed: {
opacity: 0.7,
},
headerSpacer: {
width: HEADER_BUTTON_SIZE,
height: HEADER_BUTTON_SIZE,
},
title: {
flex: 1,
height: HEADER_HEIGHT,
lineHeight: HEADER_HEIGHT,
textAlign: "center",
fontSize: 17,
fontFamily: FONT_FAMILY,
fontWeight: "600",
letterSpacing: -0.3,
color: COLORS.title,
},
content: {
paddingHorizontal: CONTENT_HORIZONTAL_PADDING,
paddingTop: CONTENT_TOP_PADDING,
paddingBottom: CONTENT_BOTTOM_PADDING,
},
profile: {
flexDirection: "row",
alignItems: "center",
width: "100%",
padding: PROFILE_PADDING,
},
avatar: {
width: AVATAR_SIZE,
height: AVATAR_SIZE,
borderRadius: AVATAR_SIZE / 2,
},
avatarPlaceholder: {
backgroundColor: COLORS.avatar,
},
profileText: {
flex: 1,
minWidth: 0,
marginLeft: CARD_GAP,
},
name: {
fontSize: 15,
fontWeight: "700",
letterSpacing: -0.15,
color: COLORS.name,
fontFamily: FONT_FAMILY,
},
email: {
marginTop: 2,
fontSize: 12.5,
fontWeight: "400",
color: COLORS.email,
},
promo: {
flexDirection: "row",
alignItems: "center",
marginTop: SECTION_GAP,
padding: PROMO_PADDING,
borderRadius: PROMO_RADIUS,
overflow: "hidden",
},
promoText: {
flex: 1,
minWidth: 0,
marginHorizontal: PROMO_GAP,
},
promoTitle: {
fontSize: 15,
fontWeight: "700",
color: COLORS.promoTitle,
fontFamily: FONT_FAMILY,
},
promoSubtitle: {
marginTop: 4,
fontSize: 11.5,
lineHeight: 16,
fontWeight: "400",
color: COLORS.promoSubtitle,
},
promoAction: {
flexDirection: "row",
alignItems: "center",
gap: 4,
paddingHorizontal: 14,
height: 32,
borderRadius: 16,
backgroundColor: COLORS.promoAction,
},
promoActionPressed: {
opacity: 0.8,
},
promoActionLabel: {
fontSize: 13,
fontWeight: "600",
letterSpacing: -0.2,
color: COLORS.promoActionLabel,
},
section: {
marginTop: SECTION_GAP,
},
label: {
marginBottom: LABEL_GAP,
marginLeft: 4,
fontSize: 12,
fontWeight: "500",
letterSpacing: 0.9,
color: COLORS.label,
},
row: {
flexDirection: "row",
alignItems: "center",
height: ROW_HEIGHT,
paddingHorizontal: ROW_HORIZONTAL_PADDING,
},
rowPressed: {
backgroundColor: COLORS.cardPressed,
},
rowIcon: {
width: ROW_ICON_SIZE,
height: ROW_ICON_SIZE,
marginRight: ROW_ICON_GAP,
alignItems: "center",
justifyContent: "center",
},
rowTitle: {
flex: 1,
fontSize: 15,
fontWeight: "500",
letterSpacing: -0.1,
color: COLORS.rowTitle,
fontFamily: FONT_FAMILY,
},
rowValue: {
marginRight: 8,
fontSize: 13.5,
fontWeight: "400",
color: COLORS.value,
},
separator: {
position: "absolute",
left: SEPARATOR_INSET,
right: 0,
bottom: 0,
height: StyleSheet.hairlineWidth,
backgroundColor: COLORS.separator,
},
});
export { ProfileSettingsV3 };
export type {
IGlyph,
IProfileSettingsV3,
ISettingsProfile,
ISettingsPromo,
ISettingsRow,
ISettingsRowItem,
ISettingsSection,
TSettingsRowAccessory,
} from "./types";
export default memo(ProfileSettingsV3);import {
Award01Icon,
BankIcon,
Comment01Icon,
CreditCardIcon,
FingerAccessIcon,
HelpCircleIcon,
PreferenceHorizontalIcon,
Settings01Icon,
Shield01Icon,
TranslateIcon,
} from "@hugeicons/core-free-icons";
import type {
ISettingsProfile,
ISettingsPromo,
ISettingsSection,
} from "./types";
import { Platform } from "react-native";
import { PHOTO_SOURCES } from "./photos";
export const DEFAULT_TITLE = "Profile";
export const DEFAULT_PROFILE: ISettingsProfile = {
name: "Ava Bennett",
email: "avabennett@gmail.com",
avatar: {
uri: PHOTO_SOURCES["profile-settings-v3-1"],
},
};
export const DEFAULT_PROMO: ISettingsPromo = {
title: "Upgrade to Pro",
subtitle:
"Unlock shared budgets, AI insights, receipt scanning, and advanced analytics.",
actionLabel: "Upgrade",
};
export const DEFAULT_SECTIONS: ISettingsSection[] = [
{
id: "account",
rows: [
{
id: "settings",
title: "Settings",
icon: Settings01Icon,
},
{
id: "plan-subscription",
title: "Plan & Subscription",
icon: CreditCardIcon,
},
{
id: "badges",
title: "Badges",
icon: Award01Icon,
},
{
id: "linked-banks",
title: "My Debit Cards & Linked Banks",
icon: BankIcon,
},
{
id: "biometrics",
title: "Enable finger Print/Face ID",
icon: FingerAccessIcon,
accessory: "switch",
defaultChecked: true,
},
],
},
{
id: "app",
rows: [
{
id: "preferences",
title: "Preferences",
icon: PreferenceHorizontalIcon,
},
{
id: "privacy-policy",
title: "Privacy Policy",
icon: Shield01Icon,
},
{
id: "help-support",
title: "Help & Support",
icon: HelpCircleIcon,
},
],
},
{
id: "more",
rows: [
{
id: "feedback",
title: "Feedback",
icon: Comment01Icon,
},
{
id: "language",
title: "Language",
icon: TranslateIcon,
accessory: "value",
value: "English",
},
],
},
];
export const CONTENT_HORIZONTAL_PADDING = 18;
export const CONTENT_TOP_PADDING = 12;
export const CONTENT_BOTTOM_PADDING = 40;
export const HEADER_HEIGHT = 52;
export const HEADER_BUTTON_SIZE = 40;
export const CARD_RADIUS = 20;
export const CARD_CORNER_SMOOTHING = 2;
export const CARD_BORDER_WIDTH = 0.5;
export const CARD_GAP = 14;
export const PROFILE_PADDING = 16;
export const AVATAR_SIZE = 50;
export const PROMO_RADIUS = 18;
export const PROMO_PADDING = 16;
export const PROMO_ICON_SIZE = 28;
export const PROMO_GAP = 14;
export const ROW_HEIGHT = 52;
export const ROW_HORIZONTAL_PADDING = 14;
export const ROW_ICON_SIZE = 20;
export const ROW_ICON_GAP = 14;
export const SEPARATOR_INSET =
ROW_HORIZONTAL_PADDING + ROW_ICON_SIZE + ROW_ICON_GAP;
export const SECTION_GAP = 14;
export const LABEL_GAP = 10;
export const PROMO_COLORS = ["#333333", "#1d1d1d"] as const;
export const SPARKLE_COLORS = ["#5b8cff", "#1f4fe0", "#ffff"] as const;
export const FONT_FAMILY = Platform.select({
ios: "SF Pro Rounded",
default: undefined,
});
export const COLORS = {
screen: "#f3f3f3",
title: "#111111",
headerButton: "#f3f3f3",
headerButtonIcon: "#111111",
card: "#ffffff",
cardBorder: "rgba(0,0,0,0.05)",
cardPressed: "#f6f6f6",
avatar: "#e4e4e4",
name: "#111111",
email: "#9b9b9b",
label: "#9b9b9b",
promoTitle: "#ffffff",
promoSubtitle: "#b3b3b3",
promoAction: "#efefef",
promoActionLabel: "#141414",
rowTitle: "#141414",
icon: "#818181",
value: "#9b9b9b",
accessory: "#b0b0b0",
separator: "rgba(0,0,0,0.07)",
switchTrack: "#34c759",
} as const;import { IconSvgElement } from "@hugeicons/react-native";
import type { ImageSourcePropType, StyleProp, ViewStyle } from "react-native";
export interface IGlyph {
size?: number;
colors?: readonly [string, string, string];
}
export type TSettingsRowAccessory = "chevron" | "switch" | "value" | "none";
export interface ISettingsRow {
id: string;
title: string;
icon: IconSvgElement;
accessory?: TSettingsRowAccessory;
value?: string;
defaultChecked?: boolean;
}
export interface ISettingsSection {
id: string;
label?: string;
rows: ISettingsRow[];
}
export interface ISettingsProfile {
name: string;
email?: string;
avatar?: ImageSourcePropType;
}
export interface ISettingsPromo {
title: string;
subtitle?: string;
actionLabel?: string;
colors?: readonly [string, string];
}
export interface ISettingsRowItem extends ISettingsRow {
isLast: boolean;
checked?: boolean;
onPress?: (id: string) => void;
onToggle?: (id: string, checked: boolean) => void;
}
export interface IProfileSettingsV3 {
title?: string;
profile?: ISettingsProfile;
promo?: ISettingsPromo;
sections?: ISettingsSection[];
hideBack?: boolean;
hideSearch?: boolean;
style?: StyleProp<ViewStyle>;
onBackPress?: () => void;
onSearchPress?: () => void;
onProfilePress?: () => void;
onPromoPress?: () => void;
onRowPress?: (id: string) => void;
onRowToggle?: (id: string, checked: boolean) => void;
}import { Asset } from "expo-asset";
const uri = (id: number): string => Asset.fromModule(id).uri;
export const PHOTO_SOURCES = {
"profile-settings-v3-1": uri(require("./profile-settings-v3-1.webp")),
};Ещё 1 файл с картинками или шрифтами. astra-rn add скачивает их сам, вручную положите их по этим путям: