feat(companion): implement ble

This commit is contained in:
2026-01-12 22:24:33 +00:00
parent 8305726f83
commit 75cfbe8dd4
55 changed files with 3457 additions and 142 deletions

24
aris/apps/companion/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
node_modules/
.expo/
dist/
npm-debug.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
web-build/
# expo router
expo-env.d.ts
ios
android
# macOS
.DS_Store
# Temporary files created by Metro to check the health of the file watcher
.metro-health-check*

View File

@@ -0,0 +1,48 @@
{
"expo": {
"name": "aris",
"slug": "aris",
"version": "1.0.0",
"scheme": "aris",
"platforms": ["ios", "android"],
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/favicon.png"
},
"plugins": ["expo-router"],
"experiments": {
"typedRoutes": true,
"tsconfigPaths": true
},
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"assetBundlePatterns": ["**/*"],
"ios": {
"supportsTablet": true,
"bundleIdentifier": "sh.nym.aris",
"backgroundModes": ["bluetooth-peripheral"],
"infoPlist": {
"NSBluetoothAlwaysUsageDescription": "Allow Bluetooth to connect to Iris Glass."
}
},
"android": {
"package": "sh.nym.aris",
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
}
}
}
}

View File

@@ -0,0 +1,81 @@
import { Ionicons } from "@expo/vector-icons";
import { Tabs } from "expo-router";
const iconSize = 22;
export default function TabLayout() {
return (
<Tabs screenOptions={{ headerShown: false }}>
<Tabs.Screen
name="orchestrator"
options={{
title: "Orchestrator",
tabBarLabel: "Orchestrator",
tabBarIcon: ({ color, focused }) => (
<Ionicons
color={color}
name={focused ? "flash" : "flash-outline"}
size={iconSize}
/>
),
}}
/>
<Tabs.Screen
name="index"
options={{
title: "BLE",
tabBarLabel: "BLE",
tabBarIcon: ({ color, focused }) => (
<Ionicons
color={color}
name={focused ? "bluetooth" : "bluetooth-outline"}
size={iconSize}
/>
),
}}
/>
<Tabs.Screen
name="todos"
options={{
title: "Todos",
tabBarLabel: "Todos",
tabBarIcon: ({ color, focused }) => (
<Ionicons
color={color}
name={focused ? "checkmark-done" : "checkmark-done-outline"}
size={iconSize}
/>
),
}}
/>
<Tabs.Screen
name="stocks"
options={{
title: "Stocks",
tabBarLabel: "Stocks",
tabBarIcon: ({ color, focused }) => (
<Ionicons
color={color}
name={focused ? "stats-chart" : "stats-chart-outline"}
size={iconSize}
/>
),
}}
/>
<Tabs.Screen
name="settings"
options={{
title: "Settings",
tabBarLabel: "Settings",
tabBarIcon: ({ color, focused }) => (
<Ionicons
color={color}
name={focused ? "settings" : "settings-outline"}
size={iconSize}
/>
),
}}
/>
</Tabs>
);
}

View File

