diff --git a/packages/react-native-opentui/index.tsx b/packages/react-native-opentui/index.tsx index cdb49f5..f4f97f1 100644 --- a/packages/react-native-opentui/index.tsx +++ b/packages/react-native-opentui/index.tsx @@ -1,11 +1,28 @@ import * as React from "react"; -import type { ViewProps, TextProps, PressableProps, ScrollViewProps } from "react-native"; +import type { + ViewProps, + TextProps, + PressableProps, + ScrollViewProps, + ModalProps, +} from "react-native"; +import { useTerminalDimensions } from "@opentui/react"; +import { RGBA } from "@opentui/core"; + +const RATIO_WIDTH = 8.433; +const RATIO_HEIGHT = 17; export function View({ children, style }: ViewProps) { const bg = style && 'backgroundColor' in style ? typeof style.backgroundColor == 'string' - ? style.backgroundColor + ? style.backgroundColor.startsWith('rgba(') + ? (() => { + const parts = style.backgroundColor.split("(")[1].split(")")[0]; + const [r, g, b, a] = parts.split(",").map(parseFloat); + return RGBA.fromInts(r, g, b, a * 255); + })() + : style.backgroundColor : undefined : undefined; const flexDirection = style && @@ -32,6 +49,31 @@ export function View({ children, style }: ViewProps) { ? style.overflow : undefined : undefined; + const position = style && + 'position' in style + ? typeof style.position == 'string' + ? style.position + : undefined + : undefined; + const justifyContent = style && + 'justifyContent' in style + ? typeof style.justifyContent == 'string' + ? style.justifyContent + : undefined + : undefined; + const alignItems = style && + 'alignItems' in style + ? typeof style.alignItems == 'string' + ? style.alignItems + : undefined + : undefined; + + const padding = style && + 'padding' in style + ? typeof style.padding == 'number' + ? style.padding + : undefined + : undefined; return {children} } @@ -46,7 +95,13 @@ export function Pressable({ children: childrenRaw, style, onPress }: PressablePr const bg = style && 'backgroundColor' in style ? typeof style.backgroundColor == 'string' - ? style.backgroundColor + ? style.backgroundColor.startsWith('rgba(') + ? (() => { + const parts = style.backgroundColor.split("(")[1].split(")")[0]; + const [r, g, b, a] = parts.split(",").map(parseFloat); + return RGBA.fromInts(r, g, b, a * 255); + })() + : style.backgroundColor : undefined : undefined; const flexDirection = style && @@ -61,17 +116,64 @@ export function Pressable({ children: childrenRaw, style, onPress }: PressablePr ? style.flex : undefined : undefined; + const flexShrink = style && + 'flexShrink' in style + ? typeof style.flexShrink == 'number' + ? style.flexShrink + : undefined + : undefined; + const overflow = style && + 'overflow' in style + ? typeof style.overflow == 'string' + ? style.overflow + : undefined + : undefined; + const position = style && + 'position' in style + ? typeof style.position == 'string' + ? style.position + : undefined + : undefined; + const justifyContent = style && + 'justifyContent' in style + ? typeof style.justifyContent == 'string' + ? style.justifyContent + : undefined + : undefined; + const alignItems = style && + 'alignItems' in style + ? typeof style.alignItems == 'string' + ? style.alignItems + : undefined + : undefined; + + const padding = style && + 'padding' in style + ? typeof style.padding == 'number' + ? style.padding + : undefined + : undefined; const children = childrenRaw instanceof Function ? childrenRaw({ pressed: true }) : childrenRaw; return { // @ts-ignore onPress(); }) : undefined} + + backgroundColor={bg} + flexDirection={flexDirection} + flexGrow={flex} + overflow={overflow} + flexShrink={flexShrink} + position={position} + justifyContent={justifyContent} + alignItems={alignItems} + paddingTop={padding && Math.round(padding / RATIO_HEIGHT)} + paddingBottom={padding && Math.round(padding / RATIO_HEIGHT)} + paddingLeft={padding && Math.round(padding / RATIO_WIDTH)} + paddingRight={padding && Math.round(padding / RATIO_WIDTH)} >{children} } @@ -91,6 +193,19 @@ export function ScrollView({ children }: ScrollViewProps) { return {children} } +export function Modal({ children, visible }: ModalProps) { + const { width, height } = useTerminalDimensions(); + return + {children} + +} + export const Platform = { OS: "tui", }; diff --git a/packages/shared/package.json b/packages/shared/package.json index 4b50f9f..6e7130d 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -12,7 +12,7 @@ "drizzle-zero": "^0.14.3" }, "scripts": { - "generate:zero": "drizzle-zero generate -s ./src/db/schema/public.ts -o ./src/zero-schema.gen.ts -f && sed -i 's/enableLegacyQueries: true,/enableLegacyQueries: false,/g' src/zero-schema.gen.ts && sed -i 's/enableLegacyMutators: true,/enableLegacyMutators: false,/g' src/zero-schema.gen.ts", + "db:gen": "drizzle-zero generate -s ./src/db/schema/public.ts -o ./src/zero-schema.gen.ts -f && sed -i 's/enableLegacyQueries: true,/enableLegacyQueries: false,/g' src/zero-schema.gen.ts && sed -i 's/enableLegacyMutators: true,/enableLegacyMutators: false,/g' src/zero-schema.gen.ts", "db:migrate": "drizzle-kit push" } } diff --git a/packages/shared/src/db/schema/public.ts b/packages/shared/src/db/schema/public.ts index e303089..192d636 100644 --- a/packages/shared/src/db/schema/public.ts +++ b/packages/shared/src/db/schema/public.ts @@ -43,6 +43,7 @@ export const balance = pgTable("balance", { avaliable: decimal("avaliable").notNull(), current: decimal("current").notNull(), name: text("name").notNull(), + tokenId: text("tokenId").notNull(), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }); diff --git a/packages/shared/src/mutators.ts b/packages/shared/src/mutators.ts index ae72048..8c07297 100644 --- a/packages/shared/src/mutators.ts +++ b/packages/shared/src/mutators.ts @@ -1,6 +1,6 @@ import type { Transaction } from "@rocicorp/zero"; import type { AuthData } from "./auth"; -import type { Schema } from "."; +import { isLoggedIn, type Schema } from "."; type Tx = Transaction; @@ -11,6 +11,31 @@ export function createMutators(authData: AuthData | null) { async get(tx: Tx, { link_token }: { link_token: string }) {}, async updateTransactions() {}, async updateBalences() {}, + async deleteAccounts(tx: Tx, { accountIds }: { accountIds: string[] }) { + isLoggedIn(authData); + for (const id of accountIds) { + const token = await tx.query.plaidAccessTokens.where("userId", '=', authData.user.id).one(); + if (!token) continue; + await tx.mutate.plaidAccessTokens.delete({ id }); + + const balances = await tx.query.balance + .where('user_id', '=', authData.user.id) + .where("tokenId", '=', token.id) + .run(); + + for (const bal of balances) { + await tx.mutate.balance.delete({ id: bal.id }); + const txs = await tx.query.transaction + .where('user_id', '=', authData.user.id) + .where('account_id', '=', bal.tokenId) + .run(); + for (const transaction of txs) { + await tx.mutate.transaction.delete({ id: transaction.id }); + } + } + + } + }, } } as const; } diff --git a/packages/shared/src/queries.ts b/packages/shared/src/queries.ts index 95cc75f..06db781 100644 --- a/packages/shared/src/queries.ts +++ b/packages/shared/src/queries.ts @@ -22,6 +22,7 @@ export const queries = { isLoggedIn(authData); return builder.plaidLink .where('user_id', '=', authData.user.id) + .where('createdAt', '<', new Date().getTime() + (1000 * 60 * 60 * 4)) .orderBy('createdAt', 'desc') .one(); }), diff --git a/packages/shared/src/zero-schema.gen.ts b/packages/shared/src/zero-schema.gen.ts index 8b917bc..35c03ba 100644 --- a/packages/shared/src/zero-schema.gen.ts +++ b/packages/shared/src/zero-schema.gen.ts @@ -80,6 +80,15 @@ export const schema = { "name" >, }, + tokenId: { + type: "string", + optional: false, + customType: null as unknown as ZeroCustomType< + ZeroSchema, + "balance", + "tokenId" + >, + }, createdAt: { type: "number", optional: true, diff --git a/packages/ui/components/Button.tsx b/packages/ui/components/Button.tsx new file mode 100644 index 0000000..49c192d --- /dev/null +++ b/packages/ui/components/Button.tsx @@ -0,0 +1,22 @@ +import type { ReactNode } from "react"; +import { Text, Pressable } from "react-native"; + +export interface ButtonProps { + children: ReactNode; + onPress?: () => void; + variant?: 'default' | 'secondary' | 'destructive'; +} + +const STYLES: Record, { backgroundColor: string, color: string }> = { + default: { backgroundColor: 'black', color: 'white' }, + secondary: { backgroundColor: '#ccc', color: 'black' }, + destructive: { backgroundColor: 'red', color: 'white' }, +}; + +export function Button({ children, variant, onPress }: ButtonProps) { + const { backgroundColor, color } = STYLES[variant || "default"]; + + return + {children} + +} diff --git a/packages/ui/src/dialog.tsx b/packages/ui/src/dialog.tsx new file mode 100644 index 0000000..6a3b070 --- /dev/null +++ b/packages/ui/src/dialog.tsx @@ -0,0 +1,36 @@ +import { type ReactNode } from "react"; +import { Modal, View, Text, Pressable } from "react-native"; +import { useKeyboard } from "./useKeyboard"; + +interface ProviderProps { + children: ReactNode; + visible?: boolean; + close?: () => void; +} +export function Provider({ children, visible, close }: ProviderProps) { + useKeyboard((key) => { + if (key.name == 'escape') { + if (close) close(); + } + }, []); + + return ( + + {/* close && close()} style={{ justifyContent: 'center', alignItems: 'center', flex: 1, backgroundColor: 'rgba(0,0,0,0.2)', }}> */} + + {children} + + + ); +} + +interface ContentProps { + children: ReactNode; +} +export function Content({ children }: ContentProps) { + return ( + + {children} + + ); +} diff --git a/packages/ui/src/index.tsx b/packages/ui/src/index.tsx index 99479a5..3e34551 100644 --- a/packages/ui/src/index.tsx +++ b/packages/ui/src/index.tsx @@ -88,7 +88,9 @@ function Main() { : (Object.keys(PAGES).sort((a, b) => b.length - a.length).find(p => route.startsWith(p)) as keyof typeof PAGES); - return PAGES[match].screen; + return + {PAGES[match].screen} + } diff --git a/packages/ui/src/settings.tsx b/packages/ui/src/settings.tsx index d72613e..3eb675d 100644 --- a/packages/ui/src/settings.tsx +++ b/packages/ui/src/settings.tsx @@ -5,20 +5,21 @@ import { General } from "./settings/general"; import { Accounts } from "./settings/accounts"; import { Family } from "./settings/family"; import { useKeyboard } from "./useKeyboard"; +import { Modal } from "react-native-opentui"; type SettingsRoute = Extract; const TABS = { "/settings": { - label: "General", + label: "💽 General", screen: }, "/settings/accounts": { - label: "Bank Accounts", + label: "🏦 Bank Accounts", screen: }, "/settings/family": { - label: "Family", + label: "👑 Family", screen: }, } as const satisfies Record; @@ -47,13 +48,13 @@ export function Settings() { return ( - + {Object.entries(TABS).map(([tabRoute, tab]) => { const isSelected = tabRoute == route; return ( setRoute(tabRoute as SettingsRoute)}> - {tab.label} + {tab.label} ); })} diff --git a/packages/ui/src/settings/accounts.tsx b/packages/ui/src/settings/accounts.tsx index 6d37bad..6bc0bec 100644 --- a/packages/ui/src/settings/accounts.tsx +++ b/packages/ui/src/settings/accounts.tsx @@ -1,8 +1,12 @@ -import { useQuery } from "@rocicorp/zero/react"; -import { queries } from '@money/shared'; +import { useQuery, useZero } from "@rocicorp/zero/react"; +import { queries, type Mutators, type Schema } from '@money/shared'; import * as Table from "../table"; -import { use } from "react"; +import { use, useEffect, useState } from "react"; import { RouterContext } from ".."; +import { View, Text, Modal, Linking } from "react-native"; +import { Button } from "../../components/Button"; +import { useKeyboard } from "../useKeyboard"; +import * as Dialog from "../dialog"; const COLUMNS: Table.Column[] = [ { name: 'name', label: 'Name' }, @@ -12,12 +16,123 @@ const COLUMNS: Table.Column[] = [ export function Accounts() { const { auth } = use(RouterContext); const [items] = useQuery(queries.getItems(auth)); + const [deleting, setDeleting] = useState([]); + const [isAddOpen, setIsAddOpen] = useState(false); + const [link] = useQuery(queries.getPlaidLink(auth)); + const [loadingLink, setLoadingLink] = useState(false); + + const z = useZero(); + + + useKeyboard((key) => { + if (key.name == 'n') { + setDeleting([]); + } else if (key.name == 'y') { + onDelete(); + } + }, [deleting]); + + useKeyboard((key) => { + if (key.name == 'a') { + addAccount(); + } + }, [link]) + + const onDelete = () => { + if (!deleting) return + const accountIds = deleting.map(account => account.id); + z.mutate.link.deleteAccounts({ accountIds }); + setDeleting([]); + } + + const addAccount = () => { + if (link) { + Linking.openURL(link.link); + } else { + setLoadingLink(true); + z.mutate.link.create(); + } + + // else { + // setLoadingLink(true); + // z.mutate.link.create().server.then(async () => { + // const link = await queries.getPlaidLink(auth).run(); + // setLoadingLink(false); + // if (link) { + // Linking.openURL(link.link); + // } + // }); + // } + } + + useEffect(() => { + if (loadingLink && link) { + Linking.openURL(link.link); + setLoadingLink(false); + } + }, [link, loadingLink]); return ( - - - + <> + + setDeleting([])}> + + Delete Account + + You are about to delete the following accounts: + + + {deleting.map(account => - {account.name})} + + + + + + + + + + + + + + + + + {/* setIsAddOpen(false)}> */} + {/* */} + {/* Add Account */} + {/**/} + {/* */} + {/* */} + {/* */} + + + + + + + + { + if (key.name == 'd') { + setDeleting(selected); + } + }}> + + + + ); } +function AddAccount() { + const { auth } = use(RouterContext); + const [link] = useQuery(queries.getPlaidLink(auth)); + + return ( + {link?.link} + ); +} diff --git a/packages/ui/src/table.tsx b/packages/ui/src/table.tsx index 6cae26b..3f09140 100644 --- a/packages/ui/src/table.tsx +++ b/packages/ui/src/table.tsx @@ -1,6 +1,7 @@ import { createContext, use, useState, type ReactNode } from "react"; import { View, Text, ScrollView } from "react-native"; import { useKeyboard } from "./useKeyboard"; +import type { KeyEvent } from "@opentui/core"; const HEADER_COLOR = '#7158e2'; const TABLE_COLORS = [ @@ -49,8 +50,9 @@ export interface ProviderProps { data: T[]; columns: Column[]; children: ReactNode; + onKey?: (event: KeyEvent, selected: T[]) => void; }; -export function Provider({ data, columns, children }: ProviderProps) { +export function Provider({ data, columns, children, onKey }: ProviderProps) { const [idx, setIdx] = useState(0); const [selectedFrom, setSelectedFrom] = useState(); @@ -71,8 +73,13 @@ export function Provider({ data, columns, children }: Pro setSelectedFrom(idx); } else if (key.name == 'escape') { setSelectedFrom(undefined); + } else { + const from = selectedFrom ? Math.min(idx, selectedFrom) : idx; + const to = selectedFrom ? Math.max(idx, selectedFrom) : idx; + const selected = data.slice(from, to + 1); + if (onKey) onKey(key, selected); } - }, [data, idx]); + }, [data, idx, selectedFrom]); const columnMap = new Map(columns.map(col => { diff --git a/packages/ui/src/transactions.tsx b/packages/ui/src/transactions.tsx index fab6ba8..86ec358 100644 --- a/packages/ui/src/transactions.tsx +++ b/packages/ui/src/transactions.tsx @@ -2,7 +2,7 @@ import * as Table from "./table"; import { useQuery } from "@rocicorp/zero/react"; import { queries, type Transaction } from '@money/shared'; import { use } from "react"; -import { View, Text, ScrollView } from "react-native"; +import { View, Text } from "react-native"; import { RouterContext } from "."; diff --git a/packages/ui/src/useKeyboard.web.ts b/packages/ui/src/useKeyboard.web.ts index 9cc1046..13f6f48 100644 --- a/packages/ui/src/useKeyboard.web.ts +++ b/packages/ui/src/useKeyboard.web.ts @@ -31,6 +31,7 @@ export function useKeyboard(handler: (key: KeyEvent) => void, deps: any[] = []) capsLock: false, numLock: false, baseCode: event.keyCode, + preventDefault: () => event.preventDefault(), }); }; diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index 9c62f74..2aea777 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -1,5 +1,8 @@ { "compilerOptions": { + "paths": { + "@/*": ["./*"] + }, // Environment setup & latest features "lib": ["ESNext"], "target": "ESNext",