Compare commits
2 Commits
7105bb72f7
...
5e0b38748b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e0b38748b | ||
|
|
429a18d1eb |
@ -21,8 +21,8 @@ const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
|
||||
|
||||
const STARTER_KIT = {
|
||||
name: 'WellNuo Starter Kit',
|
||||
price: '$249',
|
||||
priceValue: 249,
|
||||
price: '$399',
|
||||
priceValue: 399,
|
||||
};
|
||||
|
||||
export default function PurchaseScreen() {
|
||||
@ -260,7 +260,7 @@ export default function PurchaseScreen() {
|
||||
<Text style={styles.productPrice}>{STARTER_KIT.price}</Text>
|
||||
|
||||
<Text style={styles.productDescription}>
|
||||
4 smart sensors that easily plug into any outlet and set up through the app in minutes
|
||||
5 smart sensors that easily plug into any outlet and set up through the app in minutes
|
||||
</Text>
|
||||
|
||||
{/* Security Badge */}
|
||||
@ -282,7 +282,7 @@ export default function PurchaseScreen() {
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="card" size={20} color={AppColors.white} />
|
||||
<Text style={styles.purchaseButtonText}>Buy Now - {STARTER_KIT.price}</Text>
|
||||
<Text style={styles.purchaseButtonText}>Buy Now</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
@ -185,6 +185,17 @@ export default function VerifyOTPScreen() {
|
||||
const success = await verifyOtp(email, codeToVerify);
|
||||
|
||||
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();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -54,7 +54,7 @@ const getDashboardUrl = (deploymentId?: number) => {
|
||||
const FERDINAND_DEPLOYMENT_ID = 21;
|
||||
|
||||
export default function BeneficiaryDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { id, edit } = useLocalSearchParams<{ id: string; edit?: string }>();
|
||||
const { setCurrentBeneficiary } = useBeneficiary();
|
||||
const toast = useToast();
|
||||
|
||||
@ -74,6 +74,8 @@ export default function BeneficiaryDetailScreen() {
|
||||
// Edit modal state
|
||||
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
|
||||
const [editForm, setEditForm] = useState({ name: '', address: '', avatar: undefined as string | undefined });
|
||||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
|
||||
|
||||
// Avatar lightbox state
|
||||
const [lightboxVisible, setLightboxVisible] = useState(false);
|
||||
@ -193,6 +195,15 @@ export default function BeneficiaryDetailScreen() {
|
||||
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(() => {
|
||||
setIsRefreshing(true);
|
||||
loadBeneficiary(false);
|
||||
@ -235,6 +246,7 @@ export default function BeneficiaryDetailScreen() {
|
||||
}
|
||||
|
||||
const beneficiaryId = parseInt(id, 10);
|
||||
setIsSavingEdit(true);
|
||||
|
||||
try {
|
||||
// Update basic info
|
||||
@ -245,12 +257,15 @@ export default function BeneficiaryDetailScreen() {
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error('Error', response.error?.message || 'Failed to save changes.');
|
||||
setIsSavingEdit(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Upload avatar if changed (new local file URI)
|
||||
if (editForm.avatar && editForm.avatar.startsWith('file://')) {
|
||||
setIsUploadingAvatar(true);
|
||||
const avatarResult = await api.updateBeneficiaryAvatar(beneficiaryId, editForm.avatar);
|
||||
setIsUploadingAvatar(false);
|
||||
if (!avatarResult.ok) {
|
||||
console.warn('[BeneficiaryDetail] Failed to upload avatar:', avatarResult.error?.message);
|
||||
// Show info but don't fail the whole operation
|
||||
@ -263,6 +278,9 @@ export default function BeneficiaryDetailScreen() {
|
||||
loadBeneficiary(false);
|
||||
} catch (err) {
|
||||
toast.error('Error', 'Failed to save changes.');
|
||||
} finally {
|
||||
setIsSavingEdit(false);
|
||||
setIsUploadingAvatar(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -475,7 +493,11 @@ export default function BeneficiaryDetailScreen() {
|
||||
|
||||
<ScrollView style={styles.modalContent}>
|
||||
{/* Avatar */}
|
||||
<TouchableOpacity style={styles.avatarPicker} onPress={handlePickAvatar}>
|
||||
<TouchableOpacity
|
||||
style={styles.avatarPicker}
|
||||
onPress={handlePickAvatar}
|
||||
disabled={isSavingEdit}
|
||||
>
|
||||
{editForm.avatar ? (
|
||||
<Image source={{ uri: editForm.avatar }} style={styles.avatarPickerImage} />
|
||||
) : (
|
||||
@ -483,9 +505,17 @@ export default function BeneficiaryDetailScreen() {
|
||||
<Ionicons name="camera" size={32} color={AppColors.textMuted} />
|
||||
</View>
|
||||
)}
|
||||
{isUploadingAvatar && (
|
||||
<View style={styles.avatarUploadOverlay}>
|
||||
<ActivityIndicator size="large" color={AppColors.white} />
|
||||
<Text style={styles.avatarUploadText}>Uploading...</Text>
|
||||
</View>
|
||||
)}
|
||||
{!isUploadingAvatar && (
|
||||
<View style={styles.avatarPickerBadge}>
|
||||
<Ionicons name="pencil" size={12} color={AppColors.white} />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Name */}
|
||||
@ -517,13 +547,22 @@ export default function BeneficiaryDetailScreen() {
|
||||
|
||||
<View style={styles.modalFooter}>
|
||||
<TouchableOpacity
|
||||
style={styles.cancelButton}
|
||||
style={[styles.cancelButton, isSavingEdit && styles.buttonDisabled]}
|
||||
onPress={() => setIsEditModalVisible(false)}
|
||||
disabled={isSavingEdit}
|
||||
>
|
||||
<Text style={styles.cancelButtonText}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.saveButton} onPress={handleSaveEdit}>
|
||||
<TouchableOpacity
|
||||
style={[styles.saveButton, isSavingEdit && styles.buttonDisabled]}
|
||||
onPress={handleSaveEdit}
|
||||
disabled={isSavingEdit}
|
||||
>
|
||||
{isSavingEdit ? (
|
||||
<ActivityIndicator size="small" color={AppColors.white} />
|
||||
) : (
|
||||
<Text style={styles.saveButtonText}>Save</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
@ -714,6 +753,18 @@ const styles = StyleSheet.create({
|
||||
borderWidth: 2,
|
||||
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: {
|
||||
marginBottom: Spacing.md,
|
||||
},
|
||||
@ -765,4 +816,7 @@ const styles = StyleSheet.create({
|
||||
fontWeight: FontWeights.semibold,
|
||||
color: AppColors.white,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
});
|
||||
|
||||
@ -32,9 +32,9 @@ const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
|
||||
|
||||
const STARTER_KIT = {
|
||||
name: 'WellNuo Starter Kit',
|
||||
price: '$249',
|
||||
priceValue: 249,
|
||||
description: '4 smart sensors that easily plug into any outlet and set up through the app in minutes',
|
||||
price: '$399',
|
||||
priceValue: 399,
|
||||
description: '5 smart sensors that easily plug into any outlet and set up through the app in minutes',
|
||||
};
|
||||
|
||||
export default function PurchaseScreen() {
|
||||
@ -245,7 +245,7 @@ export default function PurchaseScreen() {
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="card" size={20} color={AppColors.white} />
|
||||
<Text style={styles.buyButtonText}>Buy Now — {STARTER_KIT.price}</Text>
|
||||
<Text style={styles.buyButtonText}>Buy Now</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
@ -24,16 +24,16 @@ async function setupStripeProducts() {
|
||||
});
|
||||
console.log(`✓ Product created: ${starterKit.id}`);
|
||||
|
||||
// Create price for Starter Kit ($249 one-time)
|
||||
// Create price for Starter Kit ($399 one-time)
|
||||
const starterKitPrice = await stripe.prices.create({
|
||||
product: starterKit.id,
|
||||
unit_amount: 24900, // $249.00
|
||||
unit_amount: 39900, // $399.00
|
||||
currency: 'usd',
|
||||
metadata: {
|
||||
display_name: 'Starter Kit'
|
||||
}
|
||||
});
|
||||
console.log(`✓ Price created: ${starterKitPrice.id} ($249.00)\n`);
|
||||
console.log(`✓ Price created: ${starterKitPrice.id} ($399.00)\n`);
|
||||
|
||||
// 2. Create Premium Subscription product
|
||||
console.log('Creating Premium Subscription product...');
|
||||
|
||||
@ -9,7 +9,7 @@ const PRODUCTS = {
|
||||
STARTER_KIT: {
|
||||
name: 'WellNuo Starter Kit',
|
||||
description: '2x Motion Sensors + 1x Door Sensor + 1x Hub',
|
||||
price: 24900, // $249.00 in cents
|
||||
price: 39900, // $399.00 in cents
|
||||
type: 'one_time'
|
||||
},
|
||||
PREMIUM_SUBSCRIPTION: {
|
||||
|
||||
@ -21,8 +21,8 @@ const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
|
||||
|
||||
const STARTER_KIT = {
|
||||
name: 'WellNuo Starter Kit',
|
||||
price: '$249',
|
||||
priceValue: 249,
|
||||
price: '$399',
|
||||
priceValue: 399,
|
||||
};
|
||||
|
||||
export default function PurchaseScreen() {
|
||||
@ -260,7 +260,7 @@ export default function PurchaseScreen() {
|
||||
<Text style={styles.productPrice}>{STARTER_KIT.price}</Text>
|
||||
|
||||
<Text style={styles.productDescription}>
|
||||
4 smart sensors that easily plug into any outlet and set up through the app in minutes
|
||||
5 smart sensors that easily plug into any outlet and set up through the app in minutes
|
||||
</Text>
|
||||
|
||||
{/* Security Badge */}
|
||||
@ -282,7 +282,7 @@ export default function PurchaseScreen() {
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="card" size={20} color={AppColors.white} />
|
||||
<Text style={styles.purchaseButtonText}>Buy Now - {STARTER_KIT.price}</Text>
|
||||
<Text style={styles.purchaseButtonText}>Buy Now</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
@ -22,8 +22,8 @@ import { hasBeneficiaryDevices } from '@/services/BeneficiaryDetailController';
|
||||
|
||||
const STARTER_KIT = {
|
||||
name: 'WellNuo Starter Kit',
|
||||
price: '$249',
|
||||
priceValue: 249,
|
||||
price: '$399',
|
||||
priceValue: 399,
|
||||
};
|
||||
|
||||
export default function PurchaseScreen() {
|
||||
@ -158,7 +158,7 @@ export default function PurchaseScreen() {
|
||||
<Text style={styles.productPrice}>{STARTER_KIT.price}</Text>
|
||||
|
||||
<Text style={styles.productDescription}>
|
||||
4 smart sensors that easily plug into any outlet and set up through the app in minutes
|
||||
5 smart sensors that easily plug into any outlet and set up through the app in minutes
|
||||
</Text>
|
||||
|
||||
{/* Security Badge */}
|
||||
@ -180,7 +180,7 @@ export default function PurchaseScreen() {
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="card" size={20} color={AppColors.white} />
|
||||
<Text style={styles.purchaseButtonText}>Buy Now - {STARTER_KIT.price}</Text>
|
||||
<Text style={styles.purchaseButtonText}>Buy Now</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
@ -32,9 +32,9 @@ const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
|
||||
|
||||
const STARTER_KIT = {
|
||||
name: 'WellNuo Starter Kit',
|
||||
price: '$249',
|
||||
priceValue: 249,
|
||||
description: '4 smart sensors that easily plug into any outlet and set up through the app in minutes',
|
||||
price: '$399',
|
||||
priceValue: 399,
|
||||
description: '5 smart sensors that easily plug into any outlet and set up through the app in minutes',
|
||||
};
|
||||
|
||||
export default function PurchaseScreen() {
|
||||
@ -245,7 +245,7 @@ export default function PurchaseScreen() {
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="card" size={20} color={AppColors.white} />
|
||||
<Text style={styles.buyButtonText}>Buy Now — {STARTER_KIT.price}</Text>
|
||||
<Text style={styles.buyButtonText}>Buy Now</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
@ -33,9 +33,9 @@ import { hasBeneficiaryDevices } from '@/services/BeneficiaryDetailController';
|
||||
|
||||
const STARTER_KIT = {
|
||||
name: 'WellNuo Starter Kit',
|
||||
price: '$249',
|
||||
priceValue: 249,
|
||||
description: '4 smart sensors that easily plug into any outlet and set up through the app in minutes',
|
||||
price: '$399',
|
||||
priceValue: 399,
|
||||
description: '5 smart sensors that easily plug into any outlet and set up through the app in minutes',
|
||||
};
|
||||
|
||||
export default function PurchaseScreen() {
|
||||
@ -167,7 +167,7 @@ export default function PurchaseScreen() {
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="card" size={20} color={AppColors.white} />
|
||||
<Text style={styles.buyButtonText}>Buy Now — {STARTER_KIT.price}</Text>
|
||||
<Text style={styles.buyButtonText}>Buy Now</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
@ -68,8 +68,8 @@ export function BeneficiaryMenu({
|
||||
if (onEdit) {
|
||||
onEdit();
|
||||
} else {
|
||||
// Navigate to main page with edit intent
|
||||
router.push(`/(tabs)/beneficiaries/${beneficiaryId}`);
|
||||
// Navigate to main page with edit=true param to open edit modal
|
||||
router.push(`/(tabs)/beneficiaries/${beneficiaryId}?edit=true`);
|
||||
}
|
||||
break;
|
||||
case 'access':
|
||||
|
||||
73
package-lock.json
generated
73
package-lock.json
generated
@ -9,6 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@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-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/elements": "^2.6.3",
|
||||
@ -21,7 +22,6 @@
|
||||
"expo-camera": "~17.0.10",
|
||||
"expo-clipboard": "~8.0.8",
|
||||
"expo-constants": "~18.0.12",
|
||||
"expo-crypto": "~15.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-font": "~14.0.10",
|
||||
"expo-haptics": "~15.0.8",
|
||||
@ -43,6 +43,7 @@
|
||||
"react-native": "0.81.5",
|
||||
"react-native-fs": "^2.20.0",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-get-random-values": "^2.0.0",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-root-toast": "^4.0.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
@ -4699,6 +4700,46 @@
|
||||
"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": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
@ -10699,18 +10740,6 @@
|
||||
"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": {
|
||||
"version": "19.0.21",
|
||||
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz",
|
||||
@ -11409,6 +11438,12 @@
|
||||
"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": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@ -18789,6 +18824,18 @@
|
||||
"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": {
|
||||
"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",
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@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-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/elements": "^2.6.3",
|
||||
@ -24,7 +25,6 @@
|
||||
"expo-camera": "~17.0.10",
|
||||
"expo-clipboard": "~8.0.8",
|
||||
"expo-constants": "~18.0.12",
|
||||
"expo-crypto": "~15.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-font": "~14.0.10",
|
||||
"expo-haptics": "~15.0.8",
|
||||
@ -46,6 +46,7 @@
|
||||
"react-native": "0.81.5",
|
||||
"react-native-fs": "^2.20.0",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-get-random-values": "^2.0.0",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-root-toast": "^4.0.1",
|
||||
"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 * as Crypto from 'expo-crypto';
|
||||
import { File } from 'expo-file-system';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
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)
|
||||
let onUnauthorizedCallback: (() => void) | null = null;
|
||||
@ -67,7 +67,9 @@ class ApiService {
|
||||
}
|
||||
|
||||
private generateNonce(): string {
|
||||
const randomBytes = Crypto.getRandomBytes(16);
|
||||
// Use Web Crypto API (polyfilled by react-native-get-random-values)
|
||||
const randomBytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(randomBytes);
|
||||
return Array.from(randomBytes)
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
@ -1080,6 +1082,51 @@ 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)
|
||||
async activateBeneficiary(beneficiaryId: number, serialNumber: string): Promise<ApiResponse<{
|
||||
success: boolean;
|
||||
|
||||
@ -6,27 +6,23 @@
|
||||
*
|
||||
* Uses @orbital-systems/react-native-esp-idf-provisioning which wraps
|
||||
* 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 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)
|
||||
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 {
|
||||
name: string;
|
||||
device: ESPDevice;
|
||||
device: any; // ESPDevice when native module available
|
||||
wellId?: string; // Extracted from name: WP_<wellId>_<mac>
|
||||
macPart?: string; // Last part of MAC address
|
||||
}
|
||||
@ -37,14 +33,44 @@ export interface WifiNetwork {
|
||||
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 {
|
||||
private connectedDevice: ESPDevice | null = null;
|
||||
private connectedDevice: any | null = null;
|
||||
private isScanning = false;
|
||||
|
||||
/**
|
||||
* Check if ESP provisioning is available (development build only)
|
||||
*/
|
||||
isAvailable(): boolean {
|
||||
return ESPProvisionManager !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request necessary permissions for BLE on Android
|
||||
*/
|
||||
async requestPermissions(): Promise<boolean> {
|
||||
if (!this.isAvailable()) {
|
||||
console.warn('[ESP] Provisioning not available in Expo Go');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Platform.OS !== 'android') {
|
||||
return true;
|
||||
}
|
||||
@ -76,6 +102,16 @@ class ESPProvisioningService {
|
||||
* Returns list of devices matching WP_xxx_xxxxxx pattern
|
||||
*/
|
||||
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) {
|
||||
console.warn('[ESP] Already scanning, please wait');
|
||||
return [];
|
||||
@ -93,12 +129,12 @@ class ESPProvisioningService {
|
||||
const devices = await ESPProvisionManager.searchESPDevices(
|
||||
WELLNUO_DEVICE_PREFIX,
|
||||
ESPTransport.ble,
|
||||
DEFAULT_SECURITY
|
||||
ESPSecurity.unsecure
|
||||
);
|
||||
|
||||
console.log(`[ESP] Found ${devices.length} WellNuo device(s)`);
|
||||
|
||||
return devices.map((device: ESPDevice) => {
|
||||
return devices.map((device: any) => {
|
||||
// Parse device name: WP_<wellId>_<macPart>
|
||||
const parts = device.name?.split('_') || [];
|
||||
return {
|
||||
@ -122,9 +158,14 @@ class ESPProvisioningService {
|
||||
* @param proofOfPossession - Optional PoP for secure devices
|
||||
*/
|
||||
async connect(
|
||||
device: ESPDevice,
|
||||
device: any,
|
||||
proofOfPossession?: string
|
||||
): Promise<boolean> {
|
||||
if (!this.isAvailable()) {
|
||||
console.warn('[ESP] Connect not available - running in Expo Go');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.connectedDevice) {
|
||||
console.warn('[ESP] Already connected, disconnecting first...');
|
||||
await this.disconnect();
|
||||
@ -148,6 +189,11 @@ class ESPProvisioningService {
|
||||
* Scan for available WiFi networks through connected device
|
||||
*/
|
||||
async scanWifiNetworks(): Promise<WifiNetwork[]> {
|
||||
if (!this.isAvailable()) {
|
||||
console.warn('[ESP] WiFi scan not available - running in Expo Go');
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!this.connectedDevice) {
|
||||
throw new Error('Not connected to any device');
|
||||
}
|
||||
@ -159,7 +205,7 @@ class ESPProvisioningService {
|
||||
|
||||
console.log(`[ESP] Found ${wifiList.length} WiFi network(s)`);
|
||||
|
||||
return wifiList.map((wifi: ESPWifi) => ({
|
||||
return wifiList.map((wifi: any) => ({
|
||||
ssid: wifi.ssid,
|
||||
rssi: wifi.rssi,
|
||||
auth: this.getAuthModeName(wifi.auth),
|
||||
@ -176,6 +222,11 @@ class ESPProvisioningService {
|
||||
* @param password - WiFi password
|
||||
*/
|
||||
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) {
|
||||
throw new Error('Not connected to any device');
|
||||
}
|
||||
@ -246,4 +297,4 @@ class ESPProvisioningService {
|
||||
export const espProvisioning = new ESPProvisioningService();
|
||||
|
||||
// Export types for components
|
||||
export type { ESPDevice };
|
||||
export type ESPDevice = any;
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
/**
|
||||
* Sherpa TTS Service - STUB for Expo Go development
|
||||
* 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
|
||||
* Sherpa TTS Service - Native implementation for offline Text-to-Speech
|
||||
* Uses react-native-sherpa-onnx-offline-tts with Piper VITS models
|
||||
*/
|
||||
|
||||
// Available Piper neural voices - kept for type compatibility
|
||||
import { Platform, NativeModules, NativeEventEmitter } from 'react-native';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { Asset } from 'expo-asset';
|
||||
|
||||
// Available Piper neural voices
|
||||
export interface PiperVoice {
|
||||
id: string;
|
||||
name: string;
|
||||
@ -25,23 +28,57 @@ export const AVAILABLE_VOICES: PiperVoice[] = [
|
||||
gender: 'female',
|
||||
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 {
|
||||
initialized: boolean;
|
||||
initializing: boolean;
|
||||
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 = {
|
||||
initialized: false,
|
||||
initializing: false,
|
||||
error: 'Sherpa TTS disabled for Expo Go development',
|
||||
error: null,
|
||||
currentVoice: AVAILABLE_VOICES[0],
|
||||
};
|
||||
|
||||
// State listeners
|
||||
const stateListeners: ((state: SherpaTTSState) => void)[] = [];
|
||||
|
||||
function updateState(updates: Partial<SherpaTTSState>) {
|
||||
currentState = { ...currentState, ...updates };
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
function notifyListeners() {
|
||||
stateListeners.forEach(listener => listener({ ...currentState }));
|
||||
}
|
||||
@ -59,12 +96,179 @@ export function getState(): SherpaTTSState {
|
||||
return { ...currentState };
|
||||
}
|
||||
|
||||
// Stub implementations - always return false/unavailable
|
||||
export async function initializeSherpaTTS(): Promise<boolean> {
|
||||
console.log('[SherpaTTS STUB] Sherpa TTS disabled for Expo Go - using expo-speech instead');
|
||||
return false;
|
||||
/**
|
||||
* Copy bundled TTS model assets to document directory for native access
|
||||
* NOTE: Temporarily disabled dynamic requires for Metro bundler compatibility
|
||||
*/
|
||||
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(
|
||||
text: string,
|
||||
options?: {
|
||||
@ -75,30 +279,108 @@ export async function speak(
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
): Promise<void> {
|
||||
options?.onError?.(new Error('Sherpa TTS disabled for Expo Go'));
|
||||
if (!NATIVE_MODULE_AVAILABLE || !currentState.initialized) {
|
||||
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 {
|
||||
// No-op
|
||||
if (NATIVE_MODULE_AVAILABLE && currentState.initialized) {
|
||||
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 {
|
||||
// No-op
|
||||
if (NATIVE_MODULE_AVAILABLE) {
|
||||
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 {
|
||||
return false;
|
||||
return NATIVE_MODULE_AVAILABLE && currentState.initialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current voice
|
||||
*/
|
||||
export function getCurrentVoice(): PiperVoice {
|
||||
return AVAILABLE_VOICES[0];
|
||||
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;
|
||||
}
|
||||
|
||||
// Deinitialize current and reinitialize with new voice
|
||||
deinitialize();
|
||||
return initializeSherpaTTS(voice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add listener for volume updates during playback
|
||||
*/
|
||||
export function addVolumeListener(callback: (volume: number) => void): (() => void) | null {
|
||||
if (!ttsManagerEmitter) return null;
|
||||
|
||||
const subscription = ttsManagerEmitter.addListener('VolumeUpdate', (event) => {
|
||||
callback(event.volume);
|
||||
});
|
||||
|
||||
return () => subscription.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load saved voice preference
|
||||
*/
|
||||
export async function loadSavedVoice(): Promise<PiperVoice> {
|
||||
// For now, just return default voice
|
||||
// Could add AsyncStorage persistence later
|
||||
return AVAILABLE_VOICES[0];
|
||||
}
|
||||
|
||||
@ -114,4 +396,5 @@ export default {
|
||||
getCurrentVoice,
|
||||
setVoice,
|
||||
loadSavedVoice,
|
||||
addVolumeListener,
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user