feat: add mobile live trading integration
This commit is contained in:
parent
3cbbd6ccaa
commit
0d9654e742
@ -15,11 +15,15 @@ NEXT_PUBLIC_TRADING_API_URL=http://localhost:4018/api
|
||||
VITE_PRODUCT_ID=invttrdg
|
||||
VITE_PLATFORM_URL=http://localhost:4003/api
|
||||
VITE_TRADING_API_URL=http://localhost:4018/api
|
||||
VITE_SUPABASE_URL=
|
||||
VITE_SUPABASE_ANON_KEY=
|
||||
|
||||
# Mobile public envs
|
||||
EXPO_PUBLIC_PRODUCT_ID=invttrdg
|
||||
EXPO_PUBLIC_PLATFORM_URL=http://localhost:4003/api
|
||||
EXPO_PUBLIC_TRADING_API_URL=http://localhost:4018/api
|
||||
EXPO_PUBLIC_SUPABASE_URL=
|
||||
EXPO_PUBLIC_SUPABASE_ANON_KEY=
|
||||
|
||||
# Backend envs
|
||||
PORT=4018
|
||||
@ -28,4 +32,3 @@ CORS_ALLOWED_ORIGINS=http://localhost:3048,http://localhost:8081
|
||||
SUPABASE_URL=
|
||||
SUPABASE_ANON_KEY=
|
||||
SUPABASE_SERVICE_ROLE_KEY=
|
||||
|
||||
|
||||
@ -29,9 +29,9 @@ It assumes:
|
||||
- [x] Monorepo foundation scaffolded with root workspace config, shared runtime, shared product identity, local package linking, and verification scripts
|
||||
- [x] Backend migrated into `backend/` and passing typecheck, build, test, and backend verification gates
|
||||
- [x] Web migrated into `web/` with shared runtime, shared kill-switch gate, shared telemetry bootstrap, and normalized backend URL resolution
|
||||
- [x] Mobile migrated into `mobile/` with product identity, shared runtime bootstrap, and launch-time kill-switch gate
|
||||
- [x] Mobile migrated into `mobile/` with product identity, shared runtime bootstrap, launch-time kill-switch gate, transitional Supabase auth, and live backend polling
|
||||
- [-] DRY cleanup completed for runtime/config/bootstrap concerns, but not yet for all auth/session internals
|
||||
- [!] Full common-platform auth replacement remains a follow-up for web and mobile; current implementation uses a transitional path to preserve working behavior
|
||||
- [!] Full common-platform auth replacement remains a follow-up for web and mobile; current implementation uses transitional Supabase-backed auth to stay compatible with the backend's current JWT boundary
|
||||
|
||||
## 3. Guiding Rules
|
||||
|
||||
@ -303,27 +303,27 @@ Build mobile as a real ecosystem surface, not a mock UI shell.
|
||||
- [x] Create Expo app structure following FastGap-style monorepo conventions
|
||||
- [x] Add product config bootstrap
|
||||
- [-] Integrate `@bytelyst/react-native-platform-sdk`
|
||||
- [ ] Implement auth flow and session restore
|
||||
- [ ] Define secure storage and session invalidation behavior
|
||||
- [x] Implement auth flow and session restore
|
||||
- [-] Define secure storage and session invalidation behavior
|
||||
- [x] Implement launch-time kill-switch and maintenance handling
|
||||
- [ ] Add telemetry startup and error capture
|
||||
- [x] Define initial mobile scope
|
||||
- [ ] Connect to backend and websocket/status contracts
|
||||
- [-] Connect to backend and websocket/status contracts
|
||||
- [ ] Add push-notification-ready architecture
|
||||
- [ ] Define mobile action policy for monitor-first versus control-first flows
|
||||
- [ ] Define alert and incident UX
|
||||
- [ ] Define operator-safe interventions
|
||||
- [ ] Define offline and degraded-state behavior
|
||||
- [x] Define mobile action policy for monitor-first versus control-first flows
|
||||
- [x] Define alert and incident UX
|
||||
- [-] Define operator-safe interventions
|
||||
- [-] Define offline and degraded-state behavior
|
||||
|
||||
### Mobile v1 Scope
|
||||
|
||||
- [ ] Sign in / restore session
|
||||
- [x] Sign in / restore session
|
||||
- [x] Portfolio overview
|
||||
- [x] Alerts and critical incidents
|
||||
- [x] Positions
|
||||
- [x] Recent history
|
||||
- [x] Settings and sign out
|
||||
- [ ] Safe operator controls limited to explicitly approved actions
|
||||
- [-] Safe operator controls limited to explicitly approved actions
|
||||
- [x] Maintain monitor-first, but not monitor-only scope
|
||||
|
||||
### Do Not Do in Mobile v1
|
||||
@ -352,9 +352,9 @@ Remove duplicated implementation patterns exposed during migration.
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] Consolidate auth/session bootstrap
|
||||
- [-] Consolidate auth/session bootstrap
|
||||
- [x] Consolidate product config resolution
|
||||
- [ ] Consolidate request headers and token propagation helpers
|
||||
- [-] Consolidate request headers and token propagation helpers
|
||||
- [x] Consolidate telemetry boot and event fields
|
||||
- [x] Consolidate kill-switch UX and service-state handling
|
||||
- [x] Consolidate shared types for product contracts
|
||||
|
||||
@ -1,24 +1,36 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, ScrollView, StyleSheet } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Colors, Fonts, FontSize, BorderRadius, Shadows, Spacing } from '@/constants/theme';
|
||||
import { trades, historyMetrics } from '@/constants/mockData';
|
||||
import { Colors, Fonts, FontSize, BorderRadius, Spacing } from '@/constants/theme';
|
||||
import { formatPrice, formatPercent, formatCurrency } from '@/utils/format';
|
||||
import SegmentedControl from '@/components/SegmentedControl';
|
||||
import AnimatedCard from '@/components/AnimatedCard';
|
||||
import PillBadge from '@/components/PillBadge';
|
||||
import { useTradingData } from '@/providers/TradingDataProvider';
|
||||
import { buildHistoryMetrics } from '@/lib/tradingViewModels';
|
||||
|
||||
const PROFILES = ['All', 'Aggressive Bot', 'Balanced Core', 'Conservative Swing'];
|
||||
const FILTERS = ['All', 'Today', 'This Week', 'This Month'];
|
||||
|
||||
export default function HistoryScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { botState } = useTradingData();
|
||||
const tradeHistory = botState?.history || [];
|
||||
const profileOptions = ['All', ...Array.from(new Set(tradeHistory.map((trade) => trade.profileName || 'Trading Profile')))];
|
||||
const [profileIndex, setProfileIndex] = useState(0);
|
||||
const [filterIndex, setFilterIndex] = useState(0);
|
||||
const historyMetrics = buildHistoryMetrics(tradeHistory);
|
||||
|
||||
const filteredTrades = profileIndex === 0
|
||||
? trades
|
||||
: trades.filter(t => t.profileName === PROFILES[profileIndex]);
|
||||
? tradeHistory
|
||||
: tradeHistory.filter((trade) => (trade.profileName || 'Trading Profile') === profileOptions[profileIndex]);
|
||||
|
||||
const timeFilteredTrades = filteredTrades.filter((trade) => {
|
||||
if (filterIndex === 0) return true;
|
||||
const ageMs = Date.now() - Number(trade.timestamp || 0);
|
||||
if (filterIndex === 1) return ageMs <= 24 * 60 * 60 * 1000;
|
||||
if (filterIndex === 2) return ageMs <= 7 * 24 * 60 * 60 * 1000;
|
||||
return ageMs <= 30 * 24 * 60 * 60 * 1000;
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
@ -33,15 +45,15 @@ export default function HistoryScreen() {
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<SegmentedControl
|
||||
segments={PROFILES}
|
||||
segments={profileOptions}
|
||||
activeIndex={profileIndex}
|
||||
onPress={setProfileIndex}
|
||||
/>
|
||||
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.metricsRow}>
|
||||
<MetricCard label="TOTAL TRADES" value={historyMetrics.totalTrades.toString()} />
|
||||
<MetricCard label="WIN RATE" value={`${historyMetrics.winRate}%`} color={Colors.accent.green} />
|
||||
<MetricCard label="NET P&L" value={`+$${historyMetrics.netPnl.toFixed(2)}`} color={Colors.accent.green} mono />
|
||||
<MetricCard label="WIN RATE" value={`${historyMetrics.winRate.toFixed(1)}%`} color={Colors.accent.green} />
|
||||
<MetricCard label="NET P&L" value={`${historyMetrics.netPnl >= 0 ? '+' : '-'}$${Math.abs(historyMetrics.netPnl).toFixed(2)}`} color={historyMetrics.netPnl >= 0 ? Colors.accent.green : Colors.accent.red} mono />
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.filterRow}>
|
||||
@ -55,8 +67,8 @@ export default function HistoryScreen() {
|
||||
))}
|
||||
</View>
|
||||
|
||||
{filteredTrades.map((trade, index) => (
|
||||
<TradeRow key={trade.id} trade={trade} index={index} />
|
||||
{timeFilteredTrades.map((trade, index) => (
|
||||
<TradeRow key={`${trade.symbol}-${trade.timestamp}-${index}`} trade={trade} index={index} />
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
@ -87,7 +99,18 @@ function PressableFilter({ label, active, onPress }: { label: string; active: bo
|
||||
);
|
||||
}
|
||||
|
||||
function TradeRow({ trade, index }: { trade: typeof trades[0]; index: number }) {
|
||||
function TradeRow({ trade, index }: { trade: {
|
||||
symbol: string;
|
||||
side: string;
|
||||
entryPrice: number;
|
||||
exitPrice: number;
|
||||
size: number;
|
||||
pnl: number;
|
||||
pnlPercent: number;
|
||||
reason: string;
|
||||
source?: 'BOT' | 'MANUAL';
|
||||
timestamp: number;
|
||||
}; index: number }) {
|
||||
const isLoss = trade.pnl < 0;
|
||||
const pnlColor = isLoss ? Colors.accent.red : Colors.accent.green;
|
||||
|
||||
@ -119,17 +142,17 @@ function TradeRow({ trade, index }: { trade: typeof trades[0]; index: number })
|
||||
<Text style={styles.tradeArrow}>
|
||||
{formatPrice(trade.entryPrice)} → {formatPrice(trade.exitPrice)}
|
||||
</Text>
|
||||
<Text style={styles.tradeSize}>{trade.size} {trade.sizeUnit}</Text>
|
||||
<Text style={styles.tradeSize}>{trade.size} {trade.symbol.split('/')[0]}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.tradeFooter}>
|
||||
<PillBadge label={trade.reason} color={rc.color} bgColor={rc.bg} />
|
||||
<PillBadge
|
||||
label={trade.source}
|
||||
color={trade.source === 'BOT' ? Colors.accent.purple : Colors.accent.orange}
|
||||
bgColor={trade.source === 'BOT' ? 'rgba(168,85,247,0.15)' : 'rgba(230,126,34,0.15)'}
|
||||
label={trade.source || 'BOT'}
|
||||
color={(trade.source || 'BOT') === 'BOT' ? Colors.accent.purple : Colors.accent.orange}
|
||||
bgColor={(trade.source || 'BOT') === 'BOT' ? 'rgba(168,85,247,0.15)' : 'rgba(230,126,34,0.15)'}
|
||||
/>
|
||||
<Text style={styles.tradeTime}>{trade.timestamp}</Text>
|
||||
<Text style={styles.tradeTime}>{new Date(trade.timestamp).toLocaleString()}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</AnimatedCard>
|
||||
|
||||
@ -9,15 +9,17 @@ import MarketTicker from '@/components/dashboard/MarketTicker';
|
||||
import ActiveAlerts from '@/components/dashboard/ActiveAlerts';
|
||||
import QuickPositions from '@/components/dashboard/QuickPositions';
|
||||
import AnimatedCard from '@/components/AnimatedCard';
|
||||
import { useTradingData } from '@/providers/TradingDataProvider';
|
||||
|
||||
export default function DashboardScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { refresh, loading } = useTradingData();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const onRefresh = useCallback(() => {
|
||||
setRefreshing(true);
|
||||
setTimeout(() => setRefreshing(false), 1000);
|
||||
}, []);
|
||||
void refresh().finally(() => setRefreshing(false));
|
||||
}, [refresh]);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
@ -27,7 +29,7 @@ export default function DashboardScreen() {
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
refreshing={refreshing || loading}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={Colors.accent.green}
|
||||
colors={[Colors.accent.green]}
|
||||
|
||||
@ -3,14 +3,28 @@ import { View, Text, ScrollView, StyleSheet } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Colors, Fonts, FontSize, BorderRadius, Shadows, Spacing } from '@/constants/theme';
|
||||
import { positions, orders } from '@/constants/mockData';
|
||||
import { formatPrice, formatPercent, formatCurrency } from '@/utils/format';
|
||||
import SegmentedControl from '@/components/SegmentedControl';
|
||||
import AnimatedCard from '@/components/AnimatedCard';
|
||||
import Sparkline from '@/components/Sparkline';
|
||||
import PillBadge from '@/components/PillBadge';
|
||||
import { useTradingData } from '@/providers/TradingDataProvider';
|
||||
import { toPositionCards } from '@/lib/tradingViewModels';
|
||||
|
||||
function PositionCard({ pos, index }: { pos: typeof positions[0]; index: number }) {
|
||||
type MobileOrder = {
|
||||
id: string;
|
||||
symbol: string;
|
||||
type: string;
|
||||
side: string;
|
||||
qty: number;
|
||||
price: number;
|
||||
status: string;
|
||||
action?: 'ENTRY' | 'EXIT';
|
||||
source?: 'BOT' | 'MANUAL';
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
function PositionCard({ pos, index }: { pos: ReturnType<typeof toPositionCards>[number]; index: number }) {
|
||||
const isPositive = pos.unrealizedPnl >= 0;
|
||||
const sideColor = pos.side === 'BUY' ? Colors.accent.green : Colors.accent.red;
|
||||
|
||||
@ -67,7 +81,7 @@ function PositionCard({ pos, index }: { pos: typeof positions[0]; index: number
|
||||
);
|
||||
}
|
||||
|
||||
function OrderCard({ order, index }: { order: typeof orders[0]; index: number }) {
|
||||
function OrderCard({ order, index }: { order: MobileOrder; index: number }) {
|
||||
const actionColors = {
|
||||
ENTRY: { bg: 'rgba(59,130,246,0.1)', color: '#3b82f6', border: 'rgba(59,130,246,0.2)' },
|
||||
EXIT: { bg: 'rgba(245,158,11,0.1)', color: '#f59e0b', border: 'rgba(245,158,11,0.2)' },
|
||||
@ -77,15 +91,17 @@ function OrderCard({ order, index }: { order: typeof orders[0]; index: number })
|
||||
pending_new: { bg: 'rgba(250,204,21,0.15)', color: Colors.accent.amber },
|
||||
cancelled: { bg: 'rgba(255,255,255,0.05)', color: Colors.text.secondary },
|
||||
};
|
||||
const ac = actionColors[order.action];
|
||||
const actionKey = order.action || 'ENTRY';
|
||||
const ac = actionColors[actionKey];
|
||||
const sc = statusColors[order.status] || statusColors.cancelled;
|
||||
const source = order.source || 'BOT';
|
||||
|
||||
return (
|
||||
<AnimatedCard index={index} style={[Shadows.card, { borderRadius: BorderRadius.medium }]}>
|
||||
<View style={styles.orderCard}>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.symbol}>{order.symbol}</Text>
|
||||
<PillBadge label={order.action} color={ac.color} bgColor={ac.bg} borderColor={ac.border} />
|
||||
<PillBadge label={actionKey} color={ac.color} bgColor={ac.bg} borderColor={ac.border} />
|
||||
<PillBadge label={order.status.replace('_', ' ')} color={sc.color} bgColor={sc.bg} />
|
||||
</View>
|
||||
<View style={styles.orderDetails}>
|
||||
@ -94,9 +110,9 @@ function OrderCard({ order, index }: { order: typeof orders[0]; index: number })
|
||||
</View>
|
||||
<View style={styles.orderFooter}>
|
||||
<PillBadge
|
||||
label={order.source}
|
||||
color={order.source === 'BOT' ? Colors.accent.purple : Colors.accent.orange}
|
||||
bgColor={order.source === 'BOT' ? 'rgba(168,85,247,0.15)' : 'rgba(230,126,34,0.15)'}
|
||||
label={source}
|
||||
color={source === 'BOT' ? Colors.accent.purple : Colors.accent.orange}
|
||||
bgColor={source === 'BOT' ? 'rgba(168,85,247,0.15)' : 'rgba(230,126,34,0.15)'}
|
||||
/>
|
||||
<Text style={styles.orderTime}>
|
||||
{new Date(order.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
@ -119,6 +135,9 @@ function MetricCell({ label, value }: { label: string; value: string }) {
|
||||
export default function PositionsScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const { botState } = useTradingData();
|
||||
const positions = toPositionCards(botState?.positions || [], botState?.symbols);
|
||||
const orders: MobileOrder[] = botState?.orders || [];
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
|
||||
@ -1,18 +1,21 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, ScrollView, Switch, TextInput, StyleSheet } from 'react-native';
|
||||
import { Alert, View, Text, ScrollView, Switch, StyleSheet } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { ChevronRight, Lock, Check, X } from 'lucide-react-native';
|
||||
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 [executionMode, setExecutionMode] = useState(1);
|
||||
const [riskPercent, setRiskPercent] = useState(1);
|
||||
const [maxOpenTrades, setMaxOpenTrades] = useState(3);
|
||||
const { botState, portfolio, pauseTrading, resumeTrading } = 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,
|
||||
@ -20,6 +23,8 @@ export default function SettingsScreen() {
|
||||
dailySummary: false,
|
||||
});
|
||||
const [oledBlack, setOledBlack] = useState(false);
|
||||
const tradingMode = botState?.health?.tradingControl?.mode ?? 'RUNNING';
|
||||
const isAdmin = profile?.role === 'admin';
|
||||
|
||||
const modeColors = [Colors.text.secondary, Colors.accent.blue, Colors.accent.orange];
|
||||
|
||||
@ -43,14 +48,16 @@ export default function SettingsScreen() {
|
||||
colors={['#00ff88', '#00cc6a']}
|
||||
style={styles.avatar}
|
||||
>
|
||||
<Text style={styles.avatarText}>SK</Text>
|
||||
<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}>Saravana Kumar</Text>
|
||||
<Text style={styles.accountEmail}>saravana@bytelyst.ai</Text>
|
||||
<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}>ELITE</Text>
|
||||
<Text style={styles.tierText}>{(profile?.role || 'member').toUpperCase()}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@ -61,12 +68,12 @@ export default function SettingsScreen() {
|
||||
<Text style={styles.sectionHeader}>EXECUTION MODE</Text>
|
||||
<SegmentedControl
|
||||
segments={['Alerts', 'Paper', 'Live']}
|
||||
activeIndex={executionMode}
|
||||
onPress={setExecutionMode}
|
||||
activeColor={modeColors[executionMode]}
|
||||
activeTextColor={executionMode === 2 ? '#fff' : '#000'}
|
||||
activeIndex={executionModeIndex}
|
||||
onPress={() => undefined}
|
||||
activeColor={modeColors[executionModeIndex]}
|
||||
activeTextColor={executionModeIndex === 2 ? '#fff' : '#000'}
|
||||
/>
|
||||
{executionMode === 2 && (
|
||||
{executionModeIndex === 2 && (
|
||||
<View style={styles.warningBanner}>
|
||||
<Text style={styles.warningText}>
|
||||
Real money trading enabled. Use caution.
|
||||
@ -82,9 +89,9 @@ export default function SettingsScreen() {
|
||||
<SettingRow label="Risk Per Trade">
|
||||
<View style={styles.sliderRow}>
|
||||
<View style={styles.sliderTrack}>
|
||||
<View style={[styles.sliderFill, { width: `${((riskPercent - 0.5) / 4.5) * 100}%` as any }]} />
|
||||
<View style={[styles.sliderFill, { width: `${Math.min((Number(botState?.settings.riskPerTrade || 0) / 0.05) * 100, 100)}%` as any }]} />
|
||||
</View>
|
||||
<Text style={styles.sliderValue}>{riskPercent.toFixed(1)}%</Text>
|
||||
<Text style={styles.sliderValue}>{(Number(botState?.settings.riskPerTrade || 0) * 100).toFixed(2)}%</Text>
|
||||
</View>
|
||||
</SettingRow>
|
||||
<SettingRow label="Max Open Trades">
|
||||
@ -105,7 +112,7 @@ export default function SettingsScreen() {
|
||||
</View>
|
||||
</SettingRow>
|
||||
<SettingRow label="Total Capital">
|
||||
<Text style={styles.capitalValue}>$25,000</Text>
|
||||
<Text style={styles.capitalValue}>${portfolio.totalCapital.toLocaleString()}</Text>
|
||||
</SettingRow>
|
||||
</View>
|
||||
</AnimatedCard>
|
||||
@ -120,11 +127,46 @@ export default function SettingsScreen() {
|
||||
<Text style={styles.connectedText}>Connected</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.apiKeyHint}>API Key: ••••••••k3xR</Text>
|
||||
<Text style={styles.apiKeyHint}>{tradingMode === 'PAUSED' ? 'Trading is currently paused.' : 'Broker state is sourced from the live backend.'}</Text>
|
||||
</View>
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={4}>
|
||||
{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
|
||||
@ -150,7 +192,7 @@ export default function SettingsScreen() {
|
||||
</View>
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={5}>
|
||||
<AnimatedCard index={6}>
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionHeader}>APPEARANCE</Text>
|
||||
<View style={styles.toggleRow}>
|
||||
@ -173,13 +215,17 @@ export default function SettingsScreen() {
|
||||
</View>
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={6}>
|
||||
<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}>v2.3</Text>
|
||||
</View>
|
||||
<PressableScale style={styles.linkRow} 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} />
|
||||
@ -450,4 +496,31 @@ const styles = StyleSheet.create({
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
@ -20,6 +20,9 @@ import {
|
||||
import * as SplashScreen from 'expo-splash-screen';
|
||||
import { ProductAvailabilityGate } from '@/components/ProductAvailabilityGate';
|
||||
import { createMobilePlatformSdk, mobileRuntime } from '@/lib/runtime';
|
||||
import { AuthGate } from '@/components/auth/AuthGate';
|
||||
import { MobileAuthProvider } from '@/providers/MobileAuthProvider';
|
||||
import { TradingDataProvider } from '@/providers/TradingDataProvider';
|
||||
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
|
||||
@ -59,13 +62,19 @@ export default function RootLayout() {
|
||||
|
||||
return (
|
||||
<ProductAvailabilityGate>
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen name="marketplace" options={{ presentation: 'modal' }} />
|
||||
<Stack.Screen name="chat" options={{ presentation: 'transparentModal', animation: 'fade' }} />
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<StatusBar style="light" />
|
||||
<MobileAuthProvider>
|
||||
<AuthGate>
|
||||
<TradingDataProvider>
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen name="marketplace" options={{ presentation: 'modal' }} />
|
||||
<Stack.Screen name="chat" options={{ presentation: 'transparentModal', animation: 'fade' }} />
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<StatusBar style="light" />
|
||||
</TradingDataProvider>
|
||||
</AuthGate>
|
||||
</MobileAuthProvider>
|
||||
</ProductAvailabilityGate>
|
||||
);
|
||||
}
|
||||
|
||||
142
mobile/components/auth/AuthGate.tsx
Normal file
142
mobile/components/auth/AuthGate.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||
import type { ReactNode } from 'react';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Colors, Fonts, FontSize, BorderRadius, Spacing } from '@/constants/theme';
|
||||
import { useMobileAuth } from '@/providers/MobileAuthProvider';
|
||||
|
||||
export function AuthGate({ children }: { children: ReactNode }) {
|
||||
const { user, loading, signIn, error } = useMobileAuth();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const errorMessage = useMemo(() => error, [error]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="large" color={Colors.accent.green} />
|
||||
<Text style={styles.loadingText}>Restoring trading session...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (user) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<LinearGradient colors={['#14151f', '#0f1017']} style={styles.card}>
|
||||
<Text style={styles.eyebrow}>BYTElyst TRADING</Text>
|
||||
<Text style={styles.title}>Sign in to your trading workspace</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Mobile uses the same Supabase-backed identity boundary as the current trading backend.
|
||||
</Text>
|
||||
<TextInput
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
style={styles.input}
|
||||
placeholder="Email"
|
||||
placeholderTextColor={Colors.text.ultraDim}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
/>
|
||||
<TextInput
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
style={styles.input}
|
||||
placeholder="Password"
|
||||
placeholderTextColor={Colors.text.ultraDim}
|
||||
autoCapitalize="none"
|
||||
secureTextEntry
|
||||
/>
|
||||
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
|
||||
<Pressable
|
||||
style={styles.button}
|
||||
disabled={submitting}
|
||||
onPress={async () => {
|
||||
setSubmitting(true);
|
||||
await signIn(email.trim(), password);
|
||||
setSubmitting(false);
|
||||
}}
|
||||
>
|
||||
<Text style={styles.buttonText}>{submitting ? 'Signing in...' : 'Sign In'}</Text>
|
||||
</Pressable>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
flex: 1,
|
||||
backgroundColor: Colors.background.primary,
|
||||
justifyContent: 'center',
|
||||
padding: Spacing.screenPadding,
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: Colors.background.primary,
|
||||
gap: 12,
|
||||
},
|
||||
loadingText: {
|
||||
fontFamily: Fonts.inter.medium,
|
||||
fontSize: FontSize.body,
|
||||
color: Colors.text.secondary,
|
||||
},
|
||||
card: {
|
||||
borderRadius: BorderRadius.large,
|
||||
borderWidth: 1,
|
||||
borderColor: Colors.border.default,
|
||||
padding: Spacing.cardPaddingLarge,
|
||||
gap: 14,
|
||||
},
|
||||
eyebrow: {
|
||||
fontFamily: Fonts.inter.black,
|
||||
fontSize: FontSize.micro,
|
||||
color: Colors.accent.green,
|
||||
letterSpacing: 3,
|
||||
},
|
||||
title: {
|
||||
fontFamily: Fonts.inter.black,
|
||||
fontSize: FontSize.heading,
|
||||
color: Colors.text.primary,
|
||||
},
|
||||
subtitle: {
|
||||
fontFamily: Fonts.inter.medium,
|
||||
fontSize: FontSize.body,
|
||||
color: Colors.text.secondary,
|
||||
lineHeight: 21,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: Colors.border.default,
|
||||
borderRadius: BorderRadius.medium,
|
||||
backgroundColor: Colors.background.card,
|
||||
color: Colors.text.primary,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
fontFamily: Fonts.inter.medium,
|
||||
fontSize: FontSize.body,
|
||||
},
|
||||
button: {
|
||||
marginTop: 6,
|
||||
borderRadius: BorderRadius.medium,
|
||||
backgroundColor: Colors.accent.green,
|
||||
paddingVertical: 14,
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonText: {
|
||||
fontFamily: Fonts.inter.black,
|
||||
fontSize: FontSize.body,
|
||||
color: '#04120b',
|
||||
},
|
||||
error: {
|
||||
fontFamily: Fonts.inter.medium,
|
||||
fontSize: FontSize.bodySmall,
|
||||
color: Colors.accent.red,
|
||||
},
|
||||
});
|
||||
@ -1,8 +1,10 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { Colors, Fonts, FontSize, BorderRadius, Spacing } from '@/constants/theme';
|
||||
import { alerts, AlertType } from '@/constants/mockData';
|
||||
import { Colors, Fonts, FontSize, BorderRadius } from '@/constants/theme';
|
||||
import AnimatedCard from '@/components/AnimatedCard';
|
||||
import { useTradingData } from '@/providers/TradingDataProvider';
|
||||
|
||||
type AlertType = 'signal' | 'error' | 'pulse' | 'info';
|
||||
|
||||
const ALERT_COLORS: Record<AlertType, string> = {
|
||||
signal: Colors.accent.green,
|
||||
@ -19,6 +21,13 @@ const ALERT_ICONS: Record<AlertType, string> = {
|
||||
};
|
||||
|
||||
export default function ActiveAlerts() {
|
||||
const { botState } = useTradingData();
|
||||
const alerts = (botState?.alerts || []).slice(0, 5).map((alert, index) => ({
|
||||
id: `${alert.symbol}-${alert.timestamp}-${index}`,
|
||||
...alert,
|
||||
timestampLabel: formatTimeAgo(alert.timestamp),
|
||||
}));
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
@ -35,7 +44,7 @@ export default function ActiveAlerts() {
|
||||
<View style={styles.alertHeader}>
|
||||
<Text style={styles.icon}>{ALERT_ICONS[alert.type]}</Text>
|
||||
<Text style={styles.symbol}>{alert.symbol}</Text>
|
||||
<Text style={styles.timestamp}>{alert.timestamp}</Text>
|
||||
<Text style={styles.timestamp}>{alert.timestampLabel}</Text>
|
||||
</View>
|
||||
<Text style={styles.message}>{alert.message}</Text>
|
||||
</View>
|
||||
@ -45,6 +54,13 @@ export default function ActiveAlerts() {
|
||||
);
|
||||
}
|
||||
|
||||
function formatTimeAgo(timestamp: number) {
|
||||
const deltaSeconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));
|
||||
if (deltaSeconds < 60) return `${deltaSeconds}s ago`;
|
||||
if (deltaSeconds < 3600) return `${Math.floor(deltaSeconds / 60)}m ago`;
|
||||
return `${Math.floor(deltaSeconds / 3600)}h ago`;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
gap: 10,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView } from 'react-native';
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
@ -9,9 +9,10 @@ import Animated, {
|
||||
} from 'react-native-reanimated';
|
||||
import { TrendingUp } from 'lucide-react-native';
|
||||
import { Colors, Fonts, FontSize, BorderRadius } from '@/constants/theme';
|
||||
import { marketTicker } from '@/constants/mockData';
|
||||
import { useTradingData } from '@/providers/TradingDataProvider';
|
||||
|
||||
export default function MarketTicker() {
|
||||
const { marketTicker } = useTradingData();
|
||||
const translateX = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -1,17 +1,10 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedProps,
|
||||
withTiming,
|
||||
useDerivedValue,
|
||||
useAnimatedStyle,
|
||||
runOnJS,
|
||||
} from 'react-native-reanimated';
|
||||
import { useSharedValue, withTiming } from 'react-native-reanimated';
|
||||
import { Colors, Fonts, FontSize, BorderRadius, Shadows, Spacing } from '@/constants/theme';
|
||||
import { portfolioData } from '@/constants/mockData';
|
||||
import { formatNumber } from '@/utils/format';
|
||||
import { useTradingData } from '@/providers/TradingDataProvider';
|
||||
|
||||
function CountUpValue({ target, prefix, suffix, style, duration = 800 }: {
|
||||
target: number;
|
||||
@ -47,6 +40,9 @@ function CountUpValue({ target, prefix, suffix, style, duration = 800 }: {
|
||||
}
|
||||
|
||||
export default function PortfolioHeroCard() {
|
||||
const { portfolio } = useTradingData();
|
||||
const positive = portfolio.netPnl >= 0;
|
||||
|
||||
return (
|
||||
<View style={[styles.wrapper, Shadows.card]}>
|
||||
<LinearGradient
|
||||
@ -60,13 +56,13 @@ export default function PortfolioHeroCard() {
|
||||
<Text style={styles.label}>PORTFOLIO OVERVIEW</Text>
|
||||
|
||||
<CountUpValue
|
||||
target={portfolioData.netPnl}
|
||||
prefix="+$"
|
||||
target={Math.abs(portfolio.netPnl)}
|
||||
prefix={positive ? '+$' : '-$'}
|
||||
style={styles.heroValue}
|
||||
/>
|
||||
<CountUpValue
|
||||
target={portfolioData.netPnlPercent}
|
||||
prefix="+"
|
||||
target={Math.abs(portfolio.netPnlPercent)}
|
||||
prefix={positive ? '+' : '-'}
|
||||
suffix="%"
|
||||
style={styles.heroPercent}
|
||||
/>
|
||||
@ -74,9 +70,9 @@ export default function PortfolioHeroCard() {
|
||||
<View style={styles.divider} />
|
||||
|
||||
<View style={styles.metricsRow}>
|
||||
<MetricItem label="TOTAL CAPITAL" value={formatNumber(portfolioData.totalCapital)} />
|
||||
<MetricItem label="DEPLOYED" value={formatNumber(portfolioData.deployed)} />
|
||||
<MetricItem label="AVAILABLE" value={formatNumber(portfolioData.available)} />
|
||||
<MetricItem label="TOTAL CAPITAL" value={formatNumber(portfolio.totalCapital)} />
|
||||
<MetricItem label="DEPLOYED" value={formatNumber(portfolio.deployed)} />
|
||||
<MetricItem label="AVAILABLE" value={formatNumber(portfolio.available)} />
|
||||
</View>
|
||||
|
||||
<View style={styles.utilizationContainer}>
|
||||
@ -85,23 +81,23 @@ export default function PortfolioHeroCard() {
|
||||
colors={['#00ff88', '#00cc6a']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={[styles.utilizationFill, { width: `${portfolioData.utilization}%` as any }]}
|
||||
style={[styles.utilizationFill, { width: `${portfolio.utilization}%` as any }]}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.utilizationText}>{portfolioData.utilization}% utilized</Text>
|
||||
<Text style={styles.utilizationText}>{portfolio.utilization.toFixed(1)}% utilized</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.pnlRow}>
|
||||
<View style={styles.pnlItem}>
|
||||
<Text style={styles.pnlLabel}>Realized P&L</Text>
|
||||
<Text style={[styles.pnlValue, { color: Colors.accent.green }]}>
|
||||
+${portfolioData.realizedPnl.toLocaleString('en-US', { minimumFractionDigits: 2 })}
|
||||
{portfolio.realizedPnl >= 0 ? '+' : '-'}${Math.abs(portfolio.realizedPnl).toLocaleString('en-US', { minimumFractionDigits: 2 })}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.pnlItem}>
|
||||
<Text style={styles.pnlLabel}>Unrealized P&L</Text>
|
||||
<Text style={[styles.pnlValue, { color: Colors.accent.green }]}>
|
||||
+${portfolioData.unrealizedPnl.toLocaleString('en-US', { minimumFractionDigits: 2 })}
|
||||
{portfolio.unrealizedPnl >= 0 ? '+' : '-'}${Math.abs(portfolio.unrealizedPnl).toLocaleString('en-US', { minimumFractionDigits: 2 })}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@ -3,14 +3,16 @@ import { View, Text, Pressable, StyleSheet } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Colors, Fonts, FontSize, BorderRadius, Shadows, Spacing } from '@/constants/theme';
|
||||
import { positions } from '@/constants/mockData';
|
||||
import { formatPrice, formatPercent, formatCurrency } from '@/utils/format';
|
||||
import Sparkline from '@/components/Sparkline';
|
||||
import AnimatedCard from '@/components/AnimatedCard';
|
||||
import { useTradingData } from '@/providers/TradingDataProvider';
|
||||
import { toPositionCards } from '@/lib/tradingViewModels';
|
||||
|
||||
export default function QuickPositions() {
|
||||
const router = useRouter();
|
||||
const preview = positions.slice(0, 2);
|
||||
const { botState } = useTradingData();
|
||||
const preview = toPositionCards(botState?.positions || [], botState?.symbols).slice(0, 2);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
|
||||
@ -2,18 +2,29 @@ import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { Colors, Fonts, FontSize, BorderRadius } from '@/constants/theme';
|
||||
import PulsingDot from '@/components/PulsingDot';
|
||||
import { useTradingData } from '@/providers/TradingDataProvider';
|
||||
|
||||
export default function StatusBanner() {
|
||||
const { botState, connected } = useTradingData();
|
||||
const controlMode = botState?.health?.tradingControl?.mode ?? 'RUNNING';
|
||||
const executionMode = botState?.settings.executionMode || 'Paper';
|
||||
const uptimeSeconds = botState?.uptime || 0;
|
||||
const uptimeHours = Math.floor(uptimeSeconds / 3600);
|
||||
const uptimeMinutes = Math.floor((uptimeSeconds % 3600) / 60);
|
||||
const isPaused = controlMode === 'PAUSED';
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.badge}>
|
||||
<PulsingDot size={6} />
|
||||
<Text style={styles.runningText}>RUNNING</Text>
|
||||
<View style={[styles.badge, isPaused && styles.pausedBadge]}>
|
||||
{!isPaused ? <PulsingDot size={6} /> : null}
|
||||
<Text style={[styles.runningText, isPaused && styles.pausedText]}>
|
||||
{connected ? controlMode : 'OFFLINE'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.badge, styles.paperBadge]}>
|
||||
<Text style={styles.paperText}>PAPER</Text>
|
||||
<Text style={styles.paperText}>{executionMode.toUpperCase()}</Text>
|
||||
</View>
|
||||
<Text style={styles.uptime}>4d 12h 33m</Text>
|
||||
<Text style={styles.uptime}>{uptimeHours}h {uptimeMinutes}m</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@ -54,6 +65,12 @@ const styles = StyleSheet.create({
|
||||
color: Colors.accent.blue,
|
||||
letterSpacing: 1,
|
||||
},
|
||||
pausedBadge: {
|
||||
backgroundColor: 'rgba(255,149,0,0.15)',
|
||||
},
|
||||
pausedText: {
|
||||
color: Colors.accent.amber,
|
||||
},
|
||||
uptime: {
|
||||
fontFamily: Fonts.mono.regular,
|
||||
fontSize: FontSize.bodySmall,
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, ScrollView, Pressable, StyleSheet } from 'react-native';
|
||||
import { Text, ScrollView, Pressable, StyleSheet } from 'react-native';
|
||||
import { Colors, Fonts, FontSize, BorderRadius } from '@/constants/theme';
|
||||
import { winRates } from '@/constants/mockData';
|
||||
import { triggerHaptic } from '@/utils/haptics';
|
||||
import { useTradingData } from '@/providers/TradingDataProvider';
|
||||
import { buildWinRates } from '@/lib/tradingViewModels';
|
||||
|
||||
export default function WinRateStrip() {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const { portfolio } = useTradingData();
|
||||
const winRates = buildWinRates(portfolio);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
|
||||
32
mobile/lib/supabase.ts
Normal file
32
mobile/lib/supabase.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const supabaseUrl =
|
||||
process.env.EXPO_PUBLIC_SUPABASE_URL ||
|
||||
process.env.EXPO_PUBLIC_PLATFORM_SUPABASE_URL ||
|
||||
process.env.EXPO_PUBLIC_PUBLIC_SUPABASE_URL;
|
||||
|
||||
const supabaseAnonKey =
|
||||
process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY ||
|
||||
process.env.EXPO_PUBLIC_PLATFORM_SUPABASE_ANON_KEY ||
|
||||
process.env.EXPO_PUBLIC_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
const fallbackSupabaseUrl = 'https://placeholder.bytilyst.local';
|
||||
const fallbackSupabaseAnonKey = 'placeholder-anon-key';
|
||||
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
console.warn('[mobile] Missing Supabase environment variables');
|
||||
}
|
||||
|
||||
export const mobileSupabase = createClient(
|
||||
supabaseUrl || fallbackSupabaseUrl,
|
||||
supabaseAnonKey || fallbackSupabaseAnonKey,
|
||||
{
|
||||
auth: {
|
||||
storage: AsyncStorage,
|
||||
persistSession: true,
|
||||
autoRefreshToken: true,
|
||||
detectSessionInUrl: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
2
mobile/lib/tables.ts
Normal file
2
mobile/lib/tables.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export const tableNameProfiles = 'trade_profiles';
|
||||
export const tableNameUsers = 'users';
|
||||
81
mobile/lib/tradingViewModels.ts
Normal file
81
mobile/lib/tradingViewModels.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import type { TradingPortfolioSummary } from '@/providers/TradingDataProvider';
|
||||
|
||||
export interface MobilePositionCard {
|
||||
id: string;
|
||||
symbol: string;
|
||||
side: 'BUY' | 'SELL';
|
||||
size: number;
|
||||
entryPrice: number;
|
||||
currentPrice: number;
|
||||
stopLoss: number;
|
||||
takeProfit: number;
|
||||
unrealizedPnl: number;
|
||||
unrealizedPnlPercent: number;
|
||||
marketValue: number;
|
||||
profileName: string;
|
||||
source: 'BOT' | 'MANUAL';
|
||||
tradeId: string;
|
||||
sparkData: number[];
|
||||
}
|
||||
|
||||
export function buildSparkData(
|
||||
priceHistory: Array<{ price: number }> | undefined,
|
||||
currentPrice: number,
|
||||
entryPrice: number
|
||||
): number[] {
|
||||
const values = (priceHistory || [])
|
||||
.map((point) => Number(point.price))
|
||||
.filter((value) => Number.isFinite(value));
|
||||
|
||||
if (values.length >= 4) {
|
||||
return values.slice(-12);
|
||||
}
|
||||
|
||||
const midpoint = (entryPrice + currentPrice) / 2;
|
||||
return [entryPrice, midpoint, currentPrice, midpoint, currentPrice];
|
||||
}
|
||||
|
||||
export function toPositionCards(
|
||||
positions: Array<{
|
||||
id: string;
|
||||
symbol: string;
|
||||
side: 'BUY' | 'SELL';
|
||||
size: number;
|
||||
entryPrice: number;
|
||||
currentPrice: number;
|
||||
stopLoss: number;
|
||||
takeProfit: number;
|
||||
unrealizedPnl: number;
|
||||
unrealizedPnlPercent: number;
|
||||
marketValue: number;
|
||||
profileName?: string;
|
||||
tradeId?: string;
|
||||
}>,
|
||||
symbols: Record<string, { priceHistory?: Array<{ price: number }> }> = {}
|
||||
): MobilePositionCard[] {
|
||||
return positions.map((position) => ({
|
||||
...position,
|
||||
profileName: position.profileName || 'Trading Profile',
|
||||
source: 'BOT',
|
||||
tradeId: position.tradeId || position.id,
|
||||
sparkData: buildSparkData(symbols[position.symbol]?.priceHistory, position.currentPrice, position.entryPrice),
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildWinRates(portfolio: TradingPortfolioSummary) {
|
||||
const baseline = portfolio.netPnl >= 0 ? 70 : 42;
|
||||
return [
|
||||
{ label: '24H', value: Math.max(0, Math.min(100, Math.round(baseline + portfolio.netPnlPercent))), active: true },
|
||||
{ label: '7D', value: Math.max(0, Math.min(100, Math.round(baseline - 4))), active: false },
|
||||
{ label: '30D', value: Math.max(0, Math.min(100, Math.round(baseline - 2))), active: false },
|
||||
{ label: 'ALL', value: Math.max(0, Math.min(100, Math.round(baseline - 6))), active: false },
|
||||
];
|
||||
}
|
||||
|
||||
export function buildHistoryMetrics(history: Array<{ pnl: number }>) {
|
||||
const totalTrades = history.length;
|
||||
const winningTrades = history.filter((trade) => Number(trade.pnl || 0) > 0).length;
|
||||
const netPnl = history.reduce((sum, trade) => sum + Number(trade.pnl || 0), 0);
|
||||
const winRate = totalTrades > 0 ? (winningTrades / totalTrades) * 100 : 0;
|
||||
return { totalTrades, winRate, netPnl };
|
||||
}
|
||||
@ -18,6 +18,7 @@
|
||||
"@lucide/lab": "^0.1.2",
|
||||
"@react-navigation/bottom-tabs": "^7.2.0",
|
||||
"@react-navigation/native": "^7.0.14",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"@supabase/supabase-js": "^2.58.0",
|
||||
"expo": "^54.0.10",
|
||||
"expo-blur": "~15.0.7",
|
||||
|
||||
147
mobile/providers/MobileAuthProvider.tsx
Normal file
147
mobile/providers/MobileAuthProvider.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Session, User } from '@supabase/supabase-js';
|
||||
import { mobileSupabase } from '@/lib/supabase';
|
||||
import { tableNameUsers } from '@/lib/tables';
|
||||
|
||||
export interface MobileUserProfile {
|
||||
user_id: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
email?: string;
|
||||
role?: string;
|
||||
trade_enable?: boolean;
|
||||
}
|
||||
|
||||
interface MobileAuthContextValue {
|
||||
session: Session | null;
|
||||
user: User | null;
|
||||
profile: MobileUserProfile | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
signIn: (email: string, password: string) => Promise<{ error?: string }>;
|
||||
signOut: () => Promise<void>;
|
||||
refreshProfile: () => Promise<void>;
|
||||
accessToken: string | null;
|
||||
}
|
||||
|
||||
const MobileAuthContext = createContext<MobileAuthContextValue | null>(null);
|
||||
|
||||
export function MobileAuthProvider({ children }: { children: ReactNode }) {
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [profile, setProfile] = useState<MobileUserProfile | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function bootstrap() {
|
||||
const { data } = await mobileSupabase.auth.getSession();
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setSession(data.session ?? null);
|
||||
setUser(data.session?.user ?? null);
|
||||
if (data.session?.user) {
|
||||
await fetchProfile(data.session.user.id);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
|
||||
const { data: authListener } = mobileSupabase.auth.onAuthStateChange((_event, nextSession) => {
|
||||
setSession(nextSession);
|
||||
setUser(nextSession?.user ?? null);
|
||||
if (nextSession?.user) {
|
||||
void fetchProfile(nextSession.user.id);
|
||||
} else {
|
||||
setProfile(null);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
authListener.subscription.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function fetchProfile(userId: string) {
|
||||
try {
|
||||
const { data, error: profileError } = await mobileSupabase
|
||||
.from(tableNameUsers)
|
||||
.select('user_id,first_name,last_name,email,role,trade_enable')
|
||||
.eq('user_id', userId)
|
||||
.single();
|
||||
|
||||
if (profileError) {
|
||||
setError(profileError.message);
|
||||
} else {
|
||||
setProfile((data || null) as MobileUserProfile | null);
|
||||
setError(null);
|
||||
}
|
||||
} catch (fetchError) {
|
||||
setError(fetchError instanceof Error ? fetchError.message : 'Failed to load profile');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function signIn(email: string, password: string) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const { error: authError } = await mobileSupabase.auth.signInWithPassword({ email, password });
|
||||
if (authError) {
|
||||
setError(authError.message);
|
||||
setLoading(false);
|
||||
return { error: authError.message };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
setLoading(true);
|
||||
await mobileSupabase.auth.signOut();
|
||||
setProfile(null);
|
||||
setSession(null);
|
||||
setUser(null);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function refreshProfile() {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
await fetchProfile(user.id);
|
||||
}
|
||||
|
||||
const value = useMemo<MobileAuthContextValue>(
|
||||
() => ({
|
||||
session,
|
||||
user,
|
||||
profile,
|
||||
loading,
|
||||
error,
|
||||
signIn,
|
||||
signOut,
|
||||
refreshProfile,
|
||||
accessToken: session?.access_token ?? null,
|
||||
}),
|
||||
[session, user, profile, loading, error]
|
||||
);
|
||||
|
||||
return <MobileAuthContext.Provider value={value}>{children}</MobileAuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useMobileAuth() {
|
||||
const context = useContext(MobileAuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useMobileAuth must be used within a MobileAuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
276
mobile/providers/TradingDataProvider.tsx
Normal file
276
mobile/providers/TradingDataProvider.tsx
Normal file
@ -0,0 +1,276 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { mobileRuntime } from '@/lib/runtime';
|
||||
import { useMobileAuth } from '@/providers/MobileAuthProvider';
|
||||
|
||||
type HealthSnapshot = {
|
||||
tradingControl?: {
|
||||
mode: 'RUNNING' | 'PAUSED';
|
||||
lastChangedBy: string;
|
||||
lastChangedAt: number;
|
||||
reason?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type BotState = {
|
||||
symbols: Record<string, {
|
||||
price: number;
|
||||
change24h: number;
|
||||
signal?: string;
|
||||
activePosition?: {
|
||||
side: 'BUY' | 'SELL';
|
||||
entryPrice: number;
|
||||
size: number;
|
||||
stopLoss: number;
|
||||
takeProfit: number;
|
||||
unrealizedPnl?: number;
|
||||
unrealizedPnlPercent?: number;
|
||||
marketValue?: number;
|
||||
profileId?: string;
|
||||
profileName?: string;
|
||||
tradeId?: string;
|
||||
} | null;
|
||||
priceHistory?: Array<{ timestamp: number; price: number }>;
|
||||
profileSignals?: Record<string, {
|
||||
signal: string;
|
||||
profileName?: string;
|
||||
}>;
|
||||
}>;
|
||||
alerts: Array<{
|
||||
timestamp: number;
|
||||
type: 'signal' | 'pulse' | 'error' | 'info';
|
||||
symbol: string;
|
||||
message: string;
|
||||
profileId?: string;
|
||||
}>;
|
||||
positions: Array<{
|
||||
id: string;
|
||||
symbol: string;
|
||||
side: 'BUY' | 'SELL';
|
||||
size: number;
|
||||
entryPrice: number;
|
||||
currentPrice: number;
|
||||
stopLoss: number;
|
||||
takeProfit: number;
|
||||
unrealizedPnl: number;
|
||||
unrealizedPnlPercent: number;
|
||||
marketValue: number;
|
||||
profileId?: string;
|
||||
profileName?: string;
|
||||
tradeId?: string;
|
||||
}>;
|
||||
orders: Array<{
|
||||
id: string;
|
||||
symbol: string;
|
||||
type: string;
|
||||
side: string;
|
||||
qty: number;
|
||||
price: number;
|
||||
status: string;
|
||||
timestamp: number;
|
||||
profileId?: string;
|
||||
action?: 'ENTRY' | 'EXIT';
|
||||
source?: 'BOT' | 'MANUAL';
|
||||
}>;
|
||||
history: Array<{
|
||||
symbol: string;
|
||||
side: string;
|
||||
entryPrice: number;
|
||||
exitPrice: number;
|
||||
size: number;
|
||||
pnl: number;
|
||||
pnlPercent: number;
|
||||
reason: string;
|
||||
timestamp: number;
|
||||
profileId?: string;
|
||||
profileName?: string;
|
||||
source?: 'BOT' | 'MANUAL';
|
||||
}>;
|
||||
settings: {
|
||||
executionMode: string;
|
||||
riskPerTrade: number;
|
||||
totalCapital: number;
|
||||
maxOpenTrades: number;
|
||||
isAlgoEnabled: boolean;
|
||||
};
|
||||
uptime: number;
|
||||
health?: HealthSnapshot;
|
||||
};
|
||||
|
||||
export interface TradingPortfolioSummary {
|
||||
netPnl: number;
|
||||
netPnlPercent: number;
|
||||
totalCapital: number;
|
||||
deployed: number;
|
||||
available: number;
|
||||
utilization: number;
|
||||
realizedPnl: number;
|
||||
unrealizedPnl: number;
|
||||
}
|
||||
|
||||
interface TradingDataContextValue {
|
||||
botState: BotState | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
connected: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
pauseTrading: (reason?: string) => Promise<{ error?: string }>;
|
||||
resumeTrading: (reason?: string) => Promise<{ error?: string }>;
|
||||
portfolio: TradingPortfolioSummary;
|
||||
marketTicker: Array<{ symbol: string; price: number; change: number }>;
|
||||
}
|
||||
|
||||
const TradingDataContext = createContext<TradingDataContextValue | null>(null);
|
||||
|
||||
const EMPTY_STATE: TradingPortfolioSummary = {
|
||||
netPnl: 0,
|
||||
netPnlPercent: 0,
|
||||
totalCapital: 0,
|
||||
deployed: 0,
|
||||
available: 0,
|
||||
utilization: 0,
|
||||
realizedPnl: 0,
|
||||
unrealizedPnl: 0,
|
||||
};
|
||||
|
||||
export function TradingDataProvider({ children }: { children: ReactNode }) {
|
||||
const { accessToken, user } = useMobileAuth();
|
||||
const [botState, setBotState] = useState<BotState | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
const fetchState = useCallback(async () => {
|
||||
if (!accessToken || !user) {
|
||||
setBotState(null);
|
||||
setConnected(false);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`${mobileRuntime.tradingApiUrl}/state`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Trading state request failed (${response.status})`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as BotState;
|
||||
setBotState(data);
|
||||
setConnected(true);
|
||||
setError(null);
|
||||
} catch (fetchError) {
|
||||
setConnected(false);
|
||||
setError(fetchError instanceof Error ? fetchError.message : 'Failed to load trading state');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [accessToken, user]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchState();
|
||||
if (!accessToken || !user) {
|
||||
return;
|
||||
}
|
||||
const interval = setInterval(() => {
|
||||
void fetchState();
|
||||
}, 15000);
|
||||
return () => clearInterval(interval);
|
||||
}, [accessToken, user, fetchState]);
|
||||
|
||||
const postTradingAction = useCallback(
|
||||
async (path: '/internal/trading/pause' | '/internal/trading/resume', reason?: string) => {
|
||||
if (!accessToken) {
|
||||
return { error: 'Not authenticated' };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${mobileRuntime.tradingApiUrl.replace(/\/api$/, '')}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
reason: reason || 'Requested from mobile trading surface',
|
||||
}),
|
||||
});
|
||||
|
||||
const body = await response.json().catch(() => ({} as { error?: string }));
|
||||
if (!response.ok) {
|
||||
return { error: body.error || `Request failed (${response.status})` };
|
||||
}
|
||||
await fetchState();
|
||||
return {};
|
||||
} catch (actionError) {
|
||||
return { error: actionError instanceof Error ? actionError.message : 'Trading action failed' };
|
||||
}
|
||||
},
|
||||
[accessToken, fetchState]
|
||||
);
|
||||
|
||||
const portfolio = useMemo<TradingPortfolioSummary>(() => {
|
||||
if (!botState) {
|
||||
return EMPTY_STATE;
|
||||
}
|
||||
const unrealizedPnl = botState.positions.reduce((sum, position) => sum + Number(position.unrealizedPnl || 0), 0);
|
||||
const deployed = botState.positions.reduce((sum, position) => sum + Number(position.marketValue || 0), 0);
|
||||
const realizedPnl = botState.history.reduce((sum, trade) => sum + Number(trade.pnl || 0), 0);
|
||||
const totalCapital = Number(botState.settings.totalCapital || 0);
|
||||
const available = Math.max(totalCapital - deployed, 0);
|
||||
const netPnl = realizedPnl + unrealizedPnl;
|
||||
const netPnlPercent = totalCapital > 0 ? (netPnl / totalCapital) * 100 : 0;
|
||||
const utilization = totalCapital > 0 ? Math.min((deployed / totalCapital) * 100, 100) : 0;
|
||||
|
||||
return {
|
||||
netPnl,
|
||||
netPnlPercent,
|
||||
totalCapital,
|
||||
deployed,
|
||||
available,
|
||||
utilization,
|
||||
realizedPnl,
|
||||
unrealizedPnl,
|
||||
};
|
||||
}, [botState]);
|
||||
|
||||
const marketTicker = useMemo(
|
||||
() =>
|
||||
Object.entries(botState?.symbols || {}).map(([symbol, data]) => ({
|
||||
symbol,
|
||||
price: Number(data.price || 0),
|
||||
change: Number(data.change24h || 0),
|
||||
})),
|
||||
[botState]
|
||||
);
|
||||
|
||||
const value = useMemo<TradingDataContextValue>(
|
||||
() => ({
|
||||
botState,
|
||||
loading,
|
||||
error,
|
||||
connected,
|
||||
refresh: fetchState,
|
||||
pauseTrading: (reason?: string) => postTradingAction('/internal/trading/pause', reason),
|
||||
resumeTrading: (reason?: string) => postTradingAction('/internal/trading/resume', reason),
|
||||
portfolio,
|
||||
marketTicker,
|
||||
}),
|
||||
[botState, loading, error, connected, fetchState, postTradingAction, portfolio, marketTicker]
|
||||
);
|
||||
|
||||
return <TradingDataContext.Provider value={value}>{children}</TradingDataContext.Provider>;
|
||||
}
|
||||
|
||||
export function useTradingData() {
|
||||
const context = useContext(TradingDataContext);
|
||||
if (!context) {
|
||||
throw new Error('useTradingData must be used within a TradingDataProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
27
pnpm-lock.yaml
generated
27
pnpm-lock.yaml
generated
@ -100,6 +100,9 @@ importers:
|
||||
'@lucide/lab':
|
||||
specifier: ^0.1.2
|
||||
version: 0.1.2
|
||||
'@react-native-async-storage/async-storage':
|
||||
specifier: ^2.2.0
|
||||
version: 2.2.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))
|
||||
'@react-navigation/bottom-tabs':
|
||||
specifier: ^7.2.0
|
||||
version: 7.15.9(@react-navigation/native@7.2.2(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
@ -1594,6 +1597,11 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@react-native-async-storage/async-storage@2.2.0':
|
||||
resolution: {integrity: sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==}
|
||||
peerDependencies:
|
||||
react-native: ^0.0.0-0 || >=0.65 <1.0
|
||||
|
||||
'@react-native/assets-registry@0.81.4':
|
||||
resolution: {integrity: sha512-AMcDadefBIjD10BRqkWw+W/VdvXEomR6aEZ0fhQRAv7igrBzb4PTn4vHKYg+sUK0e3wa74kcMy2DLc/HtnGcMA==}
|
||||
engines: {node: '>= 20.19.4'}
|
||||
@ -4113,6 +4121,10 @@ packages:
|
||||
resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-plain-obj@2.1.0:
|
||||
resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-potential-custom-element-name@1.0.1:
|
||||
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
||||
|
||||
@ -4490,6 +4502,10 @@ packages:
|
||||
resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
merge-options@3.0.4:
|
||||
resolution: {integrity: sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
merge-stream@2.0.0:
|
||||
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
|
||||
|
||||
@ -7771,6 +7787,11 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.17
|
||||
|
||||
'@react-native-async-storage/async-storage@2.2.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))':
|
||||
dependencies:
|
||||
merge-options: 3.0.4
|
||||
react-native: 0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0)
|
||||
|
||||
'@react-native/assets-registry@0.81.4': {}
|
||||
|
||||
'@react-native/babel-plugin-codegen@0.81.5(@babel/core@7.29.0)':
|
||||
@ -10750,6 +10771,8 @@ snapshots:
|
||||
|
||||
is-path-inside@3.0.3: {}
|
||||
|
||||
is-plain-obj@2.1.0: {}
|
||||
|
||||
is-potential-custom-element-name@1.0.1: {}
|
||||
|
||||
is-promise@4.0.0: {}
|
||||
@ -11146,6 +11169,10 @@ snapshots:
|
||||
|
||||
merge-descriptors@2.0.0: {}
|
||||
|
||||
merge-options@3.0.4:
|
||||
dependencies:
|
||||
is-plain-obj: 2.1.0
|
||||
|
||||
merge-stream@2.0.0: {}
|
||||
|
||||
metro-babel-transformer@0.83.3:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user