Files
rnpay/screens/HomeScreen.tsx
2026-03-24 17:25:12 +08:00

753 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { Component } from "react";
import {
Alert, AppState, AppStateStatus, FlatList, Image, Modal,
ScrollView, StyleSheet, Text, TextInput,
TouchableOpacity, View, ActivityIndicator,
} from "react-native";
import * as Animatable from 'react-native-animatable';
import DeviceInfo from 'react-native-device-info';
import {
PhonePeBusinessBind,
GooglePayBusinessBind,
WalletType,
PaytmBusinessBindResult,
PhonePeBusinessBindResult,
PaytmPersonalBind,
PaytmPersonalBindResult,
MobikwikPersonalBindResult,
FreechargePersonalBindResult,
GooglePayBusinessBindResult,
BharatPeBusinessBindResult,
onSmsMessage,
startSmsListener,
stopSmsListener,
checkSmsPermission,
requestSmsPermission,
PhonePePersonalBindResult,
PhonePePersonalBind,
SmsMessage,
proxyBackgroundService,
} from "rnwalletman";
import {
FreeChargeBind,
MobikwikOTPBind,
PayTmPersonalOTPBind,
PhonePePersonalOTPBind,
BharatPeBusinessOTPBind,
PaytmBusinessOTPBind,
} from '../components/WalletBindComponents';
import Api, { WalletItem, loadServerDomain, saveServerDomain, getServerDomain } from '../services/api';
// key 与服务端 WalletType 字符串一致(见 types.go
const WALLET_ICONS: Record<string, any> = {
'paytm': require('../res/paytm.png'),
'paytm business': require('../res/paytm-business.png'),
'phonepe': require('../res/phonepe.webp'),
'phonepe business': require('../res/phonepe-business.webp'),
'googlepay business': require('../res/googlepay-business.webp'),
'bharatpe business': require('../res/bharatpe-business.webp'),
'mobikwik': require('../res/mobikwik.png'),
'freecharge': require('../res/freecharge.png'),
};
const WALLET_TYPE_COLORS: Record<string, string> = {
'paytm': '#002970',
'paytm business': '#002970',
'phonepe': '#5a2d9c',
'phonepe business': '#5a2d9c',
'googlepay business': '#4285f4',
'bharatpe business': '#e91e63',
'mobikwik': '#00bcd4',
'freecharge': '#ff5722',
};
// 钱包类型展示信息walletType 与服务端一致)
const WALLET_TYPE_OPTIONS = [
{ key: 'paytm_personal_otp', walletType: 'paytm', label: 'Paytm Personal (OTP)', mode: 'otp' },
{ key: 'paytm_personal_token', walletType: 'paytm', label: 'Paytm Personal (Token)', mode: 'token' },
{ key: 'paytm_business', walletType: 'paytm business', label: 'Paytm Business (OTP)', mode: 'otp' },
{ key: 'phonepe_personal_otp', walletType: 'phonepe', label: 'PhonePe Personal (OTP)', mode: 'otp' },
{ key: 'phonepe_personal_token', walletType: 'phonepe', label: 'PhonePe Personal (Token)', mode: 'token' },
{ key: 'phonepe_business', walletType: 'phonepe business', label: 'PhonePe Business (OTP-WEB)', mode: 'otp' },
{ key: 'googlepay_business', walletType: 'googlepay business', label: 'GooglePay Business', mode: 'token' },
{ key: 'bharatpe_business', walletType: 'bharatpe business', label: 'BharatPe Business (OTP)', mode: 'otp' },
{ key: 'mobikwik_personal', walletType: 'mobikwik', label: 'Mobikwik Personal (OTP)', mode: 'otp' },
{ key: 'freecharge_personal', walletType: 'freecharge', label: 'Freecharge Personal (OTP)', mode: 'otp' },
];
interface HomeScreenState {
// bind modals
showPaytmPersonalBind: boolean;
paytmPersonalBindType: 'otpMode' | 'tokenMode';
showPaytmBusinessBind: boolean;
showPhonePePersonalBind: boolean;
phonePePersonalBindType: 'otpMode' | 'tokenMode';
showPhonePeBusinessBind: boolean;
showGooglePayBusinessBind: boolean;
showBharatPeBusinessBind: boolean;
showMobikwikPersonalBind: boolean;
showFreechargePersonalBind: boolean;
// proxy
proxyStatus: 'idle' | 'connecting' | 'connected' | 'disconnected' | 'error';
proxyError?: string;
// server settings
showServerSettings: boolean;
settingsHost: string;
settingsPort: string;
// wallet list
wallets: WalletItem[];
loadingWallets: boolean;
// vpa modal
vpaModalWallet: WalletItem | null;
vpaModalVpas: string[];
vpaModalLoading: boolean;
vpaModalSelected: string;
// add wallet
showAddWallet: boolean;
}
export default class HomeScreen extends Component<any, HomeScreenState> {
private deviceId: string;
private tuneUserId: string;
private clientId: string = '';
private appStateSubscription?: any;
constructor(props: any) {
super(props);
this.state = {
paytmPersonalBindType: 'otpMode',
showPaytmPersonalBind: false,
showPaytmBusinessBind: false,
showPhonePePersonalBind: false,
phonePePersonalBindType: 'otpMode',
showPhonePeBusinessBind: false,
showGooglePayBusinessBind: false,
showBharatPeBusinessBind: false,
showMobikwikPersonalBind: false,
showFreechargePersonalBind: false,
proxyStatus: 'idle',
showServerSettings: false,
settingsHost: '',
settingsPort: '',
wallets: [],
loadingWallets: false,
vpaModalWallet: null,
vpaModalVpas: [],
vpaModalLoading: false,
vpaModalSelected: '',
showAddWallet: false,
};
this.deviceId = DeviceInfo.getUniqueIdSync();
this.tuneUserId = Math.random().toString(36).substring(2, 15);
}
async componentDidMount() {
await loadServerDomain();
await this.setupPermissions();
const doLogin = () => {
Api.instance.login('test123', '123456')
.then(async () => {
await this.startProxyClient();
this.fetchWallets();
})
.catch((error) => {
console.log('[Login] retry in 3s:', error);
setTimeout(doLogin, 3000);
});
};
doLogin();
this.appStateSubscription = AppState.addEventListener('change', this.handleAppStateChange);
}
componentWillUnmount() {
this.stopProxyClient();
stopSmsListener();
this.appStateSubscription?.remove();
}
handleAppStateChange = (nextAppState: AppStateStatus) => {
if (nextAppState === 'active') {
this.fetchWallets();
}
}
async setupPermissions() {
const hasSms = await checkSmsPermission();
if (!hasSms) await requestSmsPermission();
startSmsListener();
onSmsMessage((msg: SmsMessage) => {
console.log('[SMS]', msg.address, msg.body);
});
}
async startProxyClient() {
try {
this.clientId = DeviceInfo.getUniqueIdSync();
const userId = Api.instance.getUserId();
this.setState({ proxyStatus: 'connecting' });
await proxyBackgroundService.start({
wsUrl: Api.WS_URL,
clientId: this.clientId || '',
userId,
debug: true,
heartbeatInterval: 10000,
reconnectInterval: 5000,
reconnectMaxAttempts: Infinity,
onConnected: () => this.setState({ proxyStatus: 'connected' }),
onDisconnected: () => this.setState({ proxyStatus: 'disconnected' }),
onError: (error: string) => this.setState({ proxyStatus: 'error', proxyError: error }),
});
} catch (error) {
console.error('[Proxy] 初始化失败:', error);
}
}
stopProxyClient() {
try { proxyBackgroundService.stop(); } catch {}
}
fetchWallets = async () => {
this.setState({ loadingWallets: true });
try {
const wallets = await Api.instance.listWallets();
this.setState({ wallets });
} catch (e) {
console.log('[fetchWallets]', e);
} finally {
this.setState({ loadingWallets: false });
}
};
openVpaModal = async (item: WalletItem) => {
this.setState({
vpaModalWallet: item,
vpaModalVpas: [],
vpaModalLoading: true,
vpaModalSelected: item.upi ?? '',
});
try {
const vpas = await Api.instance.getWalletVpas(item.id);
this.setState({ vpaModalVpas: vpas, vpaModalLoading: false });
} catch {
this.setState({ vpaModalLoading: false });
}
};
closeVpaModal = () => this.setState({ vpaModalWallet: null });
confirmVpa = async () => {
const { vpaModalWallet, vpaModalVpas, vpaModalSelected } = this.state;
if (!vpaModalWallet || !vpaModalSelected) return;
const idx = vpaModalVpas.indexOf(vpaModalSelected);
if (idx < 0) return;
try {
await Api.instance.setCurrentVpa(vpaModalWallet.id, idx);
const walletId = vpaModalWallet.id;
const vpa = vpaModalSelected;
this.setState(s => ({
vpaModalWallet: null,
wallets: s.wallets.map(w => w.id === walletId ? { ...w, upi: vpa } : w),
}));
} catch (e) {
Alert.alert('设置失败', (e as Error).message);
}
};
// ---- bind handlers ----
handleBindSuccess = (key: keyof HomeScreenState, msg: string) => async (result: any) => {
try {
const typeMap: Record<string, string> = {
showPaytmPersonalBind: WalletType.PAYTM_PERSONAL,
showPaytmBusinessBind: WalletType.PAYTM_BUSINESS,
showPhonePePersonalBind: WalletType.PHONEPE_PERSONAL,
showPhonePeBusinessBind: WalletType.PHONEPE_BUSINESS,
showGooglePayBusinessBind: WalletType.GOOGLEPAY_BUSINESS,
showBharatPeBusinessBind: WalletType.BHARATPE_BUSINESS,
showMobikwikPersonalBind: WalletType.MOBIKWIK_PERSONAL,
showFreechargePersonalBind: WalletType.FREECHARGE_PERSONAL,
};
const wt = typeMap[key as string];
if (wt) await Api.instance.register(wt as WalletType, result);
this.setState({ [key]: false } as any);
Alert.alert('绑定成功', msg);
this.fetchWallets();
} catch (error) {
this.setState({ [key]: false } as any);
Alert.alert('绑定失败', (error as Error).message);
}
};
// ---- modals ----
renderBindModal = () => {
const { showPaytmPersonalBind, paytmPersonalBindType, showPhonePePersonalBind,
phonePePersonalBindType, showPaytmBusinessBind, showPhonePeBusinessBind,
showGooglePayBusinessBind, showBharatPeBusinessBind, showMobikwikPersonalBind,
showFreechargePersonalBind } = this.state;
const close = (key: keyof HomeScreenState) => () => this.setState({ [key]: false } as any);
if (showPaytmPersonalBind && paytmPersonalBindType === 'tokenMode') return (
<Modal visible transparent onRequestClose={close('showPaytmPersonalBind')}>
<PaytmPersonalBind processString="Processing..." isDebug onSuccess={this.handleBindSuccess('showPaytmPersonalBind', 'Paytm Personal 绑定成功') as any} onError={(e: string) => { Alert.alert('绑定失败', e); close('showPaytmPersonalBind')(); }} />
</Modal>
);
if (showPaytmPersonalBind) return (
<Modal visible transparent onRequestClose={close('showPaytmPersonalBind')}>
<PayTmPersonalOTPBind isDebug
onRequestOTP={async (wt, p) => { try { return await Api.instance.requestOTP(wt, p.mobile, {}); } catch (e) { return { success: false, message: (e as Error).message }; } }}
onVerifyOTP={async (wt, p) => { try { return await Api.instance.verifyOTP(wt, p.mobile, p.otp, { sessionId: p.sessionId }); } catch (e) { return { success: false, message: (e as Error).message }; } }}
onSuccess={(r: PaytmPersonalBindResult) => { Alert.alert('绑定成功', 'Paytm Personal OTP'); this.setState({ showPaytmPersonalBind: false }); this.fetchWallets(); }}
onError={(e: string) => { Alert.alert('绑定失败', e); close('showPaytmPersonalBind')(); }}
/>
</Modal>
);
if (showPhonePePersonalBind && phonePePersonalBindType === 'tokenMode') return (
<Modal visible transparent onRequestClose={close('showPhonePePersonalBind')}>
<PhonePePersonalBind processString="Processing..." isDebug onSuccess={this.handleBindSuccess('showPhonePePersonalBind', 'PhonePe Personal 绑定成功') as any} onError={(e: string) => { Alert.alert('绑定失败', e); close('showPhonePePersonalBind')(); }} />
</Modal>
);
if (showPhonePePersonalBind) return (
<Modal visible transparent onRequestClose={close('showPhonePePersonalBind')}>
<PhonePePersonalOTPBind isDebug
onRequestOTP={async (wt, p) => { try { return await Api.instance.requestOTP(wt, p.mobile, {}); } catch (e) { return { success: false, message: (e as Error).message }; } }}
onVerifyOTP={async (wt, p) => { try { return await Api.instance.verifyOTP(wt, p.mobile, p.otp, { sessionId: p.sessionId }); } catch (e) { return { success: false, message: (e as Error).message }; } }}
onSuccess={(r: PhonePePersonalBindResult) => { Alert.alert('绑定成功', 'PhonePe Personal OTP'); this.setState({ showPhonePePersonalBind: false }); this.fetchWallets(); }}
onError={(e: string) => { Alert.alert('绑定失败', e); close('showPhonePePersonalBind')(); }}
/>
</Modal>
);
if (showPaytmBusinessBind) return (
<Modal visible transparent onRequestClose={close('showPaytmBusinessBind')}>
<PaytmBusinessOTPBind isDebug
onRequestOTP={async (wt, p) => { try { return await Api.instance.requestOTP(wt, p.mobile, { password: p.password }); } catch (e) { return { success: false, message: (e as Error).message }; } }}
onVerifyOTP={async (wt, p) => { try { return await Api.instance.verifyOTP(wt, p.mobile, p.otp, { sessionId: p.sessionId }); } catch (e) { return { success: false, message: (e as Error).message }; } }}
onSuccess={this.handleBindSuccess('showPaytmBusinessBind', 'Paytm Business 绑定成功') as any}
onError={(e: string) => { Alert.alert('绑定失败', e); close('showPaytmBusinessBind')(); }}
/>
</Modal>
);
if (showPhonePeBusinessBind) return (
<Modal visible transparent onRequestClose={close('showPhonePeBusinessBind')}>
<PhonePeBusinessBind processString="Processing..." isDebug
onSuccess={this.handleBindSuccess('showPhonePeBusinessBind', 'PhonePe Business 绑定成功') as any}
onError={(e: string) => { Alert.alert('绑定失败', e); close('showPhonePeBusinessBind')(); }}
onRenderBottomView={({ showOtpInput, loading, formError, phone, otp, onPhoneChange, onOtpChange, onGetOtp, onSubmitOtp }) => (
<View style={s.otpBar}>
{!showOtpInput ? (<>
<TextInput style={s.otpInput} placeholder="Mobile Number" placeholderTextColor="#999" keyboardType="phone-pad" value={phone} onChangeText={onPhoneChange} editable={!loading} />
{!!formError && <Text style={s.errText}>{formError}</Text>}
<TouchableOpacity style={[s.otpBtn, { opacity: loading ? 0.5 : 1 }]} onPress={onGetOtp} disabled={loading}><Text style={s.otpBtnText}>{loading ? 'Loading...' : 'GET OTP'}</Text></TouchableOpacity>
</>) : (<>
<TextInput style={s.otpInput} placeholder="OTP" placeholderTextColor="#999" keyboardType="number-pad" value={otp} onChangeText={onOtpChange} editable={!loading} />
{!!formError && <Text style={s.errText}>{formError}</Text>}
<TouchableOpacity style={[s.otpBtn, { opacity: loading ? 0.5 : 1 }]} onPress={onSubmitOtp} disabled={loading}><Text style={s.otpBtnText}>{loading ? 'Loading...' : 'Verify OTP'}</Text></TouchableOpacity>
</>)}
</View>
)}
/>
</Modal>
);
if (showGooglePayBusinessBind) return (
<Modal visible transparent onRequestClose={close('showGooglePayBusinessBind')}>
<GooglePayBusinessBind processString="Processing..." isDebug onSuccess={this.handleBindSuccess('showGooglePayBusinessBind', 'GooglePay Business 绑定成功') as any} onError={(e: string) => { Alert.alert('绑定失败', e); close('showGooglePayBusinessBind')(); }} />
</Modal>
);
if (showBharatPeBusinessBind) return (
<Modal visible transparent onRequestClose={close('showBharatPeBusinessBind')}>
<BharatPeBusinessOTPBind isDebug
onRequestOTP={async (wt, p) => { try { return await Api.instance.requestOTP(wt, p.mobile); } catch (e) { return { success: false, message: (e as Error).message }; } }}
onVerifyOTP={async (wt, p) => { try { return await Api.instance.verifyOTP(wt, p.mobile, p.otp, { sessionId: p.sessionId }); } catch (e) { return { success: false, message: (e as Error).message }; } }}
onSuccess={this.handleBindSuccess('showBharatPeBusinessBind', 'BharatPe Business 绑定成功') as any}
onError={(e: string) => { Alert.alert('绑定失败', e); close('showBharatPeBusinessBind')(); }}
/>
</Modal>
);
if (showMobikwikPersonalBind) return (
<Modal visible transparent onRequestClose={close('showMobikwikPersonalBind')}>
<MobikwikOTPBind isDebug deviceId={this.deviceId} tuneUserId={this.tuneUserId}
onRequestOTP={async (wt, p) => { try { return await Api.instance.requestOTP(wt, p.mobile, { deviceId: p.deviceId, tuneUserId: p.tuneUserId }); } catch (e) { return { success: false, message: (e as Error).message }; } }}
onVerifyOTP={async (wt, p) => { try { return await Api.instance.verifyOTP(wt, p.mobile, p.otp, { sessionId: p.sessionId, deviceId: p.deviceId, tuneUserId: p.tuneUserId, nid: p.nid }); } catch (e) { return { success: false, message: (e as Error).message }; } }}
onSuccess={this.handleBindSuccess('showMobikwikPersonalBind', 'Mobikwik 绑定成功') as any}
onError={(e: string) => { Alert.alert('绑定失败', e); close('showMobikwikPersonalBind')(); }}
/>
</Modal>
);
if (showFreechargePersonalBind) return (
<Modal visible transparent onRequestClose={close('showFreechargePersonalBind')}>
<FreeChargeBind isDebug
onRequestOTP={async (wt, p) => { try { return await Api.instance.requestOTP(wt, p.mobile); } catch (e) { return { success: false, message: (e as Error).message }; } }}
onVerifyOTP={async (wt, p) => { try { return await Api.instance.verifyOTP(wt, p.mobile, p.otp, { otpId: p.otpId, deviceId: p.deviceId, csrfId: p.csrfId, appFc: p.appFc }); } catch (e) { return { success: false, message: (e as Error).message }; } }}
onSuccess={this.handleBindSuccess('showFreechargePersonalBind', 'Freecharge 绑定成功') as any}
onError={(e: string) => { Alert.alert('绑定失败', e); close('showFreechargePersonalBind')(); }}
/>
</Modal>
);
return null;
};
openWalletBind = (key: string) => {
this.setState({ showAddWallet: false });
setTimeout(() => {
switch (key) {
case 'paytm_personal_otp': this.setState({ showPaytmPersonalBind: true, paytmPersonalBindType: 'otpMode' }); break;
case 'paytm_personal_token': this.setState({ showPaytmPersonalBind: true, paytmPersonalBindType: 'tokenMode' }); break;
case 'paytm_business': this.setState({ showPaytmBusinessBind: true }); break;
case 'phonepe_personal_otp': this.setState({ showPhonePePersonalBind: true, phonePePersonalBindType: 'otpMode' }); break;
case 'phonepe_personal_token': this.setState({ showPhonePePersonalBind: true, phonePePersonalBindType: 'tokenMode' }); break;
case 'phonepe_business': this.setState({ showPhonePeBusinessBind: true }); break;
case 'googlepay_business': this.setState({ showGooglePayBusinessBind: true }); break;
case 'bharatpe_business': this.setState({ showBharatPeBusinessBind: true }); break;
case 'mobikwik_personal': this.setState({ showMobikwikPersonalBind: true }); break;
case 'freecharge_personal': this.setState({ showFreechargePersonalBind: true }); break;
}
}, 300);
};
renderAddWalletModal() {
return (
<Modal visible={this.state.showAddWallet} transparent animationType="none" onRequestClose={() => this.setState({ showAddWallet: false })}>
<View style={s.modalOverlay}>
<Animatable.View
animation="zoomIn"
duration={220}
easing="ease-out-back"
useNativeDriver
style={s.addModalBox}
>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<Text style={s.addModalTitle}></Text>
<TouchableOpacity onPress={() => this.setState({ showAddWallet: false })}>
<Text style={{ fontSize: 20, color: '#999' }}></Text>
</TouchableOpacity>
</View>
<ScrollView bounces={false}>
{WALLET_TYPE_OPTIONS.map(opt => (
<TouchableOpacity key={opt.key} style={s.walletTypeRow} onPress={() => this.openWalletBind(opt.key)} activeOpacity={0.7}>
{WALLET_ICONS[opt.walletType]
? <Image source={WALLET_ICONS[opt.walletType]} style={s.walletTypeIcon} resizeMode="contain" />
: <View style={[s.walletTypeDot, { backgroundColor: WALLET_TYPE_COLORS[opt.walletType] ?? '#888' }]} />
}
<Text style={s.walletTypeLabel}>{opt.label}</Text>
</TouchableOpacity>
))}
</ScrollView>
</Animatable.View>
</View>
</Modal>
);
}
renderServerSettingsModal() {
const { showServerSettings, settingsHost, settingsPort } = this.state;
const presets = [
{ label: 'aa.pfgame.org', host: 'aa.pfgame.org', port: '443' },
{ label: '192.168.1.117:16000', host: '192.168.1.117', port: '16000' },
];
return (
<Modal visible={showServerSettings} transparent animationType="fade">
<View style={s.modalOverlay}>
<View style={s.settingsBox}>
<Text style={s.settingsTitle}></Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginBottom: 14 }}>
{presets.map(p => (
<TouchableOpacity key={p.label} onPress={() => this.setState({ settingsHost: p.host, settingsPort: p.port })}
style={[s.presetBtn, settingsHost === p.host && settingsPort === p.port && s.presetBtnActive]}>
<Text style={{ fontSize: 12, color: settingsHost === p.host && settingsPort === p.port ? '#fff' : '#333' }}>{p.label}</Text>
</TouchableOpacity>
))}
</View>
<Text style={s.inputLabel}>Host</Text>
<TextInput style={s.textInput} value={settingsHost} onChangeText={t => this.setState({ settingsHost: t })} placeholder="192.168.1.198" autoCapitalize="none" />
<Text style={s.inputLabel}>Port</Text>
<TextInput style={[s.textInput, { marginBottom: 20 }]} value={settingsPort} onChangeText={t => this.setState({ settingsPort: t })} placeholder="16000" keyboardType="number-pad" />
<View style={{ flexDirection: 'row', justifyContent: 'flex-end' }}>
<TouchableOpacity onPress={() => this.setState({ showServerSettings: false })} style={{ paddingHorizontal: 16, paddingVertical: 8, marginRight: 10 }}>
<Text style={{ color: '#666' }}></Text>
</TouchableOpacity>
<TouchableOpacity onPress={async () => {
const { settingsHost, settingsPort } = this.state;
const domain = settingsPort ? `${settingsHost}:${settingsPort}` : settingsHost;
await saveServerDomain(domain, settingsPort === '443');
this.setState({ showServerSettings: false });
Alert.alert('已保存', '重启 App 后生效');
}} style={s.saveBtn}>
<Text style={{ color: '#fff', fontWeight: 'bold' }}></Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
}
renderWalletItem = ({ item }: { item: WalletItem }) => {
const color = WALLET_TYPE_COLORS[item.walletType] ?? '#888';
const isActive = item.status === 'ACTIVE';
return (
<TouchableOpacity style={s.walletCard} onPress={() => this.openVpaModal(item)} activeOpacity={0.8}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View style={[s.walletBadge, !isActive && s.walletBadgeInactive]}>
{WALLET_ICONS[item.walletType]
? <Image source={WALLET_ICONS[item.walletType]} style={[s.walletIcon, !isActive && s.walletIconInactive]} resizeMode="contain" />
: <View style={[s.walletIconFallback, { backgroundColor: isActive ? color : '#ccc' }]}>
<Text style={s.walletBadgeText}>{item.walletType.split('_')[0]}</Text>
</View>
}
</View>
<View style={{ flex: 1, marginLeft: 12 }}>
<Text style={s.walletPhone}>{item.phone || '—'}</Text>
<Text style={s.walletUpi} numberOfLines={1}>{item.upi || 'No UPI'}</Text>
</View>
<View style={[s.statusDot, { backgroundColor: isActive ? '#2ecc71' : '#bbb' }]} />
</View>
</TouchableOpacity>
);
};
renderVpaModal() {
const { vpaModalWallet, vpaModalVpas, vpaModalLoading, vpaModalSelected } = this.state;
const color = WALLET_TYPE_COLORS[vpaModalWallet?.walletType ?? ''] ?? '#3498db';
return (
<Modal visible={!!vpaModalWallet} transparent animationType="none" onRequestClose={this.closeVpaModal}>
<View style={s.modalOverlay}>
<Animatable.View
animation="zoomIn"
duration={220}
easing="ease-out-back"
useNativeDriver
style={s.vpaModalBox}
>
<Text style={s.vpaModalTitle}> VPA</Text>
<Text style={s.vpaModalSub}>{vpaModalWallet?.phone}</Text>
<ScrollView style={s.vpaModalList} bounces={false}>
{vpaModalLoading
? <ActivityIndicator size="large" color={color} style={{ marginVertical: 28 }} />
: vpaModalVpas.length === 0
? <Text style={{ color: '#aaa', textAlign: 'center', marginVertical: 28 }}> VPA </Text>
: vpaModalVpas.map(vpa => {
const selected = vpa === vpaModalSelected;
return (
<TouchableOpacity
key={vpa}
style={[s.vpaOptionRow, selected && { borderColor: color, backgroundColor: color + '10' }]}
onPress={() => this.setState({ vpaModalSelected: vpa })}
activeOpacity={0.7}
>
<View style={[s.radioOuter, selected && { borderColor: color }]}>
{selected && <View style={[s.radioInner, { backgroundColor: color }]} />}
</View>
<Text style={[s.vpaOptionText, selected && { color, fontWeight: '600' }]}>{vpa}</Text>
</TouchableOpacity>
);
})
}
</ScrollView>
<View style={s.vpaModalFooter}>
<TouchableOpacity style={s.vpaModalCancelBtn} onPress={this.closeVpaModal}>
<Text style={{ color: '#666', fontSize: 15 }}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[s.vpaModalConfirmBtn, { backgroundColor: vpaModalSelected ? color : '#ccc' }]}
onPress={this.confirmVpa}
disabled={!vpaModalSelected}
>
<Text style={{ color: '#fff', fontSize: 15, fontWeight: '600' }}></Text>
</TouchableOpacity>
</View>
</Animatable.View>
</View>
</Modal>
);
}
render() {
const { proxyStatus, proxyError, wallets, loadingWallets } = this.state;
const proxyCfg: Record<string, { label: string; color: string }> = {
idle: { label: '未连接', color: '#95a5a6' },
connecting: { label: '连接中…', color: '#f39c12' },
connected: { label: '已连接', color: '#2ecc71' },
disconnected: { label: '已断开', color: '#e74c3c' },
error: { label: '连接失败', color: '#e74c3c' },
};
const { label, color } = proxyCfg[proxyStatus];
return (
<View style={s.container}>
{/* 顶栏 */}
<View style={s.topBar}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: color, marginRight: 6 }} />
<Text style={{ color, fontSize: 13 }}>
Proxy {label}{proxyStatus === 'error' && proxyError ? `${proxyError}` : ''}
</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
<TouchableOpacity onPress={() => {
const domain = getServerDomain();
const colonIdx = domain.lastIndexOf(':');
const host = colonIdx > 0 ? domain.substring(0, colonIdx) : domain;
const port = colonIdx > 0 ? domain.substring(colonIdx + 1) : '';
this.setState({ showServerSettings: true, settingsHost: host, settingsPort: port });
}}>
<Text style={{ fontSize: 13, color: '#3498db' }}></Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => this.setState({ showAddWallet: true })} style={s.addBtn}>
<Text style={s.addBtnText}>+ Add</Text>
</TouchableOpacity>
</View>
</View>
{/* 钱包列表 */}
<View style={s.listHeader}>
<Text style={s.listHeaderText}></Text>
<TouchableOpacity onPress={this.fetchWallets} disabled={loadingWallets}>
<Text style={{ fontSize: 13, color: '#3498db' }}>{loadingWallets ? '刷新中…' : '刷新'}</Text>
</TouchableOpacity>
</View>
<FlatList
style={{ flex: 1, width: '100%' }}
contentContainerStyle={{ paddingHorizontal: 14, paddingBottom: 20 }}
data={wallets}
keyExtractor={item => item.id}
renderItem={this.renderWalletItem}
ListEmptyComponent={
<View style={{ alignItems: 'center', marginTop: 60 }}>
<Text style={{ color: '#aaa', fontSize: 14 }}>{loadingWallets ? '加载中…' : '暂无绑定钱包,点右上角 + Add 添加'}</Text>
</View>
}
/>
{this.renderBindModal()}
{this.renderServerSettingsModal()}
{this.renderAddWalletModal()}
{this.renderVpaModal()}
</View>
);
}
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f0f0f0' },
topBar: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
paddingHorizontal: 14, paddingVertical: 8, backgroundColor: '#fff',
borderBottomWidth: 1, borderBottomColor: '#e0e0e0',
},
addBtn: {
backgroundColor: '#3498db', borderRadius: 6,
paddingHorizontal: 10, paddingVertical: 4,
},
addBtnText: { color: '#fff', fontSize: 13, fontWeight: '600' },
listHeader: {
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
paddingHorizontal: 14, paddingVertical: 10,
},
listHeaderText: { fontSize: 14, fontWeight: '600', color: '#333' },
walletCard: {
backgroundColor: '#fff', borderRadius: 10, padding: 14,
marginBottom: 10, elevation: 1,
shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 4, shadowOffset: { width: 0, height: 2 },
},
walletBadge: {
width: 52, height: 52, borderRadius: 10, overflow: 'hidden',
alignItems: 'center', justifyContent: 'center',
backgroundColor: '#f5f5f5',
},
walletIcon: { width: 52, height: 52, borderRadius: 10 },
walletIconInactive: { opacity: 0.3 },
walletBadgeInactive: { backgroundColor: '#f0f0f0' },
walletIconFallback: {
width: 52, height: 52, borderRadius: 10,
alignItems: 'center', justifyContent: 'center',
},
walletBadgeText: { color: '#fff', fontSize: 9, fontWeight: '700', textAlign: 'center' },
walletPhone: { fontSize: 15, fontWeight: '600', color: '#222' },
walletUpi: { fontSize: 12, color: '#888', marginTop: 2 },
statusDot: { width: 8, height: 8, borderRadius: 4 },
vpaModalBox: {
backgroundColor: '#fff', borderRadius: 20, width: '88%',
maxHeight: '72%', overflow: 'hidden',
shadowColor: '#000', shadowOpacity: 0.18, shadowRadius: 20, shadowOffset: { width: 0, height: 8 },
elevation: 10,
},
vpaModalTitle: { fontSize: 17, fontWeight: '700', color: '#222', paddingHorizontal: 20, paddingTop: 22 },
vpaModalSub: { fontSize: 13, color: '#999', paddingHorizontal: 20, marginTop: 3, marginBottom: 14 },
vpaModalList: { paddingHorizontal: 14, maxHeight: 260 },
vpaOptionRow: {
flexDirection: 'row', alignItems: 'center',
paddingVertical: 12, paddingHorizontal: 12,
borderRadius: 8, borderWidth: 1.5, borderColor: '#eee',
marginBottom: 8,
},
radioOuter: {
width: 20, height: 20, borderRadius: 10, borderWidth: 2,
borderColor: '#ccc', alignItems: 'center', justifyContent: 'center', marginRight: 12,
},
radioInner: { width: 10, height: 10, borderRadius: 5 },
vpaOptionText: { fontSize: 14, color: '#333', flex: 1 },
vpaModalFooter: {
flexDirection: 'row', borderTopWidth: 1, borderTopColor: '#f0f0f0', marginTop: 8,
},
vpaModalCancelBtn: {
flex: 1, paddingVertical: 16, alignItems: 'center',
borderRightWidth: 1, borderRightColor: '#f0f0f0',
},
vpaModalConfirmBtn: {
flex: 1, paddingVertical: 16, alignItems: 'center', borderRadius: 0,
},
modalOverlay: {
flex: 1, backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center', alignItems: 'center',
},
addModalBox: {
backgroundColor: '#fff', borderRadius: 12, padding: 20,
width: '88%', maxHeight: '70%',
},
addModalTitle: { fontSize: 16, fontWeight: '700', color: '#222' },
walletTypeRow: {
flexDirection: 'row', alignItems: 'center',
paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: '#f0f0f0',
},
walletTypeIcon: { width: 32, height: 32, borderRadius: 6, marginRight: 12 },
walletTypeDot: { width: 10, height: 10, borderRadius: 5, marginRight: 12 },
walletTypeLabel: { fontSize: 14, color: '#333' },
settingsBox: {
backgroundColor: '#fff', borderRadius: 10, padding: 20, width: '85%',
},
settingsTitle: { fontSize: 16, fontWeight: 'bold', marginBottom: 12 },
presetBtn: {
paddingHorizontal: 10, paddingVertical: 5, borderRadius: 6, backgroundColor: '#f0f0f0',
},
presetBtnActive: { backgroundColor: '#3498db' },
inputLabel: { fontSize: 13, color: '#666', marginBottom: 4 },
textInput: {
borderWidth: 1, borderColor: '#ddd', borderRadius: 6,
paddingHorizontal: 10, paddingVertical: 8, marginBottom: 12, fontSize: 14,
},
saveBtn: {
backgroundColor: '#3498db', paddingHorizontal: 16, paddingVertical: 8, borderRadius: 6,
},
otpBar: {
position: 'absolute', bottom: 0, left: 0, right: 0,
backgroundColor: '#fff', padding: 16, borderTopWidth: 1,
borderTopColor: '#e0e0e0', gap: 10,
},
otpInput: {
borderWidth: 1, borderColor: '#ccc', borderRadius: 8,
paddingHorizontal: 12, paddingVertical: 10, fontSize: 15, color: '#333',
},
otpBtn: {
backgroundColor: '#5a2d9c', borderRadius: 8, paddingVertical: 12, alignItems: 'center',
},
otpBtnText: { color: '#fff', fontSize: 15, fontWeight: '600' },
errText: { color: '#e53935', fontSize: 13 },
});