Dark edit profile: inline-editable value rows, camera-badge avatar, save states
import * as React from "react";
import { useState } from "react";
import { ScrollView, View } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { Avatar, Glass, Icon, Text, type IconName } from "@lumioui/ui";
import { AnimatedTextInput, useHaptic, useTheme } from "@lumioui/core";
import { BLOCK_STAGGER, Mount, Press, RowIcon, type IconTone } from "../shared";
export interface ProfileRowsUser {
name: string;
username: string;
email: string;
phone: string;
bio: string;
avatarUrl?: string;
}
export interface EditProfileRowsScreenProps {
user?: ProfileRowsUser;
onSave?: (user: ProfileRowsUser) => void;
onBack?: () => void;
}
const DEFAULT_USER: ProfileRowsUser = {
name: "Amara Chen",
username: "amarachen",
email: "amara.chen@lumio.dev",
phone: "+1 415 555 0132",
bio: "Product designer · coffee first",
avatarUrl: "https://i.pravatar.cc/150?img=9",
};
type FieldKey = "name" | "username" | "email" | "phone" | "bio";
const FIELDS: { key: FieldKey; label: string; icon: IconName; tone: IconTone; multiline?: boolean }[] = [
{ key: "name", label: "Name", icon: "user", tone: "blue" },
{ key: "username", label: "Username", icon: "tag", tone: "purple" },
{ key: "email", label: "Email", icon: "mail", tone: "green" },
{ key: "phone", label: "Phone", icon: "phone", tone: "teal" },
{ key: "bio", label: "Bio", icon: "file-text", tone: "orange", multiline: true },
];
/** Edit profile — dark value-row idiom (social-settings reference): label
* left, right-aligned inline-editable value, lime accents, camera-badge
* avatar, and a Save that arms once a field changes. */
export function EditProfileRowsScreen({
user = DEFAULT_USER,
onSave,
onBack,
}: EditProfileRowsScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const { colors } = useTheme();
const [values, setValues] = useState<Record<FieldKey, string>>({ ...user });
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const dirty = FIELDS.some((f) => values[f.key] !== user[f.key]);
const save = () => {
haptics.medium();
setSaving(true);
onSave?.({ ...user, ...values });
setTimeout(() => {
setSaving(false);
setSaved(true);
}, 700);
};
const set = (key: FieldKey, v: string) => {
setSaved(false);
setValues((p) => ({ ...p, [key]: v }));
};
return (
<SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
{/* Floating chrome — X disc + hug-label title pill + lime Save pill. */}
<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"
>
<Press
onPress={() => {
haptics.light();
onBack?.();
}}
accessibilityLabel="Cancel"
accessibilityRole="button"
className="h-9 w-9 items-center justify-center"
>
<Icon name="x" size="md" color="foreground" />
</Press>
</Glass>
<View
className={`h-11 items-center justify-center rounded-full px-4 ${
dirty || saving ? "bg-[#BEF04C]" : "bg-white dark:bg-[#1A1A20]"
}`}
>
<Press
onPress={save}
disabled={!dirty || saving}
accessibilityLabel="Save changes"
accessibilityRole="button"
accessibilityState={{ disabled: !dirty || saving }}
className="min-h-11 justify-center"
>
<Text
className="font-semibold"
style={{ color: dirty || saving ? "#1B1B26" : colors.mutedForeground }}
>
{saving ? "Saving…" : saved ? "Saved" : "Save"}
</Text>
</Press>
</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>
<ScrollView
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
contentContainerStyle={{ paddingTop: insets.top + 60, paddingBottom: insets.bottom + 24 }}
>
{/* Identity hero — soft lime card carries the avatar. */}
<Mount>
<View className="mx-4 items-center gap-2 rounded-3xl bg-[#EDF8C4] px-6 py-5 dark:bg-[#1E2410]">
<Press
onPress={() => haptics.light()}
accessibilityLabel="Change profile photo"
accessibilityRole="button"
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`}
/>
<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>
<Text className="text-sm font-medium text-foreground">
Change photo
</Text>
{/* Completion meter — profiles push toward 100%, not rows of fields. */}
<View className="mt-2 w-40 items-center gap-1.5">
<View className="h-1 w-full overflow-hidden rounded-full bg-black/10 dark:bg-white/10">
<View className="h-full w-4/5 rounded-full bg-[#BEF04C]" />
</View>
<Text variant="muted" className="text-xs">
Profile 80% complete — add a bio
</Text>
</View>
</View>
</Mount>
{/* Value rows — icon tile + label left, editable value right; the
multiline row top-aligns its label with the first text line. */}
<Mount delay={BLOCK_STAGGER}>
<View className="mx-4 mt-6 overflow-hidden rounded-3xl bg-white dark:bg-[#1A1A20]">
{FIELDS.map((f, i) => (
<View
key={f.key}
className={`flex-row px-4 ${f.multiline ? "items-start py-1" : "items-center"} ${
i < FIELDS.length - 1 ? "border-b border-border" : ""
}`}
>
<View className={f.multiline ? "pt-3.5" : ""}>
<RowIcon name={f.icon} tone={f.tone} />
</View>
<Text className={`w-24 shrink-0 pl-3 text-muted-foreground ${f.multiline ? "pt-4" : ""}`}>
{f.label}
</Text>
<AnimatedTextInput
value={values[f.key]}
onChangeText={(v) => set(f.key, v)}
multiline={f.multiline}
textAlign="right"
placeholderTextColor={colors.mutedForeground}
accessibilityLabel={f.label}
autoCapitalize="none"
autoCorrect={false}
keyboardType={f.key === "email" ? "email-address" : f.key === "phone" ? "phone-pad" : "default"}
className="flex-1 py-3.5 pr-1 text-foreground"
style={{ fontSize: 15 }}
/>
</View>
))}
</View>
</Mount>
{/* Footer actions — settings row, then the account-danger pair. */}
<Mount delay={BLOCK_STAGGER * 2}>
<Press
onPress={() => haptics.light()}
accessibilityLabel="Account settings"
accessibilityRole="button"
className="mx-4 mt-4 flex-row items-center gap-3 overflow-hidden rounded-2xl bg-white px-4 py-3.5 dark:bg-[#1A1A20]"
>
<RowIcon name="settings" tone="gray" />
<Text className="flex-1 text-muted-foreground">Account settings</Text>
<Icon name="chevron-right" size="sm" color="mutedForeground" />
</Press>
<View className="mx-4 mt-3 flex-row gap-3">
<Press
onPress={() => haptics.medium()}
accessibilityLabel="Sign out"
accessibilityRole="button"
className="flex-1 flex-row items-center justify-center gap-2 overflow-hidden rounded-2xl bg-white py-4 dark:bg-[#1A1A20]"
>
<Icon name="log-out" size="sm" color="mutedForeground" />
<Text className="font-medium text-muted-foreground">Sign out</Text>
</Press>
<Press
onPress={() => haptics.warning()}
accessibilityLabel="Delete account"
accessibilityRole="button"
className="flex-1 flex-row items-center justify-center gap-2 overflow-hidden rounded-2xl bg-white py-4 dark:bg-[#1A1A20]"
>
<Icon name="trash" size="sm" color="destructive" />
<Text className="font-medium text-destructive">Delete account</Text>
</Press>
</View>
</Mount>
</ScrollView>
</SafeAreaView>
);
}
EditProfileRowsScreen.displayName = "EditProfileRowsScreen";
Recreate the "Edit Profile — Rows Screen" screen in React Native using LumioUI.
Goal: Dark edit profile: inline-editable value rows, camera-badge avatar, save states 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-rows
```
Or pull just the components: `npx lumioui add avatar icon text`
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
### `icon` — Lucide icon set mapped to theme colors and sizes.
- `<Icon>` ×5
### `text` — Themed type scale from caption to display.
- `<Text>` ×8
## 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-rows.tsx` exporting EditProfileRowsScreen. 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 { ScrollView, View } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { Avatar, Glass, Icon, Text, type IconName } from "@lumioui/ui";
import { AnimatedTextInput, useHaptic, useTheme } from "@lumioui/core";
import { BLOCK_STAGGER, Mount, Press, RowIcon, type IconTone } from "../shared";
export interface ProfileRowsUser {
name: string;
username: string;
email: string;
phone: string;
bio: string;
avatarUrl?: string;
}
export interface EditProfileRowsScreenProps {
user?: ProfileRowsUser;
onSave?: (user: ProfileRowsUser) => void;
onBack?: () => void;
}
const DEFAULT_USER: ProfileRowsUser = {
name: "Amara Chen",
username: "amarachen",
email: "amara.chen@lumio.dev",
phone: "+1 415 555 0132",
bio: "Product designer · coffee first",
avatarUrl: "https://i.pravatar.cc/150?img=9",
};
type FieldKey = "name" | "username" | "email" | "phone" | "bio";
const FIELDS: { key: FieldKey; label: string; icon: IconName; tone: IconTone; multiline?: boolean }[] = [
{ key: "name", label: "Name", icon: "user", tone: "blue" },
{ key: "username", label: "Username", icon: "tag", tone: "purple" },
{ key: "email", label: "Email", icon: "mail", tone: "green" },
{ key: "phone", label: "Phone", icon: "phone", tone: "teal" },
{ key: "bio", label: "Bio", icon: "file-text", tone: "orange", multiline: true },
];
/** Edit profile — dark value-row idiom (social-settings reference): label
* left, right-aligned inline-editable value, lime accents, camera-badge
* avatar, and a Save that arms once a field changes. */
export function EditProfileRowsScreen({
user = DEFAULT_USER,
onSave,
onBack,
}: EditProfileRowsScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const { colors } = useTheme();
const [values, setValues] = useState<Record<FieldKey, string>>({ ...user });
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const dirty = FIELDS.some((f) => values[f.key] !== user[f.key]);
const save = () => {
haptics.medium();
setSaving(true);
onSave?.({ ...user, ...values });
setTimeout(() => {
setSaving(false);
setSaved(true);
}, 700);
};
const set = (key: FieldKey, v: string) => {
setSaved(false);
setValues((p) => ({ ...p, [key]: v }));
};
return (
<SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
{/* Floating chrome — X disc + hug-label title pill + lime Save pill. */}
<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"
>
<Press
onPress={() => {
haptics.light();
onBack?.();
}}
accessibilityLabel="Cancel"
accessibilityRole="button"
className="h-9 w-9 items-center justify-center"
>
<Icon name="x" size="md" color="foreground" />
</Press>
</Glass>
<View
className={`h-11 items-center justify-center rounded-full px-4 ${
dirty || saving ? "bg-[#BEF04C]" : "bg-white dark:bg-[#1A1A20]"
}`}
>
<Press
onPress={save}
disabled={!dirty || saving}
accessibilityLabel="Save changes"
accessibilityRole="button"
accessibilityState={{ disabled: !dirty || saving }}
className="min-h-11 justify-center"
>
<Text
className="font-semibold"
style={{ color: dirty || saving ? "#1B1B26" : colors.mutedForeground }}
>
{saving ? "Saving…" : saved ? "Saved" : "Save"}
</Text>
</Press>
</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>
<ScrollView
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
contentContainerStyle={{ paddingTop: insets.top + 60, paddingBottom: insets.bottom + 24 }}
>
{/* Identity hero — soft lime card carries the avatar. */}
<Mount>
<View className="mx-4 items-center gap-2 rounded-3xl bg-[#EDF8C4] px-6 py-5 dark:bg-[#1E2410]">
<Press
onPress={() => haptics.light()}
accessibilityLabel="Change profile photo"
accessibilityRole="button"
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`}
/>
<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>
<Text className="text-sm font-medium text-foreground">
Change photo
</Text>
{/* Completion meter — profiles push toward 100%, not rows of fields. */}
<View className="mt-2 w-40 items-center gap-1.5">
<View className="h-1 w-full overflow-hidden rounded-full bg-black/10 dark:bg-white/10">
<View className="h-full w-4/5 rounded-full bg-[#BEF04C]" />
</View>
<Text variant="muted" className="text-xs">
Profile 80% complete — add a bio
</Text>
</View>
</View>
</Mount>
{/* Value rows — icon tile + label left, editable value right; the
multiline row top-aligns its label with the first text line. */}
<Mount delay={BLOCK_STAGGER}>
<View className="mx-4 mt-6 overflow-hidden rounded-3xl bg-white dark:bg-[#1A1A20]">
{FIELDS.map((f, i) => (
<View
key={f.key}
className={`flex-row px-4 ${f.multiline ? "items-start py-1" : "items-center"} ${
i < FIELDS.length - 1 ? "border-b border-border" : ""
}`}
>
<View className={f.multiline ? "pt-3.5" : ""}>
<RowIcon name={f.icon} tone={f.tone} />
</View>
<Text className={`w-24 shrink-0 pl-3 text-muted-foreground ${f.multiline ? "pt-4" : ""}`}>
{f.label}
</Text>
<AnimatedTextInput
value={values[f.key]}
onChangeText={(v) => set(f.key, v)}
multiline={f.multiline}
textAlign="right"
placeholderTextColor={colors.mutedForeground}
accessibilityLabel={f.label}
autoCapitalize="none"
autoCorrect={false}
keyboardType={f.key === "email" ? "email-address" : f.key === "phone" ? "phone-pad" : "default"}
className="flex-1 py-3.5 pr-1 text-foreground"
style={{ fontSize: 15 }}
/>
</View>
))}
</View>
</Mount>
{/* Footer actions — settings row, then the account-danger pair. */}
<Mount delay={BLOCK_STAGGER * 2}>
<Press
onPress={() => haptics.light()}
accessibilityLabel="Account settings"
accessibilityRole="button"
className="mx-4 mt-4 flex-row items-center gap-3 overflow-hidden rounded-2xl bg-white px-4 py-3.5 dark:bg-[#1A1A20]"
>
<RowIcon name="settings" tone="gray" />
<Text className="flex-1 text-muted-foreground">Account settings</Text>
<Icon name="chevron-right" size="sm" color="mutedForeground" />
</Press>
<View className="mx-4 mt-3 flex-row gap-3">
<Press
onPress={() => haptics.medium()}
accessibilityLabel="Sign out"
accessibilityRole="button"
className="flex-1 flex-row items-center justify-center gap-2 overflow-hidden rounded-2xl bg-white py-4 dark:bg-[#1A1A20]"
>
<Icon name="log-out" size="sm" color="mutedForeground" />
<Text className="font-medium text-muted-foreground">Sign out</Text>
</Press>
<Press
onPress={() => haptics.warning()}
accessibilityLabel="Delete account"
accessibilityRole="button"
className="flex-1 flex-row items-center justify-center gap-2 overflow-hidden rounded-2xl bg-white py-4 dark:bg-[#1A1A20]"
>
<Icon name="trash" size="sm" color="destructive" />
<Text className="font-medium text-destructive">Delete account</Text>
</Press>
</View>
</Mount>
</ScrollView>
</SafeAreaView>
);
}
EditProfileRowsScreen.displayName = "EditProfileRowsScreen";
```
Screen: https://lumioui.dev/screens/core-edit-profile-rows