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(),