Web:
- runtime.ts: use import.meta.env (process.env is undefined in Vite browser bundle)
- tradingApiUrl local fallback: drop /api suffix (API libs already append /api/*)
- useWebSocket: deriveSocketParams() — correctly splits origin + socket path for
Caddy handle_path /invttrdg/* proxy (io(origin, {path}), not io(url-with-path))
- App.tsx: pass socket prop to AdminTab; pass connected prop to SignalsTab
- AdminTab: remove duplicate useWebSocket; accept socket as prop
- SignalsTab: connection-aware empty state message
- backtest/flags: default to disabled when VITE_BACKTEST_ENABLED unset
- EntryForm: NaN guard before live trade execution
- MarketplaceTab: null-safety on symbols.rules access
- Tests: pass socket prop to AdminTab; update empty state assertion
Mobile:
- TradingDataProvider: same deriveSocketParams fix — EXPO_PUBLIC_SOCKET_PATH
overrides auto-derived path from tradingApiUrl
- strategies: replace mock data with real GET /api/profiles + PATCH active toggle
- chat: wire to real POST /api/chat; remove hardcoded mock reply
- marketplace: fetch GET /api/marketplace-presets; USE STRATEGY calls POST /api/profiles
- settings: sign-out confirmation dialog; execution mode read-only hint;
version from expo-constants instead of hardcoded v2.3
- positions/history: empty state UI when no data
- CustomTabBar: always show tab labels (not only when focused)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
574 lines
18 KiB
TypeScript
574 lines
18 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { Alert, View, Text, ScrollView, Switch, StyleSheet } from 'react-native';
|
|
import Constants from 'expo-constants';
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
import { LinearGradient } from 'expo-linear-gradient';
|
|
import { ChevronRight, Lock, Check } from 'lucide-react-native';
|
|
import { Colors, Fonts, FontSize, BorderRadius, Spacing } from '@/constants/theme';
|
|
import SegmentedControl from '@/components/SegmentedControl';
|
|
import AnimatedCard from '@/components/AnimatedCard';
|
|
import PressableScale from '@/components/PressableScale';
|
|
import { useTradingData } from '@/providers/TradingDataProvider';
|
|
import { useMobileAuth } from '@/providers/MobileAuthProvider';
|
|
|
|
export default function SettingsScreen() {
|
|
const insets = useSafeAreaInsets();
|
|
const { botState, portfolio, pauseTrading, resumeTrading, connectionState, error, lastUpdatedAt } = useTradingData();
|
|
const { profile, signOut } = useMobileAuth();
|
|
const executionModeIndex = botState?.settings.executionMode === 'Live' ? 2 : botState?.settings.executionMode === 'Paper' ? 1 : 0;
|
|
const [maxOpenTrades, setMaxOpenTrades] = useState(botState?.settings.maxOpenTrades || 3);
|
|
const [notifications, setNotifications] = useState({
|
|
priceAlerts: true,
|
|
tradeExecuted: true,
|
|
stopLoss: true,
|
|
dailySummary: false,
|
|
});
|
|
const [oledBlack, setOledBlack] = useState(false);
|
|
const tradingMode = botState?.health?.tradingControl?.mode ?? 'RUNNING';
|
|
const isAdmin = profile?.role === 'admin';
|
|
const brokerStatusText =
|
|
connectionState === 'live' ? 'Connected' : connectionState === 'degraded' ? 'Degraded' : 'Offline';
|
|
const brokerHint = error
|
|
? error
|
|
: connectionState === 'live'
|
|
? tradingMode === 'PAUSED'
|
|
? 'Trading is currently paused.'
|
|
: 'Broker state is sourced from the live backend.'
|
|
: lastUpdatedAt
|
|
? `Last backend sync ${Math.max(Math.round((Date.now() - lastUpdatedAt) / 60000), 0)}m ago.`
|
|
: 'Waiting for backend connectivity.';
|
|
|
|
const modeColors = [Colors.text.secondary, Colors.accent.blue, Colors.accent.orange];
|
|
|
|
return (
|
|
<View style={[styles.container, { paddingTop: insets.top }]}>
|
|
<View style={styles.headerSection}>
|
|
<Text style={styles.sectionLabel}>CONFIGURATION</Text>
|
|
<Text style={styles.pageTitle}>Settings</Text>
|
|
</View>
|
|
|
|
<ScrollView
|
|
style={styles.scroll}
|
|
contentContainerStyle={styles.content}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
<AnimatedCard index={0}>
|
|
<View style={styles.section}>
|
|
<Text style={styles.sectionHeader}>ACCOUNT</Text>
|
|
<View style={styles.accountRow}>
|
|
<LinearGradient
|
|
colors={['#00ff88', '#00cc6a']}
|
|
style={styles.avatar}
|
|
>
|
|
<Text style={styles.avatarText}>
|
|
{`${profile?.first_name?.[0] || profile?.email?.[0] || 'T'}${profile?.last_name?.[0] || ''}`.slice(0, 2).toUpperCase()}
|
|
</Text>
|
|
</LinearGradient>
|
|
<View style={styles.accountInfo}>
|
|
<Text style={styles.accountName}>{[profile?.first_name, profile?.last_name].filter(Boolean).join(' ') || 'Trading User'}</Text>
|
|
<Text style={styles.accountEmail}>{profile?.email || 'No email loaded'}</Text>
|
|
</View>
|
|
<View style={styles.tierBadge}>
|
|
<Text style={styles.tierText}>{(profile?.role || 'member').toUpperCase()}</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</AnimatedCard>
|
|
|
|
<AnimatedCard index={1}>
|
|
<View style={styles.section}>
|
|
<Text style={styles.sectionHeader}>EXECUTION MODE</Text>
|
|
<SegmentedControl
|
|
segments={['Alerts', 'Paper', 'Live']}
|
|
activeIndex={executionModeIndex}
|
|
onPress={() => undefined}
|
|
activeColor={modeColors[executionModeIndex]}
|
|
activeTextColor={executionModeIndex === 2 ? '#fff' : '#000'}
|
|
/>
|
|
<Text style={styles.readOnlyHint}>Execution mode is managed from the web dashboard.</Text>
|
|
{executionModeIndex === 2 && (
|
|
<View style={styles.warningBanner}>
|
|
<Text style={styles.warningText}>
|
|
Real money trading enabled. Use caution.
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
</AnimatedCard>
|
|
|
|
<AnimatedCard index={2}>
|
|
<View style={styles.section}>
|
|
<Text style={styles.sectionHeader}>RISK CONFIGURATION</Text>
|
|
<SettingRow label="Risk Per Trade">
|
|
<View style={styles.sliderRow}>
|
|
<View style={styles.sliderTrack}>
|
|
<View style={[styles.sliderFill, { width: `${Math.min((Number(botState?.settings.riskPerTrade || 0) / 0.05) * 100, 100)}%` as any }]} />
|
|
</View>
|
|
<Text style={styles.sliderValue}>{(Number(botState?.settings.riskPerTrade || 0) * 100).toFixed(2)}%</Text>
|
|
</View>
|
|
</SettingRow>
|
|
<SettingRow label="Max Open Trades">
|
|
<View style={styles.stepperRow}>
|
|
<PressableScale
|
|
style={styles.stepperBtn}
|
|
onPress={() => setMaxOpenTrades(Math.max(1, maxOpenTrades - 1))}
|
|
>
|
|
<Text style={styles.stepperBtnText}>-</Text>
|
|
</PressableScale>
|
|
<Text style={styles.stepperValue}>{maxOpenTrades}</Text>
|
|
<PressableScale
|
|
style={styles.stepperBtn}
|
|
onPress={() => setMaxOpenTrades(Math.min(10, maxOpenTrades + 1))}
|
|
>
|
|
<Text style={styles.stepperBtnText}>+</Text>
|
|
</PressableScale>
|
|
</View>
|
|
</SettingRow>
|
|
<SettingRow label="Total Capital">
|
|
<Text style={styles.capitalValue}>${portfolio.totalCapital.toLocaleString()}</Text>
|
|
</SettingRow>
|
|
</View>
|
|
</AnimatedCard>
|
|
|
|
<AnimatedCard index={3}>
|
|
<View style={styles.section}>
|
|
<Text style={styles.sectionHeader}>BROKER CONNECTION</Text>
|
|
<View style={styles.brokerRow}>
|
|
<Text style={styles.brokerName}>Alpaca</Text>
|
|
<View style={styles.connectedBadge}>
|
|
<Check
|
|
size={12}
|
|
color={
|
|
connectionState === 'live'
|
|
? Colors.accent.green
|
|
: connectionState === 'degraded'
|
|
? Colors.accent.amber
|
|
: Colors.accent.red
|
|
}
|
|
/>
|
|
<Text
|
|
style={[
|
|
styles.connectedText,
|
|
connectionState === 'degraded' && styles.degradedText,
|
|
connectionState === 'offline' && styles.offlineText,
|
|
]}
|
|
>
|
|
{brokerStatusText}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
<Text style={styles.apiKeyHint}>{brokerHint}</Text>
|
|
</View>
|
|
</AnimatedCard>
|
|
|
|
{isAdmin ? (
|
|
<AnimatedCard index={4}>
|
|
<View style={styles.section}>
|
|
<Text style={styles.sectionHeader}>TRADING CONTROL</Text>
|
|
<Text style={styles.apiKeyHint}>
|
|
Mobile is monitor-first. Only limited safety controls are exposed here.
|
|
</Text>
|
|
<View style={styles.actionRow}>
|
|
<PressableScale
|
|
style={[styles.actionButton, tradingMode === 'PAUSED' && styles.actionButtonDisabled]}
|
|
onPress={async () => {
|
|
const result = await pauseTrading('Paused from mobile control surface');
|
|
if (result.error) {
|
|
Alert.alert('Pause failed', result.error);
|
|
}
|
|
}}
|
|
>
|
|
<Text style={styles.actionButtonText}>Pause Trading</Text>
|
|
</PressableScale>
|
|
<PressableScale
|
|
style={[styles.actionButton, styles.actionButtonSuccess, tradingMode !== 'PAUSED' && styles.actionButtonDisabled]}
|
|
onPress={async () => {
|
|
const result = await resumeTrading('Resumed from mobile control surface');
|
|
if (result.error) {
|
|
Alert.alert('Resume failed', result.error);
|
|
}
|
|
}}
|
|
>
|
|
<Text style={styles.actionButtonText}>Resume Trading</Text>
|
|
</PressableScale>
|
|
</View>
|
|
</View>
|
|
</AnimatedCard>
|
|
) : null}
|
|
|
|
<AnimatedCard index={5}>
|
|
<View style={styles.section}>
|
|
<Text style={styles.sectionHeader}>NOTIFICATIONS</Text>
|
|
<ToggleRow
|
|
label="Price Alerts"
|
|
value={notifications.priceAlerts}
|
|
onChange={(v) => setNotifications(n => ({ ...n, priceAlerts: v }))}
|
|
/>
|
|
<ToggleRow
|
|
label="Trade Executed"
|
|
value={notifications.tradeExecuted}
|
|
onChange={(v) => setNotifications(n => ({ ...n, tradeExecuted: v }))}
|
|
/>
|
|
<ToggleRow
|
|
label="Stop Loss Hit"
|
|
value={notifications.stopLoss}
|
|
onChange={(v) => setNotifications(n => ({ ...n, stopLoss: v }))}
|
|
/>
|
|
<ToggleRow
|
|
label="Daily Summary"
|
|
value={notifications.dailySummary}
|
|
onChange={(v) => setNotifications(n => ({ ...n, dailySummary: v }))}
|
|
/>
|
|
</View>
|
|
</AnimatedCard>
|
|
|
|
<AnimatedCard index={6}>
|
|
<View style={styles.section}>
|
|
<Text style={styles.sectionHeader}>APPEARANCE</Text>
|
|
<View style={styles.toggleRow}>
|
|
<View style={styles.toggleLabel}>
|
|
<Text style={styles.toggleText}>Dark Mode</Text>
|
|
<Lock size={14} color={Colors.text.muted} />
|
|
</View>
|
|
<Switch
|
|
value={true}
|
|
disabled
|
|
trackColor={{ false: Colors.background.elevated, true: 'rgba(0,255,136,0.3)' }}
|
|
thumbColor={Colors.accent.green}
|
|
/>
|
|
</View>
|
|
<ToggleRow
|
|
label="OLED Black"
|
|
value={oledBlack}
|
|
onChange={setOledBlack}
|
|
/>
|
|
</View>
|
|
</AnimatedCard>
|
|
|
|
<AnimatedCard index={7}>
|
|
<View style={styles.section}>
|
|
<Text style={styles.sectionHeader}>ABOUT</Text>
|
|
<View style={styles.aboutRow}>
|
|
<Text style={styles.aboutLabel}>Version</Text>
|
|
<Text style={styles.aboutValue}>v{Constants.expoConfig?.version ?? '1.0.0'}</Text>
|
|
</View>
|
|
<PressableScale style={styles.linkRow} onPress={() => {
|
|
Alert.alert('Sign Out', 'Are you sure you want to sign out?', [
|
|
{ text: 'Cancel', style: 'cancel' },
|
|
{ text: 'Sign Out', style: 'destructive', onPress: () => void signOut() },
|
|
]);
|
|
}}>
|
|
<Text style={styles.linkText}>Sign Out</Text>
|
|
<ChevronRight size={16} color={Colors.text.muted} />
|
|
</PressableScale>
|
|
<PressableScale style={styles.linkRow}>
|
|
<Text style={styles.linkText}>Terms of Service</Text>
|
|
<ChevronRight size={16} color={Colors.text.muted} />
|
|
</PressableScale>
|
|
<PressableScale style={styles.linkRow}>
|
|
<Text style={styles.linkText}>Support</Text>
|
|
<ChevronRight size={16} color={Colors.text.muted} />
|
|
</PressableScale>
|
|
</View>
|
|
</AnimatedCard>
|
|
</ScrollView>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function SettingRow({ label, children }: { label: string; children: React.ReactNode }) {
|
|
return (
|
|
<View style={styles.settingRow}>
|
|
<Text style={styles.settingLabel}>{label}</Text>
|
|
{children}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function ToggleRow({ label, value, onChange }: { label: string; value: boolean; onChange: (v: boolean) => void }) {
|
|
return (
|
|
<View style={styles.toggleRow}>
|
|
<Text style={styles.toggleText}>{label}</Text>
|
|
<Switch
|
|
value={value}
|
|
onValueChange={onChange}
|
|
trackColor={{ false: Colors.background.elevated, true: 'rgba(0,255,136,0.3)' }}
|
|
thumbColor={value ? Colors.accent.green : '#666'}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: Colors.background.primary,
|
|
},
|
|
headerSection: {
|
|
padding: Spacing.screenPadding,
|
|
paddingBottom: 0,
|
|
},
|
|
sectionLabel: {
|
|
fontFamily: Fonts.inter.black,
|
|
fontSize: FontSize.micro,
|
|
color: Colors.accent.green,
|
|
letterSpacing: 4,
|
|
marginBottom: 8,
|
|
},
|
|
pageTitle: {
|
|
fontFamily: Fonts.inter.black,
|
|
fontSize: FontSize.hero,
|
|
color: Colors.text.primary,
|
|
letterSpacing: -0.5,
|
|
marginBottom: 16,
|
|
},
|
|
scroll: {
|
|
flex: 1,
|
|
},
|
|
content: {
|
|
padding: Spacing.screenPadding,
|
|
gap: 16,
|
|
paddingBottom: 120,
|
|
},
|
|
section: {
|
|
backgroundColor: Colors.background.card,
|
|
borderRadius: BorderRadius.large,
|
|
padding: Spacing.cardPadding,
|
|
borderWidth: 1,
|
|
borderColor: Colors.border.default,
|
|
gap: 14,
|
|
},
|
|
sectionHeader: {
|
|
fontFamily: Fonts.inter.black,
|
|
fontSize: FontSize.micro,
|
|
color: Colors.text.secondary,
|
|
letterSpacing: 3,
|
|
},
|
|
accountRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 14,
|
|
},
|
|
avatar: {
|
|
width: 48,
|
|
height: 48,
|
|
borderRadius: 24,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
avatarText: {
|
|
fontFamily: Fonts.inter.black,
|
|
fontSize: FontSize.subheading,
|
|
color: '#000',
|
|
},
|
|
accountInfo: {
|
|
flex: 1,
|
|
},
|
|
accountName: {
|
|
fontFamily: Fonts.inter.extraBold,
|
|
fontSize: FontSize.subheading,
|
|
color: Colors.text.primary,
|
|
},
|
|
accountEmail: {
|
|
fontFamily: Fonts.inter.medium,
|
|
fontSize: FontSize.body,
|
|
color: Colors.text.secondary,
|
|
},
|
|
tierBadge: {
|
|
backgroundColor: 'rgba(0,255,136,0.1)',
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(0,255,136,0.2)',
|
|
paddingHorizontal: 10,
|
|
paddingVertical: 4,
|
|
borderRadius: 6,
|
|
},
|
|
tierText: {
|
|
fontFamily: Fonts.inter.black,
|
|
fontSize: FontSize.micro,
|
|
color: Colors.accent.green,
|
|
letterSpacing: 1,
|
|
},
|
|
readOnlyHint: {
|
|
fontFamily: Fonts.mono.regular,
|
|
fontSize: FontSize.micro,
|
|
color: Colors.text.muted,
|
|
marginTop: 4,
|
|
},
|
|
warningBanner: {
|
|
backgroundColor: 'rgba(230,126,34,0.1)',
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(230,126,34,0.2)',
|
|
borderRadius: BorderRadius.xs,
|
|
padding: 12,
|
|
marginTop: 4,
|
|
},
|
|
warningText: {
|
|
fontFamily: Fonts.inter.semiBold,
|
|
fontSize: FontSize.bodySmall,
|
|
color: Colors.accent.orange,
|
|
},
|
|
settingRow: {
|
|
gap: 10,
|
|
},
|
|
settingLabel: {
|
|
fontFamily: Fonts.inter.semiBold,
|
|
fontSize: FontSize.body,
|
|
color: Colors.text.primary,
|
|
},
|
|
sliderRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 12,
|
|
},
|
|
sliderTrack: {
|
|
flex: 1,
|
|
height: 6,
|
|
borderRadius: 3,
|
|
backgroundColor: Colors.background.elevated,
|
|
overflow: 'hidden',
|
|
},
|
|
sliderFill: {
|
|
height: 6,
|
|
borderRadius: 3,
|
|
backgroundColor: Colors.accent.green,
|
|
},
|
|
sliderValue: {
|
|
fontFamily: Fonts.mono.bold,
|
|
fontSize: FontSize.bodyLarge,
|
|
color: Colors.accent.green,
|
|
minWidth: 40,
|
|
textAlign: 'right',
|
|
},
|
|
stepperRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 16,
|
|
},
|
|
stepperBtn: {
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 10,
|
|
backgroundColor: Colors.background.elevated,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
borderWidth: 1,
|
|
borderColor: Colors.border.subtle,
|
|
},
|
|
stepperBtnText: {
|
|
fontFamily: Fonts.inter.bold,
|
|
fontSize: FontSize.heading,
|
|
color: Colors.text.primary,
|
|
},
|
|
stepperValue: {
|
|
fontFamily: Fonts.mono.bold,
|
|
fontSize: FontSize.heading,
|
|
color: Colors.text.primary,
|
|
minWidth: 30,
|
|
textAlign: 'center',
|
|
},
|
|
capitalValue: {
|
|
fontFamily: Fonts.mono.bold,
|
|
fontSize: FontSize.subheading,
|
|
color: Colors.text.primary,
|
|
},
|
|
brokerRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
},
|
|
brokerName: {
|
|
fontFamily: Fonts.inter.bold,
|
|
fontSize: FontSize.bodyLarge,
|
|
color: Colors.text.primary,
|
|
},
|
|
connectedBadge: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 6,
|
|
},
|
|
connectedText: {
|
|
fontFamily: Fonts.inter.semiBold,
|
|
fontSize: FontSize.bodySmall,
|
|
color: Colors.accent.green,
|
|
},
|
|
degradedText: {
|
|
color: Colors.accent.amber,
|
|
},
|
|
offlineText: {
|
|
color: Colors.accent.red,
|
|
},
|
|
apiKeyHint: {
|
|
fontFamily: Fonts.mono.regular,
|
|
fontSize: FontSize.bodySmall,
|
|
color: Colors.text.muted,
|
|
},
|
|
toggleRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
},
|
|
toggleLabel: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
},
|
|
toggleText: {
|
|
fontFamily: Fonts.inter.semiBold,
|
|
fontSize: FontSize.body,
|
|
color: Colors.text.primary,
|
|
},
|
|
aboutRow: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
},
|
|
aboutLabel: {
|
|
fontFamily: Fonts.inter.medium,
|
|
fontSize: FontSize.body,
|
|
color: Colors.text.secondary,
|
|
},
|
|
aboutValue: {
|
|
fontFamily: Fonts.mono.medium,
|
|
fontSize: FontSize.body,
|
|
color: Colors.text.primary,
|
|
},
|
|
linkRow: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
paddingVertical: 4,
|
|
},
|
|
linkText: {
|
|
fontFamily: Fonts.inter.semiBold,
|
|
fontSize: FontSize.body,
|
|
color: Colors.text.primary,
|
|
},
|
|
actionRow: {
|
|
flexDirection: 'row',
|
|
gap: 10,
|
|
marginTop: 14,
|
|
},
|
|
actionButton: {
|
|
flex: 1,
|
|
borderRadius: BorderRadius.medium,
|
|
backgroundColor: 'rgba(255,149,0,0.16)',
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(255,149,0,0.35)',
|
|
paddingVertical: 12,
|
|
alignItems: 'center',
|
|
},
|
|
actionButtonSuccess: {
|
|
backgroundColor: 'rgba(0,255,136,0.14)',
|
|
borderColor: 'rgba(0,255,136,0.35)',
|
|
},
|
|
actionButtonDisabled: {
|
|
opacity: 0.45,
|
|
},
|
|
actionButtonText: {
|
|
fontFamily: Fonts.inter.black,
|
|
fontSize: FontSize.badge,
|
|
color: Colors.text.primary,
|
|
letterSpacing: 0.5,
|
|
},
|
|
});
|