diff --git a/.env.example b/.env.example
index 8370369..f9611e0 100644
--- a/.env.example
+++ b/.env.example
@@ -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=
-
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 43ca4d8..b43a0a0 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -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
diff --git a/mobile/app/(tabs)/history.tsx b/mobile/app/(tabs)/history.tsx
index cdf3f7b..8b0ee82 100644
--- a/mobile/app/(tabs)/history.tsx
+++ b/mobile/app/(tabs)/history.tsx
@@ -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 (
@@ -33,15 +45,15 @@ export default function HistoryScreen() {
showsVerticalScrollIndicator={false}
>
-
-
+
+ = 0 ? '+' : '-'}$${Math.abs(historyMetrics.netPnl).toFixed(2)}`} color={historyMetrics.netPnl >= 0 ? Colors.accent.green : Colors.accent.red} mono />
@@ -55,8 +67,8 @@ export default function HistoryScreen() {
))}
- {filteredTrades.map((trade, index) => (
-
+ {timeFilteredTrades.map((trade, index) => (
+
))}
@@ -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 })
{formatPrice(trade.entryPrice)} → {formatPrice(trade.exitPrice)}
- {trade.size} {trade.sizeUnit}
+ {trade.size} {trade.symbol.split('/')[0]}
- {trade.timestamp}
+ {new Date(trade.timestamp).toLocaleString()}
diff --git a/mobile/app/(tabs)/index.tsx b/mobile/app/(tabs)/index.tsx
index ade1681..0cc6a53 100644
--- a/mobile/app/(tabs)/index.tsx
+++ b/mobile/app/(tabs)/index.tsx
@@ -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 (
@@ -27,7 +29,7 @@ export default function DashboardScreen() {
showsVerticalScrollIndicator={false}
refreshControl={
[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 (
{order.symbol}
-
+
@@ -94,9 +110,9 @@ function OrderCard({ order, index }: { order: typeof orders[0]; index: number })
{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 (
diff --git a/mobile/app/(tabs)/settings.tsx b/mobile/app/(tabs)/settings.tsx
index 206254d..b89a836 100644
--- a/mobile/app/(tabs)/settings.tsx
+++ b/mobile/app/(tabs)/settings.tsx
@@ -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}
>
- SK
+
+ {`${profile?.first_name?.[0] || profile?.email?.[0] || 'T'}${profile?.last_name?.[0] || ''}`.slice(0, 2).toUpperCase()}
+
- Saravana Kumar
- saravana@bytelyst.ai
+ {[profile?.first_name, profile?.last_name].filter(Boolean).join(' ') || 'Trading User'}
+ {profile?.email || 'No email loaded'}
- ELITE
+ {(profile?.role || 'member').toUpperCase()}
@@ -61,12 +68,12 @@ export default function SettingsScreen() {
EXECUTION MODE
undefined}
+ activeColor={modeColors[executionModeIndex]}
+ activeTextColor={executionModeIndex === 2 ? '#fff' : '#000'}
/>
- {executionMode === 2 && (
+ {executionModeIndex === 2 && (
Real money trading enabled. Use caution.
@@ -82,9 +89,9 @@ export default function SettingsScreen() {
-
+
- {riskPercent.toFixed(1)}%
+ {(Number(botState?.settings.riskPerTrade || 0) * 100).toFixed(2)}%
@@ -105,7 +112,7 @@ export default function SettingsScreen() {
- $25,000
+ ${portfolio.totalCapital.toLocaleString()}
@@ -120,11 +127,46 @@ export default function SettingsScreen() {
Connected
- API Key: ••••••••k3xR
+ {tradingMode === 'PAUSED' ? 'Trading is currently paused.' : 'Broker state is sourced from the live backend.'}
-
+ {isAdmin ? (
+
+
+ TRADING CONTROL
+
+ Mobile is monitor-first. Only limited safety controls are exposed here.
+
+
+ {
+ const result = await pauseTrading('Paused from mobile control surface');
+ if (result.error) {
+ Alert.alert('Pause failed', result.error);
+ }
+ }}
+ >
+ Pause Trading
+
+ {
+ const result = await resumeTrading('Resumed from mobile control surface');
+ if (result.error) {
+ Alert.alert('Resume failed', result.error);
+ }
+ }}
+ >
+ Resume Trading
+
+
+
+
+ ) : null}
+
+
NOTIFICATIONS
-
+
APPEARANCE
@@ -173,13 +215,17 @@ export default function SettingsScreen() {
-
+
ABOUT
Version
v2.3
+ void signOut()}>
+ Sign Out
+
+
Terms of Service
@@ -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,
+ },
});
diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx
index 07f1f9b..4578262 100644
--- a/mobile/app/_layout.tsx
+++ b/mobile/app/_layout.tsx
@@ -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 (
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/mobile/components/auth/AuthGate.tsx b/mobile/components/auth/AuthGate.tsx
new file mode 100644
index 0000000..7d639cc
--- /dev/null
+++ b/mobile/components/auth/AuthGate.tsx
@@ -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 (
+
+
+ Restoring trading session...
+
+ );
+ }
+
+ if (user) {
+ return <>{children}>;
+ }
+
+ return (
+
+
+ BYTElyst TRADING
+ Sign in to your trading workspace
+
+ Mobile uses the same Supabase-backed identity boundary as the current trading backend.
+
+
+
+ {errorMessage ? {errorMessage} : null}
+ {
+ setSubmitting(true);
+ await signIn(email.trim(), password);
+ setSubmitting(false);
+ }}
+ >
+ {submitting ? 'Signing in...' : 'Sign In'}
+
+
+
+ );
+}
+
+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,
+ },
+});
diff --git a/mobile/components/dashboard/ActiveAlerts.tsx b/mobile/components/dashboard/ActiveAlerts.tsx
index 3833682..ed83137 100644
--- a/mobile/components/dashboard/ActiveAlerts.tsx
+++ b/mobile/components/dashboard/ActiveAlerts.tsx
@@ -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 = {
signal: Colors.accent.green,
@@ -19,6 +21,13 @@ const ALERT_ICONS: Record = {
};
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 (
@@ -35,7 +44,7 @@ export default function ActiveAlerts() {
{ALERT_ICONS[alert.type]}
{alert.symbol}
- {alert.timestamp}
+ {alert.timestampLabel}
{alert.message}
@@ -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,
diff --git a/mobile/components/dashboard/MarketTicker.tsx b/mobile/components/dashboard/MarketTicker.tsx
index 1689e34..ceb04dd 100644
--- a/mobile/components/dashboard/MarketTicker.tsx
+++ b/mobile/components/dashboard/MarketTicker.tsx
@@ -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(() => {
diff --git a/mobile/components/dashboard/PortfolioHeroCard.tsx b/mobile/components/dashboard/PortfolioHeroCard.tsx
index 57127f3..4388771 100644
--- a/mobile/components/dashboard/PortfolioHeroCard.tsx
+++ b/mobile/components/dashboard/PortfolioHeroCard.tsx
@@ -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 (
PORTFOLIO OVERVIEW
@@ -74,9 +70,9 @@ export default function PortfolioHeroCard() {
-
-
-
+
+
+
@@ -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 }]}
/>
- {portfolioData.utilization}% utilized
+ {portfolio.utilization.toFixed(1)}% utilized
Realized P&L
- +${portfolioData.realizedPnl.toLocaleString('en-US', { minimumFractionDigits: 2 })}
+ {portfolio.realizedPnl >= 0 ? '+' : '-'}${Math.abs(portfolio.realizedPnl).toLocaleString('en-US', { minimumFractionDigits: 2 })}
Unrealized P&L
- +${portfolioData.unrealizedPnl.toLocaleString('en-US', { minimumFractionDigits: 2 })}
+ {portfolio.unrealizedPnl >= 0 ? '+' : '-'}${Math.abs(portfolio.unrealizedPnl).toLocaleString('en-US', { minimumFractionDigits: 2 })}
diff --git a/mobile/components/dashboard/QuickPositions.tsx b/mobile/components/dashboard/QuickPositions.tsx
index 90098fd..251361d 100644
--- a/mobile/components/dashboard/QuickPositions.tsx
+++ b/mobile/components/dashboard/QuickPositions.tsx
@@ -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 (
diff --git a/mobile/components/dashboard/StatusBanner.tsx b/mobile/components/dashboard/StatusBanner.tsx
index 88b8289..56f36d8 100644
--- a/mobile/components/dashboard/StatusBanner.tsx
+++ b/mobile/components/dashboard/StatusBanner.tsx
@@ -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 (
-
-
- RUNNING
+
+ {!isPaused ? : null}
+
+ {connected ? controlMode : 'OFFLINE'}
+
- PAPER
+ {executionMode.toUpperCase()}
- 4d 12h 33m
+ {uptimeHours}h {uptimeMinutes}m
);
}
@@ -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,
diff --git a/mobile/components/dashboard/WinRateStrip.tsx b/mobile/components/dashboard/WinRateStrip.tsx
index 1062bde..05e35a2 100644
--- a/mobile/components/dashboard/WinRateStrip.tsx
+++ b/mobile/components/dashboard/WinRateStrip.tsx
@@ -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 (
| 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 }> = {}
+): 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 };
+}
diff --git a/mobile/package.json b/mobile/package.json
index 417fdc9..935327e 100644
--- a/mobile/package.json
+++ b/mobile/package.json
@@ -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",
diff --git a/mobile/providers/MobileAuthProvider.tsx b/mobile/providers/MobileAuthProvider.tsx
new file mode 100644
index 0000000..35e0b12
--- /dev/null
+++ b/mobile/providers/MobileAuthProvider.tsx
@@ -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;
+ refreshProfile: () => Promise;
+ accessToken: string | null;
+}
+
+const MobileAuthContext = createContext(null);
+
+export function MobileAuthProvider({ children }: { children: ReactNode }) {
+ const [session, setSession] = useState(null);
+ const [user, setUser] = useState(null);
+ const [profile, setProfile] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(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(
+ () => ({
+ session,
+ user,
+ profile,
+ loading,
+ error,
+ signIn,
+ signOut,
+ refreshProfile,
+ accessToken: session?.access_token ?? null,
+ }),
+ [session, user, profile, loading, error]
+ );
+
+ return {children};
+}
+
+export function useMobileAuth() {
+ const context = useContext(MobileAuthContext);
+ if (!context) {
+ throw new Error('useMobileAuth must be used within a MobileAuthProvider');
+ }
+ return context;
+}
diff --git a/mobile/providers/TradingDataProvider.tsx b/mobile/providers/TradingDataProvider.tsx
new file mode 100644
index 0000000..0ba28e7
--- /dev/null
+++ b/mobile/providers/TradingDataProvider.tsx
@@ -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;
+ profileSignals?: Record;
+ }>;
+ 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;
+ 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(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(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(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(() => {
+ 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(
+ () => ({
+ 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 {children};
+}
+
+export function useTradingData() {
+ const context = useContext(TradingDataContext);
+ if (!context) {
+ throw new Error('useTradingData must be used within a TradingDataProvider');
+ }
+ return context;
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7b9ae61..c4144f5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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: