Files
rnpay/screens/HomeScreen.tsx
2026-05-23 12:23:47 +08:00

1323 lines
49 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,
Switch,
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,
MobikwikPersonalBindResult,
FreechargePersonalBindResult,
GooglePayBusinessBindResult,
BharatPeBusinessBindResult,
onSmsMessage,
startSmsListener,
stopSmsListener,
checkSmsPermission,
requestSmsPermission,
PhonePePersonalBind,
FreechargePersonalBind,
SmsMessage,
proxyBackgroundService,
} from 'rnwalletman';
import {
FreeChargeBind,
MobikwikOTPBind,
PayTmPersonalOTPBind,
PhonePePersonalOTPBind,
BharatPeBusinessOTPBind,
PaytmBusinessOTPBind,
} from '../components/WalletBindComponents';
import Api, {
WalletItem,
loadServerDomain,
saveServerDomain,
getServerDomain,
} from '../services/api';
// key matches server WalletType string (see 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',
};
// wallet type display info (walletType matches server)
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',
},
{
key: 'freecharge_personal_token',
walletType: 'freecharge',
label: 'Freecharge Personal (Token)',
mode: 'token',
},
];
function getBindKeyForWallet(item: WalletItem): string | null {
const otp = item.otpMode === true;
switch (item.walletType) {
case 'paytm':
return otp ? 'paytm_personal_otp' : 'paytm_personal_token';
case 'phonepe':
return otp ? 'phonepe_personal_otp' : 'phonepe_personal_token';
case 'paytm business':
return 'paytm_business';
case 'phonepe business':
return 'phonepe_business';
case 'googlepay business':
return 'googlepay_business';
case 'bharatpe business':
return 'bharatpe_business';
case 'mobikwik':
return 'mobikwik_personal';
case 'freecharge':
return otp ? 'freecharge_personal' : 'freecharge_personal_token';
default:
return null;
}
}
interface HomeScreenState {
// bind modals
showPaytmPersonalBind: boolean;
paytmPersonalBindType: 'otpMode' | 'tokenMode';
showPaytmBusinessBind: boolean;
showPhonePePersonalBind: boolean;
phonePePersonalBindType: 'otpMode' | 'tokenMode';
showPhonePeBusinessBind: boolean;
freechargePersonalBindType: 'otpMode' | 'tokenMode';
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;
bindPrefillMobile: string;
}
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,
freechargePersonalBindType: 'otpMode',
proxyStatus: 'idle',
showServerSettings: false,
settingsHost: '',
settingsPort: '',
wallets: [],
loadingWallets: false,
vpaModalWallet: null,
vpaModalVpas: [],
vpaModalLoading: false,
vpaModalSelected: '',
showAddWallet: false,
bindPrefillMobile: '',
};
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] init failed:', error);
}
}
stopProxyClient() {
try {
proxyBackgroundService.stop();
} catch {
/* ignore */
}
}
/** OTP / bindAPI catch → { success:false, message } */
private wrapOtpCall = async (fn: () => Promise<any>): Promise<any> => {
try {
return await fn();
} catch (e) {
return { success: false, message: (e as Error).message };
}
};
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('Set Failed', (e as Error).message);
}
};
toggleWalletActive = async (item: WalletItem, active: boolean) => {
try {
await Api.instance.setWalletStatus(item.id, active);
this.setState((s) => ({
wallets: s.wallets.map((w) =>
w.id === item.id ? { ...w, status: active ? 'ACTIVE' : 'INACTIVE' } : w),
}));
} catch (e) {
Alert.alert('Failed', (e as Error).message);
}
};
handleRebind = (item: WalletItem) => {
const key = getBindKeyForWallet(item);
if (!key) {
Alert.alert('Rebind', 'Unsupported wallet type');
return;
}
this.openWalletBind(key, item.phone);
};
// ---- bind handlers ----
/** Token 等需客户端 register 的流程(与 OTP 同 modal key 时勿用推断 map */
handleBindSuccess = (key: keyof HomeScreenState, walletType: WalletType, msg: string) =>
async (result: any) => {
try {
await Api.instance.register(walletType, result);
this.setState({ [key]: false } as any);
Alert.alert('Bind Success', msg);
this.fetchWallets();
} catch (error) {
this.setState({ [key]: false } as any);
Alert.alert('Bind Failed', (error as Error).message);
}
};
/** OTP服务端 verify 已注册,只提示并关弹窗 */
onOtpBindSuccess = (key: keyof HomeScreenState, msg: string) => () => {
Alert.alert('Bind Success', msg);
this.setState({ [key]: false } as any);
this.fetchWallets();
};
// ---- modals ----
renderBindModal = () => {
const {
showPaytmPersonalBind, paytmPersonalBindType, showPhonePePersonalBind, phonePePersonalBindType,
showPaytmBusinessBind, showPhonePeBusinessBind, showGooglePayBusinessBind, showBharatPeBusinessBind,
showMobikwikPersonalBind, showFreechargePersonalBind, freechargePersonalBindType,
bindPrefillMobile,
} = this.state;
const close = (key: keyof HomeScreenState) => () =>
this.setState({ [key]: false, bindPrefillMobile: '' } as any);
if (showPaytmPersonalBind && paytmPersonalBindType === 'tokenMode') {
return (
<Modal
visible
transparent
onRequestClose={close('showPaytmPersonalBind')}
>
<PaytmPersonalBind
processString="Processing..."
isDebug
onSuccess={this.handleBindSuccess('showPaytmPersonalBind', WalletType.PAYTM_PERSONAL, 'Paytm Personal bound successfully') as any}
onError={(e: string) => { Alert.alert('Bind Failed', e); close('showPaytmPersonalBind')(); }}
/>
</Modal>
);
}
if (showPaytmPersonalBind) {
return (
<Modal
visible
transparent
onRequestClose={close('showPaytmPersonalBind')}
>
<PayTmPersonalOTPBind
isDebug
initialMobile={bindPrefillMobile}
onRequestOTP={async (wt, p) =>
this.wrapOtpCall(() => Api.instance.requestOTP(wt, p.mobile, {}))}
onVerifyOTP={async (wt, p) =>
this.wrapOtpCall(() => Api.instance.verifyOTP(wt, p.mobile, p.otp, { sessionId: p.sessionId }))}
onSuccess={this.onOtpBindSuccess('showPaytmPersonalBind', 'Paytm Personal OTP')}
onError={() => {}}
/>
</Modal>
);
}
if (showPhonePePersonalBind && phonePePersonalBindType === 'tokenMode') {
return (
<Modal
visible
transparent
onRequestClose={close('showPhonePePersonalBind')}
>
<PhonePePersonalBind
processString="Processing..."
mode={1}
userToken={Api.instance.getUserToken()}
isDebug
onSuccess={this.handleBindSuccess('showPhonePePersonalBind', WalletType.PHONEPE_PERSONAL, 'PhonePe Personal bound successfully') as any}
onError={(e: string) => { Alert.alert('Bind Failed', e); close('showPhonePePersonalBind')(); }}
/>
</Modal>
);
}
if (showPhonePePersonalBind) {
return (
<Modal
visible
transparent
onRequestClose={close('showPhonePePersonalBind')}
>
<PhonePePersonalOTPBind
isDebug
initialMobile={bindPrefillMobile}
onRequestOTP={async (wt, p) =>
this.wrapOtpCall(() => Api.instance.requestOTP(wt, p.mobile, {}))}
onVerifyOTP={async (wt, p) =>
this.wrapOtpCall(() => Api.instance.verifyOTP(wt, p.mobile, p.otp, { sessionId: p.sessionId }))}
onSuccess={this.onOtpBindSuccess('showPhonePePersonalBind', 'PhonePe Personal OTP')}
onError={() => {}}
/>
</Modal>
);
}
if (showPaytmBusinessBind) {
return (
<Modal
visible
transparent
onRequestClose={close('showPaytmBusinessBind')}
>
<PaytmBusinessOTPBind
isDebug
initialMobile={bindPrefillMobile}
onRequestOTP={async (wt, p) =>
this.wrapOtpCall(() => Api.instance.requestOTP(wt, p.mobile, {}))}
onVerifyOTP={async (wt, p) =>
this.wrapOtpCall(() => Api.instance.verifyOTP(wt, p.mobile, p.otp, { sessionToken: p.sessionToken }))}
onSuccess={this.onOtpBindSuccess('showPaytmBusinessBind', 'Paytm Business bound successfully')}
onError={() => {}}
/>
</Modal>
);
}
if (showPhonePeBusinessBind) {
return (
<Modal
visible
transparent
onRequestClose={close('showPhonePeBusinessBind')}
>
<PhonePeBusinessBind
processString="Processing..."
isDebug
onSuccess={this.onOtpBindSuccess('showPhonePeBusinessBind', 'PhonePe Business bound successfully')}
onError={(e: string) => { Alert.alert('Bind Failed', 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', WalletType.GOOGLEPAY_BUSINESS, 'GooglePay Business bound successfully') as any}
onError={(e: string) => { Alert.alert('Bind Failed', e); close('showGooglePayBusinessBind')(); }}
/>
</Modal>
);
}
if (showBharatPeBusinessBind) {
return (
<Modal
visible
transparent
onRequestClose={close('showBharatPeBusinessBind')}
>
<BharatPeBusinessOTPBind
isDebug
initialMobile={bindPrefillMobile}
onRequestOTP={async (wt, p) =>
this.wrapOtpCall(() => Api.instance.requestOTP(wt, p.mobile))}
onVerifyOTP={async (wt, p) =>
this.wrapOtpCall(() => Api.instance.verifyOTP(wt, p.mobile, p.otp, { sessionToken: p.sessionToken }))}
onSuccess={this.onOtpBindSuccess('showBharatPeBusinessBind', 'BharatPe Business bound successfully')}
onError={() => {}}
/>
</Modal>
);
}
if (showMobikwikPersonalBind) {
return (
<Modal
visible
transparent
onRequestClose={close('showMobikwikPersonalBind')}
>
<MobikwikOTPBind
isDebug
deviceId={this.deviceId}
tuneUserId={this.tuneUserId}
initialMobile={bindPrefillMobile}
onRequestOTP={async (wt, p) =>
this.wrapOtpCall(() => Api.instance.requestOTP(wt, p.mobile, { deviceId: p.deviceId, tuneUserId: p.tuneUserId }))}
onVerifyOTP={async (wt, p) =>
this.wrapOtpCall(() => Api.instance.verifyOTP(wt, p.mobile, p.otp, { sessionId: p.sessionId, deviceId: p.deviceId, tuneUserId: p.tuneUserId, nid: p.nid }))}
onSuccess={this.onOtpBindSuccess('showMobikwikPersonalBind', 'Mobikwik bound successfully')}
onError={() => {}}
/>
</Modal>
);
}
if (showFreechargePersonalBind && freechargePersonalBindType === 'tokenMode') {
return (
<Modal
visible
transparent
onRequestClose={close('showFreechargePersonalBind')}
>
<FreechargePersonalBind
processString="Processing..."
isDebug
onSuccess={this.handleBindSuccess('showFreechargePersonalBind', WalletType.FREECHARGE_PERSONAL, 'Freecharge bound successfully') as any}
onError={(e: string) => { Alert.alert('Bind Failed', e); close('showFreechargePersonalBind')(); }}
/>
</Modal>
);
}
if (showFreechargePersonalBind) {
return (
<Modal
visible
transparent
onRequestClose={close('showFreechargePersonalBind')}
>
<FreeChargeBind
isDebug
initialMobile={bindPrefillMobile}
onRequestOTP={async (wt, p) =>
this.wrapOtpCall(() => Api.instance.requestOTP(wt, p.mobile))}
onVerifyOTP={async (wt, p) =>
this.wrapOtpCall(() => Api.instance.verifyOTP(wt, p.mobile, p.otp, { otpId: p.otpId, deviceId: p.deviceId, csrfId: p.csrfId, appFc: p.appFc }))}
onSuccess={this.onOtpBindSuccess('showFreechargePersonalBind', 'Freecharge bound successfully')}
onError={() => {}}
/>
</Modal>
);
}
return null;
};
openWalletBind = (key: string, prefillMobile?: string) => {
this.setState({ showAddWallet: false, bindPrefillMobile: prefillMobile ?? '' });
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, freechargePersonalBindType: 'otpMode' });
break;
case 'freecharge_personal_token':
this.setState({ showFreechargePersonalBind: true, freechargePersonalBindType: 'tokenMode' });
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}>Select Wallet Type</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}>Server Settings</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' }}>Cancel</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('Saved', 'Restart app to take effect');
}}
style={s.saveBtn}
>
<Text style={{ color: '#fff', fontWeight: 'bold' }}>Save</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
}
renderWalletItem = ({ item }: { item: WalletItem }) => {
const color = WALLET_TYPE_COLORS[item.walletType] ?? '#888';
const isActive = item.status === 'ACTIVE';
return (
<View style={s.walletCard}>
<TouchableOpacity
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>
<View style={s.walletActions}>
{!isActive && (
<TouchableOpacity style={s.rebindBtn} onPress={() => this.handleRebind(item)}>
<Text style={s.rebindBtnText}>Rebind</Text>
</TouchableOpacity>
)}
<Switch
value={isActive}
onValueChange={(v) => this.toggleWalletActive(item, v)}
trackColor={{ false: '#ddd', true: '#2ecc7180' }}
thumbColor={isActive ? '#2ecc71' : '#999'}
/>
</View>
</View>
);
};
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}>Select 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 }}>No VPA data</Text>
) : (
vpaModalVpas.map((vpa) => (
<TouchableOpacity
key={vpa}
style={[s.vpaOptionRow, vpa === vpaModalSelected && { borderColor: color, backgroundColor: `${color}10` }]}
onPress={() => this.setState({ vpaModalSelected: vpa })}
activeOpacity={0.7}
>
<View style={[s.radioOuter, vpa === vpaModalSelected && { borderColor: color }]}>
{vpa === vpaModalSelected && <View style={[s.radioInner, { backgroundColor: color }]} />}
</View>
<Text style={[s.vpaOptionText, vpa === vpaModalSelected && { color, fontWeight: '600' }]}>{vpa}</Text>
</TouchableOpacity>
))
)}
</ScrollView>
<View style={s.vpaModalFooter}>
<TouchableOpacity style={s.vpaModalCancelBtn} onPress={this.closeVpaModal}>
<Text style={{ color: '#666', fontSize: 15 }}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[s.vpaModalConfirmBtn, { backgroundColor: vpaModalSelected ? color : '#ccc' }]}
onPress={this.confirmVpa}
disabled={!vpaModalSelected}
>
<Text style={{ color: '#fff', fontSize: 15, fontWeight: '600' }}>Confirm</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: 'Idle', color: '#95a5a6' },
connecting: { label: 'Connecting…', color: '#f39c12' },
connected: { label: 'Connected', color: '#2ecc71' },
disconnected: { label: 'Disconnected', color: '#e74c3c' },
error: { label: 'Error', color: '#e74c3c' },
};
const { label, color } = proxyCfg[proxyStatus];
return (
<View style={s.container}>
{/* top bar */}
<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 i = domain.lastIndexOf(':');
const host = i > 0 ? domain.slice(0, i) : domain;
const port = i > 0 ? domain.slice(i + 1) : '';
this.setState({ showServerSettings: true, settingsHost: host, settingsPort: port });
}}
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
style={{ padding: 6 }}
>
<Text style={{ fontSize: 20, color: '#3498db' }}></Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => this.setState({ showAddWallet: true })}
style={s.addBtn}
>
<Text style={s.addBtnText}>+ Add</Text>
</TouchableOpacity>
</View>
</View>
{/* wallet list */}
<View style={s.listHeader}>
<Text style={s.listHeaderText}>Bound Wallets</Text>
<TouchableOpacity onPress={this.fetchWallets} disabled={loadingWallets}>
<Text style={{ fontSize: 13, color: '#3498db' }}>{loadingWallets ? 'Refreshing…' : 'Refresh'}</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 ? 'Loading…' : 'No wallets. Tap + Add to get started.'}
</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 },
},
walletActions: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-end',
marginTop: 10,
paddingTop: 10,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: '#eee',
},
rebindBtn: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 6,
backgroundColor: '#3498db',
marginRight: 12,
},
rebindBtnText: {
color: '#fff',
fontSize: 13,
fontWeight: '600',
},
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,
},
});