feat: add zero
This commit is contained in:
11
.env.example
Normal file
11
.env.example
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
DATABASE_URL=postgresql://postgres@localhost:5432/money
|
||||||
|
|
||||||
|
OAUTH_CLIENT_ID=
|
||||||
|
OAUTH_CLIENT_SECRET=
|
||||||
|
OAUTH_DISCOVERY_URL=https://www.example.com/.well-known/openid-configuration
|
||||||
|
|
||||||
|
ZERO_UPSTREAM_DB=postgresql://postgres@localhost:5432/money
|
||||||
|
ZERO_REPLICA_FILE="/tmp/sync-replica.db"
|
||||||
|
|
||||||
|
ZERO_GET_QUERIES_URL="http://localhost:3000/api/zero/get-queries"
|
||||||
|
|
||||||
17
api/package.json
Normal file
17
api/package.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "@money/api",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hono/node-server": "^1.19.5",
|
||||||
|
"@money/shared": "link:../shared",
|
||||||
|
"better-auth": "^1.3.27",
|
||||||
|
"hono": "^4.9.12"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.7.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ export const auth = betterAuth({
|
|||||||
database: new Pool({
|
database: new Pool({
|
||||||
connectionString: process.env.DATABASE_URL,
|
connectionString: process.env.DATABASE_URL,
|
||||||
}),
|
}),
|
||||||
|
trustedOrigins: ["money://", "http://localhost:8081"],
|
||||||
plugins: [
|
plugins: [
|
||||||
expo(),
|
expo(),
|
||||||
genericOAuth({
|
genericOAuth({
|
||||||
5
api/src/hono.ts
Normal file
5
api/src/hono.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { AuthData } from "@money/shared/auth";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
|
||||||
|
export const getHono = () =>
|
||||||
|
new Hono<{ Variables: { auth: AuthData | null } }>();
|
||||||
55
api/src/index.ts
Normal file
55
api/src/index.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { serve } from "@hono/node-server";
|
||||||
|
import { authDataSchema } from "@money/shared/auth";
|
||||||
|
import { cors } from "hono/cors";
|
||||||
|
import { auth } from "./auth";
|
||||||
|
import { getHono } from "./hono";
|
||||||
|
import { zero } from "./zero";
|
||||||
|
|
||||||
|
const app = getHono();
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
"/api/*",
|
||||||
|
cors({
|
||||||
|
origin: (origin) => origin ?? "",
|
||||||
|
allowMethods: ["POST", "GET", "OPTIONS"],
|
||||||
|
allowHeaders: ["Content-Type", "Authorization"],
|
||||||
|
credentials: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.on(["GET", "POST"], "/api/auth/*", (c) => auth.handler(c.req.raw));
|
||||||
|
|
||||||
|
app.use("*", async (c, next) => {
|
||||||
|
const authHeader = c.req.raw.headers.get("Authorization");
|
||||||
|
const cookie = authHeader?.split("Bearer ")[1];
|
||||||
|
|
||||||
|
const newHeaders = new Headers(c.req.raw.headers);
|
||||||
|
|
||||||
|
if (cookie) {
|
||||||
|
newHeaders.set("Cookie", cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await auth.api.getSession({ headers: newHeaders });
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
c.set("auth", null);
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
c.set("auth", authDataSchema.parse(session));
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.route("/api/zero", zero);
|
||||||
|
|
||||||
|
app.get("/api", (c) => c.text("OK"));
|
||||||
|
app.get("/", (c) => c.text("OK"));
|
||||||
|
|
||||||
|
serve(
|
||||||
|
{
|
||||||
|
fetch: app.fetch,
|
||||||
|
port: 3000,
|
||||||
|
},
|
||||||
|
(info) => {
|
||||||
|
console.log(`Server is running on ${info.address}:${info.port}`);
|
||||||
|
},
|
||||||
|
);
|
||||||
96
api/src/zero.ts
Normal file
96
api/src/zero.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import {
|
||||||
|
type ReadonlyJSONValue,
|
||||||
|
type ServerTransaction,
|
||||||
|
withValidation,
|
||||||
|
} from "@rocicorp/zero";
|
||||||
|
import {
|
||||||
|
handleGetQueriesRequest,
|
||||||
|
PushProcessor,
|
||||||
|
ZQLDatabase,
|
||||||
|
} from "@rocicorp/zero/server";
|
||||||
|
import {
|
||||||
|
// createMutators as createMutatorsShared,
|
||||||
|
// isLoggedIn,
|
||||||
|
// type Mutators,
|
||||||
|
queries,
|
||||||
|
schema,
|
||||||
|
type Schema,
|
||||||
|
} from "@money/shared";
|
||||||
|
import type { AuthData } from "@money/shared/auth";
|
||||||
|
// import { auditLogs } from "@zslack/shared/db";
|
||||||
|
// import {
|
||||||
|
// NodePgConnection,
|
||||||
|
// type NodePgZeroTransaction,
|
||||||
|
// } from "drizzle-zero/node-postgres";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
// import { db } from "./db";
|
||||||
|
import { getHono } from "./hono";
|
||||||
|
|
||||||
|
// type ServerTx = ServerTransaction<Schema, NodePgZeroTransaction<typeof db>>;
|
||||||
|
|
||||||
|
// const processor = new PushProcessor(
|
||||||
|
// new ZQLDatabase(new NodePgConnection(db), schema),
|
||||||
|
// );
|
||||||
|
//
|
||||||
|
// const createMutators = (authData: AuthData | null) => {
|
||||||
|
// const mutators = createMutatorsShared(authData);
|
||||||
|
//
|
||||||
|
// return {
|
||||||
|
// ...mutators,
|
||||||
|
// message: {
|
||||||
|
// ...mutators.message,
|
||||||
|
// async sendMessage(tx: ServerTx, params) {
|
||||||
|
// isLoggedIn(authData);
|
||||||
|
//
|
||||||
|
// await mutators.message.sendMessage(tx, params);
|
||||||
|
//
|
||||||
|
// // we can use the db tx to insert server-only data, like audit logs
|
||||||
|
// await tx.dbTransaction.wrappedTransaction.insert(auditLogs).values({
|
||||||
|
// id: crypto.randomUUID(),
|
||||||
|
// userId: authData.user.id,
|
||||||
|
// action: "sendMessage",
|
||||||
|
// });
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// } as const satisfies Mutators;
|
||||||
|
// };
|
||||||
|
|
||||||
|
const zero = getHono()
|
||||||
|
// .post("/mutate", async (c) => {
|
||||||
|
// // get the auth data from betterauth
|
||||||
|
// const authData = c.get("auth");
|
||||||
|
//
|
||||||
|
// const result = await processor.process(createMutators(authData), c.req.raw);
|
||||||
|
//
|
||||||
|
// return c.json(result);
|
||||||
|
// })
|
||||||
|
.post("/get-queries", async (c) => {
|
||||||
|
// get the auth data from betterauth
|
||||||
|
const authData = c.get("auth");
|
||||||
|
|
||||||
|
const result = await handleGetQueriesRequest(
|
||||||
|
(name, args) => ({ query: getQuery(authData, name, args) }),
|
||||||
|
schema,
|
||||||
|
c.req.raw,
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
const validatedQueries = Object.fromEntries(
|
||||||
|
Object.values(queries).map((q) => [q.queryName, withValidation(q)]),
|
||||||
|
);
|
||||||
|
|
||||||
|
function getQuery(
|
||||||
|
authData: AuthData | null,
|
||||||
|
name: string,
|
||||||
|
args: readonly ReadonlyJSONValue[],
|
||||||
|
) {
|
||||||
|
if (name in validatedQueries) {
|
||||||
|
const q = validatedQueries[name];
|
||||||
|
return q(authData, ...args);
|
||||||
|
}
|
||||||
|
throw new Error(`Unknown query: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { zero };
|
||||||
14
api/tsconfig.json
Normal file
14
api/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "hono/jsx",
|
||||||
|
"outDir": "./dist"
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -1,21 +1,20 @@
|
|||||||
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
|
||||||
import { Stack } from 'expo-router';
|
import { Stack } from 'expo-router';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
|
||||||
import 'react-native-reanimated';
|
import 'react-native-reanimated';
|
||||||
|
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
|
||||||
import { authClient } from '@/lib/auth-client';
|
import { authClient } from '@/lib/auth-client';
|
||||||
|
import { ZeroProvider } from '@rocicorp/zero/react';
|
||||||
|
import { zero } from '@/lib/zero';
|
||||||
|
|
||||||
export const unstable_settings = {
|
export const unstable_settings = {
|
||||||
anchor: '(tabs)',
|
anchor: '(tabs)',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
const colorScheme = useColorScheme();
|
|
||||||
const { data, isPending } = authClient.useSession();
|
const { data, isPending } = authClient.useSession();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
<ZeroProvider zero={zero}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack.Protected guard={!isPending && !!data}>
|
<Stack.Protected guard={!isPending && !!data}>
|
||||||
<Stack.Screen name="index" />
|
<Stack.Screen name="index" />
|
||||||
@@ -24,7 +23,6 @@ export default function RootLayout() {
|
|||||||
<Stack.Screen name="auth" />
|
<Stack.Screen name="auth" />
|
||||||
</Stack.Protected>
|
</Stack.Protected>
|
||||||
</Stack>
|
</Stack>
|
||||||
<StatusBar style="auto" />
|
</ZeroProvider>
|
||||||
</ThemeProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
import { auth } from "@/lib/auth";
|
|
||||||
|
|
||||||
const handler = auth.handler;
|
|
||||||
export { handler as GET, handler as POST };
|
|
||||||
@@ -5,6 +5,7 @@ export default function Auth() {
|
|||||||
const onLogin = () => {
|
const onLogin = () => {
|
||||||
authClient.signIn.oauth2({
|
authClient.signIn.oauth2({
|
||||||
providerId: "koon-family",
|
providerId: "koon-family",
|
||||||
|
callbackURL: "http://localhost:8081"
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import { ThemedText } from '@/components/themed-text';
|
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { authClient } from '@/lib/auth-client';
|
import { authClient } from '@/lib/auth-client';
|
||||||
import { Button } from 'react-native';
|
import { Button, Text } from 'react-native';
|
||||||
|
import { useQuery, useZero } from "@rocicorp/zero/react";
|
||||||
|
import { queries } from '@money/shared';
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const { data } = authClient.useSession();
|
const { data } = authClient.useSession();
|
||||||
const onLogout = () => {
|
const onLogout = () => {
|
||||||
authClient.signOut();
|
authClient.signOut();
|
||||||
}
|
}
|
||||||
|
const [transactions] = useQuery(queries.allTransactions());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView>
|
<SafeAreaView>
|
||||||
<ThemedText>Hello {data?.user.name}</ThemedText>
|
<Text>Hello {data?.user.name}</Text>
|
||||||
<Button onPress={onLogout} title="Logout" />
|
<Button onPress={onLogout} title="Logout" />
|
||||||
|
<Text>Transactions: {JSON.stringify(transactions, null, 4)}</Text>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
|
|
||||||
|
|
||||||
export function ExternalLink({ href, ...rest }: Props) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
target="_blank"
|
|
||||||
{...rest}
|
|
||||||
href={href}
|
|
||||||
onPress={async (event) => {
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
|
|
||||||
import { PlatformPressable } from '@react-navigation/elements';
|
|
||||||
import * as Haptics from 'expo-haptics';
|
|
||||||
|
|
||||||
export function HapticTab(props: BottomTabBarButtonProps) {
|
|
||||||
return (
|
|
||||||
<PlatformPressable
|
|
||||||
{...props}
|
|
||||||
onPressIn={(ev) => {
|
|
||||||
if (process.env.EXPO_OS === 'ios') {
|
|
||||||
// Add a soft haptic feedback when pressing down on the tabs.
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
}
|
|
||||||
props.onPressIn?.(ev);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import Animated from 'react-native-reanimated';
|
|
||||||
|
|
||||||
export function HelloWave() {
|
|
||||||
return (
|
|
||||||
<Animated.Text
|
|
||||||
style={{
|
|
||||||
fontSize: 28,
|
|
||||||
lineHeight: 32,
|
|
||||||
marginTop: -6,
|
|
||||||
animationName: {
|
|
||||||
'50%': { transform: [{ rotate: '25deg' }] },
|
|
||||||
},
|
|
||||||
animationIterationCount: 4,
|
|
||||||
animationDuration: '300ms',
|
|
||||||
}}>
|
|
||||||
👋
|
|
||||||
</Animated.Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import type { PropsWithChildren, ReactElement } from 'react';
|
|
||||||
import { StyleSheet } from 'react-native';
|
|
||||||
import Animated, {
|
|
||||||
interpolate,
|
|
||||||
useAnimatedRef,
|
|
||||||
useAnimatedStyle,
|
|
||||||
useScrollOffset,
|
|
||||||
} from 'react-native-reanimated';
|
|
||||||
|
|
||||||
import { ThemedView } from '@/components/themed-view';
|
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
|
||||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
|
||||||
|
|
||||||
const HEADER_HEIGHT = 250;
|
|
||||||
|
|
||||||
type Props = PropsWithChildren<{
|
|
||||||
headerImage: ReactElement;
|
|
||||||
headerBackgroundColor: { dark: string; light: string };
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export default function ParallaxScrollView({
|
|
||||||
children,
|
|
||||||
headerImage,
|
|
||||||
headerBackgroundColor,
|
|
||||||
}: Props) {
|
|
||||||
const backgroundColor = useThemeColor({}, 'background');
|
|
||||||
const colorScheme = useColorScheme() ?? 'light';
|
|
||||||
const scrollRef = useAnimatedRef<Animated.ScrollView>();
|
|
||||||
const scrollOffset = useScrollOffset(scrollRef);
|
|
||||||
const headerAnimatedStyle = useAnimatedStyle(() => {
|
|
||||||
return {
|
|
||||||
transform: [
|
|
||||||
{
|
|
||||||
translateY: interpolate(
|
|
||||||
scrollOffset.value,
|
|
||||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
|
||||||
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Animated.ScrollView
|
|
||||||
ref={scrollRef}
|
|
||||||
style={{ backgroundColor, flex: 1 }}
|
|
||||||
scrollEventThrottle={16}>
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
styles.header,
|
|
||||||
{ backgroundColor: headerBackgroundColor[colorScheme] },
|
|
||||||
headerAnimatedStyle,
|
|
||||||
]}>
|
|
||||||
{headerImage}
|
|
||||||
</Animated.View>
|
|
||||||
<ThemedView style={styles.content}>{children}</ThemedView>
|
|
||||||
</Animated.ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
height: HEADER_HEIGHT,
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
flex: 1,
|
|
||||||
padding: 32,
|
|
||||||
gap: 16,
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { StyleSheet, Text, type TextProps } from 'react-native';
|
|
||||||
|
|
||||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
|
||||||
|
|
||||||
export type ThemedTextProps = TextProps & {
|
|
||||||
lightColor?: string;
|
|
||||||
darkColor?: string;
|
|
||||||
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ThemedText({
|
|
||||||
style,
|
|
||||||
lightColor,
|
|
||||||
darkColor,
|
|
||||||
type = 'default',
|
|
||||||
...rest
|
|
||||||
}: ThemedTextProps) {
|
|
||||||
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
{ color },
|
|
||||||
type === 'default' ? styles.default : undefined,
|
|
||||||
type === 'title' ? styles.title : undefined,
|
|
||||||
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
|
|
||||||
type === 'subtitle' ? styles.subtitle : undefined,
|
|
||||||
type === 'link' ? styles.link : undefined,
|
|
||||||
style,
|
|
||||||
]}
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
default: {
|
|
||||||
fontSize: 16,
|
|
||||||
lineHeight: 24,
|
|
||||||
},
|
|
||||||
defaultSemiBold: {
|
|
||||||
fontSize: 16,
|
|
||||||
lineHeight: 24,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 32,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
lineHeight: 32,
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
},
|
|
||||||
link: {
|
|
||||||
lineHeight: 30,
|
|
||||||
fontSize: 16,
|
|
||||||
color: '#0a7ea4',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { View, type ViewProps } from 'react-native';
|
|
||||||
|
|
||||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
|
||||||
|
|
||||||
export type ThemedViewProps = ViewProps & {
|
|
||||||
lightColor?: string;
|
|
||||||
darkColor?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
|
|
||||||
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
|
|
||||||
|
|
||||||
return <View style={[{ backgroundColor }, style]} {...otherProps} />;
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { PropsWithChildren, useState } from 'react';
|
|
||||||
import { StyleSheet, TouchableOpacity } from 'react-native';
|
|
||||||
|
|
||||||
import { ThemedText } from '@/components/themed-text';
|
|
||||||
import { ThemedView } from '@/components/themed-view';
|
|
||||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
|
||||||
import { Colors } from '@/constants/theme';
|
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
|
||||||
|
|
||||||
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const theme = useColorScheme() ?? 'light';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemedView>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.heading}
|
|
||||||
onPress={() => setIsOpen((value) => !value)}
|
|
||||||
activeOpacity={0.8}>
|
|
||||||
<IconSymbol
|
|
||||||
name="chevron.right"
|
|
||||||
size={18}
|
|
||||||
weight="medium"
|
|
||||||
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
|
|
||||||
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ThemedText type="defaultSemiBold">{title}</ThemedText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
|
|
||||||
</ThemedView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
heading: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
marginTop: 6,
|
|
||||||
marginLeft: 24,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
|
|
||||||
import { StyleProp, ViewStyle } from 'react-native';
|
|
||||||
|
|
||||||
export function IconSymbol({
|
|
||||||
name,
|
|
||||||
size = 24,
|
|
||||||
color,
|
|
||||||
style,
|
|
||||||
weight = 'regular',
|
|
||||||
}: {
|
|
||||||
name: SymbolViewProps['name'];
|
|
||||||
size?: number;
|
|
||||||
color: string;
|
|
||||||
style?: StyleProp<ViewStyle>;
|
|
||||||
weight?: SymbolWeight;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<SymbolView
|
|
||||||
weight={weight}
|
|
||||||
tintColor={color}
|
|
||||||
resizeMode="scaleAspectFit"
|
|
||||||
name={name}
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
},
|
|
||||||
style,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
// Fallback for using MaterialIcons on Android and web.
|
|
||||||
|
|
||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
|
||||||
import { SymbolWeight, SymbolViewProps } from 'expo-symbols';
|
|
||||||
import { ComponentProps } from 'react';
|
|
||||||
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
|
|
||||||
|
|
||||||
type IconMapping = Record<SymbolViewProps['name'], ComponentProps<typeof MaterialIcons>['name']>;
|
|
||||||
type IconSymbolName = keyof typeof MAPPING;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add your SF Symbols to Material Icons mappings here.
|
|
||||||
* - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
|
|
||||||
* - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
|
|
||||||
*/
|
|
||||||
const MAPPING = {
|
|
||||||
'house.fill': 'home',
|
|
||||||
'paperplane.fill': 'send',
|
|
||||||
'chevron.left.forwardslash.chevron.right': 'code',
|
|
||||||
'chevron.right': 'chevron-right',
|
|
||||||
} as IconMapping;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
|
|
||||||
* This ensures a consistent look across platforms, and optimal resource usage.
|
|
||||||
* Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
|
|
||||||
*/
|
|
||||||
export function IconSymbol({
|
|
||||||
name,
|
|
||||||
size = 24,
|
|
||||||
color,
|
|
||||||
style,
|
|
||||||
}: {
|
|
||||||
name: IconSymbolName;
|
|
||||||
size?: number;
|
|
||||||
color: string | OpaqueColorValue;
|
|
||||||
style?: StyleProp<TextStyle>;
|
|
||||||
weight?: SymbolWeight;
|
|
||||||
}) {
|
|
||||||
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
/**
|
|
||||||
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
|
|
||||||
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Platform } from 'react-native';
|
|
||||||
|
|
||||||
const tintColorLight = '#0a7ea4';
|
|
||||||
const tintColorDark = '#fff';
|
|
||||||
|
|
||||||
export const Colors = {
|
|
||||||
light: {
|
|
||||||
text: '#11181C',
|
|
||||||
background: '#fff',
|
|
||||||
tint: tintColorLight,
|
|
||||||
icon: '#687076',
|
|
||||||
tabIconDefault: '#687076',
|
|
||||||
tabIconSelected: tintColorLight,
|
|
||||||
},
|
|
||||||
dark: {
|
|
||||||
text: '#ECEDEE',
|
|
||||||
background: '#151718',
|
|
||||||
tint: tintColorDark,
|
|
||||||
icon: '#9BA1A6',
|
|
||||||
tabIconDefault: '#9BA1A6',
|
|
||||||
tabIconSelected: tintColorDark,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Fonts = Platform.select({
|
|
||||||
ios: {
|
|
||||||
/** iOS `UIFontDescriptorSystemDesignDefault` */
|
|
||||||
sans: 'system-ui',
|
|
||||||
/** iOS `UIFontDescriptorSystemDesignSerif` */
|
|
||||||
serif: 'ui-serif',
|
|
||||||
/** iOS `UIFontDescriptorSystemDesignRounded` */
|
|
||||||
rounded: 'ui-rounded',
|
|
||||||
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
|
|
||||||
mono: 'ui-monospace',
|
|
||||||
},
|
|
||||||
default: {
|
|
||||||
sans: 'normal',
|
|
||||||
serif: 'serif',
|
|
||||||
rounded: 'normal',
|
|
||||||
mono: 'monospace',
|
|
||||||
},
|
|
||||||
web: {
|
|
||||||
sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
|
||||||
serif: "Georgia, 'Times New Roman', serif",
|
|
||||||
rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
|
|
||||||
mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { useColorScheme } from 'react-native';
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useColorScheme as useRNColorScheme } from 'react-native';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* To support static rendering, this value needs to be re-calculated on the client side for web
|
|
||||||
*/
|
|
||||||
export function useColorScheme() {
|
|
||||||
const [hasHydrated, setHasHydrated] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setHasHydrated(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const colorScheme = useRNColorScheme();
|
|
||||||
|
|
||||||
if (hasHydrated) {
|
|
||||||
return colorScheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'light';
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
/**
|
|
||||||
* Learn more about light and dark modes:
|
|
||||||
* https://docs.expo.dev/guides/color-schemes/
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Colors } from '@/constants/theme';
|
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
|
||||||
|
|
||||||
export function useThemeColor(
|
|
||||||
props: { light?: string; dark?: string },
|
|
||||||
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
|
|
||||||
) {
|
|
||||||
const theme = useColorScheme() ?? 'light';
|
|
||||||
const colorFromProps = props[theme];
|
|
||||||
|
|
||||||
if (colorFromProps) {
|
|
||||||
return colorFromProps;
|
|
||||||
} else {
|
|
||||||
return Colors[theme][colorName];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ import { expoClient } from "@better-auth/expo/client";
|
|||||||
import * as SecureStore from "expo-secure-store";
|
import * as SecureStore from "expo-secure-store";
|
||||||
|
|
||||||
export const authClient = createAuthClient({
|
export const authClient = createAuthClient({
|
||||||
baseURL: "http://localhost:8081",
|
baseURL: "http://localhost:3000",
|
||||||
plugins: [
|
plugins: [
|
||||||
expoClient({
|
expoClient({
|
||||||
scheme: "money",
|
scheme: "money",
|
||||||
|
|||||||
8
lib/zero.ts
Normal file
8
lib/zero.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import {Zero} from '@rocicorp/zero';
|
||||||
|
import { schema } from "@money/shared";
|
||||||
|
|
||||||
|
export const zero = new Zero({
|
||||||
|
userID: 'anon',
|
||||||
|
server: 'http://localhost:4848',
|
||||||
|
schema,
|
||||||
|
});
|
||||||
13
package.json
13
package.json
@@ -13,9 +13,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@better-auth/expo": "^1.3.27",
|
"@better-auth/expo": "^1.3.27",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
|
"@money/shared": "link:shared",
|
||||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||||
"@react-navigation/elements": "^2.6.3",
|
"@react-navigation/elements": "^2.6.3",
|
||||||
"@react-navigation/native": "^7.1.8",
|
"@react-navigation/native": "^7.1.8",
|
||||||
|
"@rocicorp/zero": "^0.23.2025090100",
|
||||||
"better-auth": "^1.3.27",
|
"better-auth": "^1.3.27",
|
||||||
"expo": "~54.0.13",
|
"expo": "~54.0.13",
|
||||||
"expo-constants": "~18.0.9",
|
"expo-constants": "~18.0.9",
|
||||||
@@ -47,5 +49,14 @@
|
|||||||
"eslint-config-expo": "~10.0.0",
|
"eslint-config-expo": "~10.0.0",
|
||||||
"typescript": "~5.9.2"
|
"typescript": "~5.9.2"
|
||||||
},
|
},
|
||||||
"private": true
|
"private": true,
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"@rocicorp/zero-sqlite3"
|
||||||
|
],
|
||||||
|
"ignoredBuiltDependencies": [
|
||||||
|
"esbuild",
|
||||||
|
"protobufjs"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2987
pnpm-lock.yaml
generated
2987
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -26,16 +26,26 @@ processes:
|
|||||||
period_seconds: 1
|
period_seconds: 1
|
||||||
|
|
||||||
expo:
|
expo:
|
||||||
command: "pnpm i && pnpm start"
|
command: "pnpm start"
|
||||||
environment:
|
environment:
|
||||||
- "DATABASE_URL=postgresql://postgres@localhost:5432/money"
|
- "DATABASE_URL=postgresql://postgres@localhost:5432/money"
|
||||||
|
|
||||||
|
api:
|
||||||
|
command: "pnpm run dev"
|
||||||
|
working_dir: ./api
|
||||||
|
|
||||||
migrate:
|
migrate:
|
||||||
command: |
|
command: |
|
||||||
createdb -h localhost -p 5432 -U postgres money 2>/dev/null || true
|
createdb -h localhost -p 5432 -U postgres money 2>/dev/null || true
|
||||||
|
|
||||||
|
psql -h localhost -p 5432 -U postgres -c "ALTER SYSTEM SET wal_level = 'logical';"
|
||||||
|
|
||||||
echo "Migration and seeding complete!"
|
echo "Migration and seeding complete!"
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: process_healthy
|
condition: process_healthy
|
||||||
|
zero:
|
||||||
|
command: npx zero-cache-dev -p shared/src/schema.ts
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: process_healthy
|
||||||
|
|||||||
10
shared/package.json
Normal file
10
shared/package.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "@money/shared",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./auth": "./src/auth.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
25
shared/src/auth.ts
Normal file
25
shared/src/auth.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const sessionSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
userId: z.string(),
|
||||||
|
expiresAt: z.date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const userSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
email: z.string(),
|
||||||
|
emailVerified: z.boolean(),
|
||||||
|
name: z.string(),
|
||||||
|
createdAt: z.date(),
|
||||||
|
updatedAt: z.date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const authDataSchema = z.object({
|
||||||
|
session: sessionSchema,
|
||||||
|
user: userSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Session = z.infer<typeof sessionSchema>;
|
||||||
|
export type User = z.infer<typeof userSchema>;
|
||||||
|
export type AuthData = z.infer<typeof authDataSchema>;
|
||||||
2
shared/src/index.ts
Normal file
2
shared/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./schema";
|
||||||
|
export * from "./queries";
|
||||||
9
shared/src/queries.ts
Normal file
9
shared/src/queries.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { syncedQuery } from "@rocicorp/zero";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { builder } from "@money/shared";
|
||||||
|
|
||||||
|
export const queries = {
|
||||||
|
allTransactions: syncedQuery('allTransactions', z.tuple([]), () =>
|
||||||
|
builder.transaction.limit(10)
|
||||||
|
),
|
||||||
|
};
|
||||||
24
shared/src/schema.ts
Normal file
24
shared/src/schema.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { createSchema, table, string, number, createBuilder, definePermissions } from "@rocicorp/zero";
|
||||||
|
|
||||||
|
const transaction = table('transaction')
|
||||||
|
.columns({
|
||||||
|
id: string(),
|
||||||
|
user_id: string(),
|
||||||
|
name: string(),
|
||||||
|
amount: number(),
|
||||||
|
})
|
||||||
|
.primaryKey('id');
|
||||||
|
|
||||||
|
export const schema = createSchema({
|
||||||
|
tables: [transaction],
|
||||||
|
enableLegacyMutators: false,
|
||||||
|
enableLegacyQueries: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const builder = createBuilder(schema);
|
||||||
|
|
||||||
|
export const permissions = definePermissions(schema, () => ({}));
|
||||||
|
|
||||||
|
export type Schema = typeof schema;
|
||||||
|
|
||||||
|
|
||||||
15
shared/tsconfig.json
Normal file
15
shared/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"declaration": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"strict": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"rootDir": "."
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -2,16 +2,10 @@
|
|||||||
"extends": "expo/tsconfig.base",
|
"extends": "expo/tsconfig.base",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": [
|
"@/*": ["./*"]
|
||||||
"./*"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
|
||||||
"**/*.ts",
|
|
||||||
"**/*.tsx",
|
|
||||||
".expo/types/**/*.ts",
|
|
||||||
"expo-env.d.ts"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user