Compare commits

...

2 Commits

Author SHA1 Message Date
Sergei
5e0b38748b Update Stripe integration, API services, and purchase screens
- Update purchase screens (auth and beneficiary)
- Update Stripe configuration and setup scripts
- Update api.ts services
- Update espProvisioning and sherpaTTS services
- Update verify-otp flow
- Package updates
2026-01-12 21:44:57 -08:00
Sergei
429a18d1eb Fix Edit navigation from menu + add avatar upload indicator
- BeneficiaryMenu: Navigate with ?edit=true param to open edit modal
- Beneficiary index: Auto-open edit modal when edit=true in URL
- Add loading indicator on Save button during edit save
- Add "Uploading..." overlay on avatar during image upload
2026-01-12 21:44:40 -08:00
16 changed files with 581 additions and 87 deletions

View File

@ -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>

View File

@ -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;
}

View File

@ -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,
},
});

View File

@ -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>

View File

@ -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...');

View File

@ -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: {

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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
View File

@ -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",

View File

@ -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",

View File

@ -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;

View File

@ -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;

View File

@ -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');
/**
* 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,
};