Profile editor — Cancel/Save nav header that arms Save on edits, avatar with a change-photo badge, name/handle/bio/location/website fields, and a centered destructive sign-out row
import * as React from "react";
import { useState } from "react";
import { View, ScrollView, TextInput } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { Avatar, Button, Glass, Icon, KeyboardAvoiding, List, Text, Textarea } from "@lumioui/ui";
import { useHaptic } from "@lumioui/core";
import { BLOCK_STAGGER, Mount, Press } from "../shared";
export interface EditableProfile {
name: string;
/** Handle without the leading @. */
handle: string;
bio?: string;
location?: string;
website?: string;
avatarUrl?: string;
}
export interface EditProfileScreenProps {
user?: EditableProfile;
/** Fires with the edited fields when Save is pressed. */
onSave?: (profile: EditableProfile) => void;
onChangePhoto?: () => void;
onSignOut?: () => void;
onBack?: () => void;
}
const face = (n: number) => `https://i.pravatar.cc/150?img=${n}`;
/** iOS-settings row: icon + label pinned left, the field fills the rest. */
function FieldRow({
icon,
label,
top,
...inputProps
}: {
icon: React.ReactNode;
label: string;
top?: boolean;
} & React.ComponentProps<typeof TextInput>) {
return (
<View
className={
"flex-row items-center gap-3 px-4 py-3.5" + (top ? "" : " border-t border-border")
}
>
<View className="w-5 items-center">{icon}</View>
<Text variant="small" className="w-16 text-muted-foreground">
{label}
</Text>
<TextInput
className="flex-1 text-sm text-foreground"
placeholderTextColor="#9A9AA7"
{...inputProps}
/>
</View>
);
}
const DEFAULT_USER: EditableProfile = {
name: "Amara Chen",
handle: "amarachen",
bio: "Product designer · coffee first · writing about design systems",
location: "Lisbon",
website: "amara.design",
avatarUrl: face(9),
};
/** Edit profile — avatar with a change-photo badge, text fields for the
* public identity, a save bar that arms once something changed, and the
* sign-out row parked at the bottom of the form. */
export function EditProfileScreen({
user = DEFAULT_USER,
onSave,
onChangePhoto,
onSignOut,
onBack,
}: EditProfileScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
// The built-in demo opens mid-edit — bio differs from the stored profile —
// so Save renders armed in the catalog. A host-supplied profile loads
// verbatim.
const initial =
user === DEFAULT_USER
? { ...user, bio: `${user.bio ?? ""} & motion` }
: user;
const [name, setName] = useState(initial.name);
const [handle, setHandle] = useState(initial.handle);
const [bio, setBio] = useState(initial.bio ?? "");
const [location, setLocation] = useState(initial.location ?? "");
const [website, setWebsite] = useState(initial.website ?? "");
const [saving, setSaving] = useState(false);
// Save arms only when a field actually differs from what was loaded —
// a permanently-enabled Save invites no-op network calls.
const dirty =
name !== user.name ||
handle !== user.handle ||
bio !== (user.bio ?? "") ||
location !== (user.location ?? "") ||
website !== (user.website ?? "");
const save = () => {
setSaving(true);
onSave?.({ ...user, name, handle, bio, location, website });
// Template placeholder — a real host resolves the save promise instead.
setTimeout(() => setSaving(false), 800);
};
return (
<SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
{/* Floating chrome — X disc + hug-label title pill + Save pill.
Save arms only once a field differs (see `dirty`); it reads lime
as the one committed action on the screen. */}
<View className="absolute inset-x-4 z-10" style={{ top: insets.top + 8 }} pointerEvents="box-none">
<View className="flex-row items-center justify-between gap-2">
<Glass
effect="regular"
className="h-11 w-11 items-center justify-center rounded-full"
>
<Button
variant="ghost"
size="icon"
haptic="light"
onPress={() => onBack?.()}
accessibilityLabel="Cancel editing"
className="h-9 w-9"
>
<Icon name="x" size="md" color="foreground" />
</Button>
</Glass>
<View className="h-11 items-center justify-center rounded-full bg-[#BEF04C] px-4">
<Button
variant="ghost"
size="sm"
loading={saving}
disabled={!dirty}
onPress={save}
haptic="medium"
textClassName="font-semibold text-[#1B1B26]"
accessibilityLabel="Save profile changes"
>
Save
</Button>
</View>
</View>
<View pointerEvents="none" className="absolute inset-0 items-center justify-center">
<Glass
effect="regular"
className="h-11 flex-row items-center justify-center rounded-full px-5"
>
<Text accessibilityRole="header" className="text-sm font-bold tracking-tight">
Edit profile
</Text>
</Glass>
</View>
</View>
<KeyboardAvoiding>
<ScrollView
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
contentContainerStyle={{ paddingTop: insets.top + 60, paddingBottom: insets.bottom + 32 }}
>
{/* Identity hero — soft lime card carries the avatar so the
screen doesn't open as a bare form. */}
<Mount>
<View className="mx-4 items-center gap-2 rounded-3xl bg-[#EDF8C4] px-6 py-5 dark:bg-[#1E2410]">
<Press
onPress={() => {
haptics.light();
onChangePhoto?.();
}}
accessibilityRole="button"
accessibilityLabel="Change profile photo"
className="min-h-11 items-center justify-center"
>
<View>
<Avatar
size="xl"
source={user.avatarUrl ? { uri: user.avatarUrl } : undefined}
fallback={user.name}
alt={`${user.name}'s photo`}
/>
{/* Badge sits on the avatar's lower-right edge; the card
ring keeps it legible over any photo. */}
<View className="absolute -bottom-1 -right-1 h-8 w-8 items-center justify-center rounded-full border-2 border-background bg-[#BEF04C]">
<Icon name="camera" size="sm" color="#1B1B26" />
</View>
</View>
</Press>
<Press
onPress={() => {
haptics.light();
onChangePhoto?.();
}}
accessibilityRole="button"
accessibilityLabel="Change profile photo"
hitSlop={8}
className="min-h-11 items-center justify-center px-2"
>
<Text variant="small" className="font-semibold text-foreground">
Change photo
</Text>
</Press>
{/* Public URL — edit screens show what the profile resolves to. */}
<View className="flex-row items-center gap-1.5 overflow-hidden rounded-full px-3 py-1">
<Glass interactive effect="clear" sheen={0.55} className="absolute inset-0 rounded-full" pointerEvents="none" />
<Icon name="link" size="sm" color="mutedForeground" />
<Text variant="muted" className="font-mono text-xs">
lumio.app/@{handle || "you"}
</Text>
</View>
</View>
</Mount>
{/* Fields — grouped iOS-settings cards instead of a label-over-
field stack, so the form reads as rows not a web form. */}
<View className="gap-4 px-4 pt-4">
<Mount delay={BLOCK_STAGGER}>
<View className="overflow-hidden rounded-2xl bg-white dark:bg-[#1A1A20]">
<FieldRow
icon={<Icon name="user" size="sm" color="mutedForeground" />}
label="Name"
top
placeholder="Your name"
value={name}
onChangeText={setName}
autoComplete="name"
textContentType="name"
returnKeyType="next"
/>
<FieldRow
icon={<Text className="text-base leading-4 text-muted-foreground">@</Text>}
label="Handle"
placeholder="username"
value={handle}
onChangeText={(v) => setHandle(v.replace(/^@+/, ""))}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="next"
/>
</View>
</Mount>
<Mount delay={BLOCK_STAGGER * 2}>
<Textarea
label="Bio"
placeholder="Tell people what you do"
value={bio}
onChangeText={setBio}
maxLength={150}
showCount
autoGrow
minHeight={96}
/>
</Mount>
<Mount delay={BLOCK_STAGGER * 3}>
<View className="overflow-hidden rounded-2xl bg-white dark:bg-[#1A1A20]">
<FieldRow
icon={<Icon name="map-pin" size="sm" color="mutedForeground" />}
label="Location"
top
placeholder="City, country"
value={location}
onChangeText={setLocation}
returnKeyType="next"
/>
<FieldRow
icon={<Icon name="link" size="sm" color="mutedForeground" />}
label="Website"
placeholder="yoursite.com"
value={website}
onChangeText={setWebsite}
keyboardType="url"
autoCapitalize="none"
autoCorrect={false}
returnKeyType="done"
onSubmitEditing={dirty ? save : undefined}
/>
</View>
</Mount>
</View>
{/* Destructive row — centered label is the iOS logout idiom, same
as settings/profile. */}
<Mount delay={BLOCK_STAGGER * 6}>
<List inset glass className="pt-8">
<List.Section>
<List.Item
title="Sign Out"
titleClassName="text-center font-medium text-destructive"
haptic={false}
onPress={() => {
haptics.warning();
onSignOut?.();
}}
accessibilityLabel="Sign out"
accessibilityHint="Ends your session on this device"
/>
</List.Section>
</List>
</Mount>
</ScrollView>
</KeyboardAvoiding>
</SafeAreaView>
);
}
EditProfileScreen.displayName = "EditProfileScreen";
Recreate the "Edit Profile Screen" screen in React Native using LumioUI.
Goal: Profile editor — Cancel/Save nav header that arms Save on edits, avatar with a change-photo badge, name/handle/bio/location/website fields, and a centered destructive sign-out row Match the reference implementation's layout, behavior, and motion exactly — this is a production screen, not a mockup.
## Setup
Fastest path — install the screen plus its component deps:
```bash
npx lumioui add core-edit-profile
```
Or pull just the components: `npx lumioui add avatar button icon input keyboard-avoiding list text textarea`
Native packages:
- @lumioui/ui@latest
- @lumioui/core@latest
- react-native-safe-area-context@*
- react-native-reanimated@*
- react-native-svg@*
- nativewind@*
Stack assumptions: Expo SDK 54 / RN 0.81, Reanimated 4, NativeWind v4, TypeScript strict, Expo Go compatible.
## Components — how, where, when
### `avatar` — Circular user image with fallback initials and status dot.
- `<Avatar>` ×1
### `button` — Tactile pill button with press spring, ink wash and async loading.
- `<Button>` ×2
### `icon` — Lucide icon set mapped to theme colors and sizes.
- `<Icon>` ×6
### `input` — Labeled field with focus ring, error shake and success check.
- imported but not rendered directly — likely used through a sibling or as a utility
### `keyboard-avoiding` — View that lifts above the keyboard on both platforms.
- `<KeyboardAvoiding>` ×1
### `list` — Settings-style rows with separators and press feedback.
- `<List>` ×1
### `text` — Themed type scale from caption to display.
- `<Text>` ×5
### `textarea` — Multiline field with auto-grow and character count.
- `<Textarea>` ×1
## Conventions (non-negotiable)
- Styling via NativeWind `className` with semantic tokens only: `bg-background`, `text-foreground`, `bg-card`, `text-card-foreground`, `bg-secondary`, `bg-muted`, `text-muted-foreground`, `bg-accent`, `bg-destructive`, `border-border`. Hard-coded colors only inside media overlays (`text-white`, `bg-black/N`).
- Motion via the token presets from `@lumioui/core` (`spring.press`, `spring.surface`, `spring.glide`, `timing.fast`, `timing.slow`, …) — no bespoke spring configs or durations.
- Gate transform motion behind `useReducedMotion()`; keep opacity/color feedback, drop travel and overshoot.
- Haptics via `useHaptic()`: `light` on press, `selection` on toggle/select, `warning` on invalid input. Never import expo-haptics directly.
- Press feedback via `usePressAnimation()` (~0.97 scale), not `activeOpacity` tricks.
- a11y: `accessibilityRole` + `accessibilityLabel` on every control, touch targets ≥44pt.
- Entrance choreography: staggered `Mount` wrappers (delay 0/60/120…) so regions land in sequence instead of popping in at once.
- Wrap the screen in `SafeAreaView`; keep dark mode working via the semantic tokens.
## Deliverable
One file: `screens/core/edit-profile.tsx` exporting EditProfileScreen. Preserve the props interface — the host app wires the callbacks (navigation, submit handlers) through them.
## Reference implementation
The LumioUI source for this screen — reproduce it faithfully:
```tsx
import * as React from "react";
import { useState } from "react";
import { View, ScrollView, TextInput } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { Avatar, Button, Glass, Icon, KeyboardAvoiding, List, Text, Textarea } from "@lumioui/ui";
import { useHaptic } from "@lumioui/core";
import { BLOCK_STAGGER, Mount, Press } from "../shared";
export interface EditableProfile {
name: string;
/** Handle without the leading @. */
handle: string;
bio?: string;
location?: string;
website?: string;
avatarUrl?: string;
}
export interface EditProfileScreenProps {
user?: EditableProfile;
/** Fires with the edited fields when Save is pressed. */
onSave?: (profile: EditableProfile) => void;
onChangePhoto?: () => void;
onSignOut?: () => void;
onBack?: () => void;
}
const face = (n: number) => `https://i.pravatar.cc/150?img=${n}`;
/** iOS-settings row: icon + label pinned left, the field fills the rest. */
function FieldRow({
icon,
label,
top,
...inputProps
}: {
icon: React.ReactNode;
label: string;
top?: boolean;
} & React.ComponentProps<typeof TextInput>) {
return (
<View
className={
"flex-row items-center gap-3 px-4 py-3.5" + (top ? "" : " border-t border-border")
}
>
<View className="w-5 items-center">{icon}</View>
<Text variant="small" className="w-16 text-muted-foreground">
{label}
</Text>
<TextInput
className="flex-1 text-sm text-foreground"
placeholderTextColor="#9A9AA7"
{...inputProps}
/>
</View>
);
}
const DEFAULT_USER: EditableProfile = {
name: "Amara Chen",
handle: "amarachen",
bio: "Product designer · coffee first · writing about design systems",
location: "Lisbon",
website: "amara.design",
avatarUrl: face(9),
};
/** Edit profile — avatar with a change-photo badge, text fields for the
* public identity, a save bar that arms once something changed, and the
* sign-out row parked at the bottom of the form. */
export function EditProfileScreen({
user = DEFAULT_USER,
onSave,
onChangePhoto,
onSignOut,
onBack,
}: EditProfileScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
// The built-in demo opens mid-edit — bio differs from the stored profile —
// so Save renders armed in the catalog. A host-supplied profile loads
// verbatim.
const initial =
user === DEFAULT_USER
? { ...user, bio: `${user.bio ?? ""} & motion` }
: user;
const [name, setName] = useState(initial.name);
const [handle, setHandle] = useState(initial.handle);
const [bio, setBio] = useState(initial.bio ?? "");
const [location, setLocation] = useState(initial.location ?? "");
const [website, setWebsite] = useState(initial.website ?? "");
const [saving, setSaving] = useState(false);
// Save arms only when a field actually differs from what was loaded —
// a permanently-enabled Save invites no-op network calls.
const dirty =
name !== user.name ||
handle !== user.handle ||
bio !== (user.bio ?? "") ||
location !== (user.location ?? "") ||
website !== (user.website ?? "");
const save = () => {
setSaving(true);
onSave?.({ ...user, name, handle, bio, location, website });
// Template placeholder — a real host resolves the save promise instead.
setTimeout(() => setSaving(false), 800);
};
return (
<SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
{/* Floating chrome — X disc + hug-label title pill + Save pill.
Save arms only once a field differs (see `dirty`); it reads lime
as the one committed action on the screen. */}
<View className="absolute inset-x-4 z-10" style={{ top: insets.top + 8 }} pointerEvents="box-none">
<View className="flex-row items-center justify-between gap-2">
<Glass
effect="regular"
className="h-11 w-11 items-center justify-center rounded-full"
>
<Button
variant="ghost"
size="icon"
haptic="light"
onPress={() => onBack?.()}
accessibilityLabel="Cancel editing"
className="h-9 w-9"
>
<Icon name="x" size="md" color="foreground" />
</Button>
</Glass>
<View className="h-11 items-center justify-center rounded-full bg-[#BEF04C] px-4">
<Button
variant="ghost"
size="sm"
loading={saving}
disabled={!dirty}
onPress={save}
haptic="medium"
textClassName="font-semibold text-[#1B1B26]"
accessibilityLabel="Save profile changes"
>
Save
</Button>
</View>
</View>
<View pointerEvents="none" className="absolute inset-0 items-center justify-center">
<Glass
effect="regular"
className="h-11 flex-row items-center justify-center rounded-full px-5"
>
<Text accessibilityRole="header" className="text-sm font-bold tracking-tight">
Edit profile
</Text>
</Glass>
</View>
</View>
<KeyboardAvoiding>
<ScrollView
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
contentContainerStyle={{ paddingTop: insets.top + 60, paddingBottom: insets.bottom + 32 }}
>
{/* Identity hero — soft lime card carries the avatar so the
screen doesn't open as a bare form. */}
<Mount>
<View className="mx-4 items-center gap-2 rounded-3xl bg-[#EDF8C4] px-6 py-5 dark:bg-[#1E2410]">
<Press
onPress={() => {
haptics.light();
onChangePhoto?.();
}}
accessibilityRole="button"
accessibilityLabel="Change profile photo"
className="min-h-11 items-center justify-center"
>
<View>
<Avatar
size="xl"
source={user.avatarUrl ? { uri: user.avatarUrl } : undefined}
fallback={user.name}
alt={`${user.name}'s photo`}
/>
{/* Badge sits on the avatar's lower-right edge; the card
ring keeps it legible over any photo. */}
<View className="absolute -bottom-1 -right-1 h-8 w-8 items-center justify-center rounded-full border-2 border-background bg-[#BEF04C]">
<Icon name="camera" size="sm" color="#1B1B26" />
</View>
</View>
</Press>
<Press
onPress={() => {
haptics.light();
onChangePhoto?.();
}}
accessibilityRole="button"
accessibilityLabel="Change profile photo"
hitSlop={8}
className="min-h-11 items-center justify-center px-2"
>
<Text variant="small" className="font-semibold text-foreground">
Change photo
</Text>
</Press>
{/* Public URL — edit screens show what the profile resolves to. */}
<View className="flex-row items-center gap-1.5 overflow-hidden rounded-full px-3 py-1">
<Glass interactive effect="clear" sheen={0.55} className="absolute inset-0 rounded-full" pointerEvents="none" />
<Icon name="link" size="sm" color="mutedForeground" />
<Text variant="muted" className="font-mono text-xs">
lumio.app/@{handle || "you"}
</Text>
</View>
</View>
</Mount>
{/* Fields — grouped iOS-settings cards instead of a label-over-
field stack, so the form reads as rows not a web form. */}
<View className="gap-4 px-4 pt-4">
<Mount delay={BLOCK_STAGGER}>
<View className="overflow-hidden rounded-2xl bg-white dark:bg-[#1A1A20]">
<FieldRow
icon={<Icon name="user" size="sm" color="mutedForeground" />}
label="Name"
top
placeholder="Your name"
value={name}
onChangeText={setName}
autoComplete="name"
textContentType="name"
returnKeyType="next"
/>
<FieldRow
icon={<Text className="text-base leading-4 text-muted-foreground">@</Text>}
label="Handle"
placeholder="username"
value={handle}
onChangeText={(v) => setHandle(v.replace(/^@+/, ""))}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="next"
/>
</View>
</Mount>
<Mount delay={BLOCK_STAGGER * 2}>
<Textarea
label="Bio"
placeholder="Tell people what you do"
value={bio}
onChangeText={setBio}
maxLength={150}
showCount
autoGrow
minHeight={96}
/>
</Mount>
<Mount delay={BLOCK_STAGGER * 3}>
<View className="overflow-hidden rounded-2xl bg-white dark:bg-[#1A1A20]">
<FieldRow
icon={<Icon name="map-pin" size="sm" color="mutedForeground" />}
label="Location"
top
placeholder="City, country"
value={location}
onChangeText={setLocation}
returnKeyType="next"
/>
<FieldRow
icon={<Icon name="link" size="sm" color="mutedForeground" />}
label="Website"
placeholder="yoursite.com"
value={website}
onChangeText={setWebsite}
keyboardType="url"
autoCapitalize="none"
autoCorrect={false}
returnKeyType="done"
onSubmitEditing={dirty ? save : undefined}
/>
</View>
</Mount>
</View>
{/* Destructive row — centered label is the iOS logout idiom, same
as settings/profile. */}
<Mount delay={BLOCK_STAGGER * 6}>
<List inset glass className="pt-8">
<List.Section>
<List.Item
title="Sign Out"
titleClassName="text-center font-medium text-destructive"
haptic={false}
onPress={() => {
haptics.warning();
onSignOut?.();
}}
accessibilityLabel="Sign out"
accessibilityHint="Ends your session on this device"
/>
</List.Section>
</List>
</Mount>
</ScrollView>
</KeyboardAvoiding>
</SafeAreaView>
);
}
EditProfileScreen.displayName = "EditProfileScreen";
```
Screen: https://lumioui.dev/screens/core-edit-profile