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

1806 lines
66 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,
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',
},
];
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;
}
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,
};
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 */
}
}
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);
}
};
// ---- 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,
} = 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',
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
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.onOtpBindSuccess(
'showPaytmPersonalBind',
'Paytm Personal OTP',
)}
onError={(e: string) => {
Alert.alert('Bind Failed', e);
close('showPaytmPersonalBind')();
}}
/>
</Modal>
);
}
if (
showPhonePePersonalBind &&
phonePePersonalBindType === 'tokenMode'
) {
return (
<Modal
visible
transparent
onRequestClose={close('showPhonePePersonalBind')}
>
<PhonePePersonalBind
processString="Processing..."
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
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.onOtpBindSuccess(
'showPhonePePersonalBind',
'PhonePe Personal OTP',
)}
onError={(e: string) => {
Alert.alert('Bind Failed', 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,
{},
);
} 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.onOtpBindSuccess(
'showPaytmBusinessBind',
'Paytm Business bound successfully',
)}
onError={(e: string) => {
Alert.alert('Bind Failed', e);
close('showPaytmBusinessBind')();
}}
/>
</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
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.onOtpBindSuccess(
'showBharatPeBusinessBind',
'BharatPe Business bound successfully',
)}
onError={(e: string) => {
Alert.alert('Bind Failed', 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.onOtpBindSuccess(
'showMobikwikPersonalBind',
'Mobikwik bound successfully',
)}
onError={(e: string) => {
Alert.alert('Bind Failed', e);
close('showMobikwikPersonalBind')();
}}
/>
</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
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.onOtpBindSuccess(
'showFreechargePersonalBind',
'Freecharge bound successfully',
)}
onError={(e: string) => {
Alert.alert('Bind Failed', 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,
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 (
<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}>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) => {
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 }}
>
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 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,
});
}}
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 },
},
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,
},
});