Files
rnpay/screens/HomeScreen.tsx
2026-05-27 12:12:03 +08:00

1524 lines
58 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,
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 {
GooglePayBusinessBind,
PhonePeBusinessBind,
WalletType,
PaytmBusinessBindResult,
PaytmPersonalBind,
MobikwikPersonalBindResult,
FreechargePersonalBindResult,
GooglePayBusinessBindResult,
BharatPeBusinessBindResult,
onSmsMessage,
startSmsListener,
stopSmsListener,
checkSmsPermission,
requestSmsPermission,
PhonePePersonalBind,
FreechargePersonalBind,
SmsMessage,
proxyBackgroundService,
PhonePePersonalBindResult,
PaytmPersonalBindResult,
BindErrorCode,
} from 'rnwalletman';
import {
FreeChargeBind,
MobikwikOTPBind,
PayTmPersonalOTPBind,
PhonePePersonalOTPBind,
BharatPeBusinessOTPBind,
PaytmBusinessOTPBind,
PhonePeBusinessOTPBind,
} 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_otp',
walletType: 'phonepe business',
label: 'PhonePe Business (OTP)',
mode: 'otp',
},
{
key: 'phonepe_business_web',
walletType: 'phonepe business',
label: 'PhonePe Business (Web)',
mode: 'token',
},
{
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 formatWalletTypeLabel(walletType: string) {
return walletType.replace(/\b\w/g, (c) => c.toUpperCase());
}
function groupBoundWallets(wallets: WalletItem[]) {
const order: string[] = [];
const map = new Map<string, WalletItem[]>();
for (const w of wallets) {
if (!map.has(w.walletType)) {
map.set(w.walletType, []);
order.push(w.walletType);
}
map.get(w.walletType)!.push(w);
}
return order.map((walletType) => ({
walletType,
items: map.get(walletType)!,
}));
}
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 otp ? 'phonepe_business_otp' : 'phonepe_business_web';
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;
phonePeBusinessBindType: 'otpMode' | 'webviewMode';
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;
expandedBoundWalletGroups: Record<string, boolean>;
}
export default class HomeScreen extends Component<any, HomeScreenState> {
private deviceId: string;
private androidId: 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,
phonePeBusinessBindType: 'otpMode',
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: '',
expandedBoundWalletGroups: {},
};
this.deviceId = DeviceInfo.getUniqueIdSync();
this.tuneUserId = "yz8mxybytus";//Math.random().toString(36).substring(2, 15);
this.androidId = DeviceInfo.getAndroidIdSync();
}
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, phonePeBusinessBindType, showGooglePayBusinessBind, showBharatPeBusinessBind,
showMobikwikPersonalBind, showFreechargePersonalBind, freechargePersonalBindType,
bindPrefillMobile,
} = this.state;
const close = (key: keyof HomeScreenState) => () =>
this.setState({ [key]: false, bindPrefillMobile: '' } as any);
if (showPaytmPersonalBind && paytmPersonalBindType === 'tokenMode') {
const remoteVersion = '1'; // 目前版本是 1
return (
<Modal
visible
transparent
onRequestClose={close('showPaytmPersonalBind')}
>
<PaytmPersonalBind
processString="Processing..."
isDebug
onSuccess={(result: PaytmPersonalBindResult) => {
if (result.chType != 'ipay') {
// 有 AIDL 通信,能拿回数据,但是不是我们的魔改 type
// 视作未安装
Alert.alert('Bind Failed', '未安装Paytm 魔改包,需要重新安装');
return;
}
if (result.chVersion != remoteVersion) {
// 版本有更新
Alert.alert('Bind Failed', '版本有更新,需要重新安装');
return;
}
// 实际请求注册钱包
this.handleBindSuccess('showPaytmPersonalBind', WalletType.PAYTM_PERSONAL, 'Paytm Personal bound successfully')(result);
}}
onError={(code: string, message: string) => {
switch (code) {
case BindErrorCode.NATIVE_MODULE_UNAVAILABLE: // 没有模块
Alert.alert('Bind Failed', 'Native module not available');
break;
case BindErrorCode.NOT_LOGGED_IN: // 未登录,提示用户登录
Alert.alert('Bind Failed', 'Please login in paytm app first');
break;
case BindErrorCode.ERROR: // catch 错误,提示错误即可
Alert.alert('Bind Failed', message);
break;
case BindErrorCode.NO_DATA: // 拿到了错误的信息,提示错误就可以 aidl 给了错误的 resp几率低提示错误就可以
Alert.alert('Bind Failed', 'No data received from Paytm');
break;
case BindErrorCode.SERVICE_DISCONNECTED: // 服务不可用 可能aidl 通信失败,几率低)提示错误就可以
Alert.alert('Bind Failed', 'Paytm service disconnected');
break;
case BindErrorCode.NOT_INSTALLED: // 未安装,提示用户安装
Alert.alert('Bind Failed', '未安装Paytm 魔改包,需要重新安装');
break;
case BindErrorCode.BIND_ERROR: // 绑定错误aidl 通信失败,可能存在 paytm 未打开的情况,出现这个,手动拉起一下
Alert.alert('Bind Failed', 'Paytm bind error');
break;
default:
Alert.alert('Bind Failed', `[${code}] ${message}`);
break;
}
close('showPaytmPersonalBind')();
}}
/>
</Modal>
);
}
if (showPaytmPersonalBind) {
return (
<Modal
visible
transparent
onRequestClose={close('showPaytmPersonalBind')}
>
<PayTmPersonalOTPBind
isDebug
initialMobile={bindPrefillMobile}
onRequestOTP={async (wt, p) => {
return this.wrapOtpCall(() => Api.instance.requestOTP(wt, p.mobile, {}));
}}
onVerifyOTP={async (wt, p) => {
return 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') {
const remoteVersion = '1'; // 目前版本是 1
return (
<Modal
visible
transparent
onRequestClose={close('showPhonePePersonalBind')}
>
<PhonePePersonalBind
processString="Processing..."
userToken={Api.instance.getUserToken()}
isDebug
onSuccess={(result: PhonePePersonalBindResult) => {
if (result.chType != 'ipay') {
// 有 AIDL 通信,能拿回数据,但是不是我们的魔改 type
// 视作未安装
Alert.alert('Bind Failed', '未安装Phonepe 魔改包,需要重新安装');
return;
}
if (result.chVersion != remoteVersion) {
// 版本有更新
Alert.alert('Bind Failed', '版本有更新,需要重新安装');
return;
}
// 实际请求注册钱包
this.handleBindSuccess('showPhonePePersonalBind', WalletType.PHONEPE_PERSONAL, 'PhonePe Personal bound successfully')(result);
}}
onError={(code: string, message: string) => {
switch (code) {
case BindErrorCode.NOT_INSTALLED:
// 未安装
Alert.alert('Bind Failed', '未安装Phonepe 魔改包,需要重新安装');
break;
case BindErrorCode.SERVICE_DISCONNECTED:
// 服务不可用 可能aidl 通信失败,几率低)提示错误就可以
Alert.alert('Bind Failed', '服务不可用');
break;
case BindErrorCode.NO_DATA:
// 拿到了错误的信息,提示错误就可以 aidl 给了错误的 resp几率低提示错误就可以
Alert.alert('Bind Failed', '未知错误信息');
break;
case BindErrorCode.BIND_ERROR:
// 绑定错误aidl 通信失败,可能存在 paytm / phonepe 未打开的情况,出现这个,手动拉起一下
Alert.alert('Bind Failed', '绑定失败,请手动打开PhonePe后重试');
break;
case BindErrorCode.ERROR:
// catch 错误,通用错误,提示错误即可
Alert.alert('Bind Failed', message);
break;
default:
Alert.alert('Bind Failed', '未知错误');
break;
}
close('showPhonePePersonalBind')();
}}
/>
</Modal>
);
}
if (showPhonePePersonalBind) {
return (
<Modal
visible
transparent
onRequestClose={close('showPhonePePersonalBind')}
>
<PhonePePersonalOTPBind
isDebug
initialMobile={bindPrefillMobile}
onRequestOTP={async (wt, p) => {
return this.wrapOtpCall(() => Api.instance.requestOTP(wt, p.mobile, {}));
}}
onVerifyOTP={async (wt, p) => {
return 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) => {
return this.wrapOtpCall(() => Api.instance.requestOTP(wt, p.mobile, {}));
}}
onVerifyOTP={async (wt, p) => {
return 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 && phonePeBusinessBindType === 'otpMode') {
return (
<Modal
visible
transparent
onRequestClose={close('showPhonePeBusinessBind')}
>
<PhonePeBusinessOTPBind
isDebug
initialMobile={bindPrefillMobile}
onRequestOTP={async (wt, p) => {
return this.wrapOtpCall(() => Api.instance.requestOTP(wt, p.mobile, {}));
}}
onVerifyOTP={async (wt, p) => {
return this.wrapOtpCall(() => Api.instance.verifyOTP(wt, p.mobile, p.otp, { sessionToken: p.sessionToken }));
}}
onSuccess={this.onOtpBindSuccess('showPhonePeBusinessBind', 'PhonePe Business bound successfully')}
onError={() => {}}
/>
</Modal>
);
}
if (showPhonePeBusinessBind && phonePeBusinessBindType === 'webviewMode') {
return (
<Modal
visible
transparent
onRequestClose={close('showPhonePeBusinessBind')}
>
<PhonePeBusinessBind
processString="Processing..."
isDebug
clearCookie
onSuccess={this.handleBindSuccess('showPhonePeBusinessBind', WalletType.PHONEPE_BUSINESS, 'PhonePe Business bound successfully') as any}
onError={(e: string) => { Alert.alert('Bind Failed', e); close('showPhonePeBusinessBind')(); }}
/>
</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) => {
return this.wrapOtpCall(() => Api.instance.requestOTP(wt, p.mobile));
}}
onVerifyOTP={async (wt, p) => {
return 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={true}
initialMobile={bindPrefillMobile}
onRequestOTP={async (wt, p) => {
return this.wrapOtpCall(() => Api.instance.requestOTP(wt, p.mobile))
}}
onVerifyOTP={async (wt, p) => {
return this.wrapOtpCall(() => Api.instance.verifyOTP(wt, p.mobile, p.otp, p));
}}
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) => {
return this.wrapOtpCall(() => Api.instance.requestOTP(wt, p.mobile));
}}
onVerifyOTP={async (wt, p) => {
return 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_otp':
this.setState({ showPhonePeBusinessBind: true, phonePeBusinessBindType: 'otpMode' });
break;
case 'phonepe_business_web':
this.setState({ showPhonePeBusinessBind: true, phonePeBusinessBindType: 'webviewMode' });
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);
};
toggleBoundWalletGroup = (walletType: string) => {
this.setState((prev) => ({
expandedBoundWalletGroups: {
...prev.expandedBoundWalletGroups,
[walletType]: !prev.expandedBoundWalletGroups[walletType],
},
}));
};
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: 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>
);
};
renderBoundWalletList = () => {
const { wallets, loadingWallets, expandedBoundWalletGroups } = this.state;
if (loadingWallets && wallets.length === 0) {
return (
<View style={{ alignItems: 'center', marginTop: 60 }}>
<Text style={{ color: '#aaa', fontSize: 14 }}>Loading</Text>
</View>
);
}
if (wallets.length === 0) {
return (
<View style={{ alignItems: 'center', marginTop: 60 }}>
<Text style={{ color: '#aaa', fontSize: 14 }}>No wallets. Tap + Add to get started.</Text>
</View>
);
}
const groups = groupBoundWallets(wallets);
return (
<ScrollView
style={{ flex: 1, width: '100%' }}
contentContainerStyle={{ paddingHorizontal: 14, paddingBottom: 20 }}
bounces={false}
>
{groups.map(({ walletType, items }) => {
if (items.length === 1) {
return this.renderWalletItem(items[0]);
}
const expanded = !!expandedBoundWalletGroups[walletType];
const color = WALLET_TYPE_COLORS[walletType] ?? '#888';
return (
<View key={walletType} style={s.boundWalletGroup}>
<TouchableOpacity
style={s.boundWalletGroupHeader}
onPress={() => this.toggleBoundWalletGroup(walletType)}
activeOpacity={0.7}
>
<View style={s.walletBadge}>
{WALLET_ICONS[walletType] ? (
<Image source={WALLET_ICONS[walletType]} style={s.walletIcon} resizeMode="contain" />
) : (
<View style={[s.walletIconFallback, { backgroundColor: color }]}>
<Text style={s.walletBadgeText}>{walletType.split(' ')[0]}</Text>
</View>
)}
</View>
<View style={{ flex: 1, marginLeft: 12 }}>
<Text style={s.boundWalletGroupTitle}>{formatWalletTypeLabel(walletType)}</Text>
<Text style={s.boundWalletGroupSub}>{items.length} accounts</Text>
</View>
<Text style={s.walletGroupChevron}>{expanded ? '▼' : '▶'}</Text>
</TouchableOpacity>
{expanded && items.map((item) => (
<View key={`${item.id}-wrap`} style={s.boundWalletGroupItem}>
{this.renderWalletItem(item)}
</View>
))}
</View>
);
})}
</ScrollView>
);
};
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, 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>
{this.renderBoundWalletList()}
{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',
},
walletTypeLabel: {
fontSize: 14,
color: '#333',
},
boundWalletGroup: {
marginBottom: 4,
},
boundWalletGroupHeader: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#fff',
borderRadius: 10,
padding: 12,
marginBottom: 8,
borderWidth: 1,
borderColor: '#eee',
},
boundWalletGroupTitle: {
fontSize: 15,
fontWeight: '600',
color: '#222',
},
boundWalletGroupSub: {
fontSize: 12,
color: '#888',
marginTop: 2,
},
boundWalletGroupItem: {
marginLeft: 10,
},
walletGroupChevron: {
fontSize: 12,
color: '#999',
marginLeft: 8,
width: 16,
textAlign: 'right',
},
walletTypeIcon: {
width: 32,
height: 32,
borderRadius: 6,
marginRight: 12,
},
walletTypeDot: {
width: 10,
height: 10,
borderRadius: 5,
marginRight: 12,
},
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,
},
});