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

View File

@ -41,7 +41,7 @@ const isLocalBeneficiary = (id: string | number): boolean => {
}; };
// Setup state types // 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 // No Devices Screen Component
function NoDevicesScreen({ 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() { export default function BeneficiaryDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>(); const { id } = useLocalSearchParams<{ id: string }>();
const { setCurrentBeneficiary, localBeneficiaries, updateLocalBeneficiary, removeLocalBeneficiary } = useBeneficiary(); const { setCurrentBeneficiary, localBeneficiaries, updateLocalBeneficiary, removeLocalBeneficiary } = useBeneficiary();
@ -178,6 +305,12 @@ export default function BeneficiaryDetailScreen() {
if (isLoading) return 'loading'; if (isLoading) return 'loading';
if (!beneficiary) 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 // Check if has devices
const hasDevices = beneficiary.hasDevices || const hasDevices = beneficiary.hasDevices ||
(beneficiary.devices && beneficiary.devices.length > 0) || (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 = () => { const handleEditPress = () => {
if (beneficiary) { if (beneficiary) {
setEditForm({ setEditForm({
@ -388,6 +546,15 @@ export default function BeneficiaryDetailScreen() {
// Render based on setup state // Render based on setup state
const renderContent = () => { const renderContent = () => {
switch (setupState) { switch (setupState) {
case 'awaiting_equipment':
return (
<AwaitingEquipmentScreen
beneficiary={beneficiary}
onActivate={handleActivateFromStatus}
onMarkReceived={handleMarkReceived}
/>
);
case 'no_devices': case 'no_devices':
return ( return (
<NoDevicesScreen <NoDevicesScreen
@ -810,6 +977,97 @@ const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
alignItems: '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 // Subscription Price Card
subscriptionPriceCard: { subscriptionPriceCard: {
width: '100%', width: '100%',

View File

@ -26,19 +26,59 @@ import {
} from '@/constants/theme'; } from '@/constants/theme';
import type { Beneficiary } from '@/types'; import type { Beneficiary } from '@/types';
// Simplified beneficiary card: Avatar + Name + Warning (if no subscription) // Beneficiary card with equipment status support
interface BeneficiaryCardProps { interface BeneficiaryCardProps {
beneficiary: Beneficiary; beneficiary: Beneficiary;
onPress: () => void; onPress: () => void;
onActivate?: () => void;
} }
function BeneficiaryCard({ beneficiary, onPress }: BeneficiaryCardProps) { // Equipment status config
// Check if subscription is missing or expired const equipmentStatusConfig = {
const hasNoSubscription = !beneficiary.subscription || ordered: {
beneficiary.subscription.status !== 'active'; 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 ( return (
<TouchableOpacity style={styles.card} onPress={onPress} activeOpacity={0.7}> <TouchableOpacity style={styles.card} onPress={handlePress} activeOpacity={0.7}>
{/* Avatar */} {/* Avatar */}
<View style={styles.avatarWrapper}> <View style={styles.avatarWrapper}>
{beneficiary.avatar ? ( {beneficiary.avatar ? (
@ -52,22 +92,41 @@ function BeneficiaryCard({ beneficiary, onPress }: BeneficiaryCardProps) {
)} )}
</View> </View>
{/* Name */} {/* Name and Status */}
<View style={styles.info}> <View style={styles.info}>
<Text style={styles.name} numberOfLines={1}>{beneficiary.name}</Text> <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> </View>
{/* Warning icon if no subscription */} {/* Warning icon if no subscription (only for active equipment) */}
{hasNoSubscription && ( {hasNoSubscription && (
<View style={styles.warningContainer}> <View style={styles.warningContainer}>
<Ionicons name="warning" size={20} color={AppColors.warning} /> <Ionicons name="warning" size={20} color={AppColors.warning} />
</View> </View>
)} )}
{/* Arrow */} {/* 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}> <View style={styles.arrowContainer}>
<Ionicons name="chevron-forward" size={20} color={AppColors.textMuted} /> <Ionicons name="chevron-forward" size={20} color={AppColors.textMuted} />
</View> </View>
)}
</TouchableOpacity> </TouchableOpacity>
); );
} }
@ -133,7 +192,20 @@ export default function HomeScreen() {
const handleBeneficiaryPress = (beneficiary: Beneficiary) => { const handleBeneficiaryPress = (beneficiary: Beneficiary) => {
setCurrentBeneficiary(beneficiary); setCurrentBeneficiary(beneficiary);
// 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`); 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 = () => { const getDisplayName = () => {
@ -213,6 +285,7 @@ export default function HomeScreen() {
<BeneficiaryCard <BeneficiaryCard
beneficiary={item} beneficiary={item}
onPress={() => handleBeneficiaryPress(item)} onPress={() => handleBeneficiaryPress(item)}
onActivate={() => handleActivate(item)}
/> />
)} )}
contentContainerStyle={styles.listContent} contentContainerStyle={styles.listContent}
@ -399,6 +472,31 @@ const styles = StyleSheet.create({
warningContainer: { warningContainer: {
marginRight: Spacing.sm, 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: { arrowContainer: {
width: 32, width: 32,
height: 32, height: 32,