@@ -0,0 +1,255 @@
import { useEffect, useState, type ReactNode } from "react";
import { Pressable, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import * as Clipboard from "expo-clipboard";
import { Container } from "@/components/Container";
import { SafeAreaScrollView } from "@/components/safe-area-scroll-view";
import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
import { Ble } from "@aris/ble";
import { useBleStore } from "@/store/ble";
import { cn } from "@/lib/utils";
import { Ionicons } from "@expo/vector-icons";
const formatTime = (timestamp: number | null) => {
if (!timestamp) {
return "Never";
}
return new Date(timestamp).toLocaleTimeString();
};
export default function BleScreen() {
const initialize = useBleStore((state) => state.initialize);
const isSupported = useBleStore((state) => state.isSupported);
const insets = useSafeAreaInsets();
useEffect(() => {
initialize();
}, [initialize]);
if (!isSupported) {
return (
<View className="flex flex-1 bg-white">
<Container className="px-8">
<View className="gap-1">
<Text variant="h3">BLE</Text>
<Text className="text-sm text-muted-foreground">
Bluetooth peripheral status and controls.
</Text>
</View>
<View className="mt-4 rounded-xl border border-muted bg-muted/20 px-4 py-3">
<Text className="text-sm font-medium">BLE Unavailable</Text>
<Text className="text-sm text-muted-foreground">
This page is currently supported on iOS only.
</Text>
</View>
</Container>
</View>
);
}
return (
<View className="flex flex-1 bg-white">
<View>
<SafeAreaScrollView>
<View className="gap-4" style={{ paddingBottom: insets.bottom + 24 }}>
<View className="gap-1 px-8">
<Text variant="h3">BLE</Text>
<Text className="text-sm text-muted-foreground">
Bluetooth peripheral status and controls.
</Text>
</View>
<View className="w-full h-px bg-muted" />
<ControlPanel />
<View className="w-full h-px bg-muted" />
<BleStatusSection />
<View className="w-full h-px bg-muted" />
<TelemetrySection />
<View className="w-full h-px bg-muted" />
<UuidsSection />
</View>
</SafeAreaScrollView>
</View>
</View>
);
}
function ValueRow({ label, value }: { label: ReactNode; value: ReactNode }) {
return (
<View className="flex flex-row items-center justify-between">
{typeof label === "string" ? (
<Text className="text-sm text-muted-foreground">{label}</Text>
) : (
label
)}
{typeof value === "string" || typeof value === "number" ? (
<Text className="text-sm">{value}</Text>
) : (
value
)}
</View>
);
}
function BleStatusSection() {
const bluetoothState = useBleStore((state) => state.bluetoothState);
const isSubscribed = useBleStore((state) => state.isSubscribed);
const subscribedCount = useBleStore((state) => state.subscribedCount);
const connectionLabel = isSubscribed ? "Connected" : "Not Connected";
return (
<View className="px-8">
<Text variant="h4" className="mb-2 font-medium">
Status
</Text>
<View className="gap-1">
<ValueRow label="State" value={connectionLabel} />
<ValueRow label="Bluetooth" value={bluetoothState} />
<ValueRow label="Subscribers" value={subscribedCount} />
</View>
</View>
);
}
function ControlPanel() {
const advertisingEnabled = useBleStore((state) => state.advertisingEnabled);
const setAdvertisingEnabled = useBleStore(
(state) => state.setAdvertisingEnabled,
);
const wifiRequested = useBleStore((state) => state.wifiRequested);
const setWifiRequested = useBleStore((state) => state.setWifiRequested);
const sendFixtureFeedNow = useBleStore((state) => state.sendFixtureFeedNow);
const handleCopyUUIDs = async () => {
const uuids = Ble.getUuids();
const text = [
`SERVICE_UUID=${uuids.serviceUUID}`,
`FEED_TX_UUID=${uuids.feedTxUUID}`,
`CONTROL_RX_UUID=${uuids.controlRxUUID}`,
`WIFI_REQUEST_TX_UUID=${uuids.wifiRequestTxUUID}`,
].join("\n");
await Clipboard.setStringAsync(text);
};
return (
<View className="px-8">
<Text variant="h4" className="mb-3 font-medium">
Control Panel
</Text>
<View className="flex-row gap-3 w-full mb-3">
<ControlTile
icon={<Ionicons name="radio-outline" size={20} />}
label="Advertising"
checked={advertisingEnabled}
onCheckChange={setAdvertisingEnabled}
/>
<ControlTile
icon={<Ionicons name="wifi-outline" size={20} />}
label="WiFi"
checked={wifiRequested}
onCheckChange={setWifiRequested}
/>
</View>
<View className="gap-2">
<Button onPress={sendFixtureFeedNow} variant="outline">
<Text>Send Fixture Feed Now</Text>
</Button>
<Button onPress={handleCopyUUIDs} variant="outline">
<Text>Copy UUIDs</Text>
</Button>
</View>
</View>
);
}
function ControlTile({
icon,
label,
checked,
onCheckChange,
}: {
icon: ReactNode;
label: ReactNode;
checked: boolean;
onCheckChange: (checked: boolean) => void;
}) {
const [isPressed, setIsPressed] = useState(false);
return (
<Pressable
onPressIn={() => {
setIsPressed(true);
}}
onPressOut={() => {
setIsPressed(false);
}}
onPress={() => {
onCheckChange(!checked);
}}
className={cn(
"flex-1 items-start justify-center rounded-md px-3 py-2 gap-1",
checked ? "border-2 border-primary" : "border border-border",
{ "bg-accent": isPressed },
)}
>
{icon}
<Text className={cn({ "font-bold": checked })}>{label}</Text>
</Pressable>
);
}
function TelemetrySection() {
const lastMsgIdSent = useBleStore((state) => state.lastMsgIdSent);
const lastPingAt = useBleStore((state) => state.lastPingAt);
const lastDataAt = useBleStore((state) => state.lastDataAt);
const lastNotifyAt = useBleStore((state) => state.lastNotifyAt);
const notifyQueueDepth = useBleStore((state) => state.notifyQueueDepth);
const droppedNotifyPackets = useBleStore(
(state) => state.droppedNotifyPackets,
);
const lastCommand = useBleStore((state) => state.lastCommand);
return (
<View className="px-8">
<Text variant="h4" className="mb-2 font-medium">
Telemetry
</Text>
<View className="gap-1">
<ValueRow label="Last msgId" value={lastMsgIdSent} />
<ValueRow label="Last ping" value={formatTime(lastPingAt)} />
<ValueRow label="Last data" value={formatTime(lastDataAt)} />
<ValueRow label="Notify queue" value={notifyQueueDepth} />
{droppedNotifyPackets > 0 ? (
<ValueRow
label="Dropped notify packets"
value={droppedNotifyPackets}
/>
) : null}
<ValueRow label="Last notify" value={formatTime(lastNotifyAt)} />
{lastCommand ? (
<ValueRow label="Last control" value={lastCommand} />
) : null}
</View>
</View>
);
}
function UuidsSection() {
const uuids = Ble.getUuids();
return (
<View className="px-8">
<Text variant="h4" className="mb-2 font-medium">
UUIDs
</Text>
<Text selectable className="text-xs text-muted-foreground">
{`SERVICE_UUID=${uuids.serviceUUID}\nFEED_TX_UUID=${uuids.feedTxUUID}\nCONTROL_RX_UUID=${uuids.controlRxUUID}\nWIFI_REQUEST_TX_UUID=${uuids.wifiRequestTxUUID}`}
</Text>
</View>
);
}

View File

@@ -0,0 +1,22 @@
import { View } from "react-native";
import { Container } from "@/components/Container";
import { Text } from "@/components/ui/text";
export default function OrchestratorScreen() {
return (
<View className={styles.container}>
<Container>
<Text variant="h3">Orchestrator</Text>
<Text className={styles.subtitle}>
Port of SwiftUI OrchestratorView will live here.
</Text>
</Container>
</View>
);
}
const styles = {
container: "flex flex-1 bg-white",
subtitle: "mt-2 text-sm text-muted-foreground",
};

View File

@@ -0,0 +1,22 @@
import { View } from "react-native";
import { Container } from "@/components/Container";
import { Text } from "@/components/ui/text";
export default function SettingsScreen() {
return (
<View className={styles.container}>
<Container>
<Text variant="h3">Settings</Text>
<Text className={styles.subtitle}>
Port of SwiftUI SettingsView will live here.
</Text>
</Container>
</View>
);
}
const styles = {
container: "flex flex-1 bg-white",
subtitle: "mt-2 text-sm text-muted-foreground",
};

View File

@@ -0,0 +1,22 @@
import { View } from "react-native";
import { Container } from "@/components/Container";
import { Text } from "@/components/ui/text";
export default function StocksScreen() {
return (
<View className={styles.container}>
<Container>
<Text variant="h3">Stocks</Text>
<Text className={styles.subtitle}>
Port of SwiftUI StockSettingsView will live here.
</Text>
</Container>
</View>
);
}
const styles = {
container: "flex flex-1 bg-white",
subtitle: "mt-2 text-sm text-muted-foreground",
};

View File

@@ -0,0 +1,22 @@
import { View } from "react-native";
import { Container } from "@/components/Container";
import { Text } from "@/components/ui/text";
export default function TodosScreen() {
return (
<View className={styles.container}>
<Container>
<Text variant="h3">Todos</Text>
<Text className={styles.subtitle}>
Port of SwiftUI TodosView will live here.
</Text>
</Container>
</View>
);
}
const styles = {
container: "flex flex-1 bg-white",
subtitle: "mt-2 text-sm text-muted-foreground",
};

View File

@@ -0,0 +1,46 @@
import { ScrollViewStyleReset } from 'expo-router/html';
// This file is web-only and used to configure the root HTML for every
// web page during static rendering.
// The contents of this function only run in Node.js environments and
// do not have access to the DOM or browser APIs.
export default function Root({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
{/*
This viewport disables scaling which makes the mobile website act more like a native app.
However this does reduce built-in accessibility. If you want to enable scaling, use this instead:
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
*/}
<meta
name="viewport"
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1.00001,viewport-fit=cover"
/>
{/*
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
*/}
<ScrollViewStyleReset />
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
{/* Add any additional <head> elements that you want globally available on web... */}
</head>
<body>{children}</body>
</html>
);
}
const responsiveBackground = `
body {
background-color: #fff;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #000;
}
}`;

View File

@@ -0,0 +1,26 @@
import { Link, Stack } from 'expo-router';
import { Text, View } from 'react-native';
import { Container } from '@/components/Container';
export default function NotFoundScreen() {
return (
<View className={styles.container}>
<Stack.Screen options={{ title: 'Oops!' }} />
<Container>
<Text className={styles.title}>{"This screen doesn't exist."}</Text>
<Link href="/" className={styles.link}>
<Text className={styles.linkText}>Go to home screen!</Text>
</Link>
</Container>
</View>
);
}
const styles = {
container: `flex flex-1 bg-white`,
title: `text-xl font-bold`,
link: `mt-4 pt-4`,
linkText: `text-base text-[#2e78b7]`,
};

View File

@@ -0,0 +1,15 @@
import "../global.css";
import { Stack } from "expo-router";
import { PortalHost } from "@rn-primitives/portal";
export default function Layout() {
return (
<>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
</Stack>
<PortalHost />
</>
);
}

View File

@@ -0,0 +1,23 @@
import { View } from 'react-native';
import { Stack, useLocalSearchParams } from 'expo-router';
import { Container } from '@/components/Container';
import { ScreenContent } from '@/components/ScreenContent';
export default function Details() {
const { name } = useLocalSearchParams();
return (
<View className={styles.container}>
<Stack.Screen options={{ title: 'Details' }} />
<Container>
<ScreenContent path="screens/details.tsx" title={`Showing details for user ${name}`} />
</Container>
</View>
);
}
const styles = {
container: 'flex flex-1 bg-white',
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -0,0 +1,12 @@
module.exports = function (api) {
api.cache(true);
let plugins = [];
plugins.push('react-native-worklets/plugin');
return {
presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'],
plugins,
};
};

2062
aris/apps/companion/bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,42 @@
// This is an optional configuration file used primarily for debugging purposes when reporting issues.
// It is safe to delete this file as it does not affect the functionality of your application.
{
"cesVersion": "2.20.1",
"projectName": "aris",
"packages": [
{
"name": "expo-router",
"type": "navigation",
"options": {
"type": "stack"
}
},
{
"name": "nativewind",
"type": "styling"
},
{
"name": "zustand",
"type": "state-management"
}
],
"flags": {
"noGit": false,
"noInstall": false,
"overwrite": false,
"importAlias": true,
"packageManager": "bun",
"eas": false,
"publish": false
},
"packageManager": {
"type": "bun",
"version": "1.2.21"
},
"os": {
"type": "Darwin",
"platform": "darwin",
"arch": "arm64",
"kernelVersion": "24.5.0"
}
}

View File

@@ -0,0 +1,19 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "global.css",
"baseColor": "neutral",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

View File

@@ -0,0 +1,24 @@
import { forwardRef } from 'react';
import { Text, TouchableOpacity, TouchableOpacityProps, View } from 'react-native';
type ButtonProps = {
title: string;
} & TouchableOpacityProps;
export const Button = forwardRef<View, ButtonProps>(({ title, ...touchableProps }, ref) => {
return (
<TouchableOpacity
ref={ref}
{...touchableProps}
className={`${styles.button} ${touchableProps.className}`}>
<Text className={styles.buttonText}>{title}</Text>
</TouchableOpacity>
);
});
Button.displayName = 'Button';
const styles = {
button: 'items-center bg-indigo-500 rounded-[28px] shadow-md p-4',
buttonText: 'text-white text-lg font-semibold text-center',
};

View File

@@ -0,0 +1,16 @@
import { cn } from "@/lib/utils";
import { SafeAreaView } from "react-native-safe-area-context";
export const Container = ({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) => {
return (
<SafeAreaView className={cn("flex flex-1", className)}>
{children}
</SafeAreaView>
);
};

View File

@@ -0,0 +1,29 @@
import { Text, View } from 'react-native';
export const EditScreenInfo = ({ path }: { path: string }) => {
const title = 'Open up the code for this screen:';
const description =
'Change any of the text, save the file, and your app will automatically update.';
return (
<View>
<View className={styles.getStartedContainer}>
<Text className={styles.getStartedText}>{title}</Text>
<View className={styles.codeHighlightContainer + styles.homeScreenFilename}>
<Text>{path}</Text>
</View>
<Text className={styles.getStartedText}>{description}</Text>
</View>
</View>
);
};
const styles = {
codeHighlightContainer: `rounded-md px-1`,
getStartedContainer: `items-center mx-12`,
getStartedText: `text-lg leading-6 text-center`,
helpContainer: `items-center mx-5 mt-4`,
helpLink: `py-4`,
helpLinkText: `text-center`,
homeScreenFilename: `my-2`,
};

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { Text, View } from 'react-native';
import { EditScreenInfo } from './EditScreenInfo';
type ScreenContentProps = {
title: string;
path: string;
children?: React.ReactNode;
};
export const ScreenContent = ({ title, path, children }: ScreenContentProps) => {
return (
<View className={styles.container}>
<Text className={styles.title}>{title}</Text>
<View className={styles.separator} />
<EditScreenInfo path={path} />
{children}
</View>
);
};
const styles = {
container: `items-center flex-1 justify-center bg-white`,
separator: `h-[1px] my-7 w-4/5 bg-gray-200`,
title: `text-xl font-bold`,
};

View File

@@ -0,0 +1,19 @@
import { ScrollView } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export function SafeAreaScrollView({ children }: React.PropsWithChildren) {
const insets = useSafeAreaInsets();
return (
<ScrollView
style={{
paddingTop: insets.top,
paddingBottom: insets.bottom,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
{children}
</ScrollView>
);
}

View File

@@ -0,0 +1,108 @@
import { TextClassContext } from '@/components/ui/text';
import { cn } from '@/lib/utils';
import { cva, type VariantProps } from 'class-variance-authority';
import { Platform, Pressable } from 'react-native';
const buttonVariants = cva(
cn(
'group shrink-0 flex-row items-center justify-center gap-2 rounded-md shadow-none',
Platform.select({
web: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
})
),
{
variants: {
variant: {
default: cn(
'bg-primary active:bg-primary/90 shadow-sm shadow-black/5',
Platform.select({ web: 'hover:bg-primary/90' })
),
destructive: cn(
'bg-destructive active:bg-destructive/90 dark:bg-destructive/60 shadow-sm shadow-black/5',
Platform.select({
web: 'hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40',
})
),
outline: cn(
'border-border bg-background active:bg-accent dark:bg-input/30 dark:border-input dark:active:bg-input/50 border shadow-sm shadow-black/5',
Platform.select({
web: 'hover:bg-accent dark:hover:bg-input/50',
})
),
secondary: cn(
'bg-secondary active:bg-secondary/80 shadow-sm shadow-black/5',
Platform.select({ web: 'hover:bg-secondary/80' })
),
ghost: cn(
'active:bg-accent dark:active:bg-accent/50',
Platform.select({ web: 'hover:bg-accent dark:hover:bg-accent/50' })
),
link: '',
},
size: {
default: cn('h-10 px-4 py-2 sm:h-9', Platform.select({ web: 'has-[>svg]:px-3' })),
sm: cn('h-9 gap-1.5 rounded-md px-3 sm:h-8', Platform.select({ web: 'has-[>svg]:px-2.5' })),
lg: cn('h-11 rounded-md px-6 sm:h-10', Platform.select({ web: 'has-[>svg]:px-4' })),
icon: 'h-10 w-10 sm:h-9 sm:w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
const buttonTextVariants = cva(
cn(
'text-foreground text-sm font-medium',
Platform.select({ web: 'pointer-events-none transition-colors' })
),
{
variants: {
variant: {
default: 'text-primary-foreground',
destructive: 'text-white',
outline: cn(
'group-active:text-accent-foreground',
Platform.select({ web: 'group-hover:text-accent-foreground' })
),
secondary: 'text-secondary-foreground',
ghost: 'group-active:text-accent-foreground',
link: cn(
'text-primary group-active:underline',
Platform.select({ web: 'underline-offset-4 hover:underline group-hover:underline' })
),
},
size: {
default: '',
sm: '',
lg: '',
icon: '',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
type ButtonProps = React.ComponentProps<typeof Pressable> &
React.RefAttributes<typeof Pressable> &
VariantProps<typeof buttonVariants>;
function Button({ className, variant, size, ...props }: ButtonProps) {
return (
<TextClassContext.Provider value={buttonTextVariants({ variant, size })}>
<Pressable
className={cn(props.disabled && 'opacity-50', buttonVariants({ variant, size }), className)}
role="button"
{...props}
/>
</TextClassContext.Provider>
);
}
export { Button, buttonTextVariants, buttonVariants };
export type { ButtonProps };

View File

@@ -0,0 +1,52 @@
import { Text, TextClassContext } from '@/components/ui/text';
import { cn } from '@/lib/utils';
import { View, type ViewProps } from 'react-native';
function Card({ className, ...props }: ViewProps & React.RefAttributes<View>) {
return (
<TextClassContext.Provider value="text-card-foreground">
<View
className={cn(
'bg-card border-border flex flex-col gap-6 rounded-xl border py-6 shadow-sm shadow-black/5',
className
)}
{...props}
/>
</TextClassContext.Provider>
);
}
function CardHeader({ className, ...props }: ViewProps & React.RefAttributes<View>) {
return <View className={cn('flex flex-col gap-1.5 px-6', className)} {...props} />;
}
function CardTitle({
className,
...props
}: React.ComponentProps<typeof Text> & React.RefAttributes<Text>) {
return (
<Text
role="heading"
aria-level={3}
className={cn('font-semibold leading-none', className)}
{...props}
/>
);
}
function CardDescription({
className,
...props
}: React.ComponentProps<typeof Text> & React.RefAttributes<Text>) {
return <Text className={cn('text-muted-foreground text-sm', className)} {...props} />;
}
function CardContent({ className, ...props }: ViewProps & React.RefAttributes<View>) {
return <View className={cn('px-6', className)} {...props} />;
}
function CardFooter({ className, ...props }: ViewProps & React.RefAttributes<View>) {
return <View className={cn('flex flex-row items-center px-6', className)} {...props} />;
}
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };

View File

@@ -0,0 +1,36 @@
import { cn } from '@/lib/utils';
import * as SwitchPrimitives from '@rn-primitives/switch';
import { Platform } from 'react-native';
function Switch({
className,
...props
}: SwitchPrimitives.RootProps & React.RefAttributes<SwitchPrimitives.RootRef>) {
return (
<SwitchPrimitives.Root
className={cn(
'flex h-[1.15rem] w-8 shrink-0 flex-row items-center rounded-full border border-transparent shadow-sm shadow-black/5',
Platform.select({
web: 'focus-visible:border-ring focus-visible:ring-ring/50 peer inline-flex outline-none transition-all focus-visible:ring-[3px] disabled:cursor-not-allowed',
}),
props.checked ? 'bg-primary' : 'bg-input dark:bg-input/80',
props.disabled && 'opacity-50',
className
)}
{...props}>
<SwitchPrimitives.Thumb
className={cn(
'bg-background size-4 rounded-full transition-transform',
Platform.select({
web: 'pointer-events-none block ring-0',
}),
props.checked
? 'dark:bg-primary-foreground translate-x-3.5'
: 'dark:bg-foreground translate-x-0'
)}
/>
</SwitchPrimitives.Root>
);
}
export { Switch };

View File

@@ -0,0 +1,89 @@
import { cn } from '@/lib/utils';
import * as Slot from '@rn-primitives/slot';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { Platform, Text as RNText, type Role } from 'react-native';
const textVariants = cva(
cn(
'text-foreground text-base',
Platform.select({
web: 'select-text',
})
),
{
variants: {
variant: {
default: '',
h1: cn(
'text-center text-4xl font-extrabold tracking-tight',
Platform.select({ web: 'scroll-m-20 text-balance' })
),
h2: cn(
'border-border border-b pb-2 text-3xl font-semibold tracking-tight',
Platform.select({ web: 'scroll-m-20 first:mt-0' })
),
h3: cn('text-2xl font-semibold tracking-tight', Platform.select({ web: 'scroll-m-20' })),
h4: cn('text-xl font-semibold tracking-tight', Platform.select({ web: 'scroll-m-20' })),
p: 'mt-3 leading-7 sm:mt-6',
blockquote: 'mt-4 border-l-2 pl-3 italic sm:mt-6 sm:pl-6',
code: cn(
'bg-muted relative rounded px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold'
),
lead: 'text-muted-foreground text-xl',
large: 'text-lg font-semibold',
small: 'text-sm font-medium leading-none',
muted: 'text-muted-foreground text-sm',
},
},
defaultVariants: {
variant: 'default',
},
}
);
type TextVariantProps = VariantProps<typeof textVariants>;
type TextVariant = NonNullable<TextVariantProps['variant']>;
const ROLE: Partial<Record<TextVariant, Role>> = {
h1: 'heading',
h2: 'heading',
h3: 'heading',
h4: 'heading',
blockquote: Platform.select({ web: 'blockquote' as Role }),
code: Platform.select({ web: 'code' as Role }),
};
const ARIA_LEVEL: Partial<Record<TextVariant, string>> = {
h1: '1',
h2: '2',
h3: '3',
h4: '4',
};
const TextClassContext = React.createContext<string | undefined>(undefined);
function Text({
className,
asChild = false,
variant = 'default',
...props
}: React.ComponentProps<typeof RNText> &
TextVariantProps &
React.RefAttributes<RNText> & {
asChild?: boolean;
}) {
const textClass = React.useContext(TextClassContext);
const Component = asChild ? Slot.Text : RNText;
return (
<Component
className={cn(textVariants({ variant }), textClass, className)}
role={variant ? ROLE[variant] : undefined}
aria-level={variant ? ARIA_LEVEL[variant] : undefined}
{...props}
/>
);
}
export { Text, TextClassContext };

View File

@@ -0,0 +1,15 @@
/* eslint-env node */
const { defineConfig } = require('eslint/config');
const expoConfig = require('eslint-config-expo/flat');
module.exports = defineConfig([
expoConfig,
{
ignores: ['dist/*'],
},
{
rules: {
'react/display-name': 'off',
},
},
]);

View File

@@ -0,0 +1,58 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 63%;
--radius: 0.625rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
.dark:root {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 70.9% 59.4%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 300 0% 45%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}

View File

@@ -0,0 +1,51 @@
export const fullFeedFixture = {
schema: 1,
generated_at: 1767716400,
feed: [
{
id: "demo:welcome",
type: "INFO",
title: "Glass Now online",
subtitle: "Connected to iPhone",
priority: 0.8,
ttl_sec: 86400,
bucket: "RIGHT_NOW",
actions: ["DISMISS"],
},
{
id: "cal:demo:1767717000",
type: "CALENDAR_EVENT",
title: "Team Sync",
subtitle: "",
priority: 0.7,
ttl_sec: 5400,
starts_at: 1767717000,
bucket: "FYI",
actions: ["DISMISS"],
},
{
id: "demo:next",
type: "INFO",
title: "Next: Calendar",
subtitle: "Then Weather + POI",
priority: 0.4,
ttl_sec: 86400,
bucket: "FYI",
actions: ["DISMISS"],
},
{
id: "music:now:demo",
type: "NOW_PLAYING",
title: "Midnight City",
subtitle: "M83 • Hurry Up, We're Dreaming",
priority: 0.35,
ttl_sec: 30,
bucket: "FYI",
actions: ["DISMISS"],
},
],
meta: {
winner_id: "demo:welcome",
unread_count: 4,
},
} as const;

View File

@@ -0,0 +1,81 @@
import { DarkTheme, DefaultTheme, type Theme } from "@react-navigation/native";
export const THEME = {
light: {
background: "hsl(0 0% 100%)",
foreground: "hsl(0 0% 3.9%)",
card: "hsl(0 0% 100%)",
cardForeground: "hsl(0 0% 3.9%)",
popover: "hsl(0 0% 100%)",
popoverForeground: "hsl(0 0% 3.9%)",
primary: "hsl(0 0% 9%)",
primaryForeground: "hsl(0 0% 98%)",
secondary: "hsl(0 0% 96.1%)",
secondaryForeground: "hsl(0 0% 9%)",
muted: "hsl(0 0% 96.1%)",
mutedForeground: "hsl(0 0% 45.1%)",
accent: "hsl(0 0% 96.1%)",
accentForeground: "hsl(0 0% 9%)",
destructive: "hsl(0 84.2% 60.2%)",
border: "hsl(0 0% 89.8%)",
input: "hsl(0 0% 89.8%)",
ring: "hsl(0 0% 63%)",
radius: "0.625rem",
chart1: "hsl(12 76% 61%)",
chart2: "hsl(173 58% 39%)",
chart3: "hsl(197 37% 24%)",
chart4: "hsl(43 74% 66%)",
chart5: "hsl(27 87% 67%)",
},
dark: {
background: "hsl(0 0% 3.9%)",
foreground: "hsl(0 0% 98%)",
card: "hsl(0 0% 3.9%)",
cardForeground: "hsl(0 0% 98%)",
popover: "hsl(0 0% 3.9%)",
popoverForeground: "hsl(0 0% 98%)",
primary: "hsl(0 0% 98%)",
primaryForeground: "hsl(0 0% 9%)",
secondary: "hsl(0 0% 14.9%)",
secondaryForeground: "hsl(0 0% 98%)",
muted: "hsl(0 0% 14.9%)",
mutedForeground: "hsl(0 0% 63.9%)",
accent: "hsl(0 0% 14.9%)",
accentForeground: "hsl(0 0% 98%)",
destructive: "hsl(0 70.9% 59.4%)",
border: "hsl(0 0% 14.9%)",
input: "hsl(0 0% 14.9%)",
ring: "hsl(300 0% 45%)",
radius: "0.625rem",
chart1: "hsl(220 70% 50%)",
chart2: "hsl(160 60% 45%)",
chart3: "hsl(30 80% 55%)",
chart4: "hsl(280 65% 60%)",
chart5: "hsl(340 75% 55%)",
},
};
export const NAV_THEME: Record<"light" | "dark", Theme> = {
light: {
...DefaultTheme,
colors: {
background: THEME.light.background,
border: THEME.light.border,
card: THEME.light.card,
notification: THEME.light.destructive,
primary: THEME.light.primary,
text: THEME.light.foreground,
},
},
dark: {
...DarkTheme,
colors: {
background: THEME.dark.background,
border: THEME.dark.border,
card: THEME.dark.card,
notification: THEME.dark.destructive,
primary: THEME.dark.primary,
text: THEME.dark.foreground,
},
},
};

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,24 @@
// Learn more https://docs.expo.io/guides/customizing-metro
const path = require("path");
const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require("nativewind/metro");
/** @type {import('expo/metro-config').MetroConfig} */
const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, "../..");
const config = getDefaultConfig(projectRoot);
config.watchFolders = [workspaceRoot];
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, "node_modules"),
path.resolve(workspaceRoot, "node_modules"),
];
config.resolver.disableHierarchicalLookup = true;
module.exports = withNativeWind(config, {
input: "./global.css",
inlineRem: 16,
});

View File

@@ -0,0 +1,2 @@
// @ts-ignore
/// <reference types="nativewind/types" />

View File

@@ -0,0 +1,57 @@
{
"name": "aris",
"version": "1.0.0",
"main": "expo-router/entry",
"scripts": {
"android": "expo run:android",
"ios": "expo run:ios",
"start": "expo start",
"prebuild": "expo prebuild",
"lint": "eslint \"**/*.{js,jsx,ts,tsx}\" && prettier -c \"**/*.{js,jsx,ts,tsx,json}\"",
"format": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix && prettier \"**/*.{js,jsx,ts,tsx,json}\" --write",
"web": "expo start --web"
},
"dependencies": {
"@aris/ble": "*",
"@expo/vector-icons": "^15.0.2",
"@react-navigation/native": "^7.1.6",
"@rn-primitives/portal": "^1.3.0",
"@rn-primitives/slot": "^1.2.0",
"@rn-primitives/switch": "^1.2.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"expo": "^54.0.0",
"expo-clipboard": "~8.0.8",
"expo-constants": "~18.0.9",
"expo-linking": "~8.0.8",
"expo-router": "~6.0.10",
"expo-status-bar": "~3.0.8",
"expo-system-ui": "~6.0.7",
"expo-web-browser": "~15.0.7",
"nativewind": "latest",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-web": "^0.21.0",
"react-native-worklets": "0.5.1",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"zustand": "^4.5.1"
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@types/react": "~19.1.10",
"eslint": "^9.25.1",
"eslint-config-expo": "~10.0.0",
"eslint-config-prettier": "^10.1.2",
"prettier": "^3.2.5",
"tailwindcss": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.5.11",
"typescript": "~5.9.2"
},
"private": true
}

View File

@@ -0,0 +1,10 @@
module.exports = {
printWidth: 100,
tabWidth: 2,
singleQuote: true,
bracketSameLine: true,
trailingComma: 'es5',
plugins: [require.resolve('prettier-plugin-tailwindcss')],
tailwindAttributes: ['className'],
};

View File

@@ -0,0 +1,61 @@
import type { EventSubscription } from "expo-modules-core";
import { create } from "zustand";
import { fullFeedFixture } from "@/lib/ble/fixtures";
import { Ble, defaultBleState } from "@aris/ble";
import type { BleStatePayload } from "@aris/ble";
export type BleStoreState = BleStatePayload & {
isSupported: boolean;
isReady: boolean;
initialize: () => void;
setAdvertisingEnabled: (enabled: boolean) => void;
setWifiRequested: (requested: boolean) => void;
sendFixtureFeedNow: () => void;
};
let subscription: EventSubscription | null = null;
const noopSubscription: EventSubscription = {
remove: () => undefined,
};
export const useBleStore = create<BleStoreState>((set, get) => ({
...defaultBleState,
isSupported: Ble.isSupported,
isReady: false,
initialize: () => {
if (!Ble.isSupported || get().isReady) {
return;
}
const state = Ble.getState();
set({ ...state, isReady: true });
subscription?.remove();
subscription =
Ble.addStateListener?.((nextState) => {
set({ ...nextState });
}) ?? noopSubscription;
Ble.start();
},
setAdvertisingEnabled: (enabled) => {
if (!Ble.isSupported) {
return;
}
set({ advertisingEnabled: enabled });
Ble.setAdvertisingEnabled(enabled);
},
setWifiRequested: (requested) => {
if (!Ble.isSupported) {
return;
}
set({ wifiRequested: requested });
Ble.setWifiRequested(requested);
},
sendFixtureFeedNow: () => {
if (!Ble.isSupported) {
return;
}
const payload = JSON.stringify(fullFeedFixture);
Ble.sendOpaque(payload, 1);
},
}));

View File

@@ -0,0 +1,15 @@
import { create } from 'zustand';
export interface BearState {
bears: number;
increasePopulation: () => void;
removeAllBears: () => void;
updateBears: (newBears: number) => void;
}
export const useStore = create<BearState>((set) => ({
bears: 0,
increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 }),
updateBears: (newBears) => set({ bears: newBears }),
}));

View File

@@ -0,0 +1,73 @@
const { hairlineWidth } = require("nativewind/theme");
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: "class",
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
presets: [require("nativewind/preset")],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
borderWidth: {
hairline: hairlineWidth(),
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
future: {
hoverOnlyWhenSupported: true,
},
plugins: [require("tailwindcss-animate")],
};

View File

@@ -0,0 +1,11 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"paths": {
"@/*": ["*"]
}
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"]
}