Add equipment status workflow to beneficiary cards

- BeneficiaryCard now shows equipment status badges (ordered/shipped/delivered)
- Added AwaitingEquipmentScreen with progress tracker
- "I received my kit" button to mark as delivered
- "Activate" button when equipment is delivered
- Fixed address field height in add-loved-one form

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sergei 2025-12-29 15:48:19 -08:00
parent 7cb07c09ce
commit 2545aec485
3 changed files with 373 additions and 29 deletions

View File

@ -184,16 +184,14 @@ export default function AddLovedOneScreen() {
<View style={styles.inputGroup}>
<Text style={styles.inputLabel}>Address (optional)</Text>
<View style={[styles.inputContainer, styles.addressInput]}>
<Ionicons name="location-outline" size={20} color={AppColors.textMuted} style={styles.addressIcon} />
<View style={styles.inputContainer}>
<Ionicons name="location-outline" size={20} color={AppColors.textMuted} />
<TextInput
style={[styles.input, styles.addressTextInput]}
style={styles.input}
value={address}
onChangeText={setAddress}
placeholder="123 Main St, City, State"
placeholderTextColor={AppColors.textMuted}
multiline
numberOfLines={2}
editable={!isLoading}
/>
</View>
@ -327,16 +325,6 @@ const styles = StyleSheet.create({
paddingVertical: Spacing.md,
marginLeft: Spacing.sm,
},
addressInput: {
alignItems: 'flex-start',
},
addressIcon: {
marginTop: Spacing.md,
},
addressTextInput: {
minHeight: 60,
textAlignVertical: 'top',
},
buttonContainer: {
marginTop: Spacing.md,
},

View File

@ -41,7 +41,7 @@ const isLocalBeneficiary = (id: string | number): boolean => {
};
// Setup state types
type SetupState = 'loading' | 'no_devices' | 'no_subscription' | 'ready';
type SetupState = 'loading' | 'awaiting_equipment' | 'no_devices' | 'no_subscription' | 'ready';
// No Devices Screen Component
function NoDevicesScreen({
@ -162,6 +162,133 @@ function NoSubscriptionScreen({
);
}
// Equipment status configuration
const equipmentStatusInfo = {
ordered: {
icon: 'cube-outline' as const,
title: 'Kit Ordered',
subtitle: 'Your WellNuo kit is being prepared for shipping',
color: AppColors.info,
bgColor: AppColors.infoLight,
steps: [
{ label: 'Order placed', done: true },
{ label: 'Preparing', done: true },
{ label: 'Shipped', done: false },
{ label: 'Delivered', done: false },
],
},
shipped: {
icon: 'car-outline' as const,
title: 'In Transit',
subtitle: 'Your WellNuo kit is on its way',
color: AppColors.warning,
bgColor: AppColors.warningLight,
steps: [
{ label: 'Order placed', done: true },
{ label: 'Preparing', done: true },
{ label: 'Shipped', done: true },
{ label: 'Delivered', done: false },
],
},
delivered: {
icon: 'checkmark-circle-outline' as const,
title: 'Delivered',
subtitle: 'Your kit has arrived! Time to set it up.',
color: AppColors.success,
bgColor: AppColors.successLight,
steps: [
{ label: 'Order placed', done: true },
{ label: 'Preparing', done: true },
{ label: 'Shipped', done: true },
{ label: 'Delivered', done: true },
],
},
};
// Awaiting Equipment Screen Component
function AwaitingEquipmentScreen({
beneficiary,
onActivate,
onMarkReceived,
}: {
beneficiary: Beneficiary;
onActivate: () => void;
onMarkReceived: () => void;
}) {
const status = beneficiary.equipmentStatus as 'ordered' | 'shipped' | 'delivered';
const info = equipmentStatusInfo[status] || equipmentStatusInfo.ordered;
const isDelivered = status === 'delivered';
return (
<View style={styles.setupContainer}>
<View style={[styles.setupIconContainer, { backgroundColor: info.bgColor }]}>
<Ionicons name={info.icon} size={48} color={info.color} />
</View>
<Text style={styles.setupTitle}>{info.title}</Text>
<Text style={styles.setupSubtitle}>{info.subtitle}</Text>
{/* Progress steps */}
<View style={styles.progressContainer}>
{info.steps.map((step, index) => (
<View key={index} style={styles.progressStep}>
<View style={[
styles.progressDot,
step.done && styles.progressDotDone,
!step.done && styles.progressDotPending,
]}>
{step.done && (
<Ionicons name="checkmark" size={12} color={AppColors.white} />
)}
</View>
<Text style={[
styles.progressLabel,
step.done && styles.progressLabelDone,
]}>
{step.label}
</Text>
{index < info.steps.length - 1 && (
<View style={[
styles.progressLine,
step.done && styles.progressLineDone,
]} />
)}
</View>
))}
</View>
{/* Tracking number if available */}
{beneficiary.trackingNumber && (
<View style={styles.trackingCard}>
<Ionicons name="locate-outline" size={20} color={AppColors.textSecondary} />
<View style={styles.trackingInfo}>
<Text style={styles.trackingLabel}>Tracking Number</Text>
<Text style={styles.trackingNumber}>{beneficiary.trackingNumber}</Text>
</View>
</View>
)}
{/* Actions */}
<View style={styles.awaitingActions}>
{isDelivered ? (
<Button
title="Activate Sensors"
onPress={onActivate}
fullWidth
size="lg"
/>
) : (
<>
<TouchableOpacity style={styles.receivedButton} onPress={onMarkReceived}>
<Ionicons name="checkmark-circle-outline" size={20} color={AppColors.primary} />
<Text style={styles.receivedButtonText}>I received my kit</Text>
</TouchableOpacity>
</>
)}
</View>
</View>
);
}
export default function BeneficiaryDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { setCurrentBeneficiary, localBeneficiaries, updateLocalBeneficiary, removeLocalBeneficiary } = useBeneficiary();
@ -178,6 +305,12 @@ export default function BeneficiaryDetailScreen() {
if (isLoading) return 'loading';
if (!beneficiary) return 'loading';
// Check if awaiting equipment (ordered, shipped, or delivered but not activated)
const equipmentStatus = beneficiary.equipmentStatus;
if (equipmentStatus && ['ordered', 'shipped', 'delivered'].includes(equipmentStatus)) {
return 'awaiting_equipment';
}
// Check if has devices
const hasDevices = beneficiary.hasDevices ||
(beneficiary.devices && beneficiary.devices.length > 0) ||
@ -280,6 +413,31 @@ export default function BeneficiaryDetailScreen() {
});
};
const handleMarkReceived = async () => {
if (!beneficiary || !id) return;
try {
// Update local beneficiary status to delivered
if (isLocal) {
await updateLocalBeneficiary(parseInt(id, 10), {
equipmentStatus: 'delivered',
});
// Reload to refresh UI
loadBeneficiary(false);
}
// For API beneficiaries, would call backend here
} catch (err) {
Alert.alert('Error', 'Failed to update status');
}
};
const handleActivateFromStatus = () => {
router.push({
pathname: '/(auth)/activate',
params: { lovedOneName: beneficiary?.name, beneficiaryId: id },
});
};
const handleEditPress = () => {
if (beneficiary) {
setEditForm({
@ -388,6 +546,15 @@ export default function BeneficiaryDetailScreen() {
// Render based on setup state
const renderContent = () => {
switch (setupState) {
case 'awaiting_equipment':
return (
<AwaitingEquipmentScreen
beneficiary={beneficiary}
onActivate={handleActivateFromStatus}
onMarkReceived={handleMarkReceived}
/>
);
case 'no_devices':
return (
<NoDevicesScreen
@ -810,6 +977,97 @@ const styles = StyleSheet.create({
justifyContent: 'center',
alignItems: 'center',
},
// Equipment Progress Styles
progressContainer: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'flex-start',
marginBottom: Spacing.xl,
width: '100%',
paddingHorizontal: Spacing.md,
},
progressStep: {
alignItems: 'center',
flex: 1,
position: 'relative',
},
progressDot: {
width: 24,
height: 24,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
marginBottom: Spacing.xs,
},
progressDotDone: {
backgroundColor: AppColors.success,
},
progressDotPending: {
backgroundColor: AppColors.border,
},
progressLabel: {
fontSize: FontSizes.xs,
color: AppColors.textMuted,
textAlign: 'center',
},
progressLabelDone: {
color: AppColors.textPrimary,
fontWeight: FontWeights.medium,
},
progressLine: {
position: 'absolute',
top: 12,
left: '50%',
right: '-50%',
height: 2,
backgroundColor: AppColors.border,
zIndex: -1,
},
progressLineDone: {
backgroundColor: AppColors.success,
},
trackingCard: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
padding: Spacing.md,
marginBottom: Spacing.lg,
width: '100%',
gap: Spacing.md,
...Shadows.sm,
},
trackingInfo: {
flex: 1,
},
trackingLabel: {
fontSize: FontSizes.xs,
color: AppColors.textMuted,
},
trackingNumber: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
awaitingActions: {
width: '100%',
},
receivedButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
padding: Spacing.md,
borderWidth: 1,
borderColor: AppColors.primary,
gap: Spacing.sm,
},
receivedButtonText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.primary,
},
// Subscription Price Card
subscriptionPriceCard: {
width: '100%',

View File

@ -26,19 +26,59 @@ import {
} from '@/constants/theme';
import type { Beneficiary } from '@/types';
// Simplified beneficiary card: Avatar + Name + Warning (if no subscription)
// Beneficiary card with equipment status support
interface BeneficiaryCardProps {
beneficiary: Beneficiary;
onPress: () => void;
onActivate?: () => void;
}
function BeneficiaryCard({ beneficiary, onPress }: BeneficiaryCardProps) {
// Check if subscription is missing or expired
const hasNoSubscription = !beneficiary.subscription ||
beneficiary.subscription.status !== 'active';
// Equipment status config
const equipmentStatusConfig = {
ordered: {
icon: 'cube-outline' as const,
label: 'Kit ordered',
sublabel: 'Preparing to ship',
color: AppColors.info,
bgColor: AppColors.infoLight,
},
shipped: {
icon: 'car-outline' as const,
label: 'In transit',
sublabel: 'Track your package',
color: AppColors.warning,
bgColor: AppColors.warningLight,
},
delivered: {
icon: 'checkmark-circle-outline' as const,
label: 'Delivered',
sublabel: 'Ready to activate',
color: AppColors.success,
bgColor: AppColors.successLight,
},
};
function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardProps) {
const equipmentStatus = beneficiary.equipmentStatus;
const isAwaitingEquipment = equipmentStatus && ['ordered', 'shipped', 'delivered'].includes(equipmentStatus);
const statusConfig = equipmentStatus ? equipmentStatusConfig[equipmentStatus as keyof typeof equipmentStatusConfig] : null;
// Check if subscription is missing or expired (only for active equipment)
const hasNoSubscription = !isAwaitingEquipment && (
!beneficiary.subscription || beneficiary.subscription.status !== 'active'
);
// Handle press - if delivered, go to activate
const handlePress = () => {
if (equipmentStatus === 'delivered' && onActivate) {
onActivate();
} else {
onPress();
}
};
return (
<TouchableOpacity style={styles.card} onPress={onPress} activeOpacity={0.7}>
<TouchableOpacity style={styles.card} onPress={handlePress} activeOpacity={0.7}>
{/* Avatar */}
<View style={styles.avatarWrapper}>
{beneficiary.avatar ? (
@ -52,22 +92,41 @@ function BeneficiaryCard({ beneficiary, onPress }: BeneficiaryCardProps) {
)}
</View>
{/* Name */}
{/* Name and Status */}
<View style={styles.info}>
<Text style={styles.name} numberOfLines={1}>{beneficiary.name}</Text>
{/* Equipment status badge */}
{isAwaitingEquipment && statusConfig && (
<View style={[styles.statusBadge, { backgroundColor: statusConfig.bgColor }]}>
<Ionicons name={statusConfig.icon} size={14} color={statusConfig.color} />
<Text style={[styles.statusText, { color: statusConfig.color }]}>
{statusConfig.label}
</Text>
</View>
)}
</View>
{/* Warning icon if no subscription */}
{/* Warning icon if no subscription (only for active equipment) */}
{hasNoSubscription && (
<View style={styles.warningContainer}>
<Ionicons name="warning" size={20} color={AppColors.warning} />
</View>
)}
{/* Arrow */}
<View style={styles.arrowContainer}>
<Ionicons name="chevron-forward" size={20} color={AppColors.textMuted} />
</View>
{/* Action button or Arrow */}
{equipmentStatus === 'delivered' ? (
<TouchableOpacity
style={styles.activateButton}
onPress={onActivate}
activeOpacity={0.8}
>
<Text style={styles.activateButtonText}>Activate</Text>
</TouchableOpacity>
) : (
<View style={styles.arrowContainer}>
<Ionicons name="chevron-forward" size={20} color={AppColors.textMuted} />
</View>
)}
</TouchableOpacity>
);
}
@ -133,7 +192,20 @@ export default function HomeScreen() {
const handleBeneficiaryPress = (beneficiary: Beneficiary) => {
setCurrentBeneficiary(beneficiary);
router.push(`/(tabs)/beneficiaries/${beneficiary.id}/dashboard`);
// If equipment is not active yet, show status page instead of dashboard
if (beneficiary.equipmentStatus && ['ordered', 'shipped'].includes(beneficiary.equipmentStatus)) {
router.push(`/(tabs)/beneficiaries/${beneficiary.id}`);
} else {
router.push(`/(tabs)/beneficiaries/${beneficiary.id}/dashboard`);
}
};
const handleActivate = (beneficiary: Beneficiary) => {
setCurrentBeneficiary(beneficiary);
router.push({
pathname: '/(auth)/activate',
params: { lovedOneName: beneficiary.name, beneficiaryId: beneficiary.id.toString() },
});
};
const getDisplayName = () => {
@ -213,6 +285,7 @@ export default function HomeScreen() {
<BeneficiaryCard
beneficiary={item}
onPress={() => handleBeneficiaryPress(item)}
onActivate={() => handleActivate(item)}
/>
)}
contentContainerStyle={styles.listContent}
@ -399,6 +472,31 @@ const styles = StyleSheet.create({
warningContainer: {
marginRight: Spacing.sm,
},
statusBadge: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: Spacing.sm,
paddingVertical: 4,
borderRadius: BorderRadius.md,
marginTop: 4,
alignSelf: 'flex-start',
gap: 4,
},
statusText: {
fontSize: FontSizes.xs,
fontWeight: FontWeights.medium,
},
activateButton: {
backgroundColor: AppColors.primary,
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
borderRadius: BorderRadius.md,
},
activateButtonText: {
color: AppColors.white,
fontSize: FontSizes.sm,
fontWeight: FontWeights.semibold,
},
arrowContainer: {
width: 32,
height: 32,