Add setup progress indicator to onboarding flow

Add SetupProgressIndicator component that shows users their current
position in the 4-step onboarding journey: Name → Beneficiary →
Equipment → Connect. The indicator displays:
- Visual progress bar with percentage fill
- Step circles with icons showing completed/current/pending status
- Current step label with "Step X of 4" text

Integrate the indicator into all four auth screens:
- enter-name.tsx (Step 1)
- add-loved-one.tsx (Step 2)
- purchase.tsx (Step 3)
- activate.tsx (Step 4)

Also add @expo/vector-icons mock to jest.setup.js for testing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sergei 2026-01-31 17:14:44 -08:00
parent 48019e0b08
commit dad084c775
7 changed files with 399 additions and 0 deletions

View File

@ -0,0 +1,170 @@
import React from 'react';
import { render } from '@testing-library/react-native';
import {
SetupProgressIndicator,
SetupStep,
} from '@/components/SetupProgressIndicator';
describe('SetupProgressIndicator', () => {
describe('Component rendering', () => {
it('renders without crashing', () => {
const { root } = render(<SetupProgressIndicator currentStep="name" />);
expect(root).toBeDefined();
});
it('renders with default variant', () => {
const { root } = render(<SetupProgressIndicator currentStep="name" />);
expect(root).toBeDefined();
});
it('renders with compact variant', () => {
const { root } = render(
<SetupProgressIndicator currentStep="name" variant="compact" />
);
expect(root).toBeDefined();
});
});
describe('Step progression', () => {
const steps: SetupStep[] = ['name', 'beneficiary', 'purchase', 'activate'];
it.each(steps)('renders correctly for step "%s"', (step) => {
const { root } = render(<SetupProgressIndicator currentStep={step} />);
expect(root).toBeDefined();
});
it('shows "Step 1 of 4" for name step', () => {
const { getByText } = render(<SetupProgressIndicator currentStep="name" />);
expect(getByText(/Step 1 of 4/)).toBeDefined();
});
it('shows "Step 2 of 4" for beneficiary step', () => {
const { getByText } = render(
<SetupProgressIndicator currentStep="beneficiary" />
);
expect(getByText(/Step 2 of 4/)).toBeDefined();
});
it('shows "Step 3 of 4" for purchase step', () => {
const { getByText } = render(
<SetupProgressIndicator currentStep="purchase" />
);
expect(getByText(/Step 3 of 4/)).toBeDefined();
});
it('shows "Step 4 of 4" for activate step', () => {
const { getByText } = render(
<SetupProgressIndicator currentStep="activate" />
);
expect(getByText(/Step 4 of 4/)).toBeDefined();
});
});
describe('Step labels', () => {
it('shows step label for name step', () => {
const { getByText } = render(<SetupProgressIndicator currentStep="name" />);
expect(getByText(/Your Name/)).toBeDefined();
});
it('shows step label for beneficiary step', () => {
const { getByText } = render(
<SetupProgressIndicator currentStep="beneficiary" />
);
expect(getByText(/Add Loved One/)).toBeDefined();
});
it('shows step label for purchase step', () => {
const { getByText } = render(
<SetupProgressIndicator currentStep="purchase" />
);
expect(getByText(/Get Equipment/)).toBeDefined();
});
it('shows step label for activate step', () => {
const { getAllByText } = render(
<SetupProgressIndicator currentStep="activate" />
);
// "Connect" appears as both the short label and in "Step 4 of 4: Connect"
expect(getAllByText(/Connect/).length).toBeGreaterThan(0);
});
});
describe('Progress visualization', () => {
it('renders all 4 step circles', () => {
const { getAllByTestId, root } = render(
<SetupProgressIndicator currentStep="name" />
);
// Should have 4 step wrappers rendered
expect(root).toBeDefined();
});
it('displays short labels in default variant', () => {
const { getByText } = render(
<SetupProgressIndicator currentStep="beneficiary" />
);
// Short labels: Name, Person, Equipment, Connect
expect(getByText('Name')).toBeDefined();
expect(getByText('Person')).toBeDefined();
expect(getByText('Equipment')).toBeDefined();
expect(getByText('Connect')).toBeDefined();
});
});
describe('Variant differences', () => {
it('default variant shows step labels', () => {
const { getByText } = render(<SetupProgressIndicator currentStep="name" />);
expect(getByText('Name')).toBeDefined();
});
it('compact variant does not show step labels', () => {
const { queryByText } = render(
<SetupProgressIndicator currentStep="name" variant="compact" />
);
// In compact mode, short labels should not be rendered
// The component conditionally renders labels based on !isCompact
// So we expect to NOT find the short labels
expect(queryByText('Name')).toBeNull();
});
});
describe('Edge cases', () => {
it('handles first step correctly', () => {
const { getByText } = render(<SetupProgressIndicator currentStep="name" />);
expect(getByText(/Step 1 of 4/)).toBeDefined();
});
it('handles last step correctly', () => {
const { getByText } = render(
<SetupProgressIndicator currentStep="activate" />
);
expect(getByText(/Step 4 of 4/)).toBeDefined();
});
it('handles middle steps correctly', () => {
const { getByText: getByText1 } = render(
<SetupProgressIndicator currentStep="beneficiary" />
);
expect(getByText1(/Step 2 of 4/)).toBeDefined();
const { getByText: getByText2 } = render(
<SetupProgressIndicator currentStep="purchase" />
);
expect(getByText2(/Step 3 of 4/)).toBeDefined();
});
});
describe('Accessibility', () => {
it('renders with accessible text content', () => {
const { getByText } = render(<SetupProgressIndicator currentStep="name" />);
// Should have readable step information
expect(getByText(/Step 1 of 4: Your Name/)).toBeDefined();
});
it('shows meaningful progress information', () => {
const { getByText } = render(
<SetupProgressIndicator currentStep="purchase" />
);
expect(getByText(/Step 3 of 4: Get Equipment/)).toBeDefined();
});
});
});

