refactor: move into monorepo
This commit is contained in:
56
apps/expo/app/_layout.tsx
Normal file
56
apps/expo/app/_layout.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Stack } from 'expo-router';
|
||||
import 'react-native-reanimated';
|
||||
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { ZeroProvider } from '@rocicorp/zero/react';
|
||||
import { useMemo } from 'react';
|
||||
import { authDataSchema } from '@money/shared/auth';
|
||||
import { Platform } from 'react-native';
|
||||
import type { ZeroOptions } from '@rocicorp/zero';
|
||||
import { schema, type Schema, createMutators, type Mutators, BASE_URL } from '@money/shared';
|
||||
import { expoSQLiteStoreProvider } from "@rocicorp/zero/react-native";
|
||||
|
||||
export const unstable_settings = {
|
||||
anchor: 'index',
|
||||
};
|
||||
|
||||
const kvStore = Platform.OS === "web" ? undefined : expoSQLiteStoreProvider();
|
||||
|
||||
export default function RootLayout() {
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
|
||||
const authData = useMemo(() => {
|
||||
const result = authDataSchema.safeParse(session);
|
||||
return result.success ? result.data : null;
|
||||
}, [session]);
|
||||
|
||||
const cookie = useMemo(() => {
|
||||
return Platform.OS == 'web' ? undefined : authClient.getCookie();
|
||||
}, [session, isPending]);
|
||||
|
||||
const zeroProps = useMemo(() => {
|
||||
return {
|
||||
storageKey: 'money',
|
||||
kvStore,
|
||||
server: process.env.NODE_ENV == 'production' ? 'https://zero.koon.us' : `${BASE_URL}:4848`,
|
||||
userID: authData?.user.id ?? "anon",
|
||||
schema,
|
||||
mutators: createMutators(authData),
|
||||
auth: cookie,
|
||||
} as const satisfies ZeroOptions<Schema, Mutators>;
|
||||
}, [authData, cookie]);
|
||||
|
||||
return (
|
||||
<ZeroProvider {...zeroProps}>
|
||||
<Stack>
|
||||
<Stack.Protected guard={!isPending && !!session}>
|
||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="settings" options={{ headerShown: false }} />
|
||||
</Stack.Protected>
|
||||
<Stack.Protected guard={!isPending && !session}>
|
||||
<Stack.Screen name="auth" />
|
||||
</Stack.Protected>
|
||||
</Stack>
|
||||
</ZeroProvider>
|
||||
);
|
||||
}
|
||||
18
apps/expo/app/auth.tsx
Normal file
18
apps/expo/app/auth.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Button, View } from "react-native";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { BASE_URL } from "@money/shared";
|
||||
|
||||
export default function Auth() {
|
||||
const onLogin = () => {
|
||||
authClient.signIn.oauth2({
|
||||
providerId: "koon-family",
|
||||
callbackURL: process.env.NODE_ENV == 'production' ? 'https://money.koon.us' : `${BASE_URL}:8081`,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Button onPress={onLogin} title="Login with Koon Family" />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
35
apps/expo/app/index.tsx
Normal file
35
apps/expo/app/index.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { RefreshControl, ScrollView, StatusBar, Text, View } from 'react-native';
|
||||
import { useQuery, useZero } from "@rocicorp/zero/react";
|
||||
import { queries, type Mutators, type Schema } from '@money/shared';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
const [balances] = useQuery(queries.getBalances(session));
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
// simulate async work
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusBar barStyle="dark-content" />
|
||||
<ScrollView contentContainerStyle={{ paddingTop: StatusBar.currentHeight, flexGrow: 1 }} refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />} style={{ paddingHorizontal: 10 }}>
|
||||
{balances.map(balance => <Balance key={balance.id} balance={balance} />)}
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Balance({ balance }: { balance: { name: string, current: number, avaliable: number } }) {
|
||||
return <View style={{ backgroundColor: "#eee", borderColor: "#ddd", borderWidth: 1, marginBottom: 10, borderRadius: 10 }}>
|
||||
<Text style={{ fontSize: 15, textAlign: "center" }}>{balance.name}</Text>
|
||||
<Text style={{ fontSize: 30, textAlign: "center" }}>{balance.current}</Text>
|
||||
</View>
|
||||
}
|
||||
98
apps/expo/app/index.web.tsx
Normal file
98
apps/expo/app/index.web.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { Button, Image, Platform, Pressable, ScrollView, Text, View } from 'react-native';
|
||||
import { useQuery, useZero } from "@rocicorp/zero/react";
|
||||
import { queries, type Mutators, type Schema } from '@money/shared';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'expo-router';
|
||||
import Header from '@/components/Header';
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
const z = useZero<Schema, Mutators>();
|
||||
const [transactions] = useQuery(queries.allTransactions(session));
|
||||
const [balances] = useQuery(queries.getBalances(session));
|
||||
|
||||
const [idx, setIdx] = useState(0);
|
||||
const [accountIdx, setAccountIdx] = useState(0);
|
||||
|
||||
const account = balances.at(accountIdx)!;
|
||||
|
||||
const filteredTransactions = transactions
|
||||
.filter(t => t.account_id == account.plaid_id)
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS != 'web') return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "j") {
|
||||
setIdx((prevIdx) => {
|
||||
if (prevIdx + 1 == filteredTransactions.length) return prevIdx;
|
||||
return prevIdx + 1
|
||||
});
|
||||
} else if (event.key === "k") {
|
||||
setIdx((prevIdx) => prevIdx == 0 ? 0 : prevIdx - 1);
|
||||
} else if (event.key == 'g') {
|
||||
setIdx(0);
|
||||
} else if (event.key == "G") {
|
||||
setIdx(transactions.length - 1);
|
||||
} else if (event.key == 'R') {
|
||||
z.mutate.link.updateTransactions();
|
||||
z.mutate.link.updateBalences();
|
||||
} else if (event.key == 'h') {
|
||||
setAccountIdx((prevIdx) => prevIdx == 0 ? 0 : prevIdx - 1);
|
||||
} else if (event.key == 'l') {
|
||||
setAccountIdx((prevIdx) => {
|
||||
if (prevIdx + 1 == balances.length) return prevIdx;
|
||||
return prevIdx + 1
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [filteredTransactions, balances]);
|
||||
|
||||
function lpad(n: number): string {
|
||||
const LEN = 9;
|
||||
const nstr = n.toFixed(2).toLocaleString();
|
||||
return Array.from({ length: LEN - nstr.length }).join(" ") + nstr;
|
||||
}
|
||||
|
||||
function uuu(t: typeof filteredTransactions[number]): string | undefined {
|
||||
if (!t.json) return;
|
||||
const j = JSON.parse(t.json);
|
||||
return j.counterparties.filter((c: any) => !!c.logo_url).at(0)?.logo_url || j.personal_finance_category_icon_url;
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Header />
|
||||
<View style={{ flexDirection: "row" }}>
|
||||
<View style={{ backgroundColor: '' }}>
|
||||
{balances.map((bal, i) => <View key={bal.id} style={{ backgroundColor: i == accountIdx ? 'black' : undefined}}>
|
||||
<Text style={{ fontFamily: 'mono', color: i == accountIdx ? 'white' : undefined }}>{bal.name}: {bal.current} ({bal.avaliable})</Text>
|
||||
</View>)}
|
||||
</View>
|
||||
|
||||
<View>
|
||||
{filteredTransactions.map((t, i) => <Pressable onHoverIn={() => {
|
||||
setIdx(i);
|
||||
}} style={{ backgroundColor: i == idx ? 'black' : undefined, cursor: 'default' as 'auto' }} key={t.id}>
|
||||
<Text style={{ fontFamily: 'mono', color: i == idx ? 'white' : undefined }}>
|
||||
{new Date(t.datetime!).toDateString()}
|
||||
<Text style={{ color: t.amount > 0 ? 'red' : 'green' }}> {lpad(t.amount)}</Text>
|
||||
<Image style={{ width: 15, height: 15, marginHorizontal: 10 }} source={{ uri: uuu(t) || "" }} />
|
||||
{t.name.substring(0, 50)}
|
||||
</Text>
|
||||
</Pressable>)}
|
||||
</View>
|
||||
<ScrollView>
|
||||
<Text style={{ fontFamily: 'mono' }}>{JSON.stringify(JSON.parse(filteredTransactions.at(idx)?.json || "null"), null, 4)}</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
130
apps/expo/app/settings.tsx
Normal file
130
apps/expo/app/settings.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { Button, Linking, Platform, Pressable, Text, View } from 'react-native';
|
||||
import { useQuery, useZero } from "@rocicorp/zero/react";
|
||||
import { queries, type Mutators, type Schema } from '@money/shared';
|
||||
import Header from '@/components/Header';
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
const onLogout = () => {
|
||||
authClient.signOut();
|
||||
}
|
||||
const z = useZero<Schema, Mutators>();
|
||||
const [plaidLink] = useQuery(queries.getPlaidLink(session));
|
||||
const [items] = useQuery(queries.getItems(session));
|
||||
|
||||
return (
|
||||
<SafeAreaView>
|
||||
<Header />
|
||||
|
||||
<UI
|
||||
columns={[
|
||||
{
|
||||
name: "Banks",
|
||||
items: items,
|
||||
renderItem: (item, props) => <Row {...props}>{item.name}</Row>
|
||||
},
|
||||
{
|
||||
name: "Family",
|
||||
items: [],
|
||||
renderItem() {
|
||||
return <View></View>;
|
||||
},
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
type Col<T> = {
|
||||
name: string;
|
||||
items: T[];
|
||||
renderItem: (item: T, props: { isSelected: boolean, isActive: boolean }) => ReactNode;
|
||||
}
|
||||
|
||||
type State = {
|
||||
idx: number;
|
||||
columns: Map<number, State>;
|
||||
};
|
||||
|
||||
function UI({ columns }: { columns: Col<any>[] }) {
|
||||
const [col, setCol] = useState(0);
|
||||
const [state, setState] = useState<State>({
|
||||
idx: 0,
|
||||
columns: new Map(
|
||||
Array.from({ length: columns.length })
|
||||
.map((_, i) => ([i, { idx: 0, columns: new Map() }]))
|
||||
)
|
||||
});
|
||||
|
||||
const getColState = (res: State): State => {
|
||||
let i = col;
|
||||
while (i > 0) {
|
||||
res = res.columns.get(res.idx)!;
|
||||
i--;
|
||||
}
|
||||
return res;
|
||||
|
||||
}
|
||||
|
||||
const colState = getColState(state);
|
||||
|
||||
const curr = columns.at(col)!;
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS != 'web') return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "j") {
|
||||
setState((prev) => {
|
||||
if (prev.idx + 1 == colState.columns.size) return prev;
|
||||
return {...prev, ...{ idx: prev.idx + 1 }};
|
||||
});
|
||||
} else if (event.key === "k") {
|
||||
setState((prev) => {
|
||||
if (prev.idx == 0) return prev;
|
||||
return {...prev, ...{ idx: prev.idx - 1 }};
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={{ flexDirection: "row" }}>
|
||||
<Column>
|
||||
{columns.map((c, i) => <Pressable onPress={() => { setCol(i) }}><Row isSelected={col == i} isActive={col == 0}>{c.name}</Row></Pressable>)}
|
||||
</Column>
|
||||
<Column>
|
||||
{curr.items.map((item, i) => curr.renderItem(item, { isSelected: colState.idx == i, isActive: col == 1 }))}
|
||||
</Column>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Column({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<View>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ children, isSelected, isActive }: { children: ReactNode, isSelected: boolean, isActive: boolean }) {
|
||||
const color = isSelected ? 'white': undefined;
|
||||
const backgroundColor = isSelected ? (isActive ? 'black' : 'gray'): undefined;
|
||||
return (
|
||||
<View>
|
||||
<Text style={{ fontFamily: 'mono', color, backgroundColor }}>{children}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user