diff --git a/.gitignore b/.gitignore index 1b85a63..8298635 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,46 @@ dist/ .wrangler/ *.log .DS_Store + +# upstream protocol clone (vendor source; never committed) +.upstream/ + +# local Claude Code permission overrides (not committed) +.claude/settings.local.json + +# --- secrets & credentials (repo-wide; never commit) --- +.env +.env.* +!.env.example +.dev.vars +.dev.vars.* +!.dev.vars.example +*.pem +*.key +*.p8 +*.p12 +*.pfx +*.cer +*.certSigningRequest +*.mobileprovision +*.keystore +*.jks +credentials.json +secrets.json +*.secret +google-services.json +GoogleService-Info.plist + +# --- terraform (state + tfvars hold secrets; never commit) --- +*.tfvars +*.tfvars.json +!*.tfvars.example +*.tfstate +*.tfstate.* +.terraform/ +crash.log +crash.*.log +override.tf +override.tf.json +*_override.tf +*_override.tf.json diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore index f8c6c2e..26d8cd3 100644 --- a/apps/mobile/.gitignore +++ b/apps/mobile/.gitignore @@ -9,7 +9,8 @@ dist/ web-build/ expo-env.d.ts -# Native +# Native / EAS credentials (EAS-managed creds live on Expo servers, not here; +# these patterns are belt-and-suspenders for local-credential or exported keys) .kotlin/ *.orig.* *.jks @@ -17,6 +18,7 @@ expo-env.d.ts *.p12 *.key *.mobileprovision +credentials.json # Metro .metro-health-check* diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 765ecc8..051ef13 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -1,14 +1,21 @@ { "expo": { - "name": "mobile", + "name": "Constructor", "slug": "mobile", "version": "1.0.0", "orientation": "portrait", "icon": "./assets/images/icon.png", "scheme": "mobile", "userInterfaceStyle": "automatic", + "runtimeVersion": { + "policy": "appVersion" + }, "ios": { - "icon": "./assets/expo.icon" + "icon": "./assets/expo.icon", + "bundleIdentifier": "dev.nejc.constructor", + "infoPlist": { + "ITSAppUsesNonExemptEncryption": false + } }, "android": { "adaptiveIcon": { @@ -36,7 +43,8 @@ } ], "expo-secure-store", - "expo-web-browser" + "expo-web-browser", + "expo-sqlite" ], "experiments": { "typedRoutes": true, @@ -48,6 +56,9 @@ "projectId": "00d6ac0f-2366-4d7e-843c-20cf79f4ea7d" } }, - "owner": "refrakts" + "owner": "refrakts", + "updates": { + "url": "https://u.expo.dev/00d6ac0f-2366-4d7e-843c-20cf79f4ea7d" + } } } diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json new file mode 100644 index 0000000..e1d9ce8 --- /dev/null +++ b/apps/mobile/eas.json @@ -0,0 +1,26 @@ +{ + "cli": { + "version": ">= 5.0.0", + "appVersionSource": "remote" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal", + "channel": "development", + "ios": { "simulator": false } + }, + "preview": { + "distribution": "internal", + "channel": "preview" + }, + "production": { + "distribution": "store", + "channel": "production", + "autoIncrement": true + } + }, + "submit": { + "production": {} + } +} diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index 73cd076..ce15145 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -9,4 +9,10 @@ config.resolver.nodeModulesPaths = [ path.resolve(workspaceRoot, "node_modules"), ]; config.resolver.disableHierarchicalLookup = true; +// markdown-it (via react-native-markdown-display) requires Node's "punycode"; +// alias it to the userland package so Metro can resolve it in RN/Hermes. +config.resolver.extraNodeModules = { + ...config.resolver.extraNodeModules, + punycode: require.resolve("punycode/"), +}; module.exports = config; diff --git a/apps/mobile/package.json b/apps/mobile/package.json index a41ac7b..7e6f93f 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -11,6 +11,7 @@ "lint": "expo lint" }, "dependencies": { + "@constructor/protocol": "workspace:*", "@expo/ui": "~55.0.16", "@react-navigation/bottom-tabs": "^7.15.5", "@react-navigation/elements": "^2.9.10", @@ -25,15 +26,19 @@ "expo-font": "~55.0.7", "expo-glass-effect": "~55.0.11", "expo-image": "~55.0.10", + "expo-insights": "~55.0.17", "expo-linking": "~55.0.15", "expo-notifications": "~55.0.23", "expo-router": "~55.0.14", "expo-secure-store": "~55.0.14", "expo-splash-screen": "~55.0.21", + "expo-sqlite": "~55.0.16", "expo-status-bar": "~55.0.6", "expo-symbols": "~55.0.8", "expo-system-ui": "~55.0.18", + "expo-updates": "~55.0.22", "expo-web-browser": "~55.0.16", + "punycode": "^2.3.1", "react": "19.2.0", "react-dom": "19.2.0", "react-native": "0.83.6", diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index b04d0a8..8f316c7 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -1,16 +1,56 @@ -import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'; -import React from 'react'; -import { useColorScheme } from 'react-native'; +import { Stack } from 'expo-router'; -import { AnimatedSplashOverlay } from '@/components/animated-icon'; -import AppTabs from '@/components/app-tabs'; +import { useAuth } from '@/data/auth'; +import { AppProviders } from '@/data/provider'; +import { useThemeColors } from '@/ui'; -export default function TabLayout() { - const colorScheme = useColorScheme(); +/** Fallback route Expo Router resolves to when a guard redirects. */ +export const unstable_settings = { anchor: 'index' }; + +/** Native iOS bottom-sheet presentation (Expo Router v55 / react-native-screens). */ +const SHEET = { + presentation: 'formSheet' as const, + sheetGrabberVisible: true, + sheetCornerRadius: 20, + sheetAllowedDetents: [0.6, 1] as number[], + sheetInitialDetentIndex: 0, +}; + +function StackNav() { + const c = useThemeColors(); + const { signedIn } = useAuth(); + return ( + + + + + + + + + + + + ); +} + +export default function RootLayout() { return ( - - - - + + + ); } diff --git a/apps/mobile/src/app/explore.tsx b/apps/mobile/src/app/explore.tsx deleted file mode 100644 index f08c5d3..0000000 --- a/apps/mobile/src/app/explore.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import { Image } from 'expo-image'; -import { SymbolView } from 'expo-symbols'; -import React from 'react'; -import { Platform, Pressable, ScrollView, StyleSheet } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; - -import { ExternalLink } from '@/components/external-link'; -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; -import { Collapsible } from '@/components/ui/collapsible'; -import { WebBadge } from '@/components/web-badge'; -import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme'; -import { useTheme } from '@/hooks/use-theme'; - -export default function TabTwoScreen() { - const safeAreaInsets = useSafeAreaInsets(); - const insets = { - ...safeAreaInsets, - bottom: safeAreaInsets.bottom + BottomTabInset + Spacing.three, - }; - const theme = useTheme(); - - const contentPlatformStyle = Platform.select({ - android: { - paddingTop: insets.top, - paddingLeft: insets.left, - paddingRight: insets.right, - paddingBottom: insets.bottom, - }, - web: { - paddingTop: Spacing.six, - paddingBottom: Spacing.four, - }, - }); - - return ( - - - - Explore - - This starter app includes example{'\n'}code to help you get started. - - - - pressed && styles.pressed}> - - Expo documentation - - - - - - - - - - This app has two screens: src/app/index.tsx and{' '} - src/app/explore.tsx - - - The layout file in src/app/_layout.tsx sets up - the tab navigator. - - - Learn more - - - - - - - You can open this project on Android, iOS, and the web. To open the web version, - press w in the terminal running this - project. - - - - - - - - For static images, you can use the @2x and{' '} - @3x suffixes to provide files for different - screen densities. - - - - Learn more - - - - - - This template has light and dark mode support. The{' '} - useColorScheme() hook lets you inspect what the - user's current color scheme is, and so you can adjust UI colors accordingly. - - - Learn more - - - - - - This template includes an example of an animated component. The{' '} - src/components/ui/collapsible.tsx component uses - the powerful react-native-reanimated library to - animate opening this hint. - - - - {Platform.OS === 'web' && } - - - ); -} - -const styles = StyleSheet.create({ - scrollView: { - flex: 1, - }, - contentContainer: { - flexDirection: 'row', - justifyContent: 'center', - }, - container: { - maxWidth: MaxContentWidth, - flexGrow: 1, - }, - titleContainer: { - gap: Spacing.three, - alignItems: 'center', - paddingHorizontal: Spacing.four, - paddingVertical: Spacing.six, - }, - centerText: { - textAlign: 'center', - }, - pressed: { - opacity: 0.7, - }, - linkButton: { - flexDirection: 'row', - paddingHorizontal: Spacing.four, - paddingVertical: Spacing.two, - borderRadius: Spacing.five, - justifyContent: 'center', - gap: Spacing.one, - alignItems: 'center', - }, - sectionsWrapper: { - gap: Spacing.five, - paddingHorizontal: Spacing.four, - paddingTop: Spacing.three, - }, - collapsibleContent: { - alignItems: 'center', - }, - imageTutorial: { - width: '100%', - aspectRatio: 296 / 171, - borderRadius: Spacing.three, - marginTop: Spacing.two, - }, - imageReact: { - width: 100, - height: 100, - alignSelf: 'center', - }, -}); diff --git a/apps/mobile/src/app/index.tsx b/apps/mobile/src/app/index.tsx index 8ec3e6f..7631b4e 100644 --- a/apps/mobile/src/app/index.tsx +++ b/apps/mobile/src/app/index.tsx @@ -1,98 +1 @@ -import * as Device from 'expo-device'; -import { Platform, StyleSheet } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; - -import { AnimatedIcon } from '@/components/animated-icon'; -import { HintRow } from '@/components/hint-row'; -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; -import { WebBadge } from '@/components/web-badge'; -import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme'; - -function getDevMenuHint() { - if (Platform.OS === 'web') { - return use browser devtools; - } - if (Device.isDevice) { - return ( - - shake device or press m in terminal - - ); - } - const shortcut = Platform.OS === 'android' ? 'cmd+m (or ctrl+m)' : 'cmd+d'; - return ( - - press {shortcut} - - ); -} - -export default function HomeScreen() { - return ( - - - - - - Welcome to Expo - - - - - get started - - - - src/app/index.tsx} - /> - - npm run reset-project} - /> - - - {Platform.OS === 'web' && } - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - justifyContent: 'center', - flexDirection: 'row', - }, - safeArea: { - flex: 1, - paddingHorizontal: Spacing.four, - alignItems: 'center', - gap: Spacing.three, - paddingBottom: BottomTabInset + Spacing.three, - maxWidth: MaxContentWidth, - }, - heroSection: { - alignItems: 'center', - justifyContent: 'center', - flex: 1, - paddingHorizontal: Spacing.four, - gap: Spacing.four, - }, - title: { - textAlign: 'center', - }, - code: { - textTransform: 'uppercase', - }, - stepContainer: { - gap: Spacing.three, - alignSelf: 'stretch', - paddingHorizontal: Spacing.three, - paddingVertical: Spacing.four, - borderRadius: Spacing.four, - }, -}); +export { SessionListScreen as default } from '@/features/sessions/list/screen'; diff --git a/apps/mobile/src/app/new.tsx b/apps/mobile/src/app/new.tsx new file mode 100644 index 0000000..373848a --- /dev/null +++ b/apps/mobile/src/app/new.tsx @@ -0,0 +1 @@ +export { CreateSessionScreen as default } from '@/features/sessions/create/screen'; diff --git a/apps/mobile/src/app/s/[id].tsx b/apps/mobile/src/app/s/[id].tsx new file mode 100644 index 0000000..9a196af --- /dev/null +++ b/apps/mobile/src/app/s/[id].tsx @@ -0,0 +1 @@ +export { SessionDetailScreen as default } from '@/features/sessions/detail/screen'; diff --git a/apps/mobile/src/app/settings.tsx b/apps/mobile/src/app/settings.tsx new file mode 100644 index 0000000..9f4d93a --- /dev/null +++ b/apps/mobile/src/app/settings.tsx @@ -0,0 +1 @@ +export { SettingsScreen as default } from '@/features/profiles/screen'; diff --git a/apps/mobile/src/app/sign-in.tsx b/apps/mobile/src/app/sign-in.tsx new file mode 100644 index 0000000..82f6e86 --- /dev/null +++ b/apps/mobile/src/app/sign-in.tsx @@ -0,0 +1 @@ +export { SignInScreen as default } from '@/features/auth/screen'; diff --git a/apps/mobile/src/components/animated-icon.module.css b/apps/mobile/src/components/animated-icon.module.css deleted file mode 100644 index f8156fe..0000000 --- a/apps/mobile/src/components/animated-icon.module.css +++ /dev/null @@ -1,6 +0,0 @@ -.expoLogoBackground { - background-image: linear-gradient(180deg, #3c9ffe, #0274df); - border-radius: 40px; - width: 128px; - height: 128px; -} diff --git a/apps/mobile/src/components/animated-icon.tsx b/apps/mobile/src/components/animated-icon.tsx deleted file mode 100644 index 91a480f..0000000 --- a/apps/mobile/src/components/animated-icon.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { Image } from 'expo-image'; -import { useState } from 'react'; -import { Dimensions, StyleSheet, View } from 'react-native'; -import Animated, { Easing, Keyframe } from 'react-native-reanimated'; -import { scheduleOnRN } from 'react-native-worklets'; - -const INITIAL_SCALE_FACTOR = Dimensions.get('screen').height / 90; -const DURATION = 600; - -export function AnimatedSplashOverlay() { - const [visible, setVisible] = useState(true); - - if (!visible) return null; - - const splashKeyframe = new Keyframe({ - 0: { - transform: [{ scale: INITIAL_SCALE_FACTOR }], - opacity: 1, - }, - 20: { - opacity: 1, - }, - 70: { - opacity: 0, - easing: Easing.elastic(0.7), - }, - 100: { - opacity: 0, - transform: [{ scale: 1 }], - easing: Easing.elastic(0.7), - }, - }); - - return ( - { - 'worklet'; - if (finished) { - scheduleOnRN(setVisible, false); - } - })} - style={styles.backgroundSolidColor} - /> - ); -} - -const keyframe = new Keyframe({ - 0: { - transform: [{ scale: INITIAL_SCALE_FACTOR }], - }, - 100: { - transform: [{ scale: 1 }], - easing: Easing.elastic(0.7), - }, -}); - -const logoKeyframe = new Keyframe({ - 0: { - transform: [{ scale: 1.3 }], - opacity: 0, - }, - 40: { - transform: [{ scale: 1.3 }], - opacity: 0, - easing: Easing.elastic(0.7), - }, - 100: { - opacity: 1, - transform: [{ scale: 1 }], - easing: Easing.elastic(0.7), - }, -}); - -const glowKeyframe = new Keyframe({ - 0: { - transform: [{ rotateZ: '0deg' }], - }, - 100: { - transform: [{ rotateZ: '7200deg' }], - }, -}); - -export function AnimatedIcon() { - return ( - - - - - - - - - - - ); -} - -const styles = StyleSheet.create({ - imageContainer: { - justifyContent: 'center', - alignItems: 'center', - }, - glow: { - width: 201, - height: 201, - position: 'absolute', - }, - iconContainer: { - justifyContent: 'center', - alignItems: 'center', - width: 128, - height: 128, - zIndex: 100, - }, - image: { - position: 'absolute', - width: 76, - height: 71, - }, - background: { - borderRadius: 40, - experimental_backgroundImage: `linear-gradient(180deg, #3C9FFE, #0274DF)`, - width: 128, - height: 128, - position: 'absolute', - }, - backgroundSolidColor: { - ...StyleSheet.absoluteFillObject, - backgroundColor: '#208AEF', - zIndex: 1000, - }, -}); diff --git a/apps/mobile/src/components/animated-icon.web.tsx b/apps/mobile/src/components/animated-icon.web.tsx deleted file mode 100644 index dfbb1fd..0000000 --- a/apps/mobile/src/components/animated-icon.web.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { Image } from 'expo-image'; -import { StyleSheet, View } from 'react-native'; -import Animated, { Keyframe, Easing } from 'react-native-reanimated'; - -import classes from './animated-icon.module.css'; -const DURATION = 300; - -export function AnimatedSplashOverlay() { - return null; -} - -const keyframe = new Keyframe({ - 0: { - transform: [{ scale: 0 }], - }, - 60: { - transform: [{ scale: 1.2 }], - easing: Easing.elastic(1.2), - }, - 100: { - transform: [{ scale: 1 }], - easing: Easing.elastic(1.2), - }, -}); - -const logoKeyframe = new Keyframe({ - 0: { - opacity: 0, - }, - 60: { - transform: [{ scale: 1.2 }], - opacity: 0, - easing: Easing.elastic(1.2), - }, - 100: { - transform: [{ scale: 1 }], - opacity: 1, - easing: Easing.elastic(1.2), - }, -}); - -const glowKeyframe = new Keyframe({ - 0: { - transform: [{ rotateZ: '-180deg' }, { scale: 0.8 }], - opacity: 0, - }, - [DURATION / 1000]: { - transform: [{ rotateZ: '0deg' }, { scale: 1 }], - opacity: 1, - easing: Easing.elastic(0.7), - }, - 100: { - transform: [{ rotateZ: '7200deg' }], - }, -}); - -export function AnimatedIcon() { - return ( - - - - - - -
- - - - - - - ); -} - -const styles = StyleSheet.create({ - container: { - alignItems: 'center', - width: '100%', - zIndex: 1000, - position: 'absolute', - top: 128 / 2 + 138, - }, - imageContainer: { - justifyContent: 'center', - alignItems: 'center', - }, - glow: { - width: 201, - height: 201, - position: 'absolute', - }, - iconContainer: { - justifyContent: 'center', - alignItems: 'center', - width: 128, - height: 128, - }, - image: { - position: 'absolute', - width: 76, - height: 71, - }, - background: { - width: 128, - height: 128, - position: 'absolute', - }, -}); diff --git a/apps/mobile/src/components/app-tabs.tsx b/apps/mobile/src/components/app-tabs.tsx deleted file mode 100644 index 0e1bc23..0000000 --- a/apps/mobile/src/components/app-tabs.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { NativeTabs } from 'expo-router/unstable-native-tabs'; -import React from 'react'; -import { useColorScheme } from 'react-native'; - -import { Colors } from '@/constants/theme'; - -export default function AppTabs() { - const scheme = useColorScheme(); - const colors = Colors[scheme === 'unspecified' ? 'light' : scheme]; - - return ( - - - Home - - - - - Explore - - - - ); -} diff --git a/apps/mobile/src/components/app-tabs.web.tsx b/apps/mobile/src/components/app-tabs.web.tsx deleted file mode 100644 index 6542e46..0000000 --- a/apps/mobile/src/components/app-tabs.web.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { - Tabs, - TabList, - TabTrigger, - TabSlot, - TabTriggerSlotProps, - TabListProps, -} from 'expo-router/ui'; -import { SymbolView } from 'expo-symbols'; -import React from 'react'; -import { Pressable, useColorScheme, View, StyleSheet } from 'react-native'; - -import { ExternalLink } from './external-link'; -import { ThemedText } from './themed-text'; -import { ThemedView } from './themed-view'; - -import { Colors, MaxContentWidth, Spacing } from '@/constants/theme'; - -export default function AppTabs() { - return ( - - - - - - Home - - - Explore - - - - - ); -} - -export function TabButton({ children, isFocused, ...props }: TabTriggerSlotProps) { - return ( - pressed && styles.pressed}> - - - {children} - - - - ); -} - -export function CustomTabList(props: TabListProps) { - const scheme = useColorScheme(); - const colors = Colors[scheme === 'unspecified' ? 'light' : scheme]; - - return ( - - - - Expo Starter - - - {props.children} - - - - Docs - - - - - - ); -} - -const styles = StyleSheet.create({ - tabListContainer: { - position: 'absolute', - width: '100%', - padding: Spacing.three, - justifyContent: 'center', - alignItems: 'center', - flexDirection: 'row', - }, - innerContainer: { - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.five, - borderRadius: Spacing.five, - flexDirection: 'row', - alignItems: 'center', - flexGrow: 1, - gap: Spacing.two, - maxWidth: MaxContentWidth, - }, - brandText: { - marginRight: 'auto', - }, - pressed: { - opacity: 0.7, - }, - tabButtonView: { - paddingVertical: Spacing.one, - paddingHorizontal: Spacing.three, - borderRadius: Spacing.three, - }, - externalPressable: { - flexDirection: 'row', - justifyContent: 'center', - alignItems: 'center', - gap: Spacing.one, - marginLeft: Spacing.three, - }, -}); diff --git a/apps/mobile/src/components/external-link.tsx b/apps/mobile/src/components/external-link.tsx deleted file mode 100644 index 883e515..0000000 --- a/apps/mobile/src/components/external-link.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Href, Link } from 'expo-router'; -import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser'; -import { type ComponentProps } from 'react'; - -type Props = Omit, 'href'> & { href: Href & string }; - -export function ExternalLink({ href, ...rest }: Props) { - return ( - { - if (process.env.EXPO_OS !== 'web') { - // Prevent the default behavior of linking to the default browser on native. - event.preventDefault(); - // Open the link in an in-app browser. - await openBrowserAsync(href, { - presentationStyle: WebBrowserPresentationStyle.AUTOMATIC, - }); - } - }} - /> - ); -} diff --git a/apps/mobile/src/components/hint-row.tsx b/apps/mobile/src/components/hint-row.tsx deleted file mode 100644 index a66062b..0000000 --- a/apps/mobile/src/components/hint-row.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React, { type ReactNode } from 'react'; -import { View, StyleSheet } from 'react-native'; - -import { ThemedText } from './themed-text'; -import { ThemedView } from './themed-view'; - -import { Spacing } from '@/constants/theme'; - -type HintRowProps = { - title?: string; - hint?: ReactNode; -}; - -export function HintRow({ title = 'Try editing', hint = 'app/index.tsx' }: HintRowProps) { - return ( - - {title} - - {hint} - - - ); -} - -const styles = StyleSheet.create({ - stepRow: { - flexDirection: 'row', - justifyContent: 'space-between', - }, - codeSnippet: { - borderRadius: Spacing.two, - paddingVertical: Spacing.half, - paddingHorizontal: Spacing.two, - }, -}); diff --git a/apps/mobile/src/components/themed-text.tsx b/apps/mobile/src/components/themed-text.tsx deleted file mode 100644 index 799c8b1..0000000 --- a/apps/mobile/src/components/themed-text.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { Platform, StyleSheet, Text, type TextProps } from 'react-native'; - -import { Fonts, ThemeColor } from '@/constants/theme'; -import { useTheme } from '@/hooks/use-theme'; - -export type ThemedTextProps = TextProps & { - type?: 'default' | 'title' | 'small' | 'smallBold' | 'subtitle' | 'link' | 'linkPrimary' | 'code'; - themeColor?: ThemeColor; -}; - -export function ThemedText({ style, type = 'default', themeColor, ...rest }: ThemedTextProps) { - const theme = useTheme(); - - return ( - - ); -} - -const styles = StyleSheet.create({ - small: { - fontSize: 14, - lineHeight: 20, - fontWeight: 500, - }, - smallBold: { - fontSize: 14, - lineHeight: 20, - fontWeight: 700, - }, - default: { - fontSize: 16, - lineHeight: 24, - fontWeight: 500, - }, - title: { - fontSize: 48, - fontWeight: 600, - lineHeight: 52, - }, - subtitle: { - fontSize: 32, - lineHeight: 44, - fontWeight: 600, - }, - link: { - lineHeight: 30, - fontSize: 14, - }, - linkPrimary: { - lineHeight: 30, - fontSize: 14, - color: '#3c87f7', - }, - code: { - fontFamily: Fonts.mono, - fontWeight: Platform.select({ android: 700 }) ?? 500, - fontSize: 12, - }, -}); diff --git a/apps/mobile/src/components/themed-view.tsx b/apps/mobile/src/components/themed-view.tsx deleted file mode 100644 index c710df9..0000000 --- a/apps/mobile/src/components/themed-view.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { View, type ViewProps } from 'react-native'; - -import { ThemeColor } from '@/constants/theme'; -import { useTheme } from '@/hooks/use-theme'; - -export type ThemedViewProps = ViewProps & { - lightColor?: string; - darkColor?: string; - type?: ThemeColor; -}; - -export function ThemedView({ style, lightColor, darkColor, type, ...otherProps }: ThemedViewProps) { - const theme = useTheme(); - - return ; -} diff --git a/apps/mobile/src/components/ui/collapsible.tsx b/apps/mobile/src/components/ui/collapsible.tsx deleted file mode 100644 index d0d745b..0000000 --- a/apps/mobile/src/components/ui/collapsible.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { SymbolView } from 'expo-symbols'; -import { PropsWithChildren, useState } from 'react'; -import { Pressable, StyleSheet } from 'react-native'; -import Animated, { FadeIn } from 'react-native-reanimated'; - -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; -import { Spacing } from '@/constants/theme'; -import { useTheme } from '@/hooks/use-theme'; - -export function Collapsible({ children, title }: PropsWithChildren & { title: string }) { - const [isOpen, setIsOpen] = useState(false); - const theme = useTheme(); - - return ( - - [styles.heading, pressed && styles.pressedHeading]} - onPress={() => setIsOpen((value) => !value)}> - - - - - {title} - - {isOpen && ( - - - {children} - - - )} - - ); -} - -const styles = StyleSheet.create({ - heading: { - flexDirection: 'row', - alignItems: 'center', - gap: Spacing.two, - }, - pressedHeading: { - opacity: 0.7, - }, - button: { - width: Spacing.four, - height: Spacing.four, - borderRadius: 12, - justifyContent: 'center', - alignItems: 'center', - }, - content: { - marginTop: Spacing.three, - borderRadius: Spacing.three, - marginLeft: Spacing.four, - padding: Spacing.four, - }, -}); diff --git a/apps/mobile/src/components/web-badge.tsx b/apps/mobile/src/components/web-badge.tsx deleted file mode 100644 index 23933d2..0000000 --- a/apps/mobile/src/components/web-badge.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { version } from 'expo/package.json'; -import { Image } from 'expo-image'; -import React from 'react'; -import { useColorScheme, StyleSheet } from 'react-native'; - -import { ThemedText } from './themed-text'; -import { ThemedView } from './themed-view'; - -import { Spacing } from '@/constants/theme'; - -export function WebBadge() { - const scheme = useColorScheme(); - - return ( - - - v{version} - - - - ); -} - -const styles = StyleSheet.create({ - container: { - padding: Spacing.five, - alignItems: 'center', - gap: Spacing.two, - }, - versionText: { - textAlign: 'center', - }, - badgeImage: { - width: 123, - aspectRatio: 123 / 24, - }, -}); diff --git a/apps/mobile/src/data/auth.tsx b/apps/mobile/src/data/auth.tsx new file mode 100644 index 0000000..6a67c4a --- /dev/null +++ b/apps/mobile/src/data/auth.tsx @@ -0,0 +1,38 @@ +/** + * Mock auth state for the navigation gate. Real GitHub OAuth (via the gateway) + * is M1 — this only flips a boolean so `Stack.Protected` in the router can gate + * the app behind sign-in. State is in-memory (resets on reload) by design: it + * is a UI gate, not a credential store. + */ +import React, { + createContext, + useCallback, + useContext, + useMemo, + useState, +} from 'react'; + +type Auth = { + signedIn: boolean; + signIn: () => void; + signOut: () => void; +}; + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [signedIn, setSignedIn] = useState(false); + const signIn = useCallback(() => setSignedIn(true), []); + const signOut = useCallback(() => setSignedIn(false), []); + const value = useMemo( + () => ({ signedIn, signIn, signOut }), + [signedIn, signIn, signOut], + ); + return {children}; +} + +export function useAuth(): Auth { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth must be used within '); + return ctx; +} diff --git a/apps/mobile/src/data/gateway.ts b/apps/mobile/src/data/gateway.ts new file mode 100644 index 0000000..bab4df4 --- /dev/null +++ b/apps/mobile/src/data/gateway.ts @@ -0,0 +1,45 @@ +/** + * The single data seam (PLAN-02 / spec approach A). Screens depend ONLY on this + * interface (via the queries hooks), never on a concrete impl. MockSessionGateway + * implements it now; the real HTTP/WS gateway implements the same interface later + * with zero screen changes. Typed entirely against the vendored protocol. + */ +import type { + CreateSessionRequest, + SandboxEvent, + Session, + SessionArtifact, + SessionState, +} from '@constructor/protocol'; + +export type { CreateSessionRequest, SandboxEvent, Session, SessionArtifact, SessionState }; + +export interface SubscribeSnapshot { + state: SessionState; + artifacts: SessionArtifact[]; + replay: { + events: SandboxEvent[]; + hasMore: boolean; + cursor: { timestamp: number; id: string } | null; + }; +} + +export interface StreamListeners { + snapshot(s: SubscribeSnapshot): void; + event(e: SandboxEvent): void; + closed?(reason?: string): void; +} + +export interface StreamHandle { + unsubscribe(): void; +} + +export interface SessionGateway { + listSessions(): Promise; + getSession(id: string): Promise; + createSession(req: CreateSessionRequest): Promise<{ sessionId: string }>; + /** Mirrors the real DO: a `snapshot` (state + replay) then a live event stream. */ + subscribe(id: string, on: StreamListeners): StreamHandle; + sendFollowUp(id: string, content: string): Promise; + stop(id: string): Promise; +} diff --git a/apps/mobile/src/data/mock/emitter.ts b/apps/mobile/src/data/mock/emitter.ts new file mode 100644 index 0000000..f7840ca --- /dev/null +++ b/apps/mobile/src/data/mock/emitter.ts @@ -0,0 +1,41 @@ +/** Replays a scripted SandboxEvent[] through stream listeners with realistic + * inter-event timing. Returns a cancel fn (clears pending timers). */ +import type { SandboxEvent } from '@constructor/protocol'; + +import type { StreamListeners, SubscribeSnapshot } from '../gateway'; + +export function startScriptedStream( + snapshot: SubscribeSnapshot, + script: SandboxEvent[], + on: StreamListeners, +): () => void { + let cancelled = false; + const timers: ReturnType[] = []; + + // snapshot first (mirrors `subscribed` frame), then stream after a beat. + const snapTimer = setTimeout(() => { + if (cancelled) return; + on.snapshot(snapshot); + let delay = 350; + script.forEach((evt) => { + delay += evt.type === 'token' ? 90 : 260; + timers.push( + setTimeout(() => { + if (cancelled) return; + on.event(evt); + }, delay), + ); + }); + timers.push( + setTimeout(() => { + if (!cancelled) on.closed?.('completed'); + }, delay + 200), + ); + }, 200); + timers.push(snapTimer); + + return () => { + cancelled = true; + timers.forEach(clearTimeout); + }; +} diff --git a/apps/mobile/src/data/mock/fixtures.ts b/apps/mobile/src/data/mock/fixtures.ts new file mode 100644 index 0000000..74cf583 --- /dev/null +++ b/apps/mobile/src/data/mock/fixtures.ts @@ -0,0 +1,103 @@ +/** + * Mock fixtures shaped to the real vendored protocol. The scripted scenario + * mirrors the real DO contract: a `snapshot` (state + replay) then a live + * `SandboxEvent` stream, so screens behave as they will post-M0 (spec §5). + * Enum literals are `satisfies`-checked against @constructor/protocol. + */ +import type { SandboxEvent, Session, SessionState } from '@constructor/protocol'; + +const now = Date.now(); +const SBX = 'sbx_mock_1'; + +export const mockSessions: Session[] = [ + { + id: 's_active', + title: 'Add dark-mode toggle to settings', + repoOwner: 'refrakts', + repoName: 'constructor-mobile', + baseBranch: 'main', + branchName: 'open-inspect/s_active', + baseSha: 'abc1234', + currentSha: 'def5678', + opencodeSessionId: null, + status: 'active', + parentSessionId: null, + spawnSource: 'user', + spawnDepth: 0, + createdAt: now - 1000 * 60 * 12, + updatedAt: now - 1000 * 30, + }, + { + id: 's_done', + title: 'Fix flaky auth test', + repoOwner: 'refrakts', + repoName: 'constructor-mobile', + baseBranch: 'main', + branchName: 'open-inspect/s_done', + baseSha: 'aaa0001', + currentSha: 'bbb0002', + opencodeSessionId: null, + status: 'completed', + parentSessionId: null, + spawnSource: 'user', + spawnDepth: 0, + createdAt: now - 1000 * 60 * 60 * 5, + updatedAt: now - 1000 * 60 * 60 * 4, + }, +] satisfies Session[]; + +export function mockSessionState(id: string): SessionState { + const src = mockSessions.find((x) => x.id === id) ?? mockSessions[0]; + return { + id: src.id, + title: src.title, + repoOwner: src.repoOwner, + repoName: src.repoName, + baseBranch: src.baseBranch, + branchName: src.branchName, + status: src.status, + sandboxStatus: 'running', + messageCount: 1, + createdAt: src.createdAt, + model: 'anthropic/claude-sonnet-4-6', + isProcessing: src.status === 'active', + totalCost: 0, + } satisfies SessionState; +} + +/** Scenario A — happy path: prompt → tool → cumulative token stream → done. */ +export function scenarioHappy(): SandboxEvent[] { + const t = () => Date.now() / 1000; + const mid = 'm_1'; + const partials = [ + 'Looking at the settings screen…', + 'Looking at the settings screen… adding a `Toggle`', + 'Looking at the settings screen… adding a `Toggle` bound to the theme store.', + 'Looking at the settings screen… adding a `Toggle` bound to the theme store.\n\n```tsx\n\n```\n\nDone.', + ]; + const evts: SandboxEvent[] = [ + { type: 'user_message', content: 'Add a dark-mode toggle to the settings screen', messageId: mid, timestamp: t() }, + { type: 'step_start', messageId: mid, sandboxId: SBX, timestamp: t() }, + { type: 'tool_call', tool: 'read_file', args: { path: 'src/app/settings.tsx' }, callId: 'c1', messageId: mid, sandboxId: SBX, timestamp: t() }, + { type: 'tool_result', callId: 'c1', result: 'export default function Settings() { … }', messageId: mid, sandboxId: SBX, timestamp: t() }, + ]; + for (const p of partials) { + evts.push({ type: 'token', content: p, messageId: mid, sandboxId: SBX, timestamp: t() }); + } + evts.push({ type: 'step_finish', cost: 0.0123, tokens: 1840, messageId: mid, sandboxId: SBX, timestamp: t() }); + evts.push({ type: 'execution_complete', messageId: mid, success: true, sandboxId: SBX, timestamp: t() }); + return evts; +} + +/** Scenario B — error path. */ +export function scenarioError(): SandboxEvent[] { + const t = () => Date.now() / 1000; + const mid = 'm_err'; + return [ + { type: 'user_message', content: 'Refactor the auth module', messageId: mid, timestamp: t() }, + { type: 'step_start', messageId: mid, sandboxId: SBX, timestamp: t() }, + { type: 'tool_call', tool: 'run_tests', args: {}, callId: 'c9', messageId: mid, sandboxId: SBX, timestamp: t() }, + { type: 'error', error: 'Test suite failed: 3 failing in auth.test.ts', messageId: mid, sandboxId: SBX, timestamp: t() }, + { type: 'execution_complete', messageId: mid, success: false, error: 'aborted', sandboxId: SBX, timestamp: t() }, + ]; +} diff --git a/apps/mobile/src/data/mock/mock-gateway.ts b/apps/mobile/src/data/mock/mock-gateway.ts new file mode 100644 index 0000000..982d60a --- /dev/null +++ b/apps/mobile/src/data/mock/mock-gateway.ts @@ -0,0 +1,70 @@ +/** In-memory SessionGateway. Same interface the real HTTP/WS gateway will + * implement later — screens never know which one is wired. */ +import type { CreateSessionRequest, Session, SessionState } from '@constructor/protocol'; + +import type { SessionGateway, StreamHandle, StreamListeners, SubscribeSnapshot } from '../gateway'; +import { startScriptedStream } from './emitter'; +import { mockSessionState, mockSessions, scenarioError, scenarioHappy } from './fixtures'; + +export class MockSessionGateway implements SessionGateway { + private sessions: Session[] = [...mockSessions]; + + async listSessions(): Promise { + await tick(); + return [...this.sessions]; + } + + async getSession(id: string): Promise { + await tick(); + return mockSessionState(id); + } + + async createSession(req: CreateSessionRequest): Promise<{ sessionId: string }> { + await tick(); + const id = `s_${Math.random().toString(36).slice(2, 8)}`; + const ts = Date.now(); + this.sessions = [ + { + id, + title: req.title ?? `${req.repoOwner}/${req.repoName}`, + repoOwner: req.repoOwner, + repoName: req.repoName, + baseBranch: req.branch ?? 'main', + branchName: `open-inspect/${id}`, + baseSha: null, + currentSha: null, + opencodeSessionId: null, + status: 'active', + parentSessionId: null, + spawnSource: 'user', + spawnDepth: 0, + createdAt: ts, + updatedAt: ts, + }, + ...this.sessions, + ]; + return { sessionId: id }; + } + + subscribe(id: string, on: StreamListeners): StreamHandle { + const state = mockSessionState(id); + const snapshot: SubscribeSnapshot = { + state, + artifacts: [], + replay: { events: [], hasMore: false, cursor: null }, + }; + const script = state.status === 'failed' ? scenarioError() : scenarioHappy(); + const cancel = startScriptedStream(snapshot, script, on); + return { unsubscribe: cancel }; + } + + async sendFollowUp(_id: string, _content: string): Promise { + await tick(); + } + + async stop(_id: string): Promise { + await tick(); + } +} + +const tick = () => new Promise((r) => setTimeout(r, 220)); diff --git a/apps/mobile/src/data/provider.tsx b/apps/mobile/src/data/provider.tsx new file mode 100644 index 0000000..fcf9060 --- /dev/null +++ b/apps/mobile/src/data/provider.tsx @@ -0,0 +1,34 @@ +/** Wires the gateway seam + TanStack Query. Swap `defaultGateway` for the real + * HTTP/WS impl later — nothing else changes. */ +import React, { createContext, useContext, useMemo } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +import type { SessionGateway } from './gateway'; +import { MockSessionGateway } from './mock/mock-gateway'; +import { AuthProvider } from './auth'; + +const GatewayContext = createContext(null); + +export function useGateway(): SessionGateway { + const g = useContext(GatewayContext); + if (!g) throw new Error('useGateway must be used within '); + return g; +} + +export function AppProviders({ + children, + gateway, +}: { + children: React.ReactNode; + gateway?: SessionGateway; +}) { + const client = useMemo(() => new QueryClient({ defaultOptions: { queries: { retry: 1, staleTime: 5_000 } } }), []); + const gw = useMemo(() => gateway ?? new MockSessionGateway(), [gateway]); + return ( + + + {children} + + + ); +} diff --git a/apps/mobile/src/data/queries.ts b/apps/mobile/src/data/queries.ts new file mode 100644 index 0000000..480ee45 --- /dev/null +++ b/apps/mobile/src/data/queries.ts @@ -0,0 +1,71 @@ +/** The only data entry points screens use. Lists go through TanStack Query; + * the live stream uses the ported pure transforms over the gateway. */ +import { useEffect, useRef, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import type { CreateSessionRequest, SandboxEvent, SessionState } from '@constructor/protocol'; +import { costDelta, foldEvent, type PendingRef } from '@/features/sessions/stream/transforms'; + +import { useGateway } from './provider'; + +export { useGateway } from './provider'; + +export function useSessions() { + const gw = useGateway(); + return useQuery({ queryKey: ['sessions'], queryFn: () => gw.listSessions() }); +} + +export function useSession(id: string) { + const gw = useGateway(); + return useQuery({ queryKey: ['session', id], queryFn: () => gw.getSession(id), enabled: !!id }); +} + +export function useCreateSession() { + const gw = useGateway(); + const qc = useQueryClient(); + return useMutation({ + mutationFn: (req: CreateSessionRequest) => gw.createSession(req), + onSuccess: () => qc.invalidateQueries({ queryKey: ['sessions'] }), + }); +} + +export type StreamStatus = 'connecting' | 'live' | 'closed'; + +export interface SessionStream { + status: StreamStatus; + state: SessionState | null; + events: SandboxEvent[]; + cost: number; +} + +export function useSessionStream(id: string): SessionStream { + const gw = useGateway(); + const [status, setStatus] = useState('connecting'); + const [state, setState] = useState(null); + const [events, setEvents] = useState([]); + const [cost, setCost] = useState(0); + const pending = useRef(null) as PendingRef; + + useEffect(() => { + if (!id) return; + pending.current = null; + setStatus('connecting'); + setEvents([]); + const handle = gw.subscribe(id, { + snapshot: (snap) => { + setState(snap.state); + setEvents(snap.replay.events); + setStatus('live'); + }, + event: (e) => { + setEvents((prev) => foldEvent(prev, e, pending)); + const d = costDelta(e); + if (d) setCost((c) => c + d); + }, + closed: () => setStatus('closed'), + }); + return () => handle.unsubscribe(); + }, [gw, id]); + + return { status, state, events, cost }; +} diff --git a/apps/mobile/src/features/auth/brand-backdrop.tsx b/apps/mobile/src/features/auth/brand-backdrop.tsx new file mode 100644 index 0000000..aab794d --- /dev/null +++ b/apps/mobile/src/features/auth/brand-backdrop.tsx @@ -0,0 +1,76 @@ +/** Phase-1 slice owner: auth. A soft brand wash built without expo-linear-gradient + * (not in the manifest, no new deps allowed): absolutely-positioned, heavily + * blurred-by-softness circular Views at low alpha. Tuned so the accent stays + * visible against both light (#ffffff) and dark (#000000) backgrounds. */ +import React from 'react'; +import { StyleSheet, View, useColorScheme } from 'react-native'; + +// The primary action color from `@/ui` Button (#208AEF) — kept in sync visually. +const ACCENT = '#208AEF'; + +function withAlpha(hex: string, alpha: number) { + const a = Math.round(Math.min(Math.max(alpha, 0), 1) * 255) + .toString(16) + .padStart(2, '0'); + return `${hex}${a}`; +} + +export function BrandBackdrop() { + const dark = useColorScheme() === 'dark'; + // Dark needs slightly stronger blobs to read against pure black; light stays soft. + const topAlpha = dark ? 0.22 : 0.14; + const midAlpha = dark ? 0.16 : 0.1; + const lowAlpha = dark ? 0.12 : 0.07; + + return ( + + {/* Large top-right wash */} + + {/* Mid-left glow */} + + {/* Soft bottom anchor behind the action */} + + + ); +} + +const styles = StyleSheet.create({ + clip: { overflow: 'hidden' }, + blob: { position: 'absolute' }, +}); diff --git a/apps/mobile/src/features/auth/github-mark.tsx b/apps/mobile/src/features/auth/github-mark.tsx new file mode 100644 index 0000000..a36d89b --- /dev/null +++ b/apps/mobile/src/features/auth/github-mark.tsx @@ -0,0 +1,94 @@ +/** Phase-1 slice owner: auth. GitHub glyph composed from pure RN Views — no SVG, + * no new deps (react-native-svg / expo-linear-gradient are not in the manifest). + * A stylized octocat silhouette: rounded head, two ear notches, a body, and a + * short tentacle. Reads as an intentional minimal mark, not a broken icon. */ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; + +export function GitHubMark({ size = 30, color = '#ffffff' }: { size?: number; color?: string }) { + // All sub-shapes are expressed as fractions of `size` so the mark scales cleanly. + const u = (n: number) => Math.round(size * n); + const tint = { backgroundColor: color }; + + return ( + + {/* Ears */} + + + {/* Head */} + + {/* Body / chin merges into the head, squared at the bottom for the silhouette */} + + {/* Tentacle */} + + + ); +} + +const styles = StyleSheet.create({ + shape: { position: 'absolute' }, +}); diff --git a/apps/mobile/src/features/auth/screen.tsx b/apps/mobile/src/features/auth/screen.tsx new file mode 100644 index 0000000..bd34af0 --- /dev/null +++ b/apps/mobile/src/features/auth/screen.tsx @@ -0,0 +1,187 @@ +/** Phase-1 slice owner: auth. Visual shell only — real OAuth is M1 (gated on + * deployment + a mobile GitHub OAuth App). The primary action is a MOCK that + * routes to '/'; no expo-auth-session, no real GitHub flow here. The in-progress + * state below is cosmetic so the screen feels production-real on Expo Go. + * + * Visual richness is built from `@/ui` primitives + RN core only: no + * expo-linear-gradient / react-native-svg (not in the manifest, no new deps). */ +import React from 'react'; +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; +import { Stack } from 'expo-router'; + +import { useAuth } from '@/data/auth'; +import { Button, Screen, useThemeColors } from '@/ui'; +import { Fonts, Spacing } from '@/constants/theme'; + +import { BrandBackdrop } from './brand-backdrop'; +import { GitHubMark } from './github-mark'; + +const ACCENT = '#208AEF'; + +export function SignInScreen() { + const { signIn } = useAuth(); + const c = useThemeColors(); + const [signingIn, setSigningIn] = React.useState(false); + + const timer = React.useRef | null>(null); + React.useEffect(() => () => { + if (timer.current) clearTimeout(timer.current); + }, []); + + // Mock-only: brief cosmetic "connecting" beat, then route home. The real + // GitHub OAuth handshake replaces this body wholesale in M1. + const onContinue = React.useCallback(() => { + if (signingIn) return; + setSigningIn(true); + // Flip mock auth; the router's Stack.Protected gate reveals the app and + // resolves to the `index` anchor. + timer.current = setTimeout(signIn, 550); + }, [signIn, signingIn]); + + return ( + + {/* Per-screen override only — does not touch the frozen src/app layout; + keeps the modal presentation, drops the stock "Sign in" header so the + brand lockup reads as the hero. */} + + + + {/* --- Brand lockup -------------------------------------------------- */} + + + C + + Constructor + + Control your background coding agents + + + + {/* --- Spacer ------------------------------------------------------- */} + + + {/* --- Auth affordance ---------------------------------------------- */} + + + + + + + GitHub + + Sign in to manage your agent sessions + + + {signingIn ? : null} + + +