View File

@ -15,6 +15,7 @@ import { Ionicons } from '@expo/vector-icons';
import { AppColors, Spacing, BorderRadius, FontSizes, FontWeights } from '@/constants/theme'; import { AppColors, Spacing, BorderRadius, FontSizes, FontWeights } from '@/constants/theme';
import { api } from '@/services/api'; import { api } from '@/services/api';
import { validateSerial, getSerialErrorMessage } from '@/utils/serialValidation'; import { validateSerial, getSerialErrorMessage } from '@/utils/serialValidation';
import { SetupProgressIndicator } from '@/components/SetupProgressIndicator';
export default function ActivateScreen() { export default function ActivateScreen() {
@ -107,6 +108,9 @@ export default function ActivateScreen() {
<View style={styles.placeholder} /> <View style={styles.placeholder} />
</View> </View>
{/* Setup Progress */}
<SetupProgressIndicator currentStep="activate" />
{/* Icon */} {/* Icon */}
<View style={styles.iconContainer}> <View style={styles.iconContainer}>
<Ionicons name="qr-code" size={64} color={AppColors.primary} /> <Ionicons name="qr-code" size={64} color={AppColors.primary} />

View File

@ -17,6 +17,7 @@ import { Ionicons } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { ErrorMessage } from '@/components/ui/ErrorMessage'; import { ErrorMessage } from '@/components/ui/ErrorMessage';
import { SetupProgressIndicator } from '@/components/SetupProgressIndicator';
import { AppColors, FontSizes, Spacing, BorderRadius, FontWeights } from '@/constants/theme'; import { AppColors, FontSizes, Spacing, BorderRadius, FontWeights } from '@/constants/theme';
import { api } from '@/services/api'; import { api } from '@/services/api';
@ -151,6 +152,9 @@ export default function AddLovedOneScreen() {
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
{/* Setup Progress */}
<SetupProgressIndicator currentStep="beneficiary" />
{/* Header */} {/* Header */}
<View style={styles.header}> <View style={styles.header}>
<Text style={styles.title}>Add a Loved One</Text> <Text style={styles.title}>Add a Loved One</Text>

View File

@ -13,6 +13,7 @@ import { Ionicons } from '@expo/vector-icons';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { ErrorMessage } from '@/components/ui/ErrorMessage'; import { ErrorMessage } from '@/components/ui/ErrorMessage';
import { SetupProgressIndicator } from '@/components/SetupProgressIndicator';
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme'; import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
import { api } from '@/services/api'; import { api } from '@/services/api';
@ -95,6 +96,9 @@ export default function EnterNameScreen() {
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{/* Setup Progress */}
<SetupProgressIndicator currentStep="name" />
{/* Icon */} {/* Icon */}
<View style={styles.iconContainer}> <View style={styles.iconContainer}>
<View style={styles.iconCircle}> <View style={styles.iconCircle}>

View File

@ -14,6 +14,7 @@ import { usePaymentSheet } from '@stripe/stripe-react-native';
import { AppColors, Spacing, BorderRadius, FontSizes, FontWeights, Shadows } from '@/constants/theme'; import { AppColors, Spacing, BorderRadius, FontSizes, FontWeights, Shadows } from '@/constants/theme';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { api } from '@/services/api'; import { api } from '@/services/api';
import { SetupProgressIndicator } from '@/components/SetupProgressIndicator';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { hasBeneficiaryDevices } from '@/services/BeneficiaryDetailController'; import { hasBeneficiaryDevices } from '@/services/BeneficiaryDetailController';
@ -242,6 +243,9 @@ export default function PurchaseScreen() {
<View style={styles.placeholder} /> <View style={styles.placeholder} />
</View> </View>
{/* Setup Progress */}
<SetupProgressIndicator currentStep="purchase" />
{/* Product Card */} {/* Product Card */}
<View style={styles.productCard}> <View style={styles.productCard}>
<View style={styles.productIcon}> <View style={styles.productIcon}>

View File

@ -0,0 +1,191 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import {
AppColors,
BorderRadius,
FontSizes,
FontWeights,
Spacing,
} from '@/constants/theme';
export type SetupStep = 'name' | 'beneficiary' | 'purchase' | 'activate';
interface SetupProgressIndicatorProps {
currentStep: SetupStep;
variant?: 'default' | 'compact';
}
interface StepConfig {
key: SetupStep;
label: string;
shortLabel: string;
icon: keyof typeof Ionicons.glyphMap;
}
const STEPS: StepConfig[] = [
{ key: 'name', label: 'Your Name', shortLabel: 'Name', icon: 'person-outline' },
{ key: 'beneficiary', label: 'Add Loved One', shortLabel: 'Person', icon: 'heart-outline' },
{ key: 'purchase', label: 'Get Equipment', shortLabel: 'Equipment', icon: 'hardware-chip-outline' },
{ key: 'activate', label: 'Connect', shortLabel: 'Connect', icon: 'wifi-outline' },
];
function getStepIndex(step: SetupStep): number {
return STEPS.findIndex((s) => s.key === step);
}
export function SetupProgressIndicator({
currentStep,
variant = 'default',
}: SetupProgressIndicatorProps) {
const currentIndex = getStepIndex(currentStep);
const isCompact = variant === 'compact';
return (
<View style={styles.container}>
{/* Progress bar */}
<View style={styles.progressBarContainer}>
<View style={styles.progressBarTrack}>
<View
style={[
styles.progressBarFill,
{ width: `${((currentIndex + 1) / STEPS.length) * 100}%` },
]}
/>
</View>
</View>
{/* Step indicators */}
<View style={styles.stepsContainer}>
{STEPS.map((step, index) => {
const isCompleted = index < currentIndex;
const isCurrent = index === currentIndex;
const isPending = index > currentIndex;
return (
<View key={step.key} style={styles.stepWrapper}>
{/* Step circle */}
<View
style={[
styles.stepCircle,
isCompleted && styles.stepCircleCompleted,
isCurrent && styles.stepCircleCurrent,
isPending && styles.stepCirclePending,
]}
>
{isCompleted ? (
<Ionicons
name="checkmark"
size={isCompact ? 12 : 14}
color={AppColors.white}
/>
) : (
<Ionicons
name={step.icon}
size={isCompact ? 12 : 14}
color={isCurrent ? AppColors.white : AppColors.textMuted}
/>
)}
</View>
{/* Step label */}
{!isCompact && (
<Text
style={[
styles.stepLabel,
isCompleted && styles.stepLabelCompleted,
isCurrent && styles.stepLabelCurrent,
isPending && styles.stepLabelPending,
]}
numberOfLines={1}
>
{step.shortLabel}
</Text>
)}
</View>
);
})}
</View>
{/* Current step text */}
<Text style={styles.currentStepText}>
Step {currentIndex + 1} of {STEPS.length}: {STEPS[currentIndex].label}
</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.md,
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
marginBottom: Spacing.lg,
},
progressBarContainer: {
marginBottom: Spacing.md,
},
progressBarTrack: {
height: 4,
backgroundColor: AppColors.borderLight,
borderRadius: BorderRadius.full,
overflow: 'hidden',
},
progressBarFill: {
height: '100%',
backgroundColor: AppColors.primary,
borderRadius: BorderRadius.full,
},
stepsContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: Spacing.sm,
},
stepWrapper: {
alignItems: 'center',
flex: 1,
},
stepCircle: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
marginBottom: Spacing.xs,
},
stepCircleCompleted: {
backgroundColor: AppColors.success,
},
stepCircleCurrent: {
backgroundColor: AppColors.primary,
},
stepCirclePending: {
backgroundColor: AppColors.backgroundSecondary,
borderWidth: 1,
borderColor: AppColors.border,
},
stepLabel: {
fontSize: FontSizes.xs,
textAlign: 'center',
},
stepLabelCompleted: {
color: AppColors.success,
fontWeight: FontWeights.medium,
},
stepLabelCurrent: {
color: AppColors.primary,
fontWeight: FontWeights.semibold,
},
stepLabelPending: {
color: AppColors.textMuted,
},
currentStepText: {
fontSize: FontSizes.sm,
color: AppColors.textSecondary,
textAlign: 'center',
fontWeight: FontWeights.medium,
},
});
export default SetupProgressIndicator;

View File

@ -11,10 +11,32 @@ jest.mock('expo-modules-core', () => ({
NativeModulesProxy: {}, NativeModulesProxy: {},
requireNativeViewManager: jest.fn(), requireNativeViewManager: jest.fn(),
requireNativeModule: jest.fn(), requireNativeModule: jest.fn(),
requireOptionalNativeModule: jest.fn(() => null),
EventEmitter: class EventEmitter {}, EventEmitter: class EventEmitter {},
UnavailabilityError: class UnavailabilityError extends Error {}, UnavailabilityError: class UnavailabilityError extends Error {},
})); }));
// Mock @expo/vector-icons
jest.mock('@expo/vector-icons', () => {
const React = require('react');
const MockIcon = (props) => React.createElement('Text', props, props.name);
return {
Ionicons: MockIcon,
MaterialIcons: MockIcon,
FontAwesome: MockIcon,
Feather: MockIcon,
AntDesign: MockIcon,
Entypo: MockIcon,
EvilIcons: MockIcon,
Foundation: MockIcon,
MaterialCommunityIcons: MockIcon,
Octicons: MockIcon,
SimpleLineIcons: MockIcon,
Zocial: MockIcon,
createIconSet: jest.fn(() => MockIcon),
};
});
// Mock expo winter runtime // Mock expo winter runtime
global.__ExpoImportMetaRegistry = { global.__ExpoImportMetaRegistry = {
register: jest.fn(), register: jest.fn(),