Compare commits
No commits in common. "5e0b38748bd067530bdd2e539ace03f74f45e465" and "7105bb72f71dc707fcff76e9cc1766539c0691c1" have entirely different histories.
5e0b38748b
...
7105bb72f7
@ -21,8 +21,8 @@ const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
|
|||||||
|
|
||||||
const STARTER_KIT = {
|
const STARTER_KIT = {
|
||||||
name: 'WellNuo Starter Kit',
|
name: 'WellNuo Starter Kit',
|
||||||
price: '$399',
|
price: '$249',
|
||||||
priceValue: 399,
|
priceValue: 249,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PurchaseScreen() {
|
export default function PurchaseScreen() {
|
||||||
@ -260,7 +260,7 @@ export default function PurchaseScreen() {
|
|||||||
<Text style={styles.productPrice}>{STARTER_KIT.price}</Text>
|
<Text style={styles.productPrice}>{STARTER_KIT.price}</Text>
|
||||||
|
|
||||||
<Text style={styles.productDescription}>
|
<Text style={styles.productDescription}>
|
||||||
5 smart sensors that easily plug into any outlet and set up through the app in minutes
|
4 smart sensors that easily plug into any outlet and set up through the app in minutes
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Security Badge */}
|
{/* Security Badge */}
|
||||||
@ -282,7 +282,7 @@ export default function PurchaseScreen() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Ionicons name="card" size={20} color={AppColors.white} />
|
<Ionicons name="card" size={20} color={AppColors.white} />
|
||||||
<Text style={styles.purchaseButtonText}>Buy Now</Text>
|
<Text style={styles.purchaseButtonText}>Buy Now - {STARTER_KIT.price}</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@ -185,17 +185,6 @@ export default function VerifyOTPScreen() {
|
|||||||
const success = await verifyOtp(email, codeToVerify);
|
const success = await verifyOtp(email, codeToVerify);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
// If user has invite code, try to accept it (silent - don't block flow)
|
|
||||||
if (inviteCode) {
|
|
||||||
console.log('[VerifyOTP] Accepting invite code:', inviteCode);
|
|
||||||
const inviteResult = await api.acceptInvitation(inviteCode);
|
|
||||||
if (inviteResult.ok) {
|
|
||||||
console.log('[VerifyOTP] Invite code accepted:', inviteResult.data?.message);
|
|
||||||
} else {
|
|
||||||
console.warn('[VerifyOTP] Failed to accept invite code:', inviteResult.error?.message);
|
|
||||||
// Don't block - continue with registration flow
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await navigateAfterSuccess();
|
await navigateAfterSuccess();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -54,7 +54,7 @@ const getDashboardUrl = (deploymentId?: number) => {
|
|||||||
const FERDINAND_DEPLOYMENT_ID = 21;
|
const FERDINAND_DEPLOYMENT_ID = 21;
|
||||||
|
|
||||||
export default function BeneficiaryDetailScreen() {
|
export default function BeneficiaryDetailScreen() {
|
||||||
const { id, edit } = useLocalSearchParams<{ id: string; edit?: string }>();
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
const { setCurrentBeneficiary } = useBeneficiary();
|
const { setCurrentBeneficiary } = useBeneficiary();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
@ -74,8 +74,6 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
// Edit modal state
|
// Edit modal state
|
||||||
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
|
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
|
||||||
const [editForm, setEditForm] = useState({ name: '', address: '', avatar: undefined as string | undefined });
|
const [editForm, setEditForm] = useState({ name: '', address: '', avatar: undefined as string | undefined });
|
||||||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
|
||||||
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
|
|
||||||
|
|
||||||
// Avatar lightbox state
|
// Avatar lightbox state
|
||||||
const [lightboxVisible, setLightboxVisible] = useState(false);
|
const [lightboxVisible, setLightboxVisible] = useState(false);
|
||||||
@ -195,15 +193,6 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
loadBeneficiary();
|
loadBeneficiary();
|
||||||
}, [loadBeneficiary]);
|
}, [loadBeneficiary]);
|
||||||
|
|
||||||
// Auto-open edit modal if navigated with ?edit=true parameter
|
|
||||||
useEffect(() => {
|
|
||||||
if (edit === 'true' && beneficiary && !isLoading && !isEditModalVisible) {
|
|
||||||
handleEditPress();
|
|
||||||
// Clear the edit param to prevent re-opening on future navigations
|
|
||||||
router.setParams({ edit: undefined });
|
|
||||||
}
|
|
||||||
}, [edit, beneficiary, isLoading, isEditModalVisible]);
|
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
setIsRefreshing(true);
|
setIsRefreshing(true);
|
||||||
loadBeneficiary(false);
|
loadBeneficiary(false);
|
||||||
@ -246,7 +235,6 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const beneficiaryId = parseInt(id, 10);
|
const beneficiaryId = parseInt(id, 10);
|
||||||
setIsSavingEdit(true);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Update basic info
|
// Update basic info
|
||||||
@ -257,15 +245,12 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
toast.error('Error', response.error?.message || 'Failed to save changes.');
|
toast.error('Error', response.error?.message || 'Failed to save changes.');
|
||||||
setIsSavingEdit(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload avatar if changed (new local file URI)
|
// Upload avatar if changed (new local file URI)
|
||||||
if (editForm.avatar && editForm.avatar.startsWith('file://')) {
|
if (editForm.avatar && editForm.avatar.startsWith('file://')) {
|
||||||
setIsUploadingAvatar(true);
|
|
||||||
const avatarResult = await api.updateBeneficiaryAvatar(beneficiaryId, editForm.avatar);
|
const avatarResult = await api.updateBeneficiaryAvatar(beneficiaryId, editForm.avatar);
|
||||||
setIsUploadingAvatar(false);
|
|
||||||
if (!avatarResult.ok) {
|
if (!avatarResult.ok) {
|
||||||
console.warn('[BeneficiaryDetail] Failed to upload avatar:', avatarResult.error?.message);
|
console.warn('[BeneficiaryDetail] Failed to upload avatar:', avatarResult.error?.message);
|
||||||
// Show info but don't fail the whole operation
|
// Show info but don't fail the whole operation
|
||||||
@ -278,9 +263,6 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
loadBeneficiary(false);
|
loadBeneficiary(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error('Error', 'Failed to save changes.');
|
toast.error('Error', 'Failed to save changes.');
|
||||||
} finally {
|
|
||||||
setIsSavingEdit(false);
|
|
||||||
setIsUploadingAvatar(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -493,11 +475,7 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
|
|
||||||
<ScrollView style={styles.modalContent}>
|
<ScrollView style={styles.modalContent}>
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity style={styles.avatarPicker} onPress={handlePickAvatar}>
|
||||||
style={styles.avatarPicker}
|
|
||||||
onPress={handlePickAvatar}
|
|
||||||
disabled={isSavingEdit}
|
|
||||||
>
|
|
||||||
{editForm.avatar ? (
|
{editForm.avatar ? (
|
||||||
<Image source={{ uri: editForm.avatar }} style={styles.avatarPickerImage} />
|
<Image source={{ uri: editForm.avatar }} style={styles.avatarPickerImage} />
|
||||||
) : (
|
) : (
|
||||||
@ -505,17 +483,9 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
<Ionicons name="camera" size={32} color={AppColors.textMuted} />
|
<Ionicons name="camera" size={32} color={AppColors.textMuted} />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{isUploadingAvatar && (
|
|
||||||
<View style={styles.avatarUploadOverlay}>
|
|
||||||
<ActivityIndicator size="large" color={AppColors.white} />
|
|
||||||
<Text style={styles.avatarUploadText}>Uploading...</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{!isUploadingAvatar && (
|
|
||||||
<View style={styles.avatarPickerBadge}>
|
<View style={styles.avatarPickerBadge}>
|
||||||
<Ionicons name="pencil" size={12} color={AppColors.white} />
|
<Ionicons name="pencil" size={12} color={AppColors.white} />
|
||||||
</View>
|
</View>
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* Name */}
|
{/* Name */}
|
||||||
@ -547,22 +517,13 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
|
|
||||||
<View style={styles.modalFooter}>
|
<View style={styles.modalFooter}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.cancelButton, isSavingEdit && styles.buttonDisabled]}
|
style={styles.cancelButton}
|
||||||
onPress={() => setIsEditModalVisible(false)}
|
onPress={() => setIsEditModalVisible(false)}
|
||||||
disabled={isSavingEdit}
|
|
||||||
>
|
>
|
||||||
<Text style={styles.cancelButtonText}>Cancel</Text>
|
<Text style={styles.cancelButtonText}>Cancel</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity style={styles.saveButton} onPress={handleSaveEdit}>
|
||||||
style={[styles.saveButton, isSavingEdit && styles.buttonDisabled]}
|
|
||||||
onPress={handleSaveEdit}
|
|
||||||
disabled={isSavingEdit}
|
|
||||||
>
|
|
||||||
{isSavingEdit ? (
|
|
||||||
<ActivityIndicator size="small" color={AppColors.white} />
|
|
||||||
) : (
|
|
||||||
<Text style={styles.saveButtonText}>Save</Text>
|
<Text style={styles.saveButtonText}>Save</Text>
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@ -753,18 +714,6 @@ const styles = StyleSheet.create({
|
|||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
borderColor: AppColors.surface,
|
borderColor: AppColors.surface,
|
||||||
},
|
},
|
||||||
avatarUploadOverlay: {
|
|
||||||
...StyleSheet.absoluteFillObject,
|
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
|
||||||
borderRadius: AvatarSizes.lg / 2,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
avatarUploadText: {
|
|
||||||
color: AppColors.white,
|
|
||||||
fontSize: FontSizes.sm,
|
|
||||||
marginTop: Spacing.xs,
|
|
||||||
},
|
|
||||||
inputGroup: {
|
inputGroup: {
|
||||||
marginBottom: Spacing.md,
|
marginBottom: Spacing.md,
|
||||||
},
|
},
|
||||||
@ -816,7 +765,4 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: FontWeights.semibold,
|
fontWeight: FontWeights.semibold,
|
||||||
color: AppColors.white,
|
color: AppColors.white,
|
||||||
},
|
},
|
||||||
buttonDisabled: {
|
|
||||||
opacity: 0.6,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -32,9 +32,9 @@ const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
|
|||||||
|
|
||||||
const STARTER_KIT = {
|
const STARTER_KIT = {
|
||||||
name: 'WellNuo Starter Kit',
|
name: 'WellNuo Starter Kit',
|
||||||
price: '$399',
|
price: '$249',
|
||||||
priceValue: 399,
|
priceValue: 249,
|
||||||
description: '5 smart sensors that easily plug into any outlet and set up through the app in minutes',
|
description: '4 smart sensors that easily plug into any outlet and set up through the app in minutes',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PurchaseScreen() {
|
export default function PurchaseScreen() {
|
||||||
@ -245,7 +245,7 @@ export default function PurchaseScreen() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Ionicons name="card" size={20} color={AppColors.white} />
|
<Ionicons name="card" size={20} color={AppColors.white} />
|
||||||
<Text style={styles.buyButtonText}>Buy Now</Text>
|
<Text style={styles.buyButtonText}>Buy Now — {STARTER_KIT.price}</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@ -24,16 +24,16 @@ async function setupStripeProducts() {
|
|||||||
});
|
});
|
||||||
console.log(`✓ Product created: ${starterKit.id}`);
|
console.log(`✓ Product created: ${starterKit.id}`);
|
||||||
|
|
||||||
// Create price for Starter Kit ($399 one-time)
|
// Create price for Starter Kit ($249 one-time)
|
||||||
const starterKitPrice = await stripe.prices.create({
|
const starterKitPrice = await stripe.prices.create({
|
||||||
product: starterKit.id,
|
product: starterKit.id,
|
||||||
unit_amount: 39900, // $399.00
|
unit_amount: 24900, // $249.00
|
||||||
currency: 'usd',
|
currency: 'usd',
|
||||||
metadata: {
|
metadata: {
|
||||||
display_name: 'Starter Kit'
|
display_name: 'Starter Kit'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
console.log(`✓ Price created: ${starterKitPrice.id} ($399.00)\n`);
|
console.log(`✓ Price created: ${starterKitPrice.id} ($249.00)\n`);
|
||||||
|
|
||||||
// 2. Create Premium Subscription product
|
// 2. Create Premium Subscription product
|
||||||
console.log('Creating Premium Subscription product...');
|
console.log('Creating Premium Subscription product...');
|
||||||
|
|||||||
@ -9,7 +9,7 @@ const PRODUCTS = {
|
|||||||
STARTER_KIT: {
|
STARTER_KIT: {
|
||||||
name: 'WellNuo Starter Kit',
|
name: 'WellNuo Starter Kit',
|
||||||
description: '2x Motion Sensors + 1x Door Sensor + 1x Hub',
|
description: '2x Motion Sensors + 1x Door Sensor + 1x Hub',
|
||||||
price: 39900, // $399.00 in cents
|
price: 24900, // $249.00 in cents
|
||||||
type: 'one_time'
|
type: 'one_time'
|
||||||
},
|
},
|
||||||
PREMIUM_SUBSCRIPTION: {
|
PREMIUM_SUBSCRIPTION: {
|
||||||
|
|||||||
@ -21,8 +21,8 @@ const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
|
|||||||
|
|
||||||
const STARTER_KIT = {
|
const STARTER_KIT = {
|
||||||
name: 'WellNuo Starter Kit',
|
name: 'WellNuo Starter Kit',
|
||||||
price: '$399',
|
price: '$249',
|
||||||
priceValue: 399,
|
priceValue: 249,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PurchaseScreen() {
|
export default function PurchaseScreen() {
|
||||||
@ -260,7 +260,7 @@ export default function PurchaseScreen() {
|
|||||||
<Text style={styles.productPrice}>{STARTER_KIT.price}</Text>
|
<Text style={styles.productPrice}>{STARTER_KIT.price}</Text>
|
||||||
|
|
||||||
<Text style={styles.productDescription}>
|
<Text style={styles.productDescription}>
|
||||||
5 smart sensors that easily plug into any outlet and set up through the app in minutes
|
4 smart sensors that easily plug into any outlet and set up through the app in minutes
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Security Badge */}
|
{/* Security Badge */}
|
||||||
@ -282,7 +282,7 @@ export default function PurchaseScreen() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Ionicons name="card" size={20} color={AppColors.white} />
|
<Ionicons name="card" size={20} color={AppColors.white} />
|
||||||
<Text style={styles.purchaseButtonText}>Buy Now</Text>
|
<Text style={styles.purchaseButtonText}>Buy Now - {STARTER_KIT.price}</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@ -22,8 +22,8 @@ import { hasBeneficiaryDevices } from '@/services/BeneficiaryDetailController';
|
|||||||
|
|
||||||
const STARTER_KIT = {
|
const STARTER_KIT = {
|
||||||
name: 'WellNuo Starter Kit',
|
name: 'WellNuo Starter Kit',
|
||||||
price: '$399',
|
price: '$249',
|
||||||
priceValue: 399,
|
priceValue: 249,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PurchaseScreen() {
|
export default function PurchaseScreen() {
|
||||||
@ -158,7 +158,7 @@ export default function PurchaseScreen() {
|
|||||||
<Text style={styles.productPrice}>{STARTER_KIT.price}</Text>
|
<Text style={styles.productPrice}>{STARTER_KIT.price}</Text>
|
||||||
|
|
||||||
<Text style={styles.productDescription}>
|
<Text style={styles.productDescription}>
|
||||||
5 smart sensors that easily plug into any outlet and set up through the app in minutes
|
4 smart sensors that easily plug into any outlet and set up through the app in minutes
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Security Badge */}
|
{/* Security Badge */}
|
||||||
@ -180,7 +180,7 @@ export default function PurchaseScreen() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Ionicons name="card" size={20} color={AppColors.white} />
|
<Ionicons name="card" size={20} color={AppColors.white} />
|
||||||
<Text style={styles.purchaseButtonText}>Buy Now</Text>
|
<Text style={styles.purchaseButtonText}>Buy Now - {STARTER_KIT.price}</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@ -32,9 +32,9 @@ const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
|
|||||||
|
|
||||||
const STARTER_KIT = {
|
const STARTER_KIT = {
|
||||||
name: 'WellNuo Starter Kit',
|
name: 'WellNuo Starter Kit',
|
||||||
price: '$399',
|
price: '$249',
|
||||||
priceValue: 399,
|
priceValue: 249,
|
||||||
description: '5 smart sensors that easily plug into any outlet and set up through the app in minutes',
|
description: '4 smart sensors that easily plug into any outlet and set up through the app in minutes',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PurchaseScreen() {
|
export default function PurchaseScreen() {
|
||||||
@ -245,7 +245,7 @@ export default function PurchaseScreen() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Ionicons name="card" size={20} color={AppColors.white} />
|
<Ionicons name="card" size={20} color={AppColors.white} />
|
||||||
<Text style={styles.buyButtonText}>Buy Now</Text>
|
<Text style={styles.buyButtonText}>Buy Now — {STARTER_KIT.price}</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@ -33,9 +33,9 @@ import { hasBeneficiaryDevices } from '@/services/BeneficiaryDetailController';
|
|||||||
|
|
||||||
const STARTER_KIT = {
|
const STARTER_KIT = {
|
||||||
name: 'WellNuo Starter Kit',
|
name: 'WellNuo Starter Kit',
|
||||||
price: '$399',
|
price: '$249',
|
||||||
priceValue: 399,
|
priceValue: 249,
|
||||||
description: '5 smart sensors that easily plug into any outlet and set up through the app in minutes',
|
description: '4 smart sensors that easily plug into any outlet and set up through the app in minutes',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PurchaseScreen() {
|
export default function PurchaseScreen() {
|
||||||
@ -167,7 +167,7 @@ export default function PurchaseScreen() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Ionicons name="card" size={20} color={AppColors.white} />
|
<Ionicons name="card" size={20} color={AppColors.white} />
|
||||||
<Text style={styles.buyButtonText}>Buy Now</Text>
|
<Text style={styles.buyButtonText}>Buy Now — {STARTER_KIT.price}</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@ -68,8 +68,8 @@ export function BeneficiaryMenu({
|
|||||||
if (onEdit) {
|
if (onEdit) {
|
||||||
onEdit();
|
onEdit();
|
||||||
} else {
|
} else {
|
||||||
// Navigate to main page with edit=true param to open edit modal
|
// Navigate to main page with edit intent
|
||||||
router.push(`/(tabs)/beneficiaries/${beneficiaryId}?edit=true`);
|
router.push(`/(tabs)/beneficiaries/${beneficiaryId}`);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'access':
|
case 'access':
|
||||||
|
|||||||
73
package-lock.json
generated
73
package-lock.json
generated
@ -9,7 +9,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
"@orbital-systems/react-native-esp-idf-provisioning": "^0.5.0",
|
|
||||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||||
"@react-navigation/elements": "^2.6.3",
|
"@react-navigation/elements": "^2.6.3",
|
||||||
@ -22,6 +21,7 @@
|
|||||||
"expo-camera": "~17.0.10",
|
"expo-camera": "~17.0.10",
|
||||||
"expo-clipboard": "~8.0.8",
|
"expo-clipboard": "~8.0.8",
|
||||||
"expo-constants": "~18.0.12",
|
"expo-constants": "~18.0.12",
|
||||||
|
"expo-crypto": "~15.0.8",
|
||||||
"expo-file-system": "~19.0.21",
|
"expo-file-system": "~19.0.21",
|
||||||
"expo-font": "~14.0.10",
|
"expo-font": "~14.0.10",
|
||||||
"expo-haptics": "~15.0.8",
|
"expo-haptics": "~15.0.8",
|
||||||
@ -43,7 +43,6 @@
|
|||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-fs": "^2.20.0",
|
"react-native-fs": "^2.20.0",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
"react-native-get-random-values": "^2.0.0",
|
|
||||||
"react-native-reanimated": "~4.1.1",
|
"react-native-reanimated": "~4.1.1",
|
||||||
"react-native-root-toast": "^4.0.1",
|
"react-native-root-toast": "^4.0.1",
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
@ -4700,46 +4699,6 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@orbital-systems/react-native-esp-idf-provisioning": {
|
|
||||||
"version": "0.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@orbital-systems/react-native-esp-idf-provisioning/-/react-native-esp-idf-provisioning-0.5.0.tgz",
|
|
||||||
"integrity": "sha512-CDwWVkRCgZF4zLWR4F/R1G5JzXPBxS5leURpYnKhpoKz9xJzxixa8gMJZes/zUYeSoDDHpIZo15YrorCJuQjFA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"buffer": "^6.0.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "*",
|
|
||||||
"react-native": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@orbital-systems/react-native-esp-idf-provisioning/node_modules/buffer": {
|
|
||||||
"version": "6.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
|
||||||
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "patreon",
|
|
||||||
"url": "https://www.patreon.com/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "consulting",
|
|
||||||
"url": "https://feross.org/support"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"base64-js": "^1.3.1",
|
|
||||||
"ieee754": "^1.2.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@pkgjs/parseargs": {
|
"node_modules/@pkgjs/parseargs": {
|
||||||
"version": "0.11.0",
|
"version": "0.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||||
@ -10740,6 +10699,18 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-crypto": {
|
||||||
|
"version": "15.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.8.tgz",
|
||||||
|
"integrity": "sha512-aF7A914TB66WIlTJvl5J6/itejfY78O7dq3ibvFltL9vnTALJ/7LYHvLT4fwmx9yUNS6ekLBtDGWivFWnj2Fcw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.3.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-file-system": {
|
"node_modules/expo-file-system": {
|
||||||
"version": "19.0.21",
|
"version": "19.0.21",
|
||||||
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz",
|
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz",
|
||||||
@ -11438,12 +11409,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fast-base64-decode": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/fast-base64-decode/-/fast-base64-decode-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-qwaScUgUGBYeDNRnbc/KyllVU88Jk1pRHPStuF/lO7B0/RTRLj7U0lkdTAutlBblY08rwZDff6tNU9cjv6j//Q==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@ -18824,18 +18789,6 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-native-get-random-values": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-native-get-random-values/-/react-native-get-random-values-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-wx7/aPqsUIiWsG35D+MsUJd8ij96e3JKddklSdrdZUrheTx89gPtz3Q2yl9knBArj5u26Cl23T88ai+Q0vypdQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"fast-base64-decode": "^1.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react-native": ">=0.81"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-native-is-edge-to-edge": {
|
"node_modules/react-native-is-edge-to-edge": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz",
|
||||||
|
|||||||
@ -12,7 +12,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
"@orbital-systems/react-native-esp-idf-provisioning": "^0.5.0",
|
|
||||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||||
"@react-navigation/elements": "^2.6.3",
|
"@react-navigation/elements": "^2.6.3",
|
||||||
@ -25,6 +24,7 @@
|
|||||||
"expo-camera": "~17.0.10",
|
"expo-camera": "~17.0.10",
|
||||||
"expo-clipboard": "~8.0.8",
|
"expo-clipboard": "~8.0.8",
|
||||||
"expo-constants": "~18.0.12",
|
"expo-constants": "~18.0.12",
|
||||||
|
"expo-crypto": "~15.0.8",
|
||||||
"expo-file-system": "~19.0.21",
|
"expo-file-system": "~19.0.21",
|
||||||
"expo-font": "~14.0.10",
|
"expo-font": "~14.0.10",
|
||||||
"expo-haptics": "~15.0.8",
|
"expo-haptics": "~15.0.8",
|
||||||
@ -46,7 +46,6 @@
|
|||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-fs": "^2.20.0",
|
"react-native-fs": "^2.20.0",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
"react-native-get-random-values": "^2.0.0",
|
|
||||||
"react-native-reanimated": "~4.1.1",
|
"react-native-reanimated": "~4.1.1",
|
||||||
"react-native-root-toast": "^4.0.1",
|
"react-native-root-toast": "^4.0.1",
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import type { ApiError, ApiResponse, AuthResponse, Beneficiary, BeneficiaryDashboardData, ChatResponse, DashboardSingleResponse, NotificationSettings } from '@/types';
|
import type { ApiError, ApiResponse, AuthResponse, Beneficiary, BeneficiaryDashboardData, ChatResponse, DashboardSingleResponse, NotificationSettings } from '@/types';
|
||||||
|
import * as Crypto from 'expo-crypto';
|
||||||
import { File } from 'expo-file-system';
|
import { File } from 'expo-file-system';
|
||||||
import * as SecureStore from 'expo-secure-store';
|
import * as SecureStore from 'expo-secure-store';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import 'react-native-get-random-values'; // Polyfill for crypto.getRandomValues
|
|
||||||
|
|
||||||
// Callback for handling unauthorized responses (401)
|
// Callback for handling unauthorized responses (401)
|
||||||
let onUnauthorizedCallback: (() => void) | null = null;
|
let onUnauthorizedCallback: (() => void) | null = null;
|
||||||
@ -67,9 +67,7 @@ class ApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private generateNonce(): string {
|
private generateNonce(): string {
|
||||||
// Use Web Crypto API (polyfilled by react-native-get-random-values)
|
const randomBytes = Crypto.getRandomBytes(16);
|
||||||
const randomBytes = new Uint8Array(16);
|
|
||||||
crypto.getRandomValues(randomBytes);
|
|
||||||
return Array.from(randomBytes)
|
return Array.from(randomBytes)
|
||||||
.map(b => b.toString(16).padStart(2, '0'))
|
.map(b => b.toString(16).padStart(2, '0'))
|
||||||
.join('');
|
.join('');
|
||||||
@ -1082,51 +1080,6 @@ class ApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Accept invitation code
|
|
||||||
async acceptInvitation(code: string): Promise<ApiResponse<{
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
beneficiary?: {
|
|
||||||
id: number;
|
|
||||||
firstName: string | null;
|
|
||||||
lastName: string | null;
|
|
||||||
email: string | null;
|
|
||||||
};
|
|
||||||
role?: string;
|
|
||||||
}>> {
|
|
||||||
const token = await this.getToken();
|
|
||||||
if (!token) {
|
|
||||||
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${WELLNUO_API_URL}/invitations/accept`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ code }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
return { data, ok: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error: { message: data.error || 'Failed to accept invitation' },
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error: { message: 'Network error. Please check your connection.' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Activate equipment for beneficiary (saves to server)
|
// Activate equipment for beneficiary (saves to server)
|
||||||
async activateBeneficiary(beneficiaryId: number, serialNumber: string): Promise<ApiResponse<{
|
async activateBeneficiary(beneficiaryId: number, serialNumber: string): Promise<ApiResponse<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|||||||
@ -6,23 +6,27 @@
|
|||||||
*
|
*
|
||||||
* Uses @orbital-systems/react-native-esp-idf-provisioning which wraps
|
* Uses @orbital-systems/react-native-esp-idf-provisioning which wraps
|
||||||
* Espressif's official provisioning libraries.
|
* Espressif's official provisioning libraries.
|
||||||
*
|
|
||||||
* NOTE: This module requires a development build. In Expo Go, a mock
|
|
||||||
* implementation is used that returns empty results.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
ESPProvisionManager,
|
||||||
|
ESPDevice,
|
||||||
|
ESPTransport,
|
||||||
|
ESPSecurity,
|
||||||
|
type ESPWifi,
|
||||||
|
} from '@orbital-systems/react-native-esp-idf-provisioning';
|
||||||
import { Platform, PermissionsAndroid, Alert } from 'react-native';
|
import { Platform, PermissionsAndroid, Alert } from 'react-native';
|
||||||
import Constants from 'expo-constants';
|
|
||||||
|
|
||||||
// Check if we're running in Expo Go (no native modules available)
|
|
||||||
const isExpoGo = Constants.appOwnership === 'expo';
|
|
||||||
|
|
||||||
// WellNuo device prefix (matches WP_xxx_xxxxxx pattern)
|
// WellNuo device prefix (matches WP_xxx_xxxxxx pattern)
|
||||||
const WELLNUO_DEVICE_PREFIX = 'WP_';
|
const WELLNUO_DEVICE_PREFIX = 'WP_';
|
||||||
|
|
||||||
|
// Security mode - most ESP32 devices use secure or secure2
|
||||||
|
// Try unsecure first if device doesn't have proof-of-possession
|
||||||
|
const DEFAULT_SECURITY = ESPSecurity.unsecure;
|
||||||
|
|
||||||
export interface WellNuoDevice {
|
export interface WellNuoDevice {
|
||||||
name: string;
|
name: string;
|
||||||
device: any; // ESPDevice when native module available
|
device: ESPDevice;
|
||||||
wellId?: string; // Extracted from name: WP_<wellId>_<mac>
|
wellId?: string; // Extracted from name: WP_<wellId>_<mac>
|
||||||
macPart?: string; // Last part of MAC address
|
macPart?: string; // Last part of MAC address
|
||||||
}
|
}
|
||||||
@ -33,44 +37,14 @@ export interface WifiNetwork {
|
|||||||
auth: string;
|
auth: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dynamic import types - will be null in Expo Go
|
|
||||||
let ESPProvisionManager: any = null;
|
|
||||||
let ESPTransport: any = null;
|
|
||||||
let ESPSecurity: any = null;
|
|
||||||
|
|
||||||
// Try to load native module only in development builds
|
|
||||||
if (!isExpoGo) {
|
|
||||||
try {
|
|
||||||
const espModule = require('@orbital-systems/react-native-esp-idf-provisioning');
|
|
||||||
ESPProvisionManager = espModule.ESPProvisionManager;
|
|
||||||
ESPTransport = espModule.ESPTransport;
|
|
||||||
ESPSecurity = espModule.ESPSecurity;
|
|
||||||
console.log('[ESP] Native provisioning module loaded');
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[ESP] Native provisioning module not available:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ESPProvisioningService {
|
class ESPProvisioningService {
|
||||||
private connectedDevice: any | null = null;
|
private connectedDevice: ESPDevice | null = null;
|
||||||
private isScanning = false;
|
private isScanning = false;
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if ESP provisioning is available (development build only)
|
|
||||||
*/
|
|
||||||
isAvailable(): boolean {
|
|
||||||
return ESPProvisionManager !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request necessary permissions for BLE on Android
|
* Request necessary permissions for BLE on Android
|
||||||
*/
|
*/
|
||||||
async requestPermissions(): Promise<boolean> {
|
async requestPermissions(): Promise<boolean> {
|
||||||
if (!this.isAvailable()) {
|
|
||||||
console.warn('[ESP] Provisioning not available in Expo Go');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Platform.OS !== 'android') {
|
if (Platform.OS !== 'android') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -102,16 +76,6 @@ class ESPProvisioningService {
|
|||||||
* Returns list of devices matching WP_xxx_xxxxxx pattern
|
* Returns list of devices matching WP_xxx_xxxxxx pattern
|
||||||
*/
|
*/
|
||||||
async scanForDevices(timeoutMs = 10000): Promise<WellNuoDevice[]> {
|
async scanForDevices(timeoutMs = 10000): Promise<WellNuoDevice[]> {
|
||||||
if (!this.isAvailable()) {
|
|
||||||
console.warn('[ESP] Scan not available - running in Expo Go');
|
|
||||||
Alert.alert(
|
|
||||||
'Development Build Required',
|
|
||||||
'WiFi provisioning requires a development build. This feature is not available in Expo Go.',
|
|
||||||
[{ text: 'OK' }]
|
|
||||||
);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isScanning) {
|
if (this.isScanning) {
|
||||||
console.warn('[ESP] Already scanning, please wait');
|
console.warn('[ESP] Already scanning, please wait');
|
||||||
return [];
|
return [];
|
||||||
@ -129,12 +93,12 @@ class ESPProvisioningService {
|
|||||||
const devices = await ESPProvisionManager.searchESPDevices(
|
const devices = await ESPProvisionManager.searchESPDevices(
|
||||||
WELLNUO_DEVICE_PREFIX,
|
WELLNUO_DEVICE_PREFIX,
|
||||||
ESPTransport.ble,
|
ESPTransport.ble,
|
||||||
ESPSecurity.unsecure
|
DEFAULT_SECURITY
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`[ESP] Found ${devices.length} WellNuo device(s)`);
|
console.log(`[ESP] Found ${devices.length} WellNuo device(s)`);
|
||||||
|
|
||||||
return devices.map((device: any) => {
|
return devices.map((device: ESPDevice) => {
|
||||||
// Parse device name: WP_<wellId>_<macPart>
|
// Parse device name: WP_<wellId>_<macPart>
|
||||||
const parts = device.name?.split('_') || [];
|
const parts = device.name?.split('_') || [];
|
||||||
return {
|
return {
|
||||||
@ -158,14 +122,9 @@ class ESPProvisioningService {
|
|||||||
* @param proofOfPossession - Optional PoP for secure devices
|
* @param proofOfPossession - Optional PoP for secure devices
|
||||||
*/
|
*/
|
||||||
async connect(
|
async connect(
|
||||||
device: any,
|
device: ESPDevice,
|
||||||
proofOfPossession?: string
|
proofOfPossession?: string
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (!this.isAvailable()) {
|
|
||||||
console.warn('[ESP] Connect not available - running in Expo Go');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.connectedDevice) {
|
if (this.connectedDevice) {
|
||||||
console.warn('[ESP] Already connected, disconnecting first...');
|
console.warn('[ESP] Already connected, disconnecting first...');
|
||||||
await this.disconnect();
|
await this.disconnect();
|
||||||
@ -189,11 +148,6 @@ class ESPProvisioningService {
|
|||||||
* Scan for available WiFi networks through connected device
|
* Scan for available WiFi networks through connected device
|
||||||
*/
|
*/
|
||||||
async scanWifiNetworks(): Promise<WifiNetwork[]> {
|
async scanWifiNetworks(): Promise<WifiNetwork[]> {
|
||||||
if (!this.isAvailable()) {
|
|
||||||
console.warn('[ESP] WiFi scan not available - running in Expo Go');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.connectedDevice) {
|
if (!this.connectedDevice) {
|
||||||
throw new Error('Not connected to any device');
|
throw new Error('Not connected to any device');
|
||||||
}
|
}
|
||||||
@ -205,7 +159,7 @@ class ESPProvisioningService {
|
|||||||
|
|
||||||
console.log(`[ESP] Found ${wifiList.length} WiFi network(s)`);
|
console.log(`[ESP] Found ${wifiList.length} WiFi network(s)`);
|
||||||
|
|
||||||
return wifiList.map((wifi: any) => ({
|
return wifiList.map((wifi: ESPWifi) => ({
|
||||||
ssid: wifi.ssid,
|
ssid: wifi.ssid,
|
||||||
rssi: wifi.rssi,
|
rssi: wifi.rssi,
|
||||||
auth: this.getAuthModeName(wifi.auth),
|
auth: this.getAuthModeName(wifi.auth),
|
||||||
@ -222,11 +176,6 @@ class ESPProvisioningService {
|
|||||||
* @param password - WiFi password
|
* @param password - WiFi password
|
||||||
*/
|
*/
|
||||||
async provisionWifi(ssid: string, password: string): Promise<boolean> {
|
async provisionWifi(ssid: string, password: string): Promise<boolean> {
|
||||||
if (!this.isAvailable()) {
|
|
||||||
console.warn('[ESP] Provisioning not available - running in Expo Go');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.connectedDevice) {
|
if (!this.connectedDevice) {
|
||||||
throw new Error('Not connected to any device');
|
throw new Error('Not connected to any device');
|
||||||
}
|
}
|
||||||
@ -297,4 +246,4 @@ class ESPProvisioningService {
|
|||||||
export const espProvisioning = new ESPProvisioningService();
|
export const espProvisioning = new ESPProvisioningService();
|
||||||
|
|
||||||
// Export types for components
|
// Export types for components
|
||||||
export type ESPDevice = any;
|
export type { ESPDevice };
|
||||||
|
|||||||
@ -1,13 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Sherpa TTS Service - Native implementation for offline Text-to-Speech
|
* Sherpa TTS Service - STUB for Expo Go development
|
||||||
* Uses react-native-sherpa-onnx-offline-tts with Piper VITS models
|
* The real implementation requires native modules which don't work in Expo Go
|
||||||
|
* This stub returns "not available" so the app falls back to expo-speech
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Platform, NativeModules, NativeEventEmitter } from 'react-native';
|
// Available Piper neural voices - kept for type compatibility
|
||||||
import * as FileSystem from 'expo-file-system';
|
|
||||||
import { Asset } from 'expo-asset';
|
|
||||||
|
|
||||||
// Available Piper neural voices
|
|
||||||
export interface PiperVoice {
|
export interface PiperVoice {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@ -28,57 +25,23 @@ export const AVAILABLE_VOICES: PiperVoice[] = [
|
|||||||
gender: 'female',
|
gender: 'female',
|
||||||
accent: 'US',
|
accent: 'US',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'ryan',
|
|
||||||
name: 'Ryan',
|
|
||||||
description: 'American Male (Natural)',
|
|
||||||
modelDir: 'vits-piper-en_US-ryan-medium',
|
|
||||||
onnxFile: 'en_US-ryan-medium.onnx',
|
|
||||||
gender: 'male',
|
|
||||||
accent: 'US',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'alba',
|
|
||||||
name: 'Alba',
|
|
||||||
description: 'British Female',
|
|
||||||
modelDir: 'vits-piper-en_GB-alba-medium',
|
|
||||||
onnxFile: 'en_GB-alba-medium.onnx',
|
|
||||||
gender: 'female',
|
|
||||||
accent: 'UK',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
interface SherpaTTSState {
|
interface SherpaTTSState {
|
||||||
initialized: boolean;
|
initialized: boolean;
|
||||||
initializing: boolean;
|
initializing: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
currentVoice: PiperVoice;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if native module is available
|
|
||||||
const TTSManager = NativeModules.TTSManager;
|
|
||||||
const NATIVE_MODULE_AVAILABLE = !!TTSManager;
|
|
||||||
|
|
||||||
let ttsManagerEmitter: NativeEventEmitter | null = null;
|
|
||||||
if (NATIVE_MODULE_AVAILABLE) {
|
|
||||||
ttsManagerEmitter = new NativeEventEmitter(TTSManager);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentState: SherpaTTSState = {
|
let currentState: SherpaTTSState = {
|
||||||
initialized: false,
|
initialized: false,
|
||||||
initializing: false,
|
initializing: false,
|
||||||
error: null,
|
error: 'Sherpa TTS disabled for Expo Go development',
|
||||||
currentVoice: AVAILABLE_VOICES[0],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// State listeners
|
// State listeners
|
||||||
const stateListeners: ((state: SherpaTTSState) => void)[] = [];
|
const stateListeners: ((state: SherpaTTSState) => void)[] = [];
|
||||||
|
|
||||||
function updateState(updates: Partial<SherpaTTSState>) {
|
|
||||||
currentState = { ...currentState, ...updates };
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
function notifyListeners() {
|
function notifyListeners() {
|
||||||
stateListeners.forEach(listener => listener({ ...currentState }));
|
stateListeners.forEach(listener => listener({ ...currentState }));
|
||||||
}
|
}
|
||||||
@ -96,179 +59,12 @@ export function getState(): SherpaTTSState {
|
|||||||
return { ...currentState };
|
return { ...currentState };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Stub implementations - always return false/unavailable
|
||||||
* Copy bundled TTS model assets to document directory for native access
|
export async function initializeSherpaTTS(): Promise<boolean> {
|
||||||
* NOTE: Temporarily disabled dynamic requires for Metro bundler compatibility
|
console.log('[SherpaTTS STUB] Sherpa TTS disabled for Expo Go - using expo-speech instead');
|
||||||
*/
|
return false;
|
||||||
async function copyModelToDocuments(voice: PiperVoice): Promise<string | null> {
|
|
||||||
// TEMP: Skip dynamic requires - TTS models will be loaded differently
|
|
||||||
console.log('[SherpaTTS] copyModelToDocuments temporarily disabled');
|
|
||||||
return null;
|
|
||||||
|
|
||||||
/* DISABLED - dynamic requires don't work with Metro bundler
|
|
||||||
try {
|
|
||||||
const destDir = `${FileSystem.documentDirectory}tts-models/${voice.modelDir}`;
|
|
||||||
const onnxPath = `${destDir}/${voice.onnxFile}`;
|
|
||||||
|
|
||||||
// Check if already copied
|
|
||||||
const onnxInfo = await FileSystem.getInfoAsync(onnxPath);
|
|
||||||
if (onnxInfo.exists) {
|
|
||||||
console.log('[SherpaTTS] Model already exists at:', destDir);
|
|
||||||
return destDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[SherpaTTS] Copying model to documents directory...');
|
|
||||||
|
|
||||||
// Create destination directory
|
|
||||||
await FileSystem.makeDirectoryAsync(destDir, { intermediates: true });
|
|
||||||
|
|
||||||
// For Expo, we need to copy from assets
|
|
||||||
// The models are in assets/tts-models/
|
|
||||||
const assetBase = `../assets/tts-models/${voice.modelDir}`;
|
|
||||||
|
|
||||||
// Copy main ONNX file
|
|
||||||
const onnxAsset = Asset.fromModule(require(`../assets/tts-models/${voice.modelDir}/${voice.onnxFile}`));
|
|
||||||
await onnxAsset.downloadAsync();
|
|
||||||
if (onnxAsset.localUri) {
|
|
||||||
await FileSystem.copyAsync({
|
|
||||||
from: onnxAsset.localUri,
|
|
||||||
to: onnxPath,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy tokens.txt
|
|
||||||
const tokensAsset = Asset.fromModule(require(`../assets/tts-models/${voice.modelDir}/tokens.txt`));
|
|
||||||
await tokensAsset.downloadAsync();
|
|
||||||
if (tokensAsset.localUri) {
|
|
||||||
await FileSystem.copyAsync({
|
|
||||||
from: tokensAsset.localUri,
|
|
||||||
to: `${destDir}/tokens.txt`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy espeak-ng-data directory
|
|
||||||
// This is more complex - need to copy entire directory
|
|
||||||
const espeakDestDir = `${destDir}/espeak-ng-data`;
|
|
||||||
await FileSystem.makeDirectoryAsync(espeakDestDir, { intermediates: true });
|
|
||||||
|
|
||||||
// For now, we'll use the bundle path directly on iOS
|
|
||||||
console.log('[SherpaTTS] Model copied successfully');
|
|
||||||
return destDir;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[SherpaTTS] Error copying model:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the path to bundled models (iOS bundle or Android assets)
|
|
||||||
*/
|
|
||||||
function getBundledModelPath(voice: PiperVoice): string | null {
|
|
||||||
if (Platform.OS === 'ios') {
|
|
||||||
// On iOS, assets are in the main bundle
|
|
||||||
const bundlePath = NativeModules.RNFSManager?.MainBundlePath || '';
|
|
||||||
if (!bundlePath) {
|
|
||||||
// Try to construct path from FileSystem
|
|
||||||
// Models should be copied during pod install or prebuild
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return `${bundlePath}/assets/tts-models/${voice.modelDir}`;
|
|
||||||
} else if (Platform.OS === 'android') {
|
|
||||||
// On Android, assets are extracted to files dir
|
|
||||||
return `${FileSystem.documentDirectory}tts-models/${voice.modelDir}`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize Sherpa TTS with a specific voice model
|
|
||||||
*/
|
|
||||||
export async function initializeSherpaTTS(voice?: PiperVoice): Promise<boolean> {
|
|
||||||
if (!NATIVE_MODULE_AVAILABLE) {
|
|
||||||
console.log('[SherpaTTS] Native module not available (Expo Go mode)');
|
|
||||||
updateState({
|
|
||||||
initialized: false,
|
|
||||||
error: 'Native module not available - use native build'
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentState.initializing) {
|
|
||||||
console.log('[SherpaTTS] Already initializing...');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedVoice = voice || currentState.currentVoice;
|
|
||||||
updateState({ initializing: true, error: null });
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log('[SherpaTTS] Initializing with voice:', selectedVoice.name);
|
|
||||||
|
|
||||||
// Get model paths
|
|
||||||
// For native build, models should be in the app bundle
|
|
||||||
// We use FileSystem.bundleDirectory on iOS
|
|
||||||
let modelBasePath: string;
|
|
||||||
|
|
||||||
if (Platform.OS === 'ios') {
|
|
||||||
// iOS: Models are copied to bundle during build
|
|
||||||
// Access via MainBundle
|
|
||||||
const mainBundle = await FileSystem.getInfoAsync(FileSystem.bundleDirectory || '');
|
|
||||||
if (mainBundle.exists) {
|
|
||||||
modelBasePath = `${FileSystem.bundleDirectory}assets/tts-models/${selectedVoice.modelDir}`;
|
|
||||||
} else {
|
|
||||||
// Fallback: try document directory
|
|
||||||
modelBasePath = `${FileSystem.documentDirectory}tts-models/${selectedVoice.modelDir}`;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Android: Extract from assets to document directory
|
|
||||||
modelBasePath = `${FileSystem.documentDirectory}tts-models/${selectedVoice.modelDir}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if model exists
|
|
||||||
const modelPath = `${modelBasePath}/${selectedVoice.onnxFile}`;
|
|
||||||
const tokensPath = `${modelBasePath}/tokens.txt`;
|
|
||||||
const dataDirPath = `${modelBasePath}/espeak-ng-data`;
|
|
||||||
|
|
||||||
console.log('[SherpaTTS] Model paths:', { modelPath, tokensPath, dataDirPath });
|
|
||||||
|
|
||||||
// Create config JSON for native module
|
|
||||||
const config = JSON.stringify({
|
|
||||||
modelPath,
|
|
||||||
tokensPath,
|
|
||||||
dataDirPath,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize native TTS
|
|
||||||
// Sample rate 22050, mono channel
|
|
||||||
TTSManager.initializeTTS(22050, 1, config);
|
|
||||||
|
|
||||||
updateState({
|
|
||||||
initialized: true,
|
|
||||||
initializing: false,
|
|
||||||
currentVoice: selectedVoice,
|
|
||||||
error: null
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('[SherpaTTS] Initialized successfully');
|
|
||||||
return true;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
||||||
console.error('[SherpaTTS] Initialization error:', errorMessage);
|
|
||||||
updateState({
|
|
||||||
initialized: false,
|
|
||||||
initializing: false,
|
|
||||||
error: errorMessage
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Speak text using Sherpa TTS
|
|
||||||
*/
|
|
||||||
export async function speak(
|
export async function speak(
|
||||||
text: string,
|
text: string,
|
||||||
options?: {
|
options?: {
|
||||||
@ -279,108 +75,30 @@ export async function speak(
|
|||||||
onError?: (error: Error) => void;
|
onError?: (error: Error) => void;
|
||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!NATIVE_MODULE_AVAILABLE || !currentState.initialized) {
|
options?.onError?.(new Error('Sherpa TTS disabled for Expo Go'));
|
||||||
options?.onError?.(new Error('Sherpa TTS not initialized'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const speed = options?.speed ?? 1.0;
|
|
||||||
const speakerId = options?.speakerId ?? 0;
|
|
||||||
|
|
||||||
try {
|
|
||||||
options?.onStart?.();
|
|
||||||
|
|
||||||
await TTSManager.generateAndPlay(text, speakerId, speed);
|
|
||||||
|
|
||||||
options?.onDone?.();
|
|
||||||
} catch (error) {
|
|
||||||
const err = error instanceof Error ? error : new Error('TTS playback failed');
|
|
||||||
console.error('[SherpaTTS] Speak error:', err);
|
|
||||||
options?.onError?.(err);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop current speech playback
|
|
||||||
*/
|
|
||||||
export function stop(): void {
|
export function stop(): void {
|
||||||
if (NATIVE_MODULE_AVAILABLE && currentState.initialized) {
|
// No-op
|
||||||
try {
|
|
||||||
TTSManager.deinitialize();
|
|
||||||
// Re-initialize after stop to be ready for next speech
|
|
||||||
setTimeout(() => {
|
|
||||||
if (currentState.currentVoice) {
|
|
||||||
initializeSherpaTTS(currentState.currentVoice);
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[SherpaTTS] Stop error:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Deinitialize and free resources
|
|
||||||
*/
|
|
||||||
export function deinitialize(): void {
|
export function deinitialize(): void {
|
||||||
if (NATIVE_MODULE_AVAILABLE) {
|
// No-op
|
||||||
try {
|
|
||||||
TTSManager.deinitialize();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[SherpaTTS] Deinitialize error:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updateState({ initialized: false, error: null });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if Sherpa TTS is available (native module loaded)
|
|
||||||
*/
|
|
||||||
export function isAvailable(): boolean {
|
export function isAvailable(): boolean {
|
||||||
return NATIVE_MODULE_AVAILABLE && currentState.initialized;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current voice
|
|
||||||
*/
|
|
||||||
export function getCurrentVoice(): PiperVoice {
|
|
||||||
return currentState.currentVoice;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set and switch to a different voice
|
|
||||||
*/
|
|
||||||
export async function setVoice(voiceId: string): Promise<boolean> {
|
|
||||||
const voice = AVAILABLE_VOICES.find(v => v.id === voiceId);
|
|
||||||
if (!voice) {
|
|
||||||
console.error('[SherpaTTS] Voice not found:', voiceId);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
|
||||||
|
|
||||||
// Deinitialize current and reinitialize with new voice
|
|
||||||
deinitialize();
|
|
||||||
return initializeSherpaTTS(voice);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function getCurrentVoice(): PiperVoice {
|
||||||
* Add listener for volume updates during playback
|
return AVAILABLE_VOICES[0];
|
||||||
*/
|
}
|
||||||
export function addVolumeListener(callback: (volume: number) => void): (() => void) | null {
|
|
||||||
if (!ttsManagerEmitter) return null;
|
export async function setVoice(voiceId: string): Promise<boolean> {
|
||||||
|
return false;
|
||||||
const subscription = ttsManagerEmitter.addListener('VolumeUpdate', (event) => {
|
|
||||||
callback(event.volume);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => subscription.remove();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Load saved voice preference
|
|
||||||
*/
|
|
||||||
export async function loadSavedVoice(): Promise<PiperVoice> {
|
export async function loadSavedVoice(): Promise<PiperVoice> {
|
||||||
// For now, just return default voice
|
|
||||||
// Could add AsyncStorage persistence later
|
|
||||||
return AVAILABLE_VOICES[0];
|
return AVAILABLE_VOICES[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -396,5 +114,4 @@ export default {
|
|||||||
getCurrentVoice,
|
getCurrentVoice,
|
||||||
setVoice,
|
setVoice,
|
||||||
loadSavedVoice,
|
loadSavedVoice,
|
||||||
addVolumeListener,
|
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user