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:
parent
48019e0b08
commit
dad084c775
170
__tests__/components/SetupProgressIndicator.test.tsx
Normal file
170
__tests__/components/SetupProgressIndicator.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -15,6 +15,7 @@ import { Ionicons } from '@expo/vector-icons';
|
||||
import { AppColors, Spacing, BorderRadius, FontSizes, FontWeights } from '@/constants/theme';
|
||||
import { api } from '@/services/api';
|
||||
import { validateSerial, getSerialErrorMessage } from '@/utils/serialValidation';
|
||||
import { SetupProgressIndicator } from '@/components/SetupProgressIndicator';
|
||||
|
||||
|
||||
export default function ActivateScreen() {
|
||||
@ -107,6 +108,9 @@ export default function ActivateScreen() {
|
||||
<View style={styles.placeholder} />
|
||||
</View>
|
||||
|
||||
{/* Setup Progress */}
|
||||
<SetupProgressIndicator currentStep="activate" />
|
||||
|
||||
{/* Icon */}
|
||||
<View style={styles.iconContainer}>
|
||||
<Ionicons name="qr-code" size={64} color={AppColors.primary} />
|
||||
|
||||
@ -17,6 +17,7 @@ import { Ionicons } from '@expo/vector-icons';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { ErrorMessage } from '@/components/ui/ErrorMessage';
|
||||
import { SetupProgressIndicator } from '@/components/SetupProgressIndicator';
|
||||
import { AppColors, FontSizes, Spacing, BorderRadius, FontWeights } from '@/constants/theme';
|
||||
import { api } from '@/services/api';
|
||||
|
||||
@ -151,6 +152,9 @@ export default function AddLovedOneScreen() {
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Setup Progress */}
|
||||
<SetupProgressIndicator currentStep="beneficiary" />
|
||||
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Add a Loved One</Text>
|
||||
|
||||
@ -13,6 +13,7 @@ import { Ionicons } from '@expo/vector-icons';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { ErrorMessage } from '@/components/ui/ErrorMessage';
|
||||
import { SetupProgressIndicator } from '@/components/SetupProgressIndicator';
|
||||
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
|
||||
import { api } from '@/services/api';
|
||||
|
||||
@ -95,6 +96,9 @@ export default function EnterNameScreen() {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Setup Progress */}
|
||||
<SetupProgressIndicator currentStep="name" />
|
||||
|
||||
{/* Icon */}
|
||||
<View style={styles.iconContainer}>
|
||||
<View style={styles.iconCircle}>
|
||||
|
||||
@ -14,6 +14,7 @@ import { usePaymentSheet } from '@stripe/stripe-react-native';
|
||||
import { AppColors, Spacing, BorderRadius, FontSizes, FontWeights, Shadows } from '@/constants/theme';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { api } from '@/services/api';
|
||||
import { SetupProgressIndicator } from '@/components/SetupProgressIndicator';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { hasBeneficiaryDevices } from '@/services/BeneficiaryDetailController';
|
||||
|
||||
@ -242,6 +243,9 @@ export default function PurchaseScreen() {
|
||||
<View style={styles.placeholder} />
|
||||
</View>
|
||||
|
||||
{/* Setup Progress */}
|
||||
<SetupProgressIndicator currentStep="purchase" />
|
||||
|
||||
{/* Product Card */}
|
||||
<View style={styles.productCard}>
|
||||
<View style={styles.productIcon}>
|
||||
|
||||
191
components/SetupProgressIndicator.tsx
Normal file
191
components/SetupProgressIndicator.tsx
Normal 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;
|
||||
@ -11,10 +11,32 @@ jest.mock('expo-modules-core', () => ({
|
||||
NativeModulesProxy: {},
|
||||
requireNativeViewManager: jest.fn(),
|
||||
requireNativeModule: jest.fn(),
|
||||
requireOptionalNativeModule: jest.fn(() => null),
|
||||
EventEmitter: class EventEmitter {},
|
||||
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
|
||||
global.__ExpoImportMetaRegistry = {
|
||||
register: jest.fn(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user