From dad084c77597894c98dc938e527e11c90bd48554 Mon Sep 17 00:00:00 2001 From: Sergei Date: Sat, 31 Jan 2026 17:14:44 -0800 Subject: [PATCH] Add setup progress indicator to onboarding flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../SetupProgressIndicator.test.tsx | 170 ++++++++++++++++ app/(auth)/activate.tsx | 4 + app/(auth)/add-loved-one.tsx | 4 + app/(auth)/enter-name.tsx | 4 + app/(auth)/purchase.tsx | 4 + components/SetupProgressIndicator.tsx | 191 ++++++++++++++++++ jest.setup.js | 22 ++ 7 files changed, 399 insertions(+) create mode 100644 __tests__/components/SetupProgressIndicator.test.tsx create mode 100644 components/SetupProgressIndicator.tsx diff --git a/__tests__/components/SetupProgressIndicator.test.tsx b/__tests__/components/SetupProgressIndicator.test.tsx new file mode 100644 index 0000000..7ae62e4 --- /dev/null +++ b/__tests__/components/SetupProgressIndicator.test.tsx @@ -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(); + expect(root).toBeDefined(); + }); + + it('renders with default variant', () => { + const { root } = render(); + expect(root).toBeDefined(); + }); + + it('renders with compact variant', () => { + const { root } = render( + + ); + 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(); + expect(root).toBeDefined(); + }); + + it('shows "Step 1 of 4" for name step', () => { + const { getByText } = render(); + expect(getByText(/Step 1 of 4/)).toBeDefined(); + }); + + it('shows "Step 2 of 4" for beneficiary step', () => { + const { getByText } = render( + + ); + expect(getByText(/Step 2 of 4/)).toBeDefined(); + }); + + it('shows "Step 3 of 4" for purchase step', () => { + const { getByText } = render( + + ); + expect(getByText(/Step 3 of 4/)).toBeDefined(); + }); + + it('shows "Step 4 of 4" for activate step', () => { + const { getByText } = render( + + ); + expect(getByText(/Step 4 of 4/)).toBeDefined(); + }); + }); + + describe('Step labels', () => { + it('shows step label for name step', () => { + const { getByText } = render(); + expect(getByText(/Your Name/)).toBeDefined(); + }); + + it('shows step label for beneficiary step', () => { + const { getByText } = render( + + ); + expect(getByText(/Add Loved One/)).toBeDefined(); + }); + + it('shows step label for purchase step', () => { + const { getByText } = render( + + ); + expect(getByText(/Get Equipment/)).toBeDefined(); + }); + + it('shows step label for activate step', () => { + const { getAllByText } = render( + + ); + // "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( + + ); + // Should have 4 step wrappers rendered + expect(root).toBeDefined(); + }); + + it('displays short labels in default variant', () => { + const { getByText } = render( + + ); + // 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(); + expect(getByText('Name')).toBeDefined(); + }); + + it('compact variant does not show step labels', () => { + const { queryByText } = render( + + ); + // 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(); + expect(getByText(/Step 1 of 4/)).toBeDefined(); + }); + + it('handles last step correctly', () => { + const { getByText } = render( + + ); + expect(getByText(/Step 4 of 4/)).toBeDefined(); + }); + + it('handles middle steps correctly', () => { + const { getByText: getByText1 } = render( + + ); + expect(getByText1(/Step 2 of 4/)).toBeDefined(); + + const { getByText: getByText2 } = render( + + ); + expect(getByText2(/Step 3 of 4/)).toBeDefined(); + }); + }); + + describe('Accessibility', () => { + it('renders with accessible text content', () => { + const { getByText } = render(); + // Should have readable step information + expect(getByText(/Step 1 of 4: Your Name/)).toBeDefined(); + }); + + it('shows meaningful progress information', () => { + const { getByText } = render( + + ); + expect(getByText(/Step 3 of 4: Get Equipment/)).toBeDefined(); + }); + }); +}); diff --git a/app/(auth)/activate.tsx b/app/(auth)/activate.tsx index a25505e..4efcc1d 100644 --- a/app/(auth)/activate.tsx +++ b/app/(auth)/activate.tsx @@ -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() { + {/* Setup Progress */} + + {/* Icon */} diff --git a/app/(auth)/add-loved-one.tsx b/app/(auth)/add-loved-one.tsx index f04437f..cee41ec 100644 --- a/app/(auth)/add-loved-one.tsx +++ b/app/(auth)/add-loved-one.tsx @@ -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 */} + + {/* Header */} Add a Loved One diff --git a/app/(auth)/enter-name.tsx b/app/(auth)/enter-name.tsx index dafcc64..cf13bcd 100644 --- a/app/(auth)/enter-name.tsx +++ b/app/(auth)/enter-name.tsx @@ -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() { + {/* Setup Progress */} + + {/* Icon */} diff --git a/app/(auth)/purchase.tsx b/app/(auth)/purchase.tsx index 4a000b0..cb187a5 100644 --- a/app/(auth)/purchase.tsx +++ b/app/(auth)/purchase.tsx @@ -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() { + {/* Setup Progress */} + + {/* Product Card */} diff --git a/components/SetupProgressIndicator.tsx b/components/SetupProgressIndicator.tsx new file mode 100644 index 0000000..c285f6a --- /dev/null +++ b/components/SetupProgressIndicator.tsx @@ -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 ( + + {/* Progress bar */} + + + + + + + {/* Step indicators */} + + {STEPS.map((step, index) => { + const isCompleted = index < currentIndex; + const isCurrent = index === currentIndex; + const isPending = index > currentIndex; + + return ( + + {/* Step circle */} + + {isCompleted ? ( + + ) : ( + + )} + + + {/* Step label */} + {!isCompact && ( + + {step.shortLabel} + + )} + + ); + })} + + + {/* Current step text */} + + Step {currentIndex + 1} of {STEPS.length}: {STEPS[currentIndex].label} + + + ); +} + +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; diff --git a/jest.setup.js b/jest.setup.js index ad88e12..c7a3a5e 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -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(),