v0.1 public beta early access on GitHub
Home / Screens / core-edit-profile-rows
Screen template

Edit Profile — Rows Screen

Dark edit profile: inline-editable value rows, camera-badge avatar, save states

pro Expo Go ✓ iosandroid gestures: tap · scroll haptics: light · medium · warning
$ npx lumioui add core-edit-profile-rows
import { EditProfileRowsScreen } from "@/screens/core/edit-profile-rows";
Registry dependencies3
Native packages installed6
@lumioui/ui@latest@lumioui/core@latestreact-native-safe-area-context@*react-native-reanimated@*react-native-svg@*nativewind@*
core/edit-profile-rows.tsxsource · yours to edit
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";
agent prompt — paste into Claude Code, Cursor, Devin…
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
iPhone 17 · iOS
9:41
Live preview — the real screen via Expo Web · open full ↗