├── .prettierrc ├── fastlane ├── metadata │ ├── en-US │ │ ├── name.txt │ │ ├── apple_tv_privacy_policy.txt │ │ ├── marketing_url.txt │ │ ├── support_url.txt │ │ ├── privacy_url.txt │ │ ├── subtitle.txt │ │ ├── keywords.txt │ │ ├── description.txt │ │ ├── promotional_text.txt │ │ └── release_notes.txt │ ├── secondary_category.txt │ ├── zh-Hans │ │ ├── name.txt │ │ ├── release_notes.txt │ │ ├── apple_tv_privacy_policy.txt │ │ ├── subtitle.txt │ │ ├── marketing_url.txt │ │ ├── support_url.txt │ │ ├── keywords.txt │ │ ├── privacy_url.txt │ │ ├── description.txt │ │ └── promotional_text.txt │ ├── primary_category.txt │ ├── primary_first_sub_category.txt │ ├── review_information │ │ ├── notes.txt │ │ ├── demo_user.txt │ │ ├── first_name.txt │ │ ├── last_name.txt │ │ ├── phone_number.txt │ │ ├── demo_password.txt │ │ └── email_address.txt │ ├── copyright.txt │ ├── primary_second_sub_category.txt │ ├── secondary_first_sub_category.txt │ ├── secondary_second_sub_category.txt │ ├── trade_representative_contact_information │ │ ├── address_line2.txt │ │ ├── address_line3.txt │ │ ├── first_name.txt │ │ ├── last_name.txt │ │ ├── state.txt │ │ ├── email_address.txt │ │ ├── phone_number.txt │ │ ├── country.txt │ │ ├── postal_code.txt │ │ ├── trade_name.txt │ │ ├── city_name.txt │ │ ├── address_line1.txt │ │ └── is_displayed_on_app_store.txt │ └── android │ │ └── en-US │ │ └── changelogs │ │ ├── 1.txt │ │ ├── 34.txt │ │ ├── 37.txt │ │ ├── 9.txt │ │ ├── 10.txt │ │ ├── 11.txt │ │ ├── 14.txt │ │ ├── 15.txt │ │ └── 16.txt ├── Deliverfile └── screenshots │ └── README.txt ├── .npmrc ├── src ├── components │ ├── ledger-guard │ │ └── index.ts │ ├── index.ts │ ├── flex-center │ │ └── index.tsx │ ├── haptic-tab │ │ └── index.tsx │ ├── progress │ │ └── index.tsx │ ├── loading-tile │ │ └── index.tsx │ ├── list │ │ └── index.tsx │ ├── tabs │ │ └── example.tsx │ ├── picker │ │ ├── example.tsx │ │ └── README.md │ ├── button │ │ └── index.tsx │ ├── text-input-screen │ │ └── index.tsx │ └── dashboard-webview │ │ └── index.tsx ├── screens │ ├── home-screen │ │ ├── index.ts │ │ ├── selectors │ │ │ ├── select-net-profit-array.ts │ │ │ ├── select-net-worth-array.ts │ │ │ ├── select-net-worth.ts │ │ │ ├── select-chart-array-helper.ts │ │ │ ├── select-account-totals.ts │ │ │ └── __tests__ │ │ │ │ └── select-account-totals-zero-bug.test.ts │ │ ├── hooks │ │ │ ├── use-account-hierarchy.ts │ │ │ └── use-home-charts.ts │ │ ├── components │ │ │ ├── net-assets-styled.tsx │ │ │ └── accounts-styled.tsx │ │ ├── text-styled.tsx │ │ └── email-icon.tsx │ ├── add-transaction-screen │ │ ├── index.ts │ │ ├── hooks │ │ │ ├── use-add-entries-to-remote.ts │ │ │ ├── use-ledger-meta.ts │ │ │ └── ledger-meta-utils.ts │ │ ├── payee-input-screen.tsx │ │ ├── narration-input-screen.tsx │ │ └── list-item.tsx │ ├── setting │ │ ├── hooks │ │ │ ├── use-update-report-subscribe.ts │ │ │ └── use-user-profile.ts │ │ ├── list-header.tsx │ │ ├── setting-screen.tsx │ │ ├── about.tsx │ │ ├── logout.ts │ │ └── account-header.tsx │ ├── journal-screen │ │ ├── journal-config.ts │ │ ├── journal-entry-item │ │ │ ├── journal-item-flag.tsx │ │ │ ├── index.tsx │ │ │ └── journal-item-date.tsx │ │ ├── journal-no-entries-for-filters-state.tsx │ │ └── journal-bottom-sheet │ │ │ ├── index.tsx │ │ │ └── balance-section.tsx │ ├── ledger-screen │ │ ├── progress-bar.tsx │ │ └── ledger-screen.tsx │ ├── welcome │ │ ├── index.tsx │ │ └── auth-modal.tsx │ ├── referral-screen │ │ └── components │ │ │ ├── contact-row.tsx │ │ │ ├── invite-section.tsx │ │ │ └── gift-icon.tsx │ └── account-picker-screen │ │ └── account-picker-screen.tsx ├── common │ ├── graphql │ │ └── queries │ │ │ ├── deleteAccount.graphql │ │ │ ├── isPaid.graphql │ │ │ ├── addPushToken.graphql │ │ │ ├── userProfile.graphql │ │ │ ├── addEntries.graphql │ │ │ ├── updateReportSubscribe.graphql │ │ │ ├── getLedgerJournal.graphql │ │ │ ├── cancelSubscription.graphql │ │ │ ├── paymentHistory.graphql │ │ │ ├── createSubscriptionSession.graphql │ │ │ ├── getLedgerEntryContext.graphql │ │ │ ├── homeCharts.graphql │ │ │ ├── listLedgers.graphql │ │ │ ├── ledgerMeta.graphql │ │ │ ├── accountHierarchy.graphql │ │ │ ├── getLedger.graphql │ │ │ ├── subscriptionStatus.graphql │ │ │ └── journalEntries.graphql │ ├── hooks │ │ ├── use-toast.ts │ │ ├── index.ts │ │ ├── use-session.ts │ │ ├── use-theme-style.ts │ │ ├── use-translations.ts │ │ └── use-page-view.ts │ ├── apollo │ │ ├── cache.ts │ │ ├── __tests__ │ │ │ ├── fixtures │ │ │ │ └── mock-expo-router.ts │ │ │ ├── cache.test.ts │ │ │ └── error-handling.test.ts │ │ ├── error-handling.ts │ │ ├── client.ts │ │ └── persistent-var.ts │ ├── vars │ │ ├── index.ts │ │ ├── locale.ts │ │ ├── ledger.ts │ │ ├── theme.ts │ │ └── session.ts │ ├── common-margin.tsx │ ├── currency-util.ts │ ├── sentry.ts │ ├── format-util.ts │ ├── request.ts │ ├── d3 │ │ ├── utils.ts │ │ └── __tests__ │ │ │ └── utils.test.ts │ ├── session-utils.ts │ ├── __tests__ │ │ ├── fixtures │ │ │ └── mock-expo-mixpanel-analytics.ts │ │ ├── currency-util.test.ts │ │ ├── session-utils.test.ts │ │ ├── format-util.test.ts │ │ ├── request.test.ts │ │ ├── globalFnFactory.test.ts │ │ ├── use-page-view.test.ts │ │ └── number-utils.test.ts │ ├── screen-util.ts │ ├── analytics.ts │ ├── providers │ │ ├── providers.tsx │ │ ├── theme-provider │ │ │ └── theme-provider.tsx │ │ └── splash-provider │ │ │ └── splash-provider.tsx │ ├── url-utils.ts │ ├── number-utils.ts │ ├── globalFnFactory.ts │ ├── progress-bar.tsx │ ├── theme │ │ └── index.ts │ └── announcement.tsx ├── assets │ ├── images │ │ ├── icon.png │ │ ├── icon96.png │ │ └── splash.png │ └── fonts │ │ └── SpaceMono-Regular.ttf ├── __mocks__ │ ├── expo-localization.ts │ └── generated-graphql │ │ └── graphql.ts ├── config.ts ├── types │ ├── screen-props.ts │ ├── ledger-meta.ts │ ├── navigation-param.ts │ ├── jsx.d.ts │ ├── theme-props.ts │ └── testing.d.ts ├── translations │ └── index.ts ├── scripts │ └── bump-version-dummy.ts └── __tests__ │ └── config.test.ts ├── .prettierignore ├── babel.config.js ├── app ├── auth │ ├── welcome.tsx │ └── _layout.tsx ├── (app) │ ├── (tabs) │ │ ├── index.tsx │ │ ├── setting.tsx │ │ ├── ledger.tsx │ │ ├── journal.tsx │ │ └── _layout.tsx │ ├── logout.tsx │ ├── ledger-selection.tsx │ ├── referral.tsx │ ├── payee-input.tsx │ ├── add-transaction.tsx │ ├── narration-input.tsx │ ├── add-transaction-next.tsx │ ├── account-picker.tsx │ └── _layout.tsx ├── _layout.tsx └── +not-found.tsx ├── .editorconfig ├── apollo.config.json ├── eas.json ├── eslint.config.js ├── tsconfig.json ├── .claude └── commands │ └── commit.md ├── .github ├── copilot-instructions.md ├── workflows │ ├── ci.yml │ └── deploy.yml └── PULL_REQUEST_TEMPLATE.md ├── codegen.ts ├── LICENSE ├── .gitignore ├── deploy.sh ├── README.md └── app.json /.prettierrc: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastlane/metadata/en-US/name.txt: -------------------------------------------------------------------------------- 1 | Beancount 2 | -------------------------------------------------------------------------------- /fastlane/metadata/secondary_category.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /fastlane/metadata/zh-Hans/name.txt: -------------------------------------------------------------------------------- 1 | Beancount 2 | -------------------------------------------------------------------------------- /fastlane/metadata/zh-Hans/release_notes.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /fastlane/metadata/primary_category.txt: -------------------------------------------------------------------------------- 1 | Finance 2 | -------------------------------------------------------------------------------- /fastlane/metadata/primary_first_sub_category.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /fastlane/metadata/review_information/notes.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | legacy-peer-deps=true 3 | -------------------------------------------------------------------------------- /fastlane/metadata/copyright.txt: -------------------------------------------------------------------------------- 1 | 2020, beancount.io 2 | -------------------------------------------------------------------------------- /fastlane/metadata/en-US/apple_tv_privacy_policy.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /fastlane/metadata/primary_second_sub_category.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /fastlane/metadata/review_information/demo_user.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /fastlane/metadata/review_information/first_name.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /fastlane/metadata/review_information/last_name.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /fastlane/metadata/review_information/phone_number.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /fastlane/metadata/secondary_first_sub_category.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /fastlane/metadata/secondary_second_sub_category.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /fastlane/metadata/zh-Hans/apple_tv_privacy_policy.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /fastlane/metadata/review_information/demo_password.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /fastlane/metadata/review_information/email_address.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /fastlane/metadata/en-US/marketing_url.txt: -------------------------------------------------------------------------------- 1 | https://beancount.io 2 | -------------------------------------------------------------------------------- /fastlane/metadata/en-US/support_url.txt: -------------------------------------------------------------------------------- 1 | https://beancount.io/ 2 | -------------------------------------------------------------------------------- /fastlane/metadata/zh-Hans/subtitle.txt: -------------------------------------------------------------------------------- 1 | 复式记账从未如此轻松,只为你最好的财富人生 2 | -------------------------------------------------------------------------------- /src/components/ledger-guard/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./ledger-guard"; 2 | -------------------------------------------------------------------------------- /fastlane/metadata/trade_representative_contact_information/address_line2.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastlane/metadata/trade_representative_contact_information/address_line3.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastlane/metadata/trade_representative_contact_information/first_name.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /fastlane/metadata/trade_representative_contact_information/last_name.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /fastlane/metadata/trade_representative_contact_information/state.txt: -------------------------------------------------------------------------------- 1 | CA 2 | -------------------------------------------------------------------------------- /fastlane/metadata/trade_representative_contact_information/email_address.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /fastlane/metadata/trade_representative_contact_information/phone_number.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /fastlane/metadata/zh-Hans/marketing_url.txt: -------------------------------------------------------------------------------- 1 | https://beancount.io/?locale=zh-CN 2 | -------------------------------------------------------------------------------- /fastlane/metadata/zh-Hans/support_url.txt: -------------------------------------------------------------------------------- 1 | https://beancount.io/?locale=zh-CN 2 | -------------------------------------------------------------------------------- /src/screens/home-screen/index.ts: -------------------------------------------------------------------------------- 1 | export { HomeScreen } from "./home-screen"; 2 | -------------------------------------------------------------------------------- /fastlane/metadata/trade_representative_contact_information/country.txt: -------------------------------------------------------------------------------- 1 | United States 2 | -------------------------------------------------------------------------------- /fastlane/metadata/trade_representative_contact_information/postal_code.txt: -------------------------------------------------------------------------------- 1 | 94025 2 | -------------------------------------------------------------------------------- /fastlane/metadata/trade_representative_contact_information/trade_name.txt: -------------------------------------------------------------------------------- 1 | Beancount 2 | -------------------------------------------------------------------------------- /fastlane/metadata/en-US/privacy_url.txt: -------------------------------------------------------------------------------- 1 | https://beancount.io/page/legal/privacy-policy/ 2 | -------------------------------------------------------------------------------- /fastlane/metadata/trade_representative_contact_information/city_name.txt: -------------------------------------------------------------------------------- 1 | Redwood City 2 | -------------------------------------------------------------------------------- /fastlane/metadata/zh-Hans/keywords.txt: -------------------------------------------------------------------------------- 1 | 会计, beancount, 复式记账, 预算, 预算计划, 加密货币, 股票, 现金, 房产 2 | -------------------------------------------------------------------------------- /fastlane/metadata/zh-Hans/privacy_url.txt: -------------------------------------------------------------------------------- 1 | https://beancount.io/page/legal/privacy-policy/ 2 | -------------------------------------------------------------------------------- /fastlane/metadata/trade_representative_contact_information/address_line1.txt: -------------------------------------------------------------------------------- 1 | 68 Willow Road 2 | -------------------------------------------------------------------------------- /fastlane/metadata/trade_representative_contact_information/is_displayed_on_app_store.txt: -------------------------------------------------------------------------------- 1 | false 2 | -------------------------------------------------------------------------------- /src/common/graphql/queries/deleteAccount.graphql: -------------------------------------------------------------------------------- 1 | mutation deleteAccount { 2 | deleteAccount 3 | } -------------------------------------------------------------------------------- /src/common/hooks/use-toast.ts: -------------------------------------------------------------------------------- 1 | export { useToast } from "@/common/providers/toast-provider"; 2 | -------------------------------------------------------------------------------- /fastlane/metadata/en-US/subtitle.txt: -------------------------------------------------------------------------------- 1 | Double-entry bookkeeping made easy for living your best financial life 2 | -------------------------------------------------------------------------------- /fastlane/metadata/zh-Hans/description.txt: -------------------------------------------------------------------------------- 1 | Beancount.io 用纯文本记录你的账目,生成美观的财务报表(损益表、资产负债表、试算表等等),帮助你更好地记录财富人生。 2 | -------------------------------------------------------------------------------- /src/screens/add-transaction-screen/index.ts: -------------------------------------------------------------------------------- 1 | export { AddTransactionScreen } from "./add-transaction-screen"; 2 | -------------------------------------------------------------------------------- /src/assets/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stargately/beancount-mobile/HEAD/src/assets/images/icon.png -------------------------------------------------------------------------------- /src/assets/images/icon96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stargately/beancount-mobile/HEAD/src/assets/images/icon96.png -------------------------------------------------------------------------------- /src/assets/images/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stargately/beancount-mobile/HEAD/src/assets/images/splash.png -------------------------------------------------------------------------------- /src/common/graphql/queries/isPaid.graphql: -------------------------------------------------------------------------------- 1 | query IsPaid { 2 | isPaid { 3 | isPaid 4 | isForcedToPay 5 | } 6 | } -------------------------------------------------------------------------------- /fastlane/metadata/zh-Hans/promotional_text.txt: -------------------------------------------------------------------------------- 1 | 一个文字账本记录所有交易。无论是虚拟货币、房地产、现金、股票,还是多币种账号,你都能统统搞定,因为我们 Beancount.io 的灵活性和复制记账的准确性。 2 | -------------------------------------------------------------------------------- /src/common/apollo/cache.ts: -------------------------------------------------------------------------------- 1 | import { InMemoryCache } from "@apollo/client"; 2 | 3 | export const cache = new InMemoryCache(); 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package-lock.json 2 | package.json 3 | .github 4 | __generated__ 5 | src/generated-graphql 6 | src/common/graphql/queries 7 | -------------------------------------------------------------------------------- /src/assets/fonts/SpaceMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stargately/beancount-mobile/HEAD/src/assets/fonts/SpaceMono-Regular.ttf -------------------------------------------------------------------------------- /src/common/graphql/queries/addPushToken.graphql: -------------------------------------------------------------------------------- 1 | mutation addPushToken($pushToken: String!) { 2 | addPushToken(token: $pushToken) 3 | } 4 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function (api) { 2 | api.cache(true); 3 | return { 4 | presets: ["babel-preset-expo"], 5 | }; 6 | }; 7 | -------------------------------------------------------------------------------- /fastlane/metadata/en-US/keywords.txt: -------------------------------------------------------------------------------- 1 | accounting, beancount, double-entry, bookkeeping, budget, budget-planning, crypto, stock, cash, real estate 2 | -------------------------------------------------------------------------------- /src/common/vars/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./locale"; 2 | export * from "./theme"; 3 | export * from "./session"; 4 | export * from "./ledger"; 5 | -------------------------------------------------------------------------------- /app/auth/welcome.tsx: -------------------------------------------------------------------------------- 1 | import { WelcomeScreen } from "@/screens/welcome"; 2 | 3 | export default function Auth() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/__mocks__/expo-localization.ts: -------------------------------------------------------------------------------- 1 | // Mock for expo-localization to use in Node.js tests 2 | export const getLocales = () => [{ languageCode: "en" }]; 3 | -------------------------------------------------------------------------------- /app/(app)/(tabs)/index.tsx: -------------------------------------------------------------------------------- 1 | import { HomeScreen } from "@/screens/home-screen"; 2 | 3 | export default function Home() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(app)/(tabs)/setting.tsx: -------------------------------------------------------------------------------- 1 | import { SettingScreen } from "@/screens/setting/setting-screen"; 2 | 3 | export default function Home() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(app)/(tabs)/ledger.tsx: -------------------------------------------------------------------------------- 1 | import { LedgerScreen } from "@/screens/ledger-screen/ledger-screen"; 2 | 3 | export default function Home() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/1.txt: -------------------------------------------------------------------------------- 1 | What's New in Version 1.20250529.1: 2 | 3 | • [Add your changes here] 4 | • [Add your changes here] 5 | • [Add your changes here] 6 | -------------------------------------------------------------------------------- /src/common/graphql/queries/userProfile.graphql: -------------------------------------------------------------------------------- 1 | query UserProfile($userId: String!) { 2 | userProfile(userId: $userId) { 3 | email 4 | emailReportStatus 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /app/(app)/(tabs)/journal.tsx: -------------------------------------------------------------------------------- 1 | import { JournalScreen } from "@/screens/journal-screen/journal-screen"; 2 | 3 | export default function Journal() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/common/apollo/__tests__/fixtures/mock-expo-router.ts: -------------------------------------------------------------------------------- 1 | export const router = { 2 | replace: () => {}, 3 | push: () => {}, 4 | back: () => {}, 5 | canGoBack: () => false, 6 | }; 7 | -------------------------------------------------------------------------------- /src/common/graphql/queries/addEntries.graphql: -------------------------------------------------------------------------------- 1 | mutation addEntries($entriesInput: [EntryInput!]!) { 2 | addEntries(entriesInput: $entriesInput) { 3 | data 4 | success 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /fastlane/Deliverfile: -------------------------------------------------------------------------------- 1 | # The Deliverfile allows you to store various App Store Connect metadata 2 | # For more information, check out the docs 3 | # https://docs.fastlane.tools/actions/deliver/ 4 | -------------------------------------------------------------------------------- /src/common/common-margin.tsx: -------------------------------------------------------------------------------- 1 | import { View } from "react-native"; 2 | import React from "react"; 3 | 4 | export function CommonMargin(): JSX.Element { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /src/common/vars/locale.ts: -------------------------------------------------------------------------------- 1 | import { createPersistentVar } from "@/common/apollo/persistent-var"; 2 | 3 | export const [localeVar, loadLocale] = createPersistentVar( 4 | "locale", 5 | "en", 6 | ); 7 | -------------------------------------------------------------------------------- /app/auth/_layout.tsx: -------------------------------------------------------------------------------- 1 | import { Stack } from "expo-router"; 2 | 3 | export default function AuthLayout() { 4 | return ( 5 | 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /src/common/vars/ledger.ts: -------------------------------------------------------------------------------- 1 | import { createPersistentVar } from "@/common/apollo/persistent-var"; 2 | 3 | export const [ledgerVar, loadLedger] = createPersistentVar( 4 | "ledgerId", 5 | null, 6 | ); 7 | -------------------------------------------------------------------------------- /src/common/graphql/queries/updateReportSubscribe.graphql: -------------------------------------------------------------------------------- 1 | mutation updateReportSubscribe($userId: String!, $status: ReportStatus!) { 2 | updateReportSubscribe(userId: $userId, status: $status) { 3 | success 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /fastlane/metadata/en-US/description.txt: -------------------------------------------------------------------------------- 1 | Beancount.io records your financial transactions in text files, visualize them into financial statements (income statement, balance sheet, trial balance, etc.), and helps you live a better financial life. 2 | -------------------------------------------------------------------------------- /src/common/graphql/queries/getLedgerJournal.graphql: -------------------------------------------------------------------------------- 1 | query GetLedgerJournal($ledgerId: String!, $query: JournalQueryInput) { 2 | getLedgerJournal(ledgerId: $ledgerId, query: $query) { 3 | total 4 | data 5 | is_empty 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig: http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 2 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | -------------------------------------------------------------------------------- /src/common/graphql/queries/cancelSubscription.graphql: -------------------------------------------------------------------------------- 1 | mutation CancelSubscription($clientId: String!, $subscriptionId: String!) { 2 | cancelSubscription(clientId: $clientId, subscriptionId: $subscriptionId) { 3 | success 4 | message 5 | } 6 | } -------------------------------------------------------------------------------- /src/common/currency-util.ts: -------------------------------------------------------------------------------- 1 | import CurrencyIcons from "currency-icons"; 2 | 3 | export const getCurrencySymbol = (currency: string) => { 4 | if (currency === "CNY") { 5 | return "¥"; 6 | } 7 | return CurrencyIcons[currency]?.symbol || ""; 8 | }; 9 | -------------------------------------------------------------------------------- /app/_layout.tsx: -------------------------------------------------------------------------------- 1 | import { Slot } from "expo-router"; 2 | import { Providers } from "@/common/providers/providers"; 3 | 4 | export default function RootLayout() { 5 | return ( 6 | 7 | 8 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/common/vars/theme.ts: -------------------------------------------------------------------------------- 1 | import { createPersistentVar } from "@/common/apollo/persistent-var"; 2 | 3 | export type Theme = "light" | "dark" | "system"; 4 | 5 | export const [themeVar, loadTheme] = createPersistentVar( 6 | "theme", 7 | "system", 8 | ); 9 | -------------------------------------------------------------------------------- /fastlane/metadata/en-US/promotional_text.txt: -------------------------------------------------------------------------------- 1 | One text ledger to rule them all. Crypto, real estate, cash, stock, or even multi-currency account - you are the master to catch 'em all, empowered by the flexibility of Beancount.io and the accuracy of double-entry bookkeeping. 2 | -------------------------------------------------------------------------------- /src/common/sentry.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from "sentry-expo"; 2 | import { config } from "@/config"; 3 | 4 | if (config.sentryDsn) { 5 | Sentry.init({ 6 | dsn: config.sentryDsn, 7 | enableInExpoDevelopment: true, 8 | debug: __DEV__, 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /src/common/graphql/queries/paymentHistory.graphql: -------------------------------------------------------------------------------- 1 | query PaymentHistory { 2 | paymentHistory { 3 | _id 4 | amount 5 | currency 6 | paymentEmail 7 | userId 8 | createAt 9 | chargeId 10 | estimatedIotx 11 | fulfilledHash 12 | } 13 | } -------------------------------------------------------------------------------- /src/common/format-util.ts: -------------------------------------------------------------------------------- 1 | export const getFormatDate = (date: Date) => { 2 | const year = date.getFullYear(); 3 | const month = String(date.getMonth() + 1).padStart(2, "0"); 4 | const day = String(date.getDate()).padStart(2, "0"); 5 | return `${year}-${month}-${day}`; 6 | }; 7 | -------------------------------------------------------------------------------- /src/common/graphql/queries/createSubscriptionSession.graphql: -------------------------------------------------------------------------------- 1 | mutation CreateSubscriptionSession($clientId: String!, $priceId: String!) { 2 | createSubscriptionSession(clientId: $clientId, priceId: $priceId) { 3 | success 4 | sessionId 5 | sessionUrl 6 | message 7 | } 8 | } -------------------------------------------------------------------------------- /src/screens/add-transaction-screen/hooks/use-add-entries-to-remote.ts: -------------------------------------------------------------------------------- 1 | import { useAddEntriesMutation } from "@/generated-graphql/graphql"; 2 | 3 | export const useAddEntriesToRemote = () => { 4 | const [mutate, { error, data }] = useAddEntriesMutation(); 5 | return { error, mutate, data }; 6 | }; 7 | -------------------------------------------------------------------------------- /apollo.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "client": { 3 | "service": { 4 | "name": "Beancount", 5 | "localSchemaFile": "./src/generated-graphql/graphql.schema.json" 6 | }, 7 | "includes": ["src/common/graphql/**/*.graphql"], 8 | "excludes": ["src/generated-graphql/**"] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/34.txt: -------------------------------------------------------------------------------- 1 | What's New in Version 1.20250922.34: 2 | 3 | • Updated internationalization for login screen 4 | • Added user deletion functionality 5 | • Upgraded to Expo SDK 54 6 | • Removed outdated changelog and feature flags 7 | • General improvements and code cleanup 8 | -------------------------------------------------------------------------------- /src/common/graphql/queries/getLedgerEntryContext.graphql: -------------------------------------------------------------------------------- 1 | query GetLedgerEntryContext($entryHash: String!, $ledgerId: String!) { 2 | getLedgerEntryContext(entryHash: $entryHash, ledgerId: $ledgerId) { 3 | slice 4 | sha256sum 5 | entry 6 | balances_before 7 | balances_after 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/common/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { useToast } from "./use-toast"; 2 | export { useSession } from "./use-session"; 3 | export { useThemeStyle } from "./use-theme-style"; 4 | export { useTranslations } from "./use-translations"; 5 | export { usePageView } from "./use-page-view"; 6 | export { useTheme } from "../theme"; 7 | -------------------------------------------------------------------------------- /src/common/vars/session.ts: -------------------------------------------------------------------------------- 1 | import { createPersistentVar } from "@/common/apollo/persistent-var"; 2 | 3 | export type Session = { 4 | userId: string; 5 | authToken: string; 6 | }; 7 | 8 | export const [sessionVar, loadSession] = createPersistentVar( 9 | "session", 10 | null, 11 | ); 12 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | export const config = { 2 | project: require("../package.json").name, 3 | sentryDsn: "", // TODO 4 | analytics: { 5 | googleTid: "UA-143353833-1", 6 | mixpanelProjectToken: "", // TODO 7 | }, 8 | serverUrl: process.env.EXPO_PUBLIC_SERVER_URL || "https://beancount.io/", 9 | }; 10 | -------------------------------------------------------------------------------- /src/screens/setting/hooks/use-update-report-subscribe.ts: -------------------------------------------------------------------------------- 1 | import { useUpdateReportSubscribeMutation } from "@/generated-graphql/graphql"; 2 | 3 | export const useUpdateReportSubscribeToRemote = () => { 4 | const [mutate, { error, data }] = useUpdateReportSubscribeMutation(); 5 | return { error, mutate, data }; 6 | }; 7 | -------------------------------------------------------------------------------- /src/common/hooks/use-session.ts: -------------------------------------------------------------------------------- 1 | import { useReactiveVar } from "@apollo/client"; 2 | import { sessionVar } from "@/common/vars"; 3 | 4 | export const useSession = () => { 5 | const session = useReactiveVar(sessionVar); 6 | if (!session) { 7 | throw new Error("Session not found"); 8 | } 9 | return session; 10 | }; 11 | -------------------------------------------------------------------------------- /src/common/graphql/queries/homeCharts.graphql: -------------------------------------------------------------------------------- 1 | query HomeCharts($userId: String!, $ledgerId: String) { 2 | homeCharts(userId: $userId, ledgerId: $ledgerId) { 3 | data { 4 | type 5 | label 6 | data { 7 | date 8 | balance 9 | budgets 10 | } 11 | } 12 | success 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/common/request.ts: -------------------------------------------------------------------------------- 1 | import { config } from "@/config"; 2 | import Constants from "expo-constants"; 3 | 4 | export const headers: { [key: string]: string } = { 5 | "x-app-id": config.project, 6 | "x-app-version": Constants.nativeAppVersion, 7 | }; 8 | 9 | export const getEndpoint = (path: string) => `${config.serverUrl}${path}`; 10 | -------------------------------------------------------------------------------- /src/screens/home-screen/selectors/select-net-profit-array.ts: -------------------------------------------------------------------------------- 1 | import { HomeChartsQuery } from "@/generated-graphql/graphql"; 2 | import { selectChartArray } from "./select-chart-array-helper"; 3 | 4 | export function selectNetProfitArray(currency: string, data?: HomeChartsQuery) { 5 | return selectChartArray("Net Profit", currency, data); 6 | } 7 | -------------------------------------------------------------------------------- /src/types/screen-props.ts: -------------------------------------------------------------------------------- 1 | import { Scope, TranslateOptions } from "i18n-js"; 2 | 3 | export type TFuncType = (scope: Scope, options?: TranslateOptions) => string; 4 | 5 | export type SetStateFuncType = (locale: string) => void; 6 | 7 | export interface ScreenProps { 8 | t: TFuncType; 9 | locale: string; 10 | setLocale: SetStateFuncType; 11 | } 12 | -------------------------------------------------------------------------------- /app/(app)/logout.tsx: -------------------------------------------------------------------------------- 1 | import { LogoutScreen } from "@/screens/setting/logout-screen"; 2 | import { Stack } from "expo-router"; 3 | 4 | export default function Logout() { 5 | return ( 6 | <> 7 | 12 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./button"; 2 | export * from "./progress"; 3 | export * from "./picker"; 4 | export * from "./list"; 5 | export * from "./tabs"; 6 | export * from "./flex-center"; 7 | export * from "./text-input-modal"; 8 | export * from "./text-input-screen"; 9 | export * from "./dashboard-webview"; 10 | export * from "./ledger-guard"; 11 | -------------------------------------------------------------------------------- /app/(app)/ledger-selection.tsx: -------------------------------------------------------------------------------- 1 | import { LedgerSelectionScreen } from "@/screens/ledger-selection"; 2 | import { Stack } from "expo-router"; 3 | import React from "react"; 4 | 5 | export default function LedgerSelection() { 6 | return ( 7 | <> 8 | 9 | 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/screens/home-screen/selectors/select-net-worth-array.ts: -------------------------------------------------------------------------------- 1 | import { HomeChartsQuery } from "@/generated-graphql/graphql"; 2 | import { selectChartArray, isSameMonth } from "./select-chart-array-helper"; 3 | 4 | export { isSameMonth }; 5 | 6 | export function selectNetWorthArray(currency: string, data?: HomeChartsQuery) { 7 | return selectChartArray("Net Worth", currency, data); 8 | } 9 | -------------------------------------------------------------------------------- /src/common/d3/utils.ts: -------------------------------------------------------------------------------- 1 | export function generateTicks(min: number, max: number, count: number) { 2 | if (count <= 0) { 3 | return []; 4 | } 5 | if (count === 1) { 6 | return [min]; 7 | } 8 | const step = (max - min) / (count - 1); 9 | const ticks = []; 10 | for (let i = 0; i < count; i++) { 11 | ticks.push(min + step * i); 12 | } 13 | return ticks; 14 | } 15 | -------------------------------------------------------------------------------- /src/common/session-utils.ts: -------------------------------------------------------------------------------- 1 | import jwtDecode from "jwt-decode"; 2 | 3 | export const createSession = (token: string) => { 4 | const decoded = jwtDecode(token) as { sub?: string | number }; 5 | 6 | if (!decoded.sub) { 7 | throw new Error("Token missing required 'sub' claim"); 8 | } 9 | 10 | return { 11 | userId: String(decoded.sub), 12 | authToken: token, 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /fastlane/metadata/en-US/release_notes.txt: -------------------------------------------------------------------------------- 1 | What's New in Version 1.20251212.37: 2 | 3 | • Added React Native detection flag to dashboard WebView for better web-app integration 4 | • Added empty state for filtered journal results to improve user experience 5 | • Fixed journal screen theme-aware overlay color and adjusted snap points 6 | • Refactored journal screen for improved performance and maintainability 7 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/37.txt: -------------------------------------------------------------------------------- 1 | What's New in Version 1.20251212.37: 2 | 3 | • Added React Native detection flag to dashboard WebView for better web-app integration 4 | • Added empty state for filtered journal results to improve user experience 5 | • Fixed journal screen theme-aware overlay color and adjusted snap points 6 | • Refactored journal screen for improved performance and maintainability 7 | -------------------------------------------------------------------------------- /app/(app)/referral.tsx: -------------------------------------------------------------------------------- 1 | import { ReferralScreen } from "@/screens/referral-screen/referral-screen"; 2 | import { Stack } from "expo-router"; 3 | import { i18n } from "@/translations"; 4 | import React from "react"; 5 | 6 | export default function Referral() { 7 | return ( 8 | <> 9 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/common/graphql/queries/listLedgers.graphql: -------------------------------------------------------------------------------- 1 | query ListLedgers($limit: Float, $page: Float) { 2 | listLedgers(limit: $limit, page: $page) { 3 | id 4 | name 5 | fullName 6 | httpUrl 7 | sshUrl 8 | private 9 | empty 10 | size 11 | createdAt 12 | updatedAt 13 | description 14 | permissions { 15 | admin 16 | pull 17 | push 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /app/(app)/payee-input.tsx: -------------------------------------------------------------------------------- 1 | import { PayeeInputScreen } from "@/screens/add-transaction-screen/payee-input-screen"; 2 | import { i18n } from "@/translations"; 3 | import { Stack } from "expo-router"; 4 | import React from "react"; 5 | 6 | export default function PayeeInput() { 7 | return ( 8 | <> 9 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/9.txt: -------------------------------------------------------------------------------- 1 | What's New in Version 1.20250529.9: 2 | 3 | • Upgraded to Expo 53 for improved performance and stability 4 | • Fixed theme and color scheme issues for better visual experience 5 | • Removed unnecessary app permissions for enhanced privacy 6 | • Removed Sentry integration for improved performance and privacy 7 | • Cleaned up dependencies by removing unused packages for a lighter app size 8 | -------------------------------------------------------------------------------- /src/common/graphql/queries/ledgerMeta.graphql: -------------------------------------------------------------------------------- 1 | query ledgerMeta($userId: String!) { 2 | ledgerMeta(userId: $userId) { 3 | data { 4 | accounts 5 | currencies 6 | errors 7 | options { 8 | name_assets 9 | name_equity 10 | name_expenses 11 | name_income 12 | name_liabilities 13 | operating_currency 14 | } 15 | } 16 | success 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/(app)/add-transaction.tsx: -------------------------------------------------------------------------------- 1 | import { AddTransactionScreen } from "@/screens/add-transaction-screen"; 2 | import { Stack } from "expo-router"; 3 | import { i18n } from "@/translations"; 4 | import React from "react"; 5 | 6 | export default function AddTransaction() { 7 | return ( 8 | <> 9 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/10.txt: -------------------------------------------------------------------------------- 1 | What's New in Version 1.20250530.10: 2 | 3 | • Upgraded to Expo 53 for improved performance and stability 4 | • Fixed theme and color scheme issues for better visual experience 5 | • Removed unnecessary app permissions for enhanced privacy 6 | • Removed Sentry integration for improved performance and privacy 7 | • Cleaned up dependencies by removing unused packages for a lighter app size 8 | -------------------------------------------------------------------------------- /app/(app)/narration-input.tsx: -------------------------------------------------------------------------------- 1 | import { NarrationInputScreen } from "@/screens/add-transaction-screen/narration-input-screen"; 2 | import { i18n } from "@/translations"; 3 | import { Stack } from "expo-router"; 4 | import React from "react"; 5 | 6 | export default function Invite() { 7 | return ( 8 | <> 9 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/screens/setting/hooks/use-user-profile.ts: -------------------------------------------------------------------------------- 1 | import { useUserProfileQuery } from "@/generated-graphql/graphql"; 2 | 3 | export const useUserProfile = (userId: string) => { 4 | const { data, error, loading } = useUserProfileQuery({ 5 | variables: { userId }, 6 | }); 7 | return { 8 | email: data?.userProfile?.email, 9 | emailReportStatus: data?.userProfile?.emailReportStatus, 10 | error, 11 | loading, 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /src/components/flex-center/index.tsx: -------------------------------------------------------------------------------- 1 | import { View, ViewProps, StyleSheet } from "react-native"; 2 | 3 | export const FlexCenter = ({ children, ...props }: ViewProps) => { 4 | return ( 5 | 6 | {children} 7 | 8 | ); 9 | }; 10 | 11 | const styles = StyleSheet.create({ 12 | flex: { 13 | flex: 1, 14 | justifyContent: "center", 15 | alignItems: "center", 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /src/types/ledger-meta.ts: -------------------------------------------------------------------------------- 1 | export interface Options { 2 | name_assets: string; 3 | name_equity: string; 4 | name_expenses: string; 5 | name_income: string; 6 | name_liabilities: string; 7 | } 8 | 9 | export interface LedgerMeta { 10 | accounts: string[]; 11 | currencies: string[]; 12 | errors: number; 13 | options: Options; 14 | } 15 | 16 | export interface LedgerMetaResponse { 17 | data: LedgerMeta; 18 | success: boolean; 19 | } 20 | -------------------------------------------------------------------------------- /src/common/graphql/queries/accountHierarchy.graphql: -------------------------------------------------------------------------------- 1 | query AccountHierarchy($userId: String!, $ledgerId: String) { 2 | accountHierarchy(userId: $userId, ledgerId: $ledgerId) { 3 | data { 4 | type 5 | label 6 | data { 7 | account 8 | balance 9 | balance_children 10 | children { 11 | account 12 | balance 13 | balance_children 14 | } 15 | } 16 | } 17 | success 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /eas.json: -------------------------------------------------------------------------------- 1 | { 2 | "cli": { 3 | "version": ">= 16.0.1", 4 | "appVersionSource": "remote" 5 | }, 6 | "build": { 7 | "development": { 8 | "developmentClient": true, 9 | "distribution": "internal" 10 | }, 11 | "preview": { 12 | "distribution": "internal" 13 | }, 14 | "production": { 15 | "autoIncrement": true 16 | } 17 | }, 18 | "submit": { 19 | "production": { 20 | "ios": { 21 | "ascAppId": "1527950512" 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/(app)/add-transaction-next.tsx: -------------------------------------------------------------------------------- 1 | import { AddTransactionNextScreen } from "@/screens/add-transaction-screen/add-transaction-next-screen"; 2 | import { Stack } from "expo-router"; 3 | import { i18n } from "@/translations"; 4 | import React from "react"; 5 | 6 | export default function AddTransactionNext() { 7 | return ( 8 | <> 9 | 14 | 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/screens/home-screen/selectors/select-net-worth.ts: -------------------------------------------------------------------------------- 1 | import { HomeChartsQuery } from "@/generated-graphql/graphql"; 2 | 3 | export function getNetWorth(currency: string, data?: HomeChartsQuery) { 4 | if (!currency) { 5 | return { netAssets: "0.00" }; 6 | } 7 | const netWorthList = data?.homeCharts?.data.find( 8 | (n) => n.label === "Net Worth", 9 | ); 10 | return { 11 | netAssets: Number( 12 | netWorthList?.data[netWorthList?.data.length - 1]?.balance[currency] || 0, 13 | ).toFixed(2), 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/11.txt: -------------------------------------------------------------------------------- 1 | What's New in Version 1.20250709.11: 2 | 3 | • Removed Ant Design (antd) library dependency for better performance 4 | • Added custom picker component for improved user experience 5 | • Fixed bar chart crash when handling negative data values 6 | • Designed lightweight Toast notification component 7 | • Implemented d3.js for enhanced chart visualization 8 | • Fixed issue where locale settings were lost after app restart 9 | • Added forgot password screen 10 | • Added Spanish and French localization 11 | -------------------------------------------------------------------------------- /fastlane/screenshots/README.txt: -------------------------------------------------------------------------------- 1 | Put all screenshots you want to use inside the folder of its language (e.g. en-US). 2 | The device type will automatically be recognized using the image resolution. Apple TV screenshots 3 | should be stored in a subdirectory named appleTV with language folders inside of it. iMessage 4 | screenshots, like Apple TV screenshots, should also be stored in a subdirectory named iMessage 5 | with language folders inside of it. 6 | 7 | The screenshots can be named whatever you want, but keep in mind they are sorted alphabetically. 8 | -------------------------------------------------------------------------------- /src/common/graphql/queries/getLedger.graphql: -------------------------------------------------------------------------------- 1 | query GetLedger($ledgerId: String!) { 2 | getLedger(ledgerId: $ledgerId) { 3 | id 4 | name 5 | fullName 6 | httpUrl 7 | sshUrl 8 | private 9 | empty 10 | size 11 | createdAt 12 | updatedAt 13 | description 14 | permissions { 15 | admin 16 | pull 17 | push 18 | } 19 | options { 20 | nameAssets 21 | nameEquity 22 | nameExpenses 23 | nameIncome 24 | nameLiabilities 25 | operatingCurrency 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/14.txt: -------------------------------------------------------------------------------- 1 | What's New in Version 1.20250811.14: 2 | 3 | • Added system default theme option - automatically switches between light and dark modes based on device settings 4 | • Expanded language support to 13 languages including Bulgarian, Catalan, German, Persian, Dutch, Portuguese, Russian, Slovak, and Ukrainian 5 | • Fixed app crash from negative data values in bar charts 6 | • Improved UI with new picker component for better theme selection 7 | • Enhanced performance by removing heavy dependencies and optimizing chart visualizations with D3.js 8 | -------------------------------------------------------------------------------- /src/common/hooks/use-theme-style.ts: -------------------------------------------------------------------------------- 1 | import { useMemo, useRef } from "react"; 2 | import { useTheme } from "@/common/theme"; 3 | import { StyleSheet } from "react-native"; 4 | import { ColorTheme } from "@/types/theme-props"; 5 | 6 | export const useThemeStyle = >( 7 | createStyleFactory: (colorTheme: ColorTheme) => T, 8 | ) => { 9 | const createFactory = useRef(createStyleFactory); 10 | const colorTheme = useTheme().colorTheme; 11 | return useMemo( 12 | () => createFactory.current(colorTheme), 13 | [colorTheme, createFactory], 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /src/screens/setting/list-header.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Text } from "react-native"; 3 | import { useTheme } from "@/common/theme"; 4 | 5 | export function ListHeader({ children }: { children: string }): JSX.Element { 6 | const theme = useTheme().colorTheme; 7 | return ( 8 | 18 | {children} 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/common/apollo/error-handling.ts: -------------------------------------------------------------------------------- 1 | import { onError } from "@apollo/client/link/error"; 2 | import { sessionVar } from "@/common/vars"; 3 | import { router } from "expo-router"; 4 | 5 | export const onErrorLink = onError(({ graphQLErrors, networkError }) => { 6 | if (graphQLErrors) { 7 | for (const err of graphQLErrors) { 8 | if (err.extensions && err.extensions.code === "UNAUTHENTICATED") { 9 | sessionVar(null); 10 | router.replace("/auth/welcome"); 11 | } 12 | } 13 | } 14 | if (networkError) { 15 | console.log(`[Network error]: ${networkError}`); 16 | } 17 | }); 18 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/15.txt: -------------------------------------------------------------------------------- 1 | What's New in Version 1.20250905.15: 2 | 3 | • ✨ Enhanced Journal Screen: Full internationalization support with translations in 13+ languages 4 | • 🎨 Improved Navigation: Moved Journal tab to better position (between Home and Ledger) 5 | • 🔧 Better Type Safety: Replaced any types with proper GraphQL generated types 6 | • 💰 Real Account Data: Fixed hardcoded account totals to show actual financial data 7 | • 🌍 Added support for Income, Expenses, and Equity account types in account hierarchy 8 | • 📱 Improved UI: More compact header design for better screen space utilization 9 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/16.txt: -------------------------------------------------------------------------------- 1 | What's New in Version 1.20250911.16: 2 | 3 | • ✨ Enhanced Journal Screen: Full internationalization support with translations in 13+ languages 4 | • 🎨 Improved Navigation: Moved Journal tab to better position (between Home and Ledger) 5 | • 🔧 Better Type Safety: Replaced any types with proper GraphQL generated types 6 | • 💰 Real Account Data: Fixed hardcoded account totals to show actual financial data 7 | • 🌍 Added support for Income, Expenses, and Equity account types in account hierarchy 8 | • 📱 Improved UI: More compact header design for better screen space utilization 9 | -------------------------------------------------------------------------------- /src/screens/home-screen/hooks/use-account-hierarchy.ts: -------------------------------------------------------------------------------- 1 | import { getAccountTotals } from "@/screens/home-screen/selectors/select-account-totals"; 2 | import { useAccountHierarchyQuery } from "@/generated-graphql/graphql"; 3 | 4 | export const useAccountHierarchy = ( 5 | userId: string, 6 | currency: string, 7 | ledgerId?: string, 8 | ) => { 9 | const { loading, data, error, refetch } = useAccountHierarchyQuery({ 10 | variables: { userId, ledgerId }, 11 | fetchPolicy: "network-only", 12 | }); 13 | const accounts = getAccountTotals(currency, data); 14 | return { loading, data, error, refetch, accounts }; 15 | }; 16 | -------------------------------------------------------------------------------- /src/screens/setting/setting-screen.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useTheme } from "@/common/theme"; 3 | import { About } from "./about"; 4 | import { SafeAreaView } from "react-native-safe-area-context"; 5 | import { usePageView } from "@/common/hooks"; 6 | 7 | export function SettingScreen(): JSX.Element { 8 | const theme = useTheme().colorTheme; 9 | usePageView("mine"); 10 | 11 | return ( 12 | 19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // https://docs.expo.dev/guides/using-eslint/ 2 | const { defineConfig } = require("eslint/config"); 3 | const expoConfig = require("eslint-config-expo/flat"); 4 | const eslintPluginPrettierRecommended = require("eslint-plugin-prettier/recommended"); 5 | 6 | module.exports = defineConfig([ 7 | expoConfig, 8 | eslintPluginPrettierRecommended, 9 | { 10 | ignores: [ 11 | "dist/*", 12 | "__generated__", 13 | "node_modules/*", 14 | "src/generated-graphql/*", 15 | ], 16 | }, 17 | { 18 | rules: { 19 | "@typescript-eslint/no-unused-vars": "off", 20 | }, 21 | }, 22 | ]); 23 | -------------------------------------------------------------------------------- /src/common/hooks/use-translations.ts: -------------------------------------------------------------------------------- 1 | import { useReactiveVar } from "@apollo/client"; 2 | import { i18n } from "@/translations"; 3 | import { localeVar } from "@/common/vars"; 4 | 5 | /** 6 | * Hook that provides reactive translations that automatically re-render when locale changes 7 | */ 8 | export const useTranslations = () => { 9 | const locale = useReactiveVar(localeVar); 10 | 11 | // Make sure i18n locale is in sync 12 | if (i18n.locale !== locale) { 13 | i18n.locale = locale; 14 | } 15 | 16 | return { 17 | t: (key: string, params?: Record) => i18n.t(key, params), 18 | locale, 19 | }; 20 | }; 21 | -------------------------------------------------------------------------------- /src/components/haptic-tab/index.tsx: -------------------------------------------------------------------------------- 1 | import { PlatformPressable } from "@react-navigation/elements"; 2 | import * as Haptics from "expo-haptics"; 3 | import { ComponentProps } from "react"; 4 | 5 | export function HapticTab(props: ComponentProps) { 6 | return ( 7 | { 10 | if (process.env.EXPO_OS === "ios") { 11 | // Add a soft haptic feedback when pressing down on the tabs. 12 | Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); 13 | } 14 | props.onPressIn?.(ev); 15 | }} 16 | /> 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/types/navigation-param.ts: -------------------------------------------------------------------------------- 1 | export type RootStackParamList = { 2 | Root: undefined; 3 | AddTransaction: undefined; 4 | AddTransactionNext: undefined; 5 | AccountPicker: undefined; 6 | PayeeInput: undefined; 7 | NarrationInput: undefined; 8 | Referral: undefined; 9 | Invite: undefined; 10 | }; 11 | 12 | export type MainTabParamList = { 13 | Home: undefined; 14 | Ledger: undefined; 15 | Mine: undefined; 16 | }; 17 | 18 | export type HomeParamList = { 19 | HomeScreen: undefined; 20 | }; 21 | 22 | export type LedgerParamList = { 23 | LedgerScreen: undefined; 24 | }; 25 | 26 | export type MineParamList = { 27 | MineScreen: undefined; 28 | }; 29 | -------------------------------------------------------------------------------- /app/+not-found.tsx: -------------------------------------------------------------------------------- 1 | import { Link, Stack } from "expo-router"; 2 | import { StyleSheet, Text } from "react-native"; 3 | 4 | import { FlexCenter } from "@/components/flex-center"; 5 | 6 | import React from "react"; 7 | 8 | export default function NotFoundScreen() { 9 | return ( 10 | <> 11 | 12 | 13 | 14 | Go to Welcome 15 | 16 | 17 | 18 | ); 19 | } 20 | 21 | const styles = StyleSheet.create({ 22 | link: { 23 | marginTop: 15, 24 | paddingVertical: 15, 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /app/(app)/account-picker.tsx: -------------------------------------------------------------------------------- 1 | import { AccountPickerScreen } from "@/screens/account-picker-screen/account-picker-screen"; 2 | import { Stack } from "expo-router"; 3 | import { i18n } from "@/translations"; 4 | import React from "react"; 5 | import { useTheme } from "@/common/theme"; 6 | 7 | export default function AccountPicker() { 8 | const theme = useTheme().colorTheme; 9 | 10 | return ( 11 | <> 12 | 20 | 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/common/graphql/queries/subscriptionStatus.graphql: -------------------------------------------------------------------------------- 1 | query SubscriptionStatus { 2 | subscriptionStatus { 3 | hasActiveSubscription 4 | subscriptions { 5 | id 6 | status 7 | cancelAt 8 | cancelAtPeriodEnd 9 | canceledAt 10 | clientId 11 | currentPeriodEnd 12 | currentPeriodStart 13 | items { 14 | id 15 | quantity 16 | price { 17 | id 18 | amount 19 | currency 20 | interval 21 | intervalCount 22 | trialPeriodDays 23 | } 24 | product { 25 | id 26 | name 27 | description 28 | images 29 | } 30 | } 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /src/screens/setting/about.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ScrollView, View } from "react-native"; 3 | import { useTheme } from "@/common/theme"; 4 | import { AccountHeader } from "./account-header"; 5 | import { InviteSection } from "@/screens/referral-screen/components/invite-section"; 6 | 7 | import { MainContent } from "./main-content"; 8 | 9 | export const About = () => { 10 | const theme = useTheme().colorTheme; 11 | 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /src/types/jsx.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare namespace JSX { 4 | interface IntrinsicElements extends React.JSX.IntrinsicElements {} 5 | interface Element extends React.JSX.Element {} 6 | interface ElementClass extends React.JSX.ElementClass {} 7 | interface ElementAttributesProperty 8 | extends React.JSX.ElementAttributesProperty {} 9 | interface ElementChildrenAttribute 10 | extends React.JSX.ElementChildrenAttribute {} 11 | type LibraryManagedAttributes = React.JSX.LibraryManagedAttributes< 12 | C, 13 | P 14 | >; 15 | interface IntrinsicAttributes extends React.JSX.IntrinsicAttributes {} 16 | interface IntrinsicClassAttributes 17 | extends React.JSX.IntrinsicClassAttributes {} 18 | } 19 | -------------------------------------------------------------------------------- /src/screens/setting/logout.ts: -------------------------------------------------------------------------------- 1 | import fetch from "isomorphic-unfetch"; 2 | import { analytics } from "@/common/analytics"; 3 | import { getEndpoint, headers } from "@/common/request"; 4 | import { sessionVar } from "@/common/vars"; 5 | 6 | export async function actionLogout(authToken: string) { 7 | sessionVar(null); 8 | try { 9 | await fetch(getEndpoint("logout"), { 10 | method: "GET", 11 | headers: { 12 | ...headers, 13 | authorization: `Bearer ${authToken}`, 14 | }, 15 | }); 16 | analytics.track("logged_out", {}); 17 | analytics.peopleDeleteUser(); 18 | } catch (err) { 19 | // it is fine not to handle the server-side token invalidation 20 | console.log(`failed to request logout: ${err}`); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/screens/add-transaction-screen/payee-input-screen.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { i18n } from "@/translations"; 3 | import { useLocalSearchParams } from "expo-router"; 4 | import { SelectedPayee } from "@/common/globalFnFactory"; 5 | import { TextInputScreen } from "@/components"; 6 | 7 | export function PayeeInputScreen(): JSX.Element { 8 | const { payee } = useLocalSearchParams<{ 9 | payee: string; 10 | }>(); 11 | const onSaved = SelectedPayee.getFn(); 12 | 13 | return ( 14 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "expo/tsconfig.base", 3 | "compilerOptions": { 4 | "allowSyntheticDefaultImports": true, 5 | "experimentalDecorators": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "importHelpers": true, 8 | "noEmitHelpers": true, 9 | "noEmitOnError": true, 10 | "noImplicitAny": true, 11 | "noImplicitReturns": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "preserveConstEnums": true, 15 | "skipDefaultLibCheck": true, 16 | "sourceMap": true, 17 | "strictNullChecks": true, 18 | "baseUrl": "./", 19 | "paths": { 20 | "@/*": ["src/*"] 21 | } 22 | }, 23 | "include": ["src/**/*.ts", "src/**/*.tsx", "app/**/*.ts", "app/**/*.tsx"], 24 | "exclude": ["node_modules"] 25 | } 26 | -------------------------------------------------------------------------------- /src/screens/add-transaction-screen/narration-input-screen.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { i18n } from "@/translations"; 3 | import { SelectedNarration } from "@/common/globalFnFactory"; 4 | import { useLocalSearchParams } from "expo-router"; 5 | import { TextInputScreen } from "@/components"; 6 | 7 | export function NarrationInputScreen(): JSX.Element { 8 | const { narration } = useLocalSearchParams<{ 9 | narration: string; 10 | }>(); 11 | const onSaved = SelectedNarration.getFn(); 12 | 13 | return ( 14 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/screens/journal-screen/journal-config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Configuration for journal screen column widths 3 | */ 4 | 5 | export interface JournalFieldWidth { 6 | /** Width of the field (number for fixed width, "flex" for flexible) */ 7 | width: number | "flex"; 8 | } 9 | 10 | /** 11 | * Journal field width configurations 12 | * Defines the column widths displayed in the journal screen 13 | */ 14 | export const JOURNAL_FIELD_WIDTHS: Record = { 15 | date: { 16 | width: 100, 17 | }, 18 | flag: { 19 | width: 80, 20 | }, 21 | description: { 22 | width: "flex", 23 | }, 24 | }; 25 | 26 | /** 27 | * Get field width configuration by field ID 28 | */ 29 | export const getFieldWidth = (fieldId: string): number | "flex" => { 30 | return JOURNAL_FIELD_WIDTHS[fieldId]?.width ?? "flex"; 31 | }; 32 | -------------------------------------------------------------------------------- /src/common/__tests__/fixtures/mock-expo-mixpanel-analytics.ts: -------------------------------------------------------------------------------- 1 | export class MockExpoMixpanelAnalytics { 2 | static instances: MockExpoMixpanelAnalytics[] = []; 3 | 4 | static reset(): void { 5 | MockExpoMixpanelAnalytics.instances = []; 6 | } 7 | 8 | token: string; 9 | 10 | identifyCalls: Array = []; 11 | 12 | trackCalls: Array<{ name: string; props: Record }> = []; 13 | 14 | constructor(token: string) { 15 | this.token = token; 16 | MockExpoMixpanelAnalytics.instances.push(this); 17 | } 18 | 19 | identify(id?: string): void { 20 | this.identifyCalls.push(id); 21 | } 22 | 23 | track(name: string, props: Record): void { 24 | this.trackCalls.push({ name, props }); 25 | } 26 | } 27 | 28 | export { MockExpoMixpanelAnalytics as ExpoMixpanelAnalytics }; 29 | -------------------------------------------------------------------------------- /src/screens/home-screen/components/net-assets-styled.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Text, View, StyleSheet } from "react-native"; 3 | import { ColorTheme } from "@/types/theme-props"; 4 | import { useThemeStyle } from "@/common/hooks/use-theme-style"; 5 | 6 | const getStyles = (theme: ColorTheme) => 7 | StyleSheet.create({ 8 | text: { 9 | fontSize: 28, 10 | color: theme.text01, 11 | fontWeight: "bold", 12 | }, 13 | container: { 14 | height: 40, 15 | justifyContent: "center", 16 | }, 17 | }); 18 | 19 | export function NetAssetsStyled({ 20 | netAssets, 21 | }: { 22 | netAssets: string; 23 | }): JSX.Element { 24 | const styles = useThemeStyle(getStyles); 25 | return ( 26 | 27 | {netAssets} 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /.claude/commands/commit.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Generates a commit message from staged (or unstaged) changes. 3 | argument-hint: [optional context...] 4 | allowed-tools: Bash(git diff:*) 5 | --- 6 | 7 | # Task: Generate a Conventional Commit Message 8 | 9 | Based on the following code changes, please generate a concise and descriptive commit message that follows the Conventional Commits specification. 10 | 11 | Provide only the commit message itself, without any introduction or explanation. 12 | 13 | - If it contains frontend changes, then briefly describe UI before / after the change. 14 | - !!Important!! Never mention `Generated with Claude Code` or `Co-Authored-By` 15 | 16 | ## Staged Changes: 17 | 18 | ``` 19 | !git diff --cached 20 | ``` 21 | 22 | ## Unstaged Changes: 23 | 24 | ``` 25 | !git diff 26 | ``` 27 | 28 | ## Optional User Context: 29 | 30 | $ARGUMENTS 31 | -------------------------------------------------------------------------------- /src/common/hooks/use-page-view.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { analytics } from "@/common/analytics"; 3 | 4 | /** 5 | * A hook that tracks page view analytics when a component mounts. 6 | * This replaces the common boilerplate pattern: 7 | * 8 | * ```typescript 9 | * useEffect(() => { 10 | * async function init() { 11 | * await analytics.track("page_view_xxx", {}); 12 | * } 13 | * init(); 14 | * }, []); 15 | * ``` 16 | * 17 | * @param pageName - The name of the page to track (will be prefixed with "page_view_") 18 | * @param props - Optional additional properties to include in the analytics event 19 | */ 20 | export const usePageView = ( 21 | pageName: string, 22 | props: Record = {}, 23 | ): void => { 24 | useEffect(() => { 25 | analytics.track(`page_view_${pageName}`, props); 26 | }, []); 27 | }; 28 | -------------------------------------------------------------------------------- /src/common/screen-util.ts: -------------------------------------------------------------------------------- 1 | import { Dimensions, PixelRatio, Platform, StatusBar } from "react-native"; 2 | 3 | export const ScreenWidth = Dimensions.get("window").width; 4 | export const ScreenHeight = Dimensions.get("window").height; 5 | const androidStatusBarHeight = StatusBar.currentHeight 6 | ? StatusBar.currentHeight 7 | : 0; 8 | const isIphoneX = 9 | Platform.OS === "ios" && 10 | Number(`${ScreenHeight / ScreenWidth}`.substring(0, 4)) * 100 === 216; 11 | const BAR_HEIGHT = isIphoneX ? 44 : 20; 12 | const NAV_BAR_HEIGHT = isIphoneX ? 88 : 64; 13 | const statusBarHeight = 14 | Platform.OS === "ios" ? BAR_HEIGHT : androidStatusBarHeight; 15 | const navigationBarHeight = 16 | Platform.OS === "ios" ? NAV_BAR_HEIGHT : androidStatusBarHeight + 59; 17 | const onePx = 1 / PixelRatio.get(); 18 | const contentPadding = 16; 19 | export { statusBarHeight, navigationBarHeight, onePx, contentPadding }; 20 | -------------------------------------------------------------------------------- /src/screens/home-screen/text-styled.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Text } from "react-native"; 3 | import { useTheme } from "@/common/theme"; 4 | 5 | export function HeaderText({ 6 | children, 7 | ...otherProps 8 | }: { 9 | children: React.ReactNode; 10 | }): JSX.Element { 11 | const theme = useTheme().colorTheme; 12 | return ( 13 | 17 | {children} 18 | 19 | ); 20 | } 21 | 22 | export function SmallHeaderText({ 23 | children, 24 | ...otherProps 25 | }: { 26 | children: React.ReactNode; 27 | }): JSX.Element { 28 | const theme = useTheme().colorTheme; 29 | return ( 30 | 34 | {children} 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | # GitHub Copilot Instructions 2 | 3 | ## File Modification Rules 4 | 5 | ### Never Modify yarn.lock 6 | 7 | **IMPORTANT**: Do not modify, update, or regenerate the `yarn.lock` file under any circumstances. 8 | 9 | - The `yarn.lock` file is automatically managed by the Yarn package manager 10 | - Manual or automated changes to this file can cause dependency inconsistencies 11 | - If dependencies need to be updated, ask the developer to run `yarn install` or `yarn upgrade` manually 12 | - Never suggest changes to `yarn.lock` in code suggestions or completions 13 | 14 | ## Project Context 15 | 16 | This is a React Native mobile application built with Expo. When making suggestions or generating code: 17 | - Follow existing TypeScript patterns and conventions in the codebase 18 | - Respect the project's dependency management through Yarn 19 | - Do not modify lock files or package manager configuration 20 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: CI 5 | 6 | on: 7 | push: 8 | branches: [main] 9 | pull_request: 10 | branches: [main] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [20.x] 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | cache: "yarn" 27 | cache-dependency-path: "./yarn.lock" 28 | - run: yarn install 29 | - run: yarn lint 30 | - run: yarn typecheck 31 | - run: yarn test:unit 32 | -------------------------------------------------------------------------------- /src/screens/add-transaction-screen/hooks/use-ledger-meta.ts: -------------------------------------------------------------------------------- 1 | import { useLedgerMetaQuery } from "@/generated-graphql/graphql"; 2 | import { 3 | getAccountsAndCurrency, 4 | handleOptions, 5 | type OptionTab, 6 | } from "./ledger-meta-utils"; 7 | 8 | export type { OptionTab }; 9 | 10 | export const useLedgerMeta = (userId: string) => { 11 | const { data, error, loading, refetch } = useLedgerMetaQuery({ 12 | variables: { userId }, 13 | fetchPolicy: "network-only", 14 | }); 15 | 16 | const { assets, expenses, currencies } = getAccountsAndCurrency( 17 | data?.ledgerMeta.data, 18 | ); 19 | 20 | const assetsOptionTabs = handleOptions(assets); 21 | const expensesOptionTabs = handleOptions(expenses); 22 | 23 | return { 24 | data: data?.ledgerMeta.data, 25 | assets, 26 | expenses, 27 | assetsOptionTabs, 28 | expensesOptionTabs, 29 | currencies, 30 | error, 31 | loading, 32 | refetch, 33 | }; 34 | }; 35 | -------------------------------------------------------------------------------- /src/common/apollo/client.ts: -------------------------------------------------------------------------------- 1 | import { ApolloClient, ApolloLink, HttpLink } from "@apollo/client"; 2 | 3 | import fetch from "isomorphic-unfetch"; 4 | import { getEndpoint } from "@/common/request"; 5 | import { sessionVar } from "@/common/vars"; 6 | import { onErrorLink } from "@/common/apollo/error-handling"; 7 | import { cache } from "@/common/apollo/cache"; 8 | 9 | const middlewareLink = new ApolloLink((operation, forward) => { 10 | const token = sessionVar()?.authToken; 11 | if (token) { 12 | operation.setContext({ 13 | headers: { authorization: `Bearer ${token}` }, 14 | }); 15 | } 16 | return forward(operation); 17 | }); 18 | 19 | // use with apollo-client 20 | const link = middlewareLink.concat( 21 | ApolloLink.from([ 22 | onErrorLink, 23 | new HttpLink({ 24 | uri: getEndpoint("api-gateway/"), 25 | fetch, 26 | }), 27 | ]), 28 | ); 29 | 30 | export const apolloClient = new ApolloClient({ 31 | link, 32 | cache, 33 | }); 34 | -------------------------------------------------------------------------------- /src/__mocks__/generated-graphql/graphql.ts: -------------------------------------------------------------------------------- 1 | // Mock for generated GraphQL types and hooks 2 | // Re-export types from generated code 3 | export type { 4 | HomeChartsQuery, 5 | AccountHierarchyQuery, 6 | LedgerMetaQuery, 7 | } from "../../generated-graphql/graphql"; 8 | 9 | // LedgerMeta type for legacy compatibility 10 | export type LedgerMeta = { 11 | accounts: string[]; 12 | currencies: string[]; 13 | errors: number; 14 | options: { 15 | name_assets: string; 16 | name_expenses: string; 17 | name_income: string; 18 | name_liabilities: string; 19 | name_equity: string; 20 | operating_currency: string[]; 21 | }; 22 | }; 23 | 24 | // Mock implementations for hooks 25 | export const useLedgerMetaQuery = () => ({}); 26 | export const useHomeChartsQuery = () => ({}); 27 | export const useAccountHierarchyQuery = () => ({}); 28 | export const useAddEntriesMutation = () => [() => {}, {}]; 29 | export const useUpdateReportSubscribeMutation = () => [() => {}, {}]; 30 | export const useUserProfileQuery = () => ({}); 31 | -------------------------------------------------------------------------------- /src/screens/home-screen/hooks/use-home-charts.ts: -------------------------------------------------------------------------------- 1 | import { useHomeChartsQuery } from "@/generated-graphql/graphql"; 2 | import { selectNetWorthArray } from "@/screens/home-screen/selectors/select-net-worth-array"; 3 | import { selectNetProfitArray } from "@/screens/home-screen/selectors/select-net-profit-array"; 4 | import { getNetWorth } from "@/screens/home-screen/selectors/select-net-worth"; 5 | 6 | export const useHomeCharts = ( 7 | userId: string, 8 | currency: string, 9 | ledgerId?: string, 10 | ) => { 11 | const { loading, data, error, refetch } = useHomeChartsQuery({ 12 | variables: { userId, ledgerId }, 13 | fetchPolicy: "network-only", 14 | }); 15 | 16 | const netWorth = getNetWorth(currency, data); 17 | const lastSixProfitData = selectNetProfitArray(currency, data); 18 | const lastSixWorthData = selectNetWorthArray(currency, data); 19 | 20 | return { 21 | loading, 22 | data, 23 | error, 24 | refetch, 25 | netWorth, 26 | lastSixProfitData, 27 | lastSixWorthData, 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /codegen.ts: -------------------------------------------------------------------------------- 1 | import type { CodegenConfig } from "@graphql-codegen/cli"; 2 | 3 | const schema = 4 | (process.env.EXPO_PUBLIC_SERVER_URL && 5 | process.env.EXPO_PUBLIC_SERVER_URL + "api-gateway/") || 6 | "https://beancount.io/api-gateway/"; 7 | 8 | const config: CodegenConfig = { 9 | overwrite: true, 10 | schema, 11 | documents: "src/common/graphql/**/*.graphql", 12 | generates: { 13 | "src/generated-graphql/graphql.tsx": { 14 | plugins: [ 15 | "typescript", 16 | "typescript-operations", 17 | "typescript-react-apollo", 18 | ], 19 | config: { 20 | scalars: { 21 | JSONObject: "Record", 22 | }, 23 | }, 24 | }, 25 | "src/generated-graphql/graphql.schema.json": { 26 | plugins: ["introspection"], 27 | }, 28 | "src/generated-graphql/schema.graphql": { 29 | plugins: ["schema-ast"], 30 | config: { 31 | includeDirectives: true, 32 | }, 33 | }, 34 | }, 35 | }; 36 | 37 | export default config; 38 | -------------------------------------------------------------------------------- /src/common/analytics.ts: -------------------------------------------------------------------------------- 1 | import { config } from "@/config"; 2 | import { ExpoMixpanelAnalytics } from "@/common/expo-mixpanel-analytics"; 3 | 4 | class MyAnalytics { 5 | mixpanel?: ExpoMixpanelAnalytics; 6 | 7 | constructor() { 8 | if (config.analytics.mixpanelProjectToken) { 9 | this.mixpanel = new ExpoMixpanelAnalytics( 10 | config.analytics.mixpanelProjectToken, 11 | ); 12 | } 13 | } 14 | 15 | identify(id: string): void { 16 | if (this.mixpanel) { 17 | this.mixpanel.identify(id); 18 | } 19 | } 20 | 21 | async track( 22 | name: string, 23 | props: Record, 24 | ): Promise { 25 | if (__DEV__) { 26 | return; 27 | } 28 | 29 | if (this.mixpanel) { 30 | this.mixpanel.track(name, props); 31 | } 32 | } 33 | 34 | peopleDeleteUser(): void { 35 | if (this.mixpanel) { 36 | this.mixpanel.people_delete_user(); 37 | } 38 | } 39 | } 40 | 41 | const analytics = new MyAnalytics(); 42 | 43 | export { analytics }; 44 | -------------------------------------------------------------------------------- /src/screens/ledger-screen/progress-bar.tsx: -------------------------------------------------------------------------------- 1 | import { StyleSheet, View } from "react-native"; 2 | import { useEffect, useState } from "react"; 3 | import { Progress } from "@/components"; 4 | 5 | type Props = { 6 | progress: number; 7 | }; 8 | 9 | const styles = StyleSheet.create({ 10 | container: { 11 | height: 3, 12 | }, 13 | }); 14 | 15 | export function ProgressBar(props: Props): JSX.Element { 16 | const { progress } = props; 17 | const [debouncedProgress, setDebouncedProgress] = useState(progress); 18 | 19 | useEffect(() => { 20 | if (progress !== 1) { 21 | setDebouncedProgress(progress); 22 | return; 23 | } 24 | const timer = setTimeout(() => { 25 | setDebouncedProgress(progress); 26 | }, 500); 27 | 28 | return () => clearTimeout(timer); 29 | }, [progress]); 30 | 31 | if (debouncedProgress === 1) { 32 | return ; 33 | } 34 | 35 | return ( 36 | 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/common/providers/providers.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ApolloProvider } from "@apollo/client"; 3 | import { apolloClient } from "@/common/apollo/client"; 4 | import { SplashProvider } from "./splash-provider/splash-provider"; 5 | import { ThemeProvider } from "./theme-provider/theme-provider"; 6 | import { ToastProvider } from "./toast-provider"; 7 | import { GestureHandlerRootView } from "react-native-gesture-handler"; 8 | import { StyleSheet } from "react-native"; 9 | 10 | const styles = StyleSheet.create({ 11 | container: { 12 | flex: 1, 13 | }, 14 | }); 15 | 16 | export function Providers({ 17 | children, 18 | }: { 19 | children: JSX.Element | JSX.Element[]; 20 | }): JSX.Element { 21 | return ( 22 | 23 | 24 | 25 | 26 | {children} 27 | 28 | 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/types/theme-props.ts: -------------------------------------------------------------------------------- 1 | export interface ThemeProps { 2 | name: string; 3 | antdTheme: AntdTheme; 4 | colorTheme: ColorTheme; 5 | sizing: number[]; 6 | } 7 | 8 | export interface AntdTheme { 9 | color_text_base: string; 10 | brand_primary: string; 11 | color_link: string; 12 | primary_button_fill: string; 13 | primary_button_fill_tap: string; 14 | } 15 | 16 | export interface ColorTheme { 17 | overlay: string; 18 | primary: string; 19 | primaryLight: string; 20 | primaryDark: string; 21 | secondary: string; 22 | white: string; 23 | black: string; 24 | black90: string; 25 | black80: string; 26 | black60: string; 27 | black40: string; 28 | black20: string; 29 | black10: string; 30 | text01: string; 31 | error: string; 32 | success: string; 33 | warning: string; 34 | information: string; 35 | nav01: string; 36 | nav02: string; 37 | tabIconDefault: string; 38 | tabIconSelected: string; 39 | activeTintColor: string; 40 | inactiveTintColor: string; 41 | activeBackgroundColor: string; 42 | inactiveBackgroundColor: string; 43 | navBg: string; 44 | navText: string; 45 | } 46 | -------------------------------------------------------------------------------- /src/common/url-utils.ts: -------------------------------------------------------------------------------- 1 | import { i18n } from "@/translations"; 2 | import { themeVar } from "@/common/vars"; 3 | import { getSystemColorScheme } from "@/common/theme"; 4 | 5 | /** 6 | * Appends the current application language and theme as query parameters to a URL 7 | * @param url - The URL to append the lang and theme parameters to 8 | * @returns New URL with lang and theme parameters appended based on current i18n locale and theme 9 | */ 10 | export function appendPreferenceParam(url: string): string { 11 | try { 12 | const targetUrl = new URL(url); 13 | 14 | // Append language parameter 15 | const currentLocale = i18n.locale; 16 | if (currentLocale) { 17 | targetUrl.searchParams.set("lang", currentLocale); 18 | } 19 | 20 | // Append theme parameter 21 | const currentTheme = themeVar(); 22 | const effectiveTheme = 23 | currentTheme === "system" ? getSystemColorScheme() : currentTheme; 24 | targetUrl.searchParams.set("theme", effectiveTheme); 25 | 26 | return targetUrl.toString(); 27 | } catch { 28 | // If URL parsing fails, return original URL 29 | return url; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2019-2020 Beancount.io 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | with: 14 | fetch-depth: 2 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version: 20 18 | cache: yarn 19 | cache-dependency-path: ./yarn.lock 20 | - name: Detect version change 21 | id: version 22 | run: | 23 | CURRENT_VERSION=$(jq -r '.version' package.json) 24 | PREVIOUS_VERSION=$(git show HEAD^:package.json 2>/dev/null | jq -r '.version') 25 | echo "current=${CURRENT_VERSION}" 26 | echo "previous=${PREVIOUS_VERSION}" 27 | if [ "${CURRENT_VERSION}" != "${PREVIOUS_VERSION}" ]; then 28 | echo "changed=true" >> "$GITHUB_OUTPUT" 29 | else 30 | echo "changed=false" >> "$GITHUB_OUTPUT" 31 | fi 32 | - name: Build and submit 33 | if: steps.version.outputs.changed == 'true' 34 | env: 35 | EAS_TOKEN: ${{ secrets.EAS_TOKEN }} 36 | run: ./deploy.sh 37 | -------------------------------------------------------------------------------- /src/common/number-utils.ts: -------------------------------------------------------------------------------- 1 | export const shortNumber = (number: number | string): string => { 2 | // Convert string to number if needed 3 | const num = typeof number === "string" ? parseFloat(number) : number; 4 | 5 | // Handle invalid numbers 6 | if (isNaN(num)) { 7 | return number.toString(); 8 | } 9 | 10 | // Handle negative numbers 11 | const isNegative = num < 0; 12 | const absNum = Math.abs(num); 13 | 14 | if (absNum < 1000) { 15 | return num.toFixed(1); 16 | } 17 | 18 | const suffixes = [ 19 | { value: 1e3, symbol: "K" }, 20 | { value: 1e6, symbol: "M" }, 21 | { value: 1e9, symbol: "B" }, 22 | { value: 1e12, symbol: "T" }, 23 | { value: 1e15, symbol: "Q" }, 24 | ]; 25 | 26 | for (let i = suffixes.length - 1; i >= 0; i--) { 27 | const { value, symbol } = suffixes[i]; 28 | if (absNum >= value) { 29 | const shortNum = absNum / value; 30 | // If the result is a whole number, don't show decimal 31 | if (shortNum === Math.floor(shortNum)) { 32 | return (isNegative ? "-" : "") + shortNum.toString() + symbol; 33 | } 34 | // Otherwise show one decimal place 35 | return (isNegative ? "-" : "") + shortNum.toFixed(1) + symbol; 36 | } 37 | } 38 | 39 | return num.toFixed(1); 40 | }; 41 | -------------------------------------------------------------------------------- /src/screens/journal-screen/journal-entry-item/journal-item-flag.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { StyleSheet, Text } from "react-native"; 3 | import { useThemeStyle } from "@/common/hooks"; 4 | import { ColorTheme } from "@/types/theme-props"; 5 | import { getFieldWidth } from "../journal-config"; 6 | import { isJournalTransaction, JournalDirectiveType } from "../types"; 7 | 8 | const getStyles = (theme: ColorTheme) => 9 | StyleSheet.create({ 10 | flagCell: { 11 | fontSize: 14, 12 | fontFamily: "monospace", 13 | color: theme.black90, 14 | textAlign: "center", 15 | }, 16 | }); 17 | 18 | interface JournalItemFlagProps { 19 | entry: JournalDirectiveType; 20 | } 21 | 22 | /** 23 | * Component for rendering the flag cell of a journal entry 24 | * Uses journal-config.ts for width configuration 25 | */ 26 | export const JournalItemFlag: React.FC = ({ entry }) => { 27 | const styles = useThemeStyle(getStyles); 28 | const displayFlag = isJournalTransaction(entry) 29 | ? entry.flag 30 | : entry.directive_type; 31 | const width = getFieldWidth("flag"); 32 | 33 | return ( 34 | 35 | {displayFlag} 36 | 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /src/common/providers/theme-provider/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { Appearance } from "react-native"; 3 | import { 4 | themes, 5 | ThemeProvider as CallStackThemeProvider, 6 | getSystemColorScheme, 7 | } from "@/common/theme"; 8 | import { themeVar } from "@/common/vars"; 9 | import { useReactiveVar } from "@apollo/client"; 10 | 11 | export const ThemeProvider = ({ 12 | children, 13 | }: { 14 | children: JSX.Element | JSX.Element[]; 15 | }) => { 16 | const currentThemeSetting = useReactiveVar(themeVar); 17 | const [systemColorScheme, setSystemColorScheme] = useState( 18 | getSystemColorScheme(), 19 | ); 20 | 21 | useEffect(() => { 22 | const subscription = Appearance.addChangeListener(({ colorScheme }) => { 23 | setSystemColorScheme(colorScheme === "dark" ? "dark" : "light"); 24 | }); 25 | 26 | return () => subscription?.remove(); 27 | }, []); 28 | 29 | const getEffectiveTheme = () => { 30 | if (currentThemeSetting === "system") { 31 | return systemColorScheme; 32 | } 33 | return currentThemeSetting; 34 | }; 35 | 36 | const effectiveTheme = getEffectiveTheme(); 37 | 38 | return ( 39 | 40 | {children} 41 | 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /app/(app)/_layout.tsx: -------------------------------------------------------------------------------- 1 | import { Redirect, Stack, router } from "expo-router"; 2 | import { useReactiveVar } from "@apollo/client"; 3 | import { sessionVar } from "@/common/vars"; 4 | import { useCallback } from "react"; 5 | 6 | import { HeaderBackButton } from "@react-navigation/elements"; 7 | import { useTheme } from "@/common/theme"; 8 | 9 | export const DefaultHeaderLeftBack = () => { 10 | const theme = useTheme().colorTheme; 11 | const handlePress = useCallback(() => { 12 | router.back(); 13 | }, []); 14 | return ; 15 | }; 16 | 17 | export default function AppLayout() { 18 | const session = useReactiveVar(sessionVar); 19 | const theme = useTheme().colorTheme; 20 | if (!session) { 21 | return ; 22 | } 23 | 24 | return ( 25 | 38 | 44 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/**/* 2 | .expo/ 3 | npm-debug.* 4 | *.jks 5 | *.p12 6 | *.key 7 | *.mobileprovision 8 | .idea/* 9 | 10 | ### Node ### 11 | # Logs 12 | logs 13 | *.log 14 | npm-debug.log* 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | 21 | # Directory for instrumented libs generated by jscoverage/JSCover 22 | lib-cov 23 | 24 | # Coverage directory used by tools like istanbul 25 | coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (http://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules 39 | jspm_packages 40 | 41 | # Optional npm cache directory 42 | .npm 43 | 44 | # Optional REPL history 45 | .node_repl_history 46 | 47 | ### JetBrains ### 48 | .idea/ 49 | 50 | .DS_Store 51 | 52 | # flow-typed definitions 53 | flow-typed/npm/ 54 | 55 | *-translations/ 56 | .env 57 | dump.rdb 58 | .vscode/ 59 | package-lock.json 60 | 61 | *.apk 62 | *.ipa 63 | 64 | __generated__ 65 | schema.json 66 | 67 | .eslintcache 68 | 69 | # @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb 70 | # The following patterns were generated by expo-cli 71 | 72 | expo-env.d.ts 73 | # @end expo-cli 74 | 75 | ios 76 | android 77 | !fastlane/metadata/android 78 | dist 79 | -------------------------------------------------------------------------------- /src/components/progress/index.tsx: -------------------------------------------------------------------------------- 1 | import { View as RNView } from "react-native"; 2 | import { useEffect } from "react"; 3 | import Animated, { 4 | useSharedValue, 5 | useAnimatedStyle, 6 | withTiming, 7 | } from "react-native-reanimated"; 8 | import { useTheme } from "@/common/theme"; 9 | import { useThemeStyle } from "@/common/hooks/use-theme-style"; 10 | 11 | type ProgressProps = { 12 | percent: number; 13 | height?: number; 14 | duration?: number; 15 | }; 16 | 17 | export const Progress = ({ 18 | percent, 19 | height = 3, 20 | duration = 500, 21 | }: ProgressProps) => { 22 | const percentValue = useSharedValue(percent); 23 | const theme = useTheme().colorTheme; 24 | const styles = useThemeStyle(() => ({ 25 | container: { 26 | backgroundColor: theme.white, 27 | height: height, 28 | width: "100%", 29 | }, 30 | })); 31 | const animatedStyle = useAnimatedStyle(() => ({ 32 | width: `${Math.min(percentValue.value, 100)}%`, 33 | backgroundColor: theme.primary, 34 | height: height, 35 | })); 36 | 37 | useEffect(() => { 38 | percentValue.value = withTiming(percent, { duration }); 39 | // eslint-disable-next-line react-hooks/exhaustive-deps 40 | }, [percent]); 41 | 42 | return ( 43 | 44 | 45 | 46 | ); 47 | }; 48 | 49 | export default Progress; 50 | -------------------------------------------------------------------------------- /src/common/__tests__/currency-util.test.ts: -------------------------------------------------------------------------------- 1 | type GetCurrencySymbol = typeof import("../currency-util").getCurrencySymbol; 2 | 3 | describe("getCurrencySymbol", () => { 4 | let getCurrencySymbol: GetCurrencySymbol; 5 | 6 | beforeAll(() => { 7 | const currencyIconsPath = require.resolve("currency-icons"); 8 | require.cache[currencyIconsPath] = { 9 | exports: { 10 | USD: { symbol: "$" }, 11 | EUR: {}, 12 | }, 13 | } as NodeModule; 14 | 15 | const modulePath = require.resolve("../currency-util"); 16 | delete require.cache[modulePath]; 17 | ({ getCurrencySymbol } = require("../currency-util")); 18 | }); 19 | 20 | afterAll(() => { 21 | const currencyIconsPath = require.resolve("currency-icons"); 22 | delete require.cache[currencyIconsPath]; 23 | 24 | const modulePath = require.resolve("../currency-util"); 25 | delete require.cache[modulePath]; 26 | }); 27 | 28 | it("returns the Yuan symbol for CNY regardless of configured icons", () => { 29 | expect(getCurrencySymbol("CNY")).toBe("¥"); 30 | }); 31 | 32 | it("returns the symbol provided by currency-icons when available", () => { 33 | expect(getCurrencySymbol("USD")).toBe("$"); 34 | }); 35 | 36 | it("falls back to an empty string when a symbol is unavailable", () => { 37 | expect(getCurrencySymbol("EUR")).toBe(""); 38 | expect(getCurrencySymbol("ABC")).toBe(""); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Pull Request 2 | 3 | ## Description 4 | 5 | 6 | ## Type of Change 7 | 8 | - [ ] 🐛 Bug fix 9 | - [ ] ✨ New feature 10 | - [ ] ♻️ Code refactor 11 | - [ ] 📝 Documentation update 12 | - [ ] 🎨 UI/UX improvement 13 | - [ ] 🧪 Test addition/update 14 | - [ ] 🚀 Performance improvement 15 | - [ ] 🔧 Configuration change 16 | - [ ] Other (please describe): 17 | 18 | ## Related Issues 19 | 20 | Fixes # 21 | 22 | ## Testing Instructions 23 | 24 | 1. 25 | 2. 26 | 3. 27 | 28 | ## Screenshots/Recordings 29 | 30 | 31 | 32 | ## Checklist 33 | 34 | - [ ] I have read and followed the [contributing guidelines](CONTRIBUTING.md) 35 | - [ ] My code follows the project's coding style 36 | - [ ] I have added tests that prove my fix is effective or that my feature works 37 | - [ ] I have updated the documentation accordingly 38 | - [ ] All tests pass locally 39 | - [ ] I have checked my code for any security issues 40 | - [ ] I have performed a self-review of my own code 41 | 42 | ## Additional Notes 43 | 44 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | # https://docs.expo.dev/build/introduction/ 4 | 5 | set -e # exit on error 6 | 7 | # Install dependencies 8 | yarn 9 | 10 | # 1. Install npx eas-cli@latest CLI if not already installed 11 | yarn global add eas-cli@latest 12 | 13 | # No need for login; EAS_TOKEN will be used automatically by the CLI if set 14 | # export EAS_TOKEN=your-token # <-- make sure this is set in your CI or shell environment 15 | 16 | # 2. Send Over-the-Air Updates #### 17 | npx eas-cli@latest update --channel production --message "Production update $(date +'%Y-%m-%d %H:%M:%S')" 18 | 19 | # -------------------------------------------------- 20 | # iOS 21 | # -------------------------------------------------- 22 | 23 | 24 | # 3. Build iOS App #### 25 | echo "Building iOS app..." 26 | npx eas-cli@latest build --platform ios --profile production --non-interactive --no-wait 27 | 28 | # 4. Submit iOS App #### 29 | echo "Submitting iOS app to App Store..." 30 | npx eas-cli@latest submit --platform ios --latest --non-interactive 31 | 32 | # -------------------------------------------------- 33 | # android 34 | # -------------------------------------------------- 35 | 36 | 37 | # 5. Build Android App #### 38 | echo "Building Android app..." 39 | npx eas-cli@latest build --platform android --profile production --non-interactive --no-wait 40 | 41 | # 6. Submit Android App #### 42 | echo "Submitting Android app to Play Store..." 43 | npx eas-cli@latest submit --platform android --latest --non-interactive 44 | -------------------------------------------------------------------------------- /src/screens/add-transaction-screen/list-item.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { StyleSheet, Text, TouchableOpacity, View } from "react-native"; 3 | import { useTheme } from "@/common/theme"; 4 | import { Ionicons } from "@expo/vector-icons"; 5 | import { ColorTheme } from "@/types/theme-props"; 6 | import { useThemeStyle } from "@/common/hooks/use-theme-style"; 7 | 8 | type ListItemProps = { 9 | onPress?: () => void; 10 | title: string; 11 | content?: string; 12 | }; 13 | 14 | export const ListItem = ({ onPress, title, content }: ListItemProps) => { 15 | const theme = useTheme().colorTheme; 16 | const styles = useThemeStyle((theme: ColorTheme) => 17 | StyleSheet.create({ 18 | container: { 19 | backgroundColor: theme.white, 20 | paddingVertical: 8, 21 | paddingHorizontal: 16, 22 | flexDirection: "row", 23 | justifyContent: "space-between", 24 | alignItems: "center", 25 | }, 26 | title: { 27 | fontSize: 14, 28 | color: theme.black80, 29 | }, 30 | content: { 31 | fontSize: 20, 32 | color: theme.black, 33 | }, 34 | }), 35 | ); 36 | return ( 37 | 42 | 43 | {title} 44 | {content} 45 | 46 | 47 | 48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /src/types/testing.d.ts: -------------------------------------------------------------------------------- 1 | declare type DoneCallback = () => void; 2 | 3 | declare interface TestContext { 4 | name: string; 5 | } 6 | 7 | declare type TestFunction = 8 | | (() => void | Promise) 9 | | ((this: TestContext) => void | Promise); 10 | 11 | declare function describe(name: string, fn: () => void): void; 12 | declare namespace describe { 13 | function only(name: string, fn: () => void): void; 14 | function skip(name: string, fn: () => void): void; 15 | } 16 | 17 | declare function it(name: string, fn: TestFunction): void; 18 | declare namespace it { 19 | function only(name: string, fn: TestFunction): void; 20 | function skip(name: string, fn: TestFunction): void; 21 | } 22 | 23 | declare function test(name: string, fn: TestFunction): void; 24 | declare namespace test { 25 | function only(name: string, fn: TestFunction): void; 26 | function skip(name: string, fn: TestFunction): void; 27 | } 28 | 29 | declare function beforeAll(fn: TestFunction): void; 30 | declare function afterAll(fn: TestFunction): void; 31 | declare function beforeEach(fn: TestFunction): void; 32 | declare function afterEach(fn: TestFunction): void; 33 | 34 | declare function expect(value: T): Expectation; 35 | 36 | declare interface Expectation { 37 | toBe(expected: T): void; 38 | toEqual(expected: unknown): void; 39 | toBeCloseTo(expected: number, precision?: number): void; 40 | toBeTruthy(): void; 41 | toBeFalsy(): void; 42 | toThrow( 43 | expected?: string | RegExp | (new (...args: unknown[]) => unknown), 44 | ): void; 45 | readonly not: Expectation; 46 | } 47 | -------------------------------------------------------------------------------- /src/common/__tests__/session-utils.test.ts: -------------------------------------------------------------------------------- 1 | import { createSession } from "../session-utils"; 2 | 3 | const createTokenWithPayload = (payload: Record) => { 4 | const header = Buffer.from( 5 | JSON.stringify({ alg: "HS256", typ: "JWT" }), 6 | ).toString("base64url"); 7 | const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); 8 | return `${header}.${body}.signature`; 9 | }; 10 | 11 | describe("createSession", () => { 12 | it("extracts the user id from the JWT subject", () => { 13 | const token = createTokenWithPayload({ sub: "user-123" }); 14 | expect(createSession(token)).toEqual({ 15 | userId: "user-123", 16 | authToken: token, 17 | }); 18 | }); 19 | 20 | it("works with tokens that include additional claims", () => { 21 | const token = createTokenWithPayload({ sub: "user-456", role: "admin" }); 22 | expect(createSession(token)).toEqual({ 23 | userId: "user-456", 24 | authToken: token, 25 | }); 26 | }); 27 | 28 | it("throws error for invalid token format", () => { 29 | expect(() => createSession("invalid-token")).toThrow(); 30 | }); 31 | 32 | it("throws error for token without sub claim", () => { 33 | const token = createTokenWithPayload({ role: "admin" }); 34 | expect(() => createSession(token)).toThrow(); 35 | }); 36 | 37 | it("handles numeric sub claim", () => { 38 | const token = createTokenWithPayload({ sub: 12345 }); 39 | const session = createSession(token); 40 | expect(session.userId).toBe("12345"); 41 | expect(session.authToken).toBe(token); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/common/globalFnFactory.ts: -------------------------------------------------------------------------------- 1 | // utils/globalFnFactory.ts 2 | 3 | // Generic type for callback functions - more type-safe than 'Function' 4 | type CallbackFunction = (...args: never[]) => unknown; 5 | 6 | const globalFnStore = new Map(); 7 | 8 | function createGlobalFn( 9 | key: string, 10 | initialFn?: T, 11 | ) { 12 | if (initialFn) { 13 | globalFnStore.set(key, initialFn); 14 | } 15 | 16 | return { 17 | setFn: (fn: T) => { 18 | globalFnStore.set(key, fn); 19 | }, 20 | getFn: (): T | undefined => { 21 | return globalFnStore.get(key) as T | undefined; 22 | }, 23 | deleteFn: () => { 24 | globalFnStore.delete(key); 25 | }, 26 | hasFn: () => { 27 | return globalFnStore.has(key); 28 | }, 29 | }; 30 | } 31 | 32 | export const getGlobalFn = (key: string) => { 33 | return globalFnStore.get(key) as T | undefined; 34 | }; 35 | 36 | export const SelectedAssets = 37 | createGlobalFn<(value: string) => void>("SelectedAssets"); 38 | export const SelectedExpenses = 39 | createGlobalFn<(value: string) => void>("SelectedExpenses"); 40 | export const SelectedCurrency = 41 | createGlobalFn<(value: string) => void>("SelectedCurrency"); 42 | 43 | export const SelectedPayee = 44 | createGlobalFn<(value: string) => void>("SelectedPayee"); 45 | 46 | export const SelectedNarration = 47 | createGlobalFn<(value: string) => void>("SelectedNarration"); 48 | 49 | export const AddTransactionCallback = createGlobalFn<() => Promise>( 50 | "AddTransactionCallback", 51 | ); 52 | -------------------------------------------------------------------------------- /src/translations/index.ts: -------------------------------------------------------------------------------- 1 | import * as Localization from "expo-localization"; 2 | import { I18n } from "i18n-js"; 3 | import { en } from "@/translations/en"; 4 | import { zh } from "@/translations/zh"; 5 | import { bg } from "@/translations/bg"; 6 | import { ca } from "@/translations/ca"; 7 | import { de } from "@/translations/de"; 8 | import { es } from "@/translations/es"; 9 | import { fa } from "@/translations/fa"; 10 | import { fr } from "@/translations/fr"; 11 | import { nl } from "@/translations/nl"; 12 | import { pt } from "@/translations/pt"; 13 | import { ru } from "@/translations/ru"; 14 | import { sk } from "@/translations/sk"; 15 | import { uk } from "@/translations/uk"; 16 | 17 | const SUPPORTED_LOCALES = [ 18 | "en", 19 | "zh", 20 | "bg", 21 | "ca", 22 | "de", 23 | "es", 24 | "fa", 25 | "fr", 26 | "nl", 27 | "pt", 28 | "ru", 29 | "sk", 30 | "uk", 31 | ]; 32 | 33 | const getLocale = () => { 34 | const locales = Localization.getLocales(); 35 | for (let i = 0; i < locales.length; i++) { 36 | const locale = locales[i]; 37 | if ( 38 | locale.languageCode && 39 | SUPPORTED_LOCALES.includes(locale.languageCode) 40 | ) { 41 | return locale.languageCode; 42 | } 43 | } 44 | return "en"; 45 | }; 46 | 47 | export const i18n = new I18n({ 48 | en, 49 | zh, 50 | bg, 51 | ca, 52 | de, 53 | es, 54 | fa, 55 | fr, 56 | nl, 57 | pt, 58 | ru, 59 | sk, 60 | uk, 61 | }); 62 | i18n.enableFallback = true; 63 | 64 | export const setLocale = (locale: string) => { 65 | i18n.locale = locale; 66 | }; 67 | 68 | i18n.locale = getLocale(); 69 | -------------------------------------------------------------------------------- /src/common/progress-bar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { View, StyleSheet, Animated } from "react-native"; 3 | import { useThemeStyle } from "@/common/hooks/use-theme-style"; 4 | import { ColorTheme } from "@/types/theme-props"; 5 | 6 | type Props = { 7 | progress: number; 8 | }; 9 | 10 | const getStyles = (theme: ColorTheme) => { 11 | return StyleSheet.create({ 12 | container: { 13 | width: "100%", 14 | height: 3, 15 | }, 16 | progressTrack: { 17 | width: "100%", 18 | height: "100%", 19 | backgroundColor: theme.black10, 20 | }, 21 | progressBar: { 22 | height: "100%", 23 | backgroundColor: theme.primary, 24 | }, 25 | }); 26 | }; 27 | 28 | export const ProgressBar = ({ progress }: Props) => { 29 | const animatedWidth = React.useRef(new Animated.Value(0)).current; 30 | const styles = useThemeStyle(getStyles); 31 | 32 | React.useEffect(() => { 33 | Animated.timing(animatedWidth, { 34 | toValue: progress, 35 | duration: 200, 36 | useNativeDriver: false, 37 | }).start(); 38 | }, [progress, animatedWidth]); 39 | 40 | if (progress >= 1) { 41 | return null; 42 | } 43 | 44 | return ( 45 | 46 | 47 | 58 | 59 | 60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /src/screens/journal-screen/journal-entry-item/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { StyleSheet, TouchableOpacity, View } from "react-native"; 3 | import { useThemeStyle } from "@/common/hooks"; 4 | import { ColorTheme } from "@/types/theme-props"; 5 | import { JournalItemDate } from "./journal-item-date"; 6 | import { JournalItemFlag } from "./journal-item-flag"; 7 | import { JournalItemDescription } from "./journal-item-description"; 8 | import { JournalDirectiveType } from "../types"; 9 | 10 | const getStyles = (theme: ColorTheme) => 11 | StyleSheet.create({ 12 | entryContainer: { 13 | flexDirection: "row", 14 | alignItems: "center", 15 | paddingHorizontal: 16, 16 | paddingVertical: 12, 17 | backgroundColor: theme.white, 18 | }, 19 | }); 20 | 21 | interface JournalEntryItemProps { 22 | entry: JournalDirectiveType; 23 | onPress?: () => void; 24 | } 25 | 26 | /** 27 | * Component for rendering a single journal entry row 28 | */ 29 | export const JournalEntryItem: React.FC = ({ 30 | entry, 31 | onPress, 32 | }) => { 33 | const styles = useThemeStyle(getStyles); 34 | 35 | const content = ( 36 | <> 37 | 38 | 39 | 40 | 41 | ); 42 | 43 | if (onPress) { 44 | return ( 45 | 50 | {content} 51 | 52 | ); 53 | } 54 | 55 | return {content}; 56 | }; 57 | -------------------------------------------------------------------------------- /src/screens/journal-screen/journal-no-entries-for-filters-state.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { StyleSheet, Text, View } from "react-native"; 3 | import { Ionicons } from "@expo/vector-icons"; 4 | import { useThemeStyle, useTheme } from "@/common/hooks"; 5 | import { ColorTheme } from "@/types/theme-props"; 6 | import { useTranslations } from "@/common/hooks/use-translations"; 7 | 8 | const getStyles = (theme: ColorTheme) => 9 | StyleSheet.create({ 10 | container: { 11 | flex: 1, 12 | alignItems: "center", 13 | justifyContent: "center", 14 | paddingHorizontal: 32, 15 | paddingVertical: 48, 16 | }, 17 | iconContainer: { 18 | width: 80, 19 | height: 80, 20 | borderRadius: 40, 21 | backgroundColor: theme.black10, 22 | alignItems: "center", 23 | justifyContent: "center", 24 | marginBottom: 20, 25 | }, 26 | message: { 27 | fontSize: 16, 28 | fontWeight: "500", 29 | color: theme.black80, 30 | textAlign: "center", 31 | lineHeight: 24, 32 | }, 33 | }); 34 | 35 | /** 36 | * Component for rendering the state when the journal has entries, 37 | * but none match the current filters. 38 | */ 39 | export const JournalNoEntriesForFiltersState = () => { 40 | const styles = useThemeStyle(getStyles); 41 | const { t } = useTranslations(); 42 | const theme = useTheme().colorTheme; 43 | 44 | return ( 45 | 46 | 47 | 48 | 49 | {t("journalNoEntriesForFilters")} 50 | 51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /src/common/apollo/__tests__/cache.test.ts: -------------------------------------------------------------------------------- 1 | // Test the cache module 2 | describe("Apollo cache", () => { 3 | it("exports cache instance", () => { 4 | const { cache } = require("../../apollo/cache"); 5 | expect(cache).toBeTruthy(); 6 | }); 7 | 8 | it("cache is InMemoryCache instance", () => { 9 | const { cache } = require("../../apollo/cache"); 10 | expect(typeof cache.extract).toBe("function"); 11 | expect(typeof cache.restore).toBe("function"); 12 | }); 13 | 14 | it("cache has read method", () => { 15 | const { cache } = require("../../apollo/cache"); 16 | expect(typeof cache.read).toBe("function"); 17 | }); 18 | 19 | it("cache has write method", () => { 20 | const { cache } = require("../../apollo/cache"); 21 | expect(typeof cache.write).toBe("function"); 22 | }); 23 | 24 | it("cache has modify method", () => { 25 | const { cache } = require("../../apollo/cache"); 26 | expect(typeof cache.modify).toBe("function"); 27 | }); 28 | 29 | it("cache has gc method", () => { 30 | const { cache } = require("../../apollo/cache"); 31 | expect(typeof cache.gc).toBe("function"); 32 | }); 33 | 34 | it("cache has reset method", () => { 35 | const { cache } = require("../../apollo/cache"); 36 | expect(typeof cache.reset).toBe("function"); 37 | }); 38 | 39 | it("cache has evict method", () => { 40 | const { cache } = require("../../apollo/cache"); 41 | expect(typeof cache.evict).toBe("function"); 42 | }); 43 | 44 | it("cache extract returns empty object initially", () => { 45 | const { cache } = require("../../apollo/cache"); 46 | const extracted = cache.extract(); 47 | expect(typeof extracted).toBe("object"); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /src/screens/journal-screen/journal-entry-item/journal-item-date.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { StyleSheet, Text } from "react-native"; 3 | import { useThemeStyle } from "@/common/hooks"; 4 | import { ColorTheme } from "@/types/theme-props"; 5 | import { getFieldWidth } from "../journal-config"; 6 | 7 | const getStyles = (theme: ColorTheme) => 8 | StyleSheet.create({ 9 | dateCell: { 10 | fontSize: 14, 11 | fontFamily: "monospace", 12 | color: theme.black90, 13 | textAlign: "center", 14 | }, 15 | }); 16 | 17 | /** 18 | * Formats a date string as YYYY-MM-DD (ISO format) 19 | */ 20 | const formatDateISO = (dateString: string | null | undefined): string => { 21 | if (!dateString) return ""; 22 | try { 23 | const date = new Date(dateString); 24 | if (Number.isNaN(date.getTime())) return dateString; 25 | const year = date.getUTCFullYear(); 26 | const month = String(date.getUTCMonth() + 1).padStart(2, "0"); 27 | const day = String(date.getUTCDate()).padStart(2, "0"); 28 | return `${year}-${month}-${day}`; 29 | } catch { 30 | return dateString; 31 | } 32 | }; 33 | 34 | interface JournalItemDateProps { 35 | date: string | null | undefined; 36 | } 37 | 38 | /** 39 | * Component for rendering the date cell of a journal entry 40 | * Uses journal-config.ts for width configuration 41 | */ 42 | export const JournalItemDate: React.FC = ({ date }) => { 43 | const styles = useThemeStyle(getStyles); 44 | const formattedDate = formatDateISO(date); 45 | const width = getFieldWidth("date"); 46 | 47 | return ( 48 | 49 | {formattedDate} 50 | 51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /src/components/loading-tile/index.tsx: -------------------------------------------------------------------------------- 1 | import { useReactiveVar } from "@apollo/client"; 2 | import React, { useEffect } from "react"; 3 | import { StyleSheet, ViewStyle } from "react-native"; 4 | import { themeVar } from "@/common/vars"; 5 | import Animated, { 6 | useSharedValue, 7 | useAnimatedStyle, 8 | withRepeat, 9 | withTiming, 10 | withSequence, 11 | } from "react-native-reanimated"; 12 | 13 | const styles = StyleSheet.create({ 14 | light: { 15 | backgroundColor: "rgba(0,0,0,0.1)", 16 | }, 17 | dark: { 18 | backgroundColor: "rgba(255, 255, 255, 0.1)", 19 | }, 20 | loadingTile: { 21 | overflow: "hidden", 22 | borderRadius: 3, 23 | }, 24 | }); 25 | 26 | type LoadingTileProps = { 27 | mx?: number; 28 | height?: number; 29 | width?: number; 30 | style?: ViewStyle; 31 | }; 32 | 33 | export const LoadingTile = (props: LoadingTileProps) => { 34 | const { style, mx, height } = props; 35 | const theme = useReactiveVar(themeVar); 36 | 37 | const dynamicStyles: ViewStyle = { 38 | ...(mx && { marginHorizontal: mx }), 39 | ...(height && { height }), 40 | }; 41 | 42 | const opacity = useSharedValue(1); 43 | 44 | useEffect(() => { 45 | opacity.value = withRepeat( 46 | withSequence( 47 | withTiming(0.5, { duration: 300 }), 48 | withTiming(1, { duration: 300 }), 49 | ), 50 | -1, 51 | true, 52 | ); 53 | }, [opacity]); 54 | 55 | const animatedStyle = useAnimatedStyle(() => { 56 | return { 57 | opacity: opacity.value, 58 | }; 59 | }); 60 | 61 | return ( 62 | 71 | ); 72 | }; 73 | -------------------------------------------------------------------------------- /src/screens/welcome/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Dimensions, View, StyleSheet, Image } from "react-native"; 3 | import { useTranslations } from "@/common/hooks/use-translations"; 4 | import { ColorTheme } from "@/types/theme-props"; 5 | import { useThemeStyle, usePageView } from "@/common/hooks"; 6 | import { Button } from "@/components"; 7 | import { LoginOrSignUp } from "@/screens/welcome/auth-modal"; 8 | 9 | const { height } = Dimensions.get("window"); 10 | 11 | const getStyles = (theme: ColorTheme) => 12 | StyleSheet.create({ 13 | container: { 14 | height, 15 | backgroundColor: theme.white, 16 | alignItems: "center", 17 | justifyContent: "center", 18 | }, 19 | icon: { 20 | height: 144, 21 | width: 144, 22 | }, 23 | buttonContainer: { 24 | position: "absolute", 25 | left: 0, 26 | right: 0, 27 | bottom: 80, 28 | height: 44, 29 | flexDirection: "row", 30 | justifyContent: "space-around", 31 | paddingHorizontal: 20, 32 | gap: 10, 33 | }, 34 | flex: { 35 | flex: 1, 36 | }, 37 | }); 38 | 39 | export function WelcomeScreen(): JSX.Element { 40 | usePageView("pre_auth"); 41 | const styles = useThemeStyle(getStyles); 42 | const { t } = useTranslations(); 43 | return ( 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /src/components/list/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from "react"; 2 | import { StyleProp, StyleSheet, View, ViewStyle } from "react-native"; 3 | import { ColorTheme } from "@/types/theme-props"; 4 | import { useThemeStyle } from "@/common/hooks/use-theme-style"; 5 | 6 | export const ListDivider = () => { 7 | const styles = useThemeStyle((theme: ColorTheme) => 8 | StyleSheet.create({ 9 | divider: { 10 | height: StyleSheet.hairlineWidth, 11 | backgroundColor: theme.black60, 12 | width: "100%", 13 | }, 14 | }), 15 | ); 16 | return ; 17 | }; 18 | 19 | export const List = ({ 20 | children, 21 | style, 22 | }: { 23 | children: React.ReactNode; 24 | style?: StyleProp; 25 | }) => { 26 | const styles = useThemeStyle((theme: ColorTheme) => 27 | StyleSheet.create({ 28 | container: { 29 | borderBottomWidth: StyleSheet.hairlineWidth, 30 | borderBottomColor: theme.black20, 31 | borderTopWidth: StyleSheet.hairlineWidth, 32 | borderTopColor: theme.black20, 33 | }, 34 | }), 35 | ); 36 | 37 | // Convert children to array and add dividers between ListItem components 38 | const childrenArray = React.Children.toArray(children); 39 | const itemsWithDividers: React.ReactNode[] = []; 40 | 41 | childrenArray.forEach((child, index) => { 42 | // Add the child 43 | itemsWithDividers.push(child); 44 | 45 | // Add divider after each child except the last one 46 | if (index < childrenArray.length - 1) { 47 | itemsWithDividers.push(); 48 | } 49 | }); 50 | 51 | const containerStyle = useMemo( 52 | () => StyleSheet.flatten([styles.container, style]), 53 | [style, styles.container], 54 | ); 55 | 56 | return {itemsWithDividers}; 57 | }; 58 | -------------------------------------------------------------------------------- /src/screens/home-screen/selectors/select-chart-array-helper.ts: -------------------------------------------------------------------------------- 1 | import { HomeChartsQuery } from "@/generated-graphql/graphql"; 2 | import { i18n } from "@/translations"; 3 | 4 | export function isSameMonth(date1?: string, date2?: string): boolean { 5 | if (!date1 || !date2) { 6 | return date1 === date2; 7 | } 8 | // Compare both year and month (YYYY-MM) 9 | return date1.slice(0, 7) === date2.slice(0, 7); 10 | } 11 | 12 | /** 13 | * Helper function to extract and format chart data for a given label 14 | * @param label - The chart label to search for (e.g., "Net Worth", "Net Profit") 15 | * @param currency - The currency to extract from balance data 16 | * @param data - The home charts query data 17 | * @returns Object with labels (month strings) and numbers (balance values) 18 | */ 19 | export function selectChartArray( 20 | label: string, 21 | currency: string, 22 | data?: HomeChartsQuery, 23 | ) { 24 | const chartData = data?.homeCharts?.data.find((n) => n.label === label); 25 | const last = chartData?.data.slice( 26 | chartData?.data.length - 7, 27 | chartData?.data.length, 28 | ); 29 | 30 | // Remove duplicate month entries (keep the most recent one for each month) 31 | const deduplicated = last?.reduceRight((acc, current) => { 32 | const currentMonth = current.date.slice(0, 7); 33 | const hasMonth = acc.some((item) => item.date.slice(0, 7) === currentMonth); 34 | if (!hasMonth) { 35 | acc.unshift(current); 36 | } 37 | return acc; 38 | }, []); 39 | 40 | let labels = deduplicated?.map((l) => l.date.slice(5, 7)) || []; 41 | let numbers = 42 | deduplicated?.map((l) => Number(l.balance[currency] || 0)) || []; 43 | 44 | if (labels.length === 0) { 45 | labels = [i18n.t("noDataCharts")]; 46 | } 47 | if (numbers.length === 0) { 48 | numbers = [0]; 49 | } 50 | 51 | return { labels, numbers }; 52 | } 53 | -------------------------------------------------------------------------------- /src/screens/referral-screen/components/contact-row.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Text, View, StyleSheet, TouchableOpacity } from "react-native"; 3 | import { MaterialIcons } from "@expo/vector-icons"; 4 | import { contentPadding } from "@/common/screen-util"; 5 | import { useTheme } from "@/common/theme"; 6 | import { CommonMargin } from "@/common/common-margin"; 7 | import { ColorTheme } from "@/types/theme-props"; 8 | 9 | type ContactRowProps = { 10 | name: string; 11 | emailOrNumber: string; 12 | onPress: () => void; 13 | selected: boolean; 14 | }; 15 | 16 | const getStyles = (theme: ColorTheme) => 17 | StyleSheet.create({ 18 | rowContainer: { 19 | paddingHorizontal: contentPadding, 20 | paddingVertical: contentPadding * 0.5, 21 | flexDirection: "row", 22 | alignItems: "center", 23 | }, 24 | name: { color: theme.text01, fontSize: 16, fontWeight: "500" }, 25 | emailOrNum: { marginTop: 4, color: theme.black80, fontSize: 14 }, 26 | }); 27 | 28 | export function ContactRow({ 29 | onPress, 30 | name, 31 | emailOrNumber, 32 | selected, 33 | }: ContactRowProps): JSX.Element { 34 | const theme = useTheme().colorTheme; 35 | const styles = getStyles(theme); 36 | const iconName = selected 37 | ? ("radio-button-checked" as const) 38 | : ("radio-button-unchecked" as const); 39 | return ( 40 | 41 | 42 | 43 | 44 | 45 | {name || emailOrNumber} 46 | {name.length > 0 && ( 47 | {emailOrNumber} 48 | )} 49 | 50 | 51 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/common/__tests__/format-util.test.ts: -------------------------------------------------------------------------------- 1 | import { getFormatDate } from "../format-util"; 2 | 3 | describe("getFormatDate", () => { 4 | it("formats a date with zero-padded month and day", () => { 5 | const formatted = getFormatDate(new Date(2025, 1, 3)); 6 | expect(formatted).toBe("2025-02-03"); 7 | }); 8 | 9 | it("supports double-digit months and days without extra zeroes", () => { 10 | const formatted = getFormatDate(new Date(2025, 10, 23)); 11 | expect(formatted).toBe("2025-11-23"); 12 | }); 13 | 14 | it("handles the first day of the year", () => { 15 | const formatted = getFormatDate(new Date(2025, 0, 1)); 16 | expect(formatted).toBe("2025-01-01"); 17 | }); 18 | 19 | it("handles the last day of the year", () => { 20 | const formatted = getFormatDate(new Date(2025, 11, 31)); 21 | expect(formatted).toBe("2025-12-31"); 22 | }); 23 | 24 | it("formats dates from different years correctly", () => { 25 | const formatted1 = getFormatDate(new Date(2020, 5, 15)); 26 | const formatted2 = getFormatDate(new Date(2030, 5, 15)); 27 | expect(formatted1).toBe("2020-06-15"); 28 | expect(formatted2).toBe("2030-06-15"); 29 | }); 30 | 31 | it("handles leap year dates", () => { 32 | const formatted = getFormatDate(new Date(2024, 1, 29)); 33 | expect(formatted).toBe("2024-02-29"); 34 | }); 35 | 36 | it("handles invalid dates gracefully", () => { 37 | const invalidDate = new Date("invalid"); 38 | const formatted = getFormatDate(invalidDate); 39 | expect(formatted).toBe("NaN-NaN-NaN"); 40 | }); 41 | 42 | it("handles year 2000 (Y2K)", () => { 43 | const formatted = getFormatDate(new Date(2000, 0, 1)); 44 | expect(formatted).toBe("2000-01-01"); 45 | }); 46 | 47 | it("handles far future dates", () => { 48 | const formatted = getFormatDate(new Date(2100, 11, 31)); 49 | expect(formatted).toBe("2100-12-31"); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Beancount.io logo

2 | 3 |

Beancount Mobile CE

4 | 5 |

6 | 7 |

8 | 9 | Beancount Mobile Community Edition is an iOS and Android App for [Beancount.io - Double-entry bookkeeping made easy for living your best financial life](https://beancount.io/?utm_source=github.com&utm_medium=readme&utm_campaign=os_oct_1) 💰 10 | 11 | ![Beancount Mobile](https://beancount-io.b-cdn.net/beancount-ios-app-2.png) 12 | 13 | ## Development 14 | 15 | Run it locally 16 | 17 | ```zsh 18 | git clone https://github.com/beancount/beancount-mobile.git 19 | cd beancount-mobile 20 | yarn install 21 | yarn start 22 | ``` 23 | 24 | Scripts 25 | 26 | - `yarn test`: run lint and type checks 27 | - `yarn lint`: run the linter 28 | - `yarn codegen`: generate Apollo GraphQL schema types 29 | 30 | ## Languages 31 | 32 | The app will use your device language when possible. Available translations: 33 | 34 | - English 35 | - Chinese 36 | - Spanish 37 | - French 38 | 39 | ## Like it? 40 | 41 | Star ⭐️ the repo, download the App, and give it a review! 42 | 43 | download from App Store 44 | 45 | download from Play Store 46 | 47 | ## Have a question? 48 | 49 | Ask us at https://t.me/beancount 50 | -------------------------------------------------------------------------------- /src/screens/home-screen/email-icon.tsx: -------------------------------------------------------------------------------- 1 | import { G, Path, Svg } from "react-native-svg"; 2 | import * as React from "react"; 3 | import { useTheme } from "@/common/theme"; 4 | 5 | export function EmailIcon(): JSX.Element { 6 | const theme = useTheme().colorTheme; 7 | return ( 8 | 9 | 17 | 23 | 27 | 32 | 33 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/common/apollo/persistent-var.ts: -------------------------------------------------------------------------------- 1 | // persistentVar.ts 2 | import { makeVar, ReactiveVar } from "@apollo/client"; 3 | import AsyncStorage from "@react-native-async-storage/async-storage"; 4 | 5 | // 定义泛型类型,支持任意类型的持久化变量 6 | export function createPersistentVar( 7 | key: string, // AsyncStorage 的键名 8 | defaultValue: T, // 默认值(初始加载失败时使用) 9 | serialize?: (value: T) => string, // 序列化函数(默认 JSON.stringify) 10 | deserialize?: (value: string) => T, // 反序列化函数(默认 JSON.parse) 11 | ): [ReactiveVar, () => Promise] { 12 | // 初始化变量(内存中的响应式变量) 13 | const varInstance = makeVar(defaultValue); 14 | 15 | // 序列化与反序列化方法(默认使用 JSON) 16 | const serializeValue = serialize || ((value) => JSON.stringify(value)); 17 | const deserializeValue = deserialize || ((value) => JSON.parse(value) as T); 18 | 19 | // 加载存储的值并更新变量(异步) 20 | const loadFromStorage = async (): Promise => { 21 | try { 22 | const storedValue = await AsyncStorage.getItem(key); 23 | if (storedValue !== null) { 24 | const result = deserializeValue(storedValue); 25 | varInstance(result); // 更新内存变量 26 | return result; 27 | } 28 | return null; 29 | } catch (error) { 30 | console.error(`Failed to load ${key} from AsyncStorage:`, error); 31 | return null; 32 | } 33 | }; 34 | 35 | // 监听变量变化并写入存储(异步) 36 | const saveToStorage = async (newValue: T): Promise => { 37 | try { 38 | const serializedValue = serializeValue(newValue); 39 | await AsyncStorage.setItem(key, serializedValue); 40 | } catch (error) { 41 | console.error(`Failed to save ${key} to AsyncStorage:`, error); 42 | } 43 | }; 44 | 45 | varInstance.onNextChange(function onNextChange(value) { 46 | saveToStorage(value); 47 | // https://github.com/apollographql/apollo-client/blob/v3.13.8/src/react/hooks/useReactiveVar.ts#L33 48 | varInstance.onNextChange(onNextChange); 49 | }); 50 | 51 | // 返回变量实例和手动加载方法(可选) 52 | return [varInstance, loadFromStorage]; 53 | } 54 | -------------------------------------------------------------------------------- /src/common/graphql/queries/journalEntries.graphql: -------------------------------------------------------------------------------- 1 | query JournalEntries( 2 | # Pagination parameters 3 | $first: Int 4 | $after: String 5 | $last: Int 6 | $before: String 7 | $detailed: Boolean 8 | # Enhanced search and filtering parameters 9 | $searchQuery: String 10 | $accountFilter: String 11 | $amountMin: Float 12 | $amountMax: Float 13 | $entryTypes: [String!] 14 | $sortBy: String 15 | $sortOrder: String 16 | $groupBy: String 17 | ) { 18 | journalEntries( 19 | first: $first 20 | after: $after 21 | last: $last 22 | before: $before 23 | detailed: $detailed 24 | searchQuery: $searchQuery 25 | accountFilter: $accountFilter 26 | amountMin: $amountMin 27 | amountMax: $amountMax 28 | entryTypes: $entryTypes 29 | sortBy: $sortBy 30 | sortOrder: $sortOrder 31 | groupBy: $groupBy 32 | ) { 33 | success 34 | data { 35 | date 36 | type 37 | meta { 38 | filename 39 | lineno 40 | } 41 | # Open/Close entry fields 42 | account 43 | booking 44 | currencies 45 | # Transaction fields 46 | flag 47 | links 48 | narration 49 | payee 50 | postings { 51 | account 52 | cost 53 | flag 54 | meta { 55 | filename 56 | lineno 57 | } 58 | price 59 | units { 60 | currency 61 | number 62 | } 63 | } 64 | tags 65 | # Balance entry fields 66 | amount { 67 | currency 68 | number 69 | } 70 | # Note/Document fields 71 | comment 72 | filename 73 | # Entry hash for detailed mode 74 | entry_hash 75 | entry_type 76 | # Error handling 77 | error 78 | error_message 79 | # Enhanced fields for UI rendering 80 | netAmount 81 | primaryAccount 82 | searchableText 83 | } 84 | # Enhanced pagination info 85 | pageInfo { 86 | hasNextPage 87 | hasPreviousPage 88 | startCursor 89 | endCursor 90 | totalCount 91 | } 92 | } 93 | } -------------------------------------------------------------------------------- /src/common/__tests__/request.test.ts: -------------------------------------------------------------------------------- 1 | import { config } from "../../config"; 2 | 3 | type HeadersType = typeof import("../request").headers; 4 | type GetEndpointType = typeof import("../request").getEndpoint; 5 | 6 | describe("request utilities", () => { 7 | let headers: HeadersType; 8 | let getEndpoint: GetEndpointType; 9 | let restoreResolveFilename: (() => void) | undefined; 10 | 11 | beforeAll(() => { 12 | const Module = require("module"); 13 | const originalResolveFilename = Module._resolveFilename; 14 | const configPath = require.resolve("../../config"); 15 | Module._resolveFilename = function patch( 16 | request: string, 17 | parent: NodeModule | null | undefined, 18 | isMain: boolean, 19 | options?: { paths?: string[] }, 20 | ) { 21 | if (request === "@/config") { 22 | return configPath; 23 | } 24 | return originalResolveFilename.call( 25 | this, 26 | request, 27 | parent, 28 | isMain, 29 | options, 30 | ); 31 | }; 32 | restoreResolveFilename = () => { 33 | Module._resolveFilename = originalResolveFilename; 34 | }; 35 | 36 | const constantsPath = require.resolve("expo-constants"); 37 | require.cache[constantsPath] = { 38 | exports: { nativeAppVersion: "9.9.9" }, 39 | } as NodeModule; 40 | 41 | const modulePath = require.resolve("../request"); 42 | delete require.cache[modulePath]; 43 | ({ headers, getEndpoint } = require("../request")); 44 | }); 45 | 46 | afterAll(() => { 47 | const constantsPath = require.resolve("expo-constants"); 48 | delete require.cache[constantsPath]; 49 | 50 | const modulePath = require.resolve("../request"); 51 | delete require.cache[modulePath]; 52 | 53 | restoreResolveFilename?.(); 54 | }); 55 | 56 | it("includes the app id and version headers", () => { 57 | expect(headers["x-app-id"]).toBe(config.project); 58 | expect(headers["x-app-version"]).toBe("9.9.9"); 59 | }); 60 | 61 | it("combines the server URL with the provided path", () => { 62 | expect(getEndpoint("api/data")).toBe(`${config.serverUrl}api/data`); 63 | expect(getEndpoint("/users")).toBe(`${config.serverUrl}/users`); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/common/__tests__/globalFnFactory.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AddTransactionCallback, 3 | SelectedAssets, 4 | getGlobalFn, 5 | } from "../globalFnFactory"; 6 | 7 | describe("globalFnFactory helpers", () => { 8 | afterEach(() => { 9 | SelectedAssets.deleteFn(); 10 | AddTransactionCallback.deleteFn(); 11 | }); 12 | 13 | it("stores and retrieves callbacks with SelectedAssets", () => { 14 | const handler = () => 42; 15 | SelectedAssets.setFn(handler); 16 | 17 | const stored = SelectedAssets.getFn(); 18 | expect(stored).toBe(handler); 19 | expect(SelectedAssets.hasFn()).toBe(true); 20 | 21 | SelectedAssets.deleteFn(); 22 | expect(SelectedAssets.getFn()).toBe(undefined); 23 | expect(SelectedAssets.hasFn()).toBe(false); 24 | }); 25 | 26 | it("allows retrieving callbacks via getGlobalFn", async () => { 27 | const callback = async (): Promise => { 28 | await Promise.resolve("done"); 29 | }; 30 | AddTransactionCallback.setFn(callback); 31 | 32 | const lookedUp = getGlobalFn("AddTransactionCallback"); 33 | expect(lookedUp).toBe(callback); 34 | await lookedUp?.(); 35 | }); 36 | 37 | it("returns undefined for non-existent keys", () => { 38 | const result = getGlobalFn("NonExistentKey"); 39 | expect(result).toBe(undefined); 40 | }); 41 | 42 | it("can store multiple different callbacks", () => { 43 | const callback1 = (value: string) => console.log(value); 44 | const callback2 = async () => Promise.resolve(); 45 | 46 | SelectedAssets.setFn(callback1); 47 | AddTransactionCallback.setFn(callback2); 48 | 49 | expect(SelectedAssets.getFn()).toBe(callback1); 50 | expect(AddTransactionCallback.getFn()).toBe(callback2); 51 | }); 52 | 53 | it("overwrites previous callback when setting a new one", () => { 54 | const callback1 = (value: string) => console.log("first", value); 55 | const callback2 = (value: string) => console.log("second", value); 56 | 57 | SelectedAssets.setFn(callback1); 58 | expect(SelectedAssets.getFn()).toBe(callback1); 59 | 60 | SelectedAssets.setFn(callback2); 61 | expect(SelectedAssets.getFn()).toBe(callback2); 62 | expect(SelectedAssets.getFn()).not.toBe(callback1); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/screens/journal-screen/journal-bottom-sheet/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from "react"; 2 | import { StyleSheet } from "react-native"; 3 | import BottomSheet, { 4 | BottomSheetBackdrop, 5 | BottomSheetScrollView, 6 | } from "@gorhom/bottom-sheet"; 7 | import { useThemeStyle } from "@/common/hooks"; 8 | import { ColorTheme } from "@/types/theme-props"; 9 | import { JournalDirectiveType } from "../types"; 10 | import { JournalBottomSheetContent } from "./sheet-content"; 11 | 12 | export const getStyles = (theme: ColorTheme) => 13 | StyleSheet.create({ 14 | contentContainer: { 15 | paddingHorizontal: 16, 16 | paddingTop: 16, 17 | paddingBottom: 32, 18 | }, 19 | backgroundStyle: { 20 | backgroundColor: theme.white, 21 | }, 22 | overlayStyle: { 23 | backgroundColor: theme.overlay, 24 | }, 25 | }); 26 | 27 | interface JournalBottomSheetProps { 28 | bottomSheetRef: React.RefObject; 29 | entry: JournalDirectiveType | null; 30 | ledgerId: string; 31 | } 32 | 33 | /** 34 | * Bottom sheet component for displaying journal entry details (read-only) 35 | * Shows entry location, balances, and source code 36 | */ 37 | export const JournalBottomSheet: React.FC = ({ 38 | bottomSheetRef, 39 | entry, 40 | ledgerId, 41 | }) => { 42 | const styles = useThemeStyle(getStyles); 43 | const renderBackdrop = useCallback( 44 | (props: any) => ( 45 | bottomSheetRef.current?.close()} 51 | /> 52 | ), 53 | [bottomSheetRef, styles.overlayStyle], 54 | ); 55 | 56 | return ( 57 | 66 | 67 | 68 | 69 | 70 | ); 71 | }; 72 | -------------------------------------------------------------------------------- /src/screens/home-screen/selectors/select-account-totals.ts: -------------------------------------------------------------------------------- 1 | import { AccountHierarchyQuery } from "@/generated-graphql/graphql"; 2 | 3 | export function getAccountTotals( 4 | currency: string, 5 | data?: AccountHierarchyQuery, 6 | ) { 7 | if (!currency || !data?.accountHierarchy?.data) { 8 | return { 9 | assets: "0.00", 10 | liabilities: "0.00", 11 | income: "0.00", 12 | expenses: "0.00", 13 | equity: "0.00", 14 | }; 15 | } 16 | 17 | const hierarchyData = data.accountHierarchy.data; 18 | const totals = { 19 | assets: "0.00", 20 | liabilities: "0.00", 21 | income: "0.00", 22 | expenses: "0.00", 23 | equity: "0.00", 24 | }; 25 | 26 | // Extract totals from the hierarchy data based on account type labels 27 | hierarchyData.forEach((item) => { 28 | if (!item.data?.balance_children) return; 29 | 30 | // Get the currency balance with proper null/undefined handling 31 | const balanceChildren = item.data.balance_children as Record< 32 | string, 33 | number | string 34 | >; 35 | // Use 'in' operator to check if currency exists, to handle 0 values correctly 36 | const balanceValue = 37 | currency in balanceChildren 38 | ? balanceChildren[currency] 39 | : (balanceChildren.USD ?? 0); 40 | // Convert to number if it's a string, with NaN handling 41 | let balance: number; 42 | if (typeof balanceValue === "string") { 43 | const parsed = Number(balanceValue); 44 | balance = isNaN(parsed) ? 0 : parsed; 45 | } else { 46 | balance = balanceValue; 47 | } 48 | const formattedBalance = Math.abs(balance).toFixed(2); 49 | 50 | switch (item.label.toLowerCase()) { 51 | case "assets": 52 | totals.assets = formattedBalance; 53 | break; 54 | case "liabilities": 55 | totals.liabilities = formattedBalance; 56 | break; 57 | case "income": 58 | totals.income = balance < 0 ? `-${formattedBalance}` : formattedBalance; 59 | break; 60 | case "expenses": 61 | totals.expenses = formattedBalance; 62 | break; 63 | case "equity": 64 | totals.equity = balance < 0 ? `-${formattedBalance}` : formattedBalance; 65 | break; 66 | } 67 | }); 68 | 69 | return totals; 70 | } 71 | -------------------------------------------------------------------------------- /src/common/__tests__/use-page-view.test.ts: -------------------------------------------------------------------------------- 1 | describe("usePageView", () => { 2 | describe("hook behavior", () => { 3 | it("should format page_view prefix correctly for common page names", () => { 4 | const pageNames = [ 5 | "home", 6 | "settings", 7 | "referral", 8 | "account_picker", 9 | "add_transaction", 10 | "add_transaction_next", 11 | "payee_input", 12 | "narration_input", 13 | "ledger", 14 | "journal", 15 | "pre_auth", 16 | "mine", 17 | ]; 18 | 19 | pageNames.forEach((pageName) => { 20 | const expectedEventName = `page_view_${pageName}`; 21 | expect(expectedEventName.startsWith("page_view_")).toBe(true); 22 | expect(expectedEventName.replace("page_view_", "")).toBe(pageName); 23 | }); 24 | }); 25 | 26 | it("should handle empty page name", () => { 27 | const pageName = ""; 28 | const expectedEventName = `page_view_${pageName}`; 29 | expect(expectedEventName).toBe("page_view_"); 30 | }); 31 | 32 | it("should handle page names with underscores", () => { 33 | const pageName = "add_transaction_next"; 34 | const expectedEventName = `page_view_${pageName}`; 35 | expect(expectedEventName).toBe("page_view_add_transaction_next"); 36 | }); 37 | 38 | it("should handle page names with numbers", () => { 39 | const pageName = "test_page_123"; 40 | const expectedEventName = `page_view_${pageName}`; 41 | expect(expectedEventName).toBe("page_view_test_page_123"); 42 | }); 43 | }); 44 | 45 | describe("props handling", () => { 46 | it("should handle empty props object", () => { 47 | const props = {}; 48 | expect(Object.keys(props).length).toBe(0); 49 | }); 50 | 51 | it("should handle props with string values", () => { 52 | const props = { key: "value" }; 53 | expect(props.key).toBe("value"); 54 | }); 55 | 56 | it("should handle props with boolean values", () => { 57 | const props = { isLoggedIn: true, hasSubscription: false }; 58 | expect(props.isLoggedIn).toBe(true); 59 | expect(props.hasSubscription).toBe(false); 60 | }); 61 | 62 | it("should handle props with number values", () => { 63 | const props = { amount: 100.5, count: 3 }; 64 | expect(props.amount).toBe(100.5); 65 | expect(props.count).toBe(3); 66 | }); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /src/screens/journal-screen/journal-bottom-sheet/balance-section.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Text, View, StyleSheet } from "react-native"; 3 | import { useThemeStyle } from "@/common/hooks"; 4 | import { ColorTheme } from "@/types/theme-props"; 5 | 6 | const getStyles = (theme: ColorTheme) => 7 | StyleSheet.create({ 8 | balanceSection: { 9 | borderBottomWidth: StyleSheet.hairlineWidth, 10 | borderBottomColor: theme.black10, 11 | }, 12 | balanceSectionHeader: { 13 | paddingVertical: 8, 14 | paddingHorizontal: 12, 15 | backgroundColor: theme.black10, 16 | }, 17 | balanceSectionHeaderText: { 18 | fontSize: 12, 19 | fontWeight: "600", 20 | color: theme.black80, 21 | }, 22 | balanceRow: { 23 | flexDirection: "row", 24 | justifyContent: "space-between", 25 | paddingVertical: 8, 26 | paddingHorizontal: 12, 27 | borderBottomWidth: StyleSheet.hairlineWidth, 28 | borderBottomColor: theme.black10, 29 | }, 30 | balanceRowLast: { 31 | borderBottomWidth: 0, 32 | }, 33 | balanceAccount: { 34 | fontSize: 13, 35 | fontFamily: "monospace", 36 | color: theme.text01, 37 | flex: 1, 38 | }, 39 | balanceAmount: { 40 | fontSize: 13, 41 | fontFamily: "monospace", 42 | color: theme.text01, 43 | textAlign: "right", 44 | }, 45 | }); 46 | 47 | interface BalanceSectionProps { 48 | title: string; 49 | balances: { account: string; amount: string }[]; 50 | } 51 | 52 | export const BalanceSection: React.FC = ({ 53 | title, 54 | balances, 55 | }) => { 56 | const styles = useThemeStyle(getStyles); 57 | 58 | if (balances.length === 0) { 59 | return null; 60 | } 61 | 62 | return ( 63 | 64 | 65 | {title} 66 | 67 | {balances.map((balance, index) => ( 68 | 75 | {balance.account} 76 | {balance.amount} 77 | 78 | ))} 79 | 80 | ); 81 | }; 82 | -------------------------------------------------------------------------------- /src/screens/referral-screen/components/invite-section.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { View, Text, StyleSheet, TouchableOpacity } from "react-native"; 3 | import { useTheme } from "@/common/theme"; 4 | import { contentPadding, ScreenWidth, onePx } from "@/common/screen-util"; 5 | import { useTranslations } from "@/common/hooks/use-translations"; 6 | import { GiftIcon } from "@/screens/referral-screen/components/gift-icon"; 7 | import { analytics } from "@/common/analytics"; 8 | import { ColorTheme } from "@/types/theme-props"; 9 | import { useRouter } from "expo-router"; 10 | 11 | const getStyles = (theme: ColorTheme) => 12 | StyleSheet.create({ 13 | container: { 14 | paddingHorizontal: contentPadding, 15 | backgroundColor: theme.white, 16 | marginVertical: contentPadding, 17 | }, 18 | title: { 19 | fontSize: 18, 20 | fontWeight: "bold", 21 | marginBottom: 0.5 * contentPadding, 22 | color: theme.text01, 23 | }, 24 | section: { 25 | flexDirection: "row", 26 | height: 80, 27 | borderTopColor: theme.black80, 28 | borderTopWidth: onePx, 29 | borderBottomColor: theme.black60, 30 | borderBottomWidth: onePx, 31 | }, 32 | summaryContainer: { 33 | width: ScreenWidth - 2 * contentPadding - 80, 34 | justifyContent: "center", 35 | }, 36 | summary: { 37 | fontSize: 16, 38 | lineHeight: 20, 39 | color: theme.text01, 40 | }, 41 | imageContainer: { 42 | height: 80, 43 | width: 80, 44 | justifyContent: "center", 45 | alignItems: "center", 46 | }, 47 | }); 48 | 49 | export function InviteSection(): JSX.Element { 50 | const theme = useTheme().colorTheme; 51 | const styles = getStyles(theme); 52 | const router = useRouter(); 53 | const { t } = useTranslations(); 54 | 55 | return ( 56 | 57 | {t("inviteFriends")} 58 | { 62 | analytics.track("tap_navigate_to_referral", {}); 63 | router.navigate("/(app)/referral"); 64 | }} 65 | > 66 | 67 | {t("inviteSummary")} 68 | 69 | 70 | 71 | 72 | 73 | 74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /src/screens/setting/account-header.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { StyleSheet, Text, View } from "react-native"; 3 | import { useTheme } from "@/common/theme"; 4 | import { ScreenWidth } from "@/common/screen-util"; 5 | import { useUserProfile } from "./hooks/use-user-profile"; 6 | import { LoadingTile } from "@/components/loading-tile"; 7 | import { ColorTheme } from "@/types/theme-props"; 8 | import { SafeAreaView } from "react-native-safe-area-context"; 9 | import { useSession } from "@/common/hooks/use-session"; 10 | import { useThemeStyle } from "@/common/hooks/use-theme-style"; 11 | import { useToast } from "@/common/hooks"; 12 | 13 | const getStyles = (theme: ColorTheme) => 14 | StyleSheet.create({ 15 | titleContainer: { 16 | paddingHorizontal: 14, 17 | paddingTop: 28, 18 | paddingBottom: 28, 19 | flexDirection: "row", 20 | backgroundColor: theme.primary, 21 | }, 22 | nameText: { 23 | color: theme.white, 24 | fontWeight: "600", 25 | fontSize: 24, 26 | }, 27 | loginSignUpText: { 28 | fontSize: 24, 29 | color: theme.white, 30 | fontWeight: "600", 31 | }, 32 | closeButton: { 33 | width: 60, 34 | height: 60, 35 | borderRadius: 30, 36 | backgroundColor: theme.primary, 37 | position: "absolute", 38 | bottom: 10, 39 | right: 10, 40 | }, 41 | closeText: { 42 | color: theme.white, 43 | fontSize: 24, 44 | }, 45 | }); 46 | 47 | export const EmailHeader = ({ userId }: { userId: string }) => { 48 | const { email, loading, error } = useUserProfile(userId); 49 | const styles = useThemeStyle(getStyles); 50 | const toast = useToast(); 51 | if (loading || error || !email) { 52 | if (error) { 53 | toast.showToast({ 54 | message: `failed to fetch user: ${error}`, 55 | type: "error", 56 | }); 57 | } 58 | return ; 59 | } 60 | return ( 61 | 62 | 63 | {email} 64 | 65 | 66 | ); 67 | }; 68 | 69 | export const AccountHeader = () => { 70 | const session = useSession(); 71 | const theme = useTheme().colorTheme; 72 | const styles = getStyles(theme); 73 | return ( 74 | 78 | {session ? : null} 79 | 80 | ); 81 | }; 82 | -------------------------------------------------------------------------------- /src/components/tabs/example.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { View, Text, StyleSheet } from "react-native"; 3 | import { Tabs, TabItem } from "./index"; 4 | import { useTheme } from "@/common/theme"; 5 | import { ColorTheme } from "@/types/theme-props"; 6 | 7 | const getStyles = (theme: ColorTheme) => 8 | StyleSheet.create({ 9 | container: { 10 | flex: 1, 11 | backgroundColor: theme.white, 12 | }, 13 | content: { 14 | flex: 1, 15 | justifyContent: "center", 16 | alignItems: "center", 17 | padding: 20, 18 | }, 19 | text: { 20 | fontSize: 18, 21 | color: theme.black, 22 | textAlign: "center", 23 | }, 24 | }); 25 | 26 | export const TabsExample: React.FC = () => { 27 | const theme = useTheme().colorTheme; 28 | const styles = getStyles(theme); 29 | 30 | const tabs: TabItem[] = [ 31 | { 32 | key: "tab1", 33 | title: "First Tab", 34 | component: ( 35 | 36 | This is the first tab content 37 | 38 | ), 39 | }, 40 | { 41 | key: "tab2", 42 | title: "Second Tab", 43 | component: ( 44 | 45 | This is the second tab content 46 | 47 | ), 48 | }, 49 | { 50 | key: "tab3", 51 | title: "Third Tab", 52 | component: ( 53 | 54 | This is the third tab content 55 | 56 | ), 57 | }, 58 | { 59 | key: "tab4", 60 | title: "Third Tab", 61 | component: ( 62 | 63 | This is the fourth tab content 64 | 65 | ), 66 | }, 67 | { 68 | key: "tab5", 69 | title: "Third Tab", 70 | component: ( 71 | 72 | This is the fifth tab content 73 | 74 | ), 75 | }, 76 | ]; 77 | 78 | const handleTabChange = (index: number) => { 79 | console.log("Tab changed to index:", index); 80 | }; 81 | 82 | return ( 83 | 84 | 91 | 92 | ); 93 | }; 94 | 95 | export default TabsExample; 96 | -------------------------------------------------------------------------------- /src/scripts/bump-version-dummy.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as path from "path"; 3 | 4 | /** 5 | * Bumps the version in app.json using the format 1.date.buildNumber 6 | * WITHOUT updating changelog content (dummy bump) 7 | * - date: current date in format YYYYMMDD 8 | * - buildNumber: incremented by 1, or 0 if not exists 9 | * - versionCode: same as buildNumber 10 | */ 11 | function bumpVersionDummy() { 12 | // Get the current date in YYYYMMDD format 13 | const today = new Date(); 14 | const year = today.getFullYear(); 15 | const month = String(today.getMonth() + 1).padStart(2, "0"); 16 | const day = String(today.getDate()).padStart(2, "0"); 17 | const dateStr = `${year}${month}${day}`; 18 | 19 | // Read the app.json file 20 | const appJsonPath = path.resolve(process.cwd(), "app.json"); 21 | const appJson = JSON.parse(fs.readFileSync(appJsonPath, "utf8")); 22 | 23 | // Parse the current version 24 | const currentVersion = appJson.expo.version || "1.0.0"; 25 | const versionParts = currentVersion.split("."); 26 | 27 | // Get the current build number 28 | let buildNumber = 0; 29 | if (versionParts.length >= 3) { 30 | buildNumber = parseInt(versionParts[2], 10) || 0; 31 | } 32 | 33 | // Increment build number 34 | buildNumber += 1; 35 | 36 | // Create the new version string 37 | const newVersion = `1.${dateStr}.${buildNumber}`; 38 | 39 | // Update the version in app.json 40 | appJson.expo.version = newVersion; 41 | 42 | // Update iOS build number 43 | if (appJson.expo.ios) { 44 | appJson.expo.ios.buildNumber = String(buildNumber); 45 | } 46 | 47 | // Update Android version code 48 | if (appJson.expo.android) { 49 | appJson.expo.android.versionCode = buildNumber; 50 | } 51 | 52 | // Update package.json version 53 | const packageJsonPath = path.resolve(process.cwd(), "package.json"); 54 | if (fs.existsSync(packageJsonPath)) { 55 | const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); 56 | packageJson.version = newVersion; 57 | fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); 58 | console.log(`Updated version in package.json to ${newVersion}`); 59 | } 60 | 61 | // Write the updated app.json file 62 | fs.writeFileSync(appJsonPath, JSON.stringify(appJson, null, 2)); 63 | 64 | console.log( 65 | `Version bumped to ${newVersion} (dummy bump - no changelog changes)`, 66 | ); 67 | console.log(`iOS build number: ${appJson.expo.ios?.buildNumber}`); 68 | console.log(`Android version code: ${appJson.expo.android?.versionCode}`); 69 | } 70 | 71 | // Execute the function 72 | bumpVersionDummy(); 73 | -------------------------------------------------------------------------------- /src/components/picker/example.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { View, Text, TouchableOpacity, StyleSheet } from "react-native"; 3 | import { Picker } from "./index"; 4 | import { useTheme } from "@/common/theme"; 5 | import { ColorTheme } from "@/types/theme-props"; 6 | 7 | const getStyles = (theme: ColorTheme) => 8 | StyleSheet.create({ 9 | container: { 10 | flex: 1, 11 | justifyContent: "center", 12 | alignItems: "center", 13 | backgroundColor: theme.white, 14 | }, 15 | button: { 16 | backgroundColor: theme.primary, 17 | paddingHorizontal: 20, 18 | paddingVertical: 12, 19 | borderRadius: 8, 20 | }, 21 | buttonText: { 22 | color: theme.white, 23 | fontSize: 16, 24 | fontWeight: "600", 25 | }, 26 | selectedText: { 27 | marginTop: 20, 28 | fontSize: 18, 29 | color: theme.text01, 30 | }, 31 | }); 32 | 33 | const sampleItems = [ 34 | { label: "Option 1", value: "option1" }, 35 | { label: "Option 2", value: "option2" }, 36 | { label: "Option 3", value: "option3" }, 37 | { label: "Option 4", value: "option4" }, 38 | { label: "Option 5", value: "option5" }, 39 | { label: "Option 6", value: "option6" }, 40 | { label: "Option 7", value: "option7" }, 41 | { label: "Option 8", value: "option8" }, 42 | { label: "Option 9", value: "option9" }, 43 | { label: "Option 10", value: "option10" }, 44 | ]; 45 | 46 | export const PickerExample: React.FC = () => { 47 | const [visible, setVisible] = useState(false); 48 | const [selectedItem, setSelectedItem] = useState<{ 49 | label: string; 50 | value: string; 51 | } | null>(null); 52 | const theme = useTheme().colorTheme; 53 | const styles = getStyles(theme); 54 | 55 | const handleSelect = (item: { label: string; value: string }) => { 56 | setSelectedItem(item); 57 | setVisible(false); 58 | }; 59 | 60 | const handleCancel = () => { 61 | setVisible(false); 62 | }; 63 | 64 | return ( 65 | 66 | setVisible(true)}> 67 | Open Picker 68 | 69 | 70 | {selectedItem && ( 71 | 72 | Selected: {selectedItem.label} ({selectedItem.value}) 73 | 74 | )} 75 | 76 | 83 | 84 | ); 85 | }; 86 | -------------------------------------------------------------------------------- /src/screens/add-transaction-screen/hooks/ledger-meta-utils.ts: -------------------------------------------------------------------------------- 1 | import { LedgerMeta } from "@/generated-graphql/graphql"; 2 | 3 | export interface OptionTab { 4 | title: string; 5 | options: string[]; 6 | } 7 | 8 | export function getAccountsAndCurrency(data: LedgerMeta | undefined) { 9 | let assets: string[] = []; 10 | let expenses: string[] = []; 11 | let currencies: string[] = []; 12 | 13 | if (data) { 14 | const assetsName = data.options.name_assets; 15 | const expensesName = data.options.name_expenses; 16 | const incomeName = data.options.name_income; 17 | const liabilityName = data.options.name_liabilities; 18 | const equity = data.options.name_equity; 19 | 20 | // Factory function to create order getter with custom ordering 21 | const createOrderGetter = (orderMap: Record) => { 22 | return (name: string): number => { 23 | for (const [accountType, order] of Object.entries(orderMap)) { 24 | if (name.startsWith(accountType)) { 25 | return order; 26 | } 27 | } 28 | return 5; // Default order for unknown types 29 | }; 30 | }; 31 | 32 | // Order for "from" accounts (sources of funds) 33 | const fromOrderMap: Record = { 34 | [assetsName]: 0, 35 | [liabilityName]: 1, 36 | [incomeName]: 2, 37 | [expensesName]: 3, 38 | [equity]: 4, 39 | }; 40 | 41 | // Order for "to" accounts (destinations of funds) 42 | const toOrderMap: Record = { 43 | [expensesName]: 0, 44 | [assetsName]: 1, 45 | [incomeName]: 2, 46 | [liabilityName]: 3, 47 | [equity]: 4, 48 | }; 49 | 50 | const getFromOrder = createOrderGetter(fromOrderMap); 51 | const getToOrder = createOrderGetter(toOrderMap); 52 | 53 | const fromInOrder = (a: string, b: string) => 54 | getFromOrder(a) - getFromOrder(b); 55 | const toInOrder = (a: string, b: string) => getToOrder(a) - getToOrder(b); 56 | 57 | assets = [...data.accounts].sort(fromInOrder); 58 | expenses = [...data.accounts].sort(toInOrder); 59 | currencies = data.options.operating_currency; 60 | } 61 | return { assets, expenses, currencies }; 62 | } 63 | 64 | export function handleOptions(options: string[]) { 65 | const optionTabs: OptionTab[] = [{ title: "All", options }]; 66 | options.forEach((val) => { 67 | const prefix = val.split(":")[0]; 68 | const index = optionTabs.findIndex((opt) => opt.title === prefix); 69 | if (index === -1) { 70 | optionTabs.push({ title: prefix, options: [val] }); 71 | } else { 72 | optionTabs[index].options.push(val); 73 | } 74 | }); 75 | return optionTabs; 76 | } 77 | -------------------------------------------------------------------------------- /src/screens/welcome/auth-modal.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { 3 | StyleSheet, 4 | Text, 5 | View, 6 | Modal, 7 | TouchableOpacity, 8 | StyleProp, 9 | ViewStyle, 10 | } from "react-native"; 11 | import { LoginWebView } from "@/screens/welcome/login-web-view"; 12 | import { analytics } from "@/common/analytics"; 13 | import { ColorTheme } from "@/types/theme-props"; 14 | import { useThemeStyle } from "@/common/hooks/use-theme-style"; 15 | 16 | const getLoginOrSignUpStyles = (theme: ColorTheme) => { 17 | return StyleSheet.create({ 18 | modalContainer: { 19 | flex: 1, 20 | backgroundColor: theme.white, 21 | }, 22 | closeButton: { 23 | width: 60, 24 | height: 60, 25 | borderRadius: 30, 26 | backgroundColor: theme.primary, 27 | position: "absolute", 28 | bottom: 10, 29 | right: 10, 30 | alignItems: "center", 31 | justifyContent: "center", 32 | elevation: 5, 33 | shadowColor: theme.black, 34 | shadowOffset: { width: 0, height: 2 }, 35 | shadowOpacity: 0.25, 36 | shadowRadius: 3.84, 37 | }, 38 | closeText: { 39 | color: theme.white, 40 | fontSize: 24, 41 | fontWeight: "bold", 42 | }, 43 | }); 44 | }; 45 | 46 | type LoginOrSignUpProps = { 47 | children: JSX.Element; 48 | isSignUp?: boolean; 49 | style?: StyleProp; 50 | }; 51 | 52 | export function LoginOrSignUp(props: LoginOrSignUpProps): JSX.Element { 53 | const [shouldDisplayModal, setShouldDisplayModal] = React.useState(false); 54 | 55 | const onCloseModal = () => { 56 | setShouldDisplayModal(false); 57 | }; 58 | 59 | const styles = useThemeStyle(getLoginOrSignUpStyles); 60 | 61 | return ( 62 | <> 63 | { 66 | setShouldDisplayModal(true); 67 | await analytics.track("tap_login_or_signup", { 68 | isSignUp: props.isSignUp ?? false, 69 | }); 70 | }} 71 | > 72 | {props.children} 73 | 74 | 75 | 81 | 82 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /src/__tests__/config.test.ts: -------------------------------------------------------------------------------- 1 | describe("config", () => { 2 | describe("config object", () => { 3 | it("exports config object", () => { 4 | const { config } = require("../config"); 5 | expect(config).toBeTruthy(); 6 | expect(typeof config).toBe("object"); 7 | }); 8 | 9 | it("has project property", () => { 10 | const { config } = require("../config"); 11 | expect(config.project).toBe("mobile-beancount"); 12 | }); 13 | 14 | it("has sentryDsn property", () => { 15 | const { config } = require("../config"); 16 | expect(typeof config.sentryDsn).toBe("string"); 17 | }); 18 | 19 | it("has analytics object", () => { 20 | const { config } = require("../config"); 21 | expect(config.analytics).toBeTruthy(); 22 | expect(typeof config.analytics).toBe("object"); 23 | }); 24 | 25 | it("has googleTid in analytics", () => { 26 | const { config } = require("../config"); 27 | expect(config.analytics.googleTid).toBe("UA-143353833-1"); 28 | }); 29 | 30 | it("has mixpanelProjectToken in analytics", () => { 31 | const { config } = require("../config"); 32 | expect(typeof config.analytics.mixpanelProjectToken).toBe("string"); 33 | }); 34 | 35 | it("has serverUrl property", () => { 36 | const { config } = require("../config"); 37 | expect(typeof config.serverUrl).toBe("string"); 38 | }); 39 | 40 | it("serverUrl defaults to beancount.io", () => { 41 | const { config } = require("../config"); 42 | expect(config.serverUrl).toBe("https://beancount.io/"); 43 | }); 44 | 45 | it("serverUrl ends with trailing slash", () => { 46 | const { config } = require("../config"); 47 | expect(config.serverUrl.endsWith("/")).toBe(true); 48 | }); 49 | 50 | it("serverUrl is a valid URL", () => { 51 | const { config } = require("../config"); 52 | expect(config.serverUrl.startsWith("https://")).toBe(true); 53 | }); 54 | }); 55 | 56 | describe("config structure", () => { 57 | it("has all required top-level keys", () => { 58 | const { config } = require("../config"); 59 | const keys = Object.keys(config); 60 | expect(keys.includes("project")).toBe(true); 61 | expect(keys.includes("sentryDsn")).toBe(true); 62 | expect(keys.includes("analytics")).toBe(true); 63 | expect(keys.includes("serverUrl")).toBe(true); 64 | }); 65 | 66 | it("has all required analytics keys", () => { 67 | const { config } = require("../config"); 68 | const analyticsKeys = Object.keys(config.analytics); 69 | expect(analyticsKeys.includes("googleTid")).toBe(true); 70 | expect(analyticsKeys.includes("mixpanelProjectToken")).toBe(true); 71 | }); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "version": "1.20251212.37", 4 | "name": "Beancount", 5 | "scheme": "beancount", 6 | "slug": "beancount", 7 | "platforms": ["ios", "android"], 8 | "orientation": "portrait", 9 | "icon": "./src/assets/images/icon.png", 10 | "updates": { 11 | "fallbackToCacheTimeout": 0, 12 | "url": "https://u.expo.dev/eef0c5cf-1a6f-4566-a6a6-e03821db7f8d" 13 | }, 14 | "assetBundlePatterns": ["**/*"], 15 | "ios": { 16 | "supportsTablet": true, 17 | "bundleIdentifier": "io.beancount.ios", 18 | "userInterfaceStyle": "automatic", 19 | "infoPlist": { 20 | "NSCalendarsUsageDescription": false, 21 | "NSCameraUsageDescription": false, 22 | "NSContactsUsageDescription": false, 23 | "NSLocationWhenInUseUsageDescription": false, 24 | "NSLocationAlwaysUsageDescription": false, 25 | "NSLocationAlwaysAndWhenInUseUsageDescription": false, 26 | "NSMicrophoneUsageDescription": false, 27 | "NSMotionUsageDescription": false, 28 | "NSPhotoLibraryUsageDescription": false, 29 | "NSPhotoLibraryAddUsageDescription": false, 30 | "NSFaceIDUsageDescription": false, 31 | "ITSAppUsesNonExemptEncryption": false 32 | }, 33 | "buildNumber": "37" 34 | }, 35 | "android": { 36 | "versionCode": 37, 37 | "package": "io.beancount.android", 38 | "permissions": ["com.google.android.gms.permission.AD_ID"], 39 | "edgeToEdgeEnabled": true 40 | }, 41 | "notification": { 42 | "icon": "./src/assets/images/icon96.png" 43 | }, 44 | "plugins": [ 45 | [ 46 | "expo-build-properties", 47 | { 48 | "android": { 49 | "compileSdkVersion": 35, 50 | "targetSdkVersion": 35, 51 | "buildToolsVersion": "35.0.0" 52 | } 53 | } 54 | ], 55 | [ 56 | "expo-font", 57 | { 58 | "fonts": ["./src/assets/fonts/SpaceMono-Regular.ttf"] 59 | } 60 | ], 61 | [ 62 | "expo-splash-screen", 63 | { 64 | "image": "./src/assets/images/icon.png", 65 | "resizeMode": "contain", 66 | "imageWidth": 200, 67 | "backgroundColor": "#ffffff" 68 | } 69 | ], 70 | [ 71 | "expo-asset", 72 | { 73 | "assets": ["./src/assets"] 74 | } 75 | ], 76 | "expo-localization", 77 | "sentry-expo", 78 | "expo-router", 79 | "expo-web-browser" 80 | ], 81 | "extra": { 82 | "eas": { 83 | "projectId": "eef0c5cf-1a6f-4566-a6a6-e03821db7f8d" 84 | } 85 | }, 86 | "runtimeVersion": { 87 | "policy": "appVersion" 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/screens/ledger-screen/ledger-screen.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Ionicons } from "@expo/vector-icons"; 3 | import { StyleSheet, View, TouchableOpacity } from "react-native"; 4 | import { analytics } from "@/common/analytics"; 5 | import { headers, getEndpoint } from "@/common/request"; 6 | import { statusBarHeight } from "@/common/screen-util"; 7 | import { ProgressBar } from "./progress-bar"; 8 | import { ColorTheme } from "@/types/theme-props"; 9 | import { SafeAreaView } from "react-native-safe-area-context"; 10 | import { useSession } from "@/common/hooks/use-session"; 11 | import { useThemeStyle, usePageView } from "@/common/hooks"; 12 | import { useTheme } from "@/common/theme"; 13 | import { appendPreferenceParam } from "@/common/url-utils"; 14 | import { DashboardWebView } from "@/components/dashboard-webview"; 15 | 16 | const getStyles = (theme: ColorTheme) => 17 | StyleSheet.create({ 18 | container: { 19 | flex: 1, 20 | backgroundColor: theme.white, 21 | flexDirection: "column", 22 | }, 23 | refreshButton: { 24 | width: 56, 25 | height: 56, 26 | borderRadius: 28, 27 | backgroundColor: theme.primary, 28 | position: "absolute", 29 | top: statusBarHeight, 30 | right: 10, 31 | alignItems: "center", 32 | justifyContent: "center", 33 | }, 34 | webViewContainer: { 35 | flex: 1, 36 | }, 37 | }); 38 | 39 | export const LedgerScreen = () => { 40 | const styles = useThemeStyle(getStyles); 41 | const theme = useTheme().colorTheme; 42 | const [progress, setProgress] = useState(0); 43 | const [key, setKey] = useState(0); 44 | usePageView("ledger"); 45 | 46 | const onRefresh = async () => { 47 | await analytics.track("tap_refresh", {}); 48 | setKey((key) => key + 1); 49 | }; 50 | const { authToken } = useSession(); 51 | const uri = appendPreferenceParam(getEndpoint("ledger/editor/")); 52 | return ( 53 | 54 | 55 | 56 | 60 | setProgress(nativeEvent.progress) 61 | } 62 | source={{ 63 | uri, 64 | headers: { Authorization: `Bearer ${authToken}`, ...headers }, 65 | }} 66 | /> 67 | 72 | 73 | 74 | 75 | 76 | ); 77 | }; 78 | -------------------------------------------------------------------------------- /src/common/__tests__/number-utils.test.ts: -------------------------------------------------------------------------------- 1 | import { shortNumber } from "../number-utils"; 2 | 3 | describe("shortNumber", () => { 4 | it("formats values below one thousand with a fixed decimal", () => { 5 | expect(shortNumber(0)).toBe("0.0"); 6 | expect(shortNumber(12)).toBe("12.0"); 7 | expect(shortNumber(999.4)).toBe("999.4"); 8 | }); 9 | 10 | it("applies suffixes for large magnitudes", () => { 11 | expect(shortNumber(1000)).toBe("1K"); 12 | expect(shortNumber(1250)).toBe("1.3K"); 13 | expect(shortNumber(1500000)).toBe("1.5M"); 14 | expect(shortNumber(2000000)).toBe("2M"); 15 | expect(shortNumber(3500000000)).toBe("3.5B"); 16 | }); 17 | 18 | it("preserves the negative sign for negative values", () => { 19 | expect(shortNumber(-50)).toBe("-50.0"); 20 | expect(shortNumber(-2100)).toBe("-2.1K"); 21 | expect(shortNumber(-3500000)).toBe("-3.5M"); 22 | }); 23 | 24 | it("handles numeric strings", () => { 25 | expect(shortNumber("1250")).toBe("1.3K"); 26 | expect(shortNumber("999" as const)).toBe("999.0"); 27 | }); 28 | 29 | it("returns the original value when parsing fails", () => { 30 | expect(shortNumber("not-a-number")).toBe("not-a-number"); 31 | }); 32 | 33 | it("handles zero correctly", () => { 34 | expect(shortNumber(0)).toBe("0.0"); 35 | expect(shortNumber("0")).toBe("0.0"); 36 | }); 37 | 38 | it("handles decimal values", () => { 39 | expect(shortNumber(123.45)).toBe("123.5"); 40 | expect(shortNumber(567.89)).toBe("567.9"); 41 | }); 42 | 43 | it("formats thousands correctly", () => { 44 | expect(shortNumber(1000)).toBe("1K"); 45 | expect(shortNumber(5000)).toBe("5K"); 46 | expect(shortNumber(10000)).toBe("10K"); 47 | }); 48 | 49 | it("formats millions correctly", () => { 50 | expect(shortNumber(1000000)).toBe("1M"); 51 | expect(shortNumber(5000000)).toBe("5M"); 52 | expect(shortNumber(10000000)).toBe("10M"); 53 | }); 54 | 55 | it("formats billions correctly", () => { 56 | expect(shortNumber(1000000000)).toBe("1B"); 57 | expect(shortNumber(5000000000)).toBe("5B"); 58 | expect(shortNumber(10000000000)).toBe("10B"); 59 | }); 60 | 61 | it("handles very small positive numbers", () => { 62 | expect(shortNumber(0.1)).toBe("0.1"); 63 | expect(shortNumber(0.01)).toBe("0.0"); 64 | }); 65 | 66 | it("handles very small negative numbers", () => { 67 | expect(shortNumber(-0.1)).toBe("-0.1"); 68 | expect(shortNumber(-0.01)).toBe("-0.0"); 69 | }); 70 | 71 | it("handles empty string", () => { 72 | expect(shortNumber("")).toBe(""); 73 | }); 74 | 75 | it("handles boundary values", () => { 76 | expect(shortNumber(999)).toBe("999.0"); 77 | expect(shortNumber(999999)).toBe("1000.0K"); 78 | expect(shortNumber(999999999)).toBe("1000.0M"); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /src/common/d3/__tests__/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { generateTicks } from "../utils"; 2 | 3 | describe("generateTicks", () => { 4 | it("generates evenly spaced ticks between min and max", () => { 5 | const result = generateTicks(0, 100, 5); 6 | expect(result).toEqual([0, 25, 50, 75, 100]); 7 | }); 8 | 9 | it("handles negative ranges", () => { 10 | const result = generateTicks(-100, 0, 5); 11 | expect(result).toEqual([-100, -75, -50, -25, 0]); 12 | }); 13 | 14 | it("handles ranges crossing zero", () => { 15 | const result = generateTicks(-50, 50, 5); 16 | expect(result).toEqual([-50, -25, 0, 25, 50]); 17 | }); 18 | 19 | it("generates single tick when count is 1", () => { 20 | const result = generateTicks(0, 100, 1); 21 | expect(result).toEqual([0]); 22 | }); 23 | 24 | it("generates two ticks for count of 2", () => { 25 | const result = generateTicks(0, 100, 2); 26 | expect(result).toEqual([0, 100]); 27 | }); 28 | 29 | it("handles decimal values", () => { 30 | const result = generateTicks(0, 1, 3); 31 | expect(result).toEqual([0, 0.5, 1]); 32 | }); 33 | 34 | it("handles same min and max values", () => { 35 | const result = generateTicks(50, 50, 5); 36 | expect(result).toEqual([50, 50, 50, 50, 50]); 37 | }); 38 | 39 | it("handles reversed range (max less than min)", () => { 40 | const result = generateTicks(100, 0, 5); 41 | expect(result).toEqual([100, 75, 50, 25, 0]); 42 | }); 43 | 44 | it("handles large ranges", () => { 45 | const result = generateTicks(0, 1000000, 3); 46 | expect(result).toEqual([0, 500000, 1000000]); 47 | }); 48 | 49 | it("handles very small ranges", () => { 50 | const result = generateTicks(0, 0.001, 3); 51 | expect(result[0]).toBeCloseTo(0); 52 | expect(result[1]).toBeCloseTo(0.0005); 53 | expect(result[2]).toBeCloseTo(0.001); 54 | }); 55 | 56 | it("generates correct number of ticks", () => { 57 | const count = 10; 58 | const result = generateTicks(0, 100, count); 59 | expect(result.length).toBe(count); 60 | }); 61 | 62 | it("handles negative to positive range with many ticks", () => { 63 | const result = generateTicks(-100, 100, 9); 64 | expect(result).toEqual([-100, -75, -50, -25, 0, 25, 50, 75, 100]); 65 | }); 66 | 67 | it("handles zero count gracefully", () => { 68 | const result = generateTicks(0, 100, 0); 69 | expect(result.length).toBe(0); 70 | }); 71 | 72 | it("handles negative count gracefully", () => { 73 | const result = generateTicks(0, 100, -5); 74 | expect(result.length).toBe(0); 75 | }); 76 | 77 | it("handles fractional step values", () => { 78 | const result = generateTicks(0, 3, 4); 79 | expect(result[0]).toBeCloseTo(0); 80 | expect(result[1]).toBeCloseTo(1); 81 | expect(result[2]).toBeCloseTo(2); 82 | expect(result[3]).toBeCloseTo(3); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /src/common/apollo/__tests__/error-handling.test.ts: -------------------------------------------------------------------------------- 1 | // Test the error handling logic without complex module mocking 2 | 3 | interface GraphQLError { 4 | message: string; 5 | extensions?: { 6 | code: string; 7 | }; 8 | } 9 | 10 | interface ErrorCode { 11 | code: string; 12 | shouldClearSession: boolean; 13 | } 14 | 15 | describe("Apollo error handling", () => { 16 | it("checks that UNAUTHENTICATED error code exists in error extensions", () => { 17 | const error: GraphQLError = { 18 | message: "Unauthorized", 19 | extensions: { 20 | code: "UNAUTHENTICATED", 21 | }, 22 | }; 23 | 24 | expect(error.extensions?.code).toBe("UNAUTHENTICATED"); 25 | }); 26 | 27 | it("handles graphQL errors with extensions", () => { 28 | const graphQLErrors: GraphQLError[] = [ 29 | { 30 | message: "Not found", 31 | extensions: { 32 | code: "NOT_FOUND", 33 | }, 34 | }, 35 | { 36 | message: "Unauthorized", 37 | extensions: { 38 | code: "UNAUTHENTICATED", 39 | }, 40 | }, 41 | ]; 42 | 43 | const unauthError = graphQLErrors.find( 44 | (err) => err.extensions?.code === "UNAUTHENTICATED", 45 | ); 46 | 47 | expect(unauthError).toBeTruthy(); 48 | expect(unauthError?.message).toBe("Unauthorized"); 49 | }); 50 | 51 | it("handles graphQL errors without extensions", () => { 52 | const graphQLErrors: GraphQLError[] = [ 53 | { 54 | message: "Some error", 55 | }, 56 | ]; 57 | 58 | const unauthError = graphQLErrors.find( 59 | (err) => err.extensions?.code === "UNAUTHENTICATED", 60 | ); 61 | 62 | expect(unauthError).toBe(undefined); 63 | }); 64 | 65 | it("processes multiple error codes correctly", () => { 66 | const errorCodes: ErrorCode[] = [ 67 | { code: "BAD_REQUEST", shouldClearSession: false }, 68 | { code: "UNAUTHENTICATED", shouldClearSession: true }, 69 | { code: "NOT_FOUND", shouldClearSession: false }, 70 | { code: "FORBIDDEN", shouldClearSession: false }, 71 | ]; 72 | 73 | const unauthCode = errorCodes.find((e) => e.code === "UNAUTHENTICATED"); 74 | 75 | expect(unauthCode?.shouldClearSession).toBe(true); 76 | 77 | const otherCodes = errorCodes.filter((e) => e.code !== "UNAUTHENTICATED"); 78 | 79 | otherCodes.forEach((code) => { 80 | expect(code.shouldClearSession).toBe(false); 81 | }); 82 | }); 83 | 84 | it("validates network error message format", () => { 85 | const networkError = new Error("Network connection failed"); 86 | const errorMessage = `[Network error]: ${networkError}`; 87 | 88 | // Check that error message contains the expected strings 89 | expect(errorMessage.includes("[Network error]")).toBeTruthy(); 90 | expect(errorMessage.includes("Network connection failed")).toBeTruthy(); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /app/(app)/(tabs)/_layout.tsx: -------------------------------------------------------------------------------- 1 | import { Tabs } from "expo-router"; 2 | import React, { useEffect, useState } from "react"; 3 | import { Ionicons } from "@expo/vector-icons"; 4 | import { HapticTab } from "@/components/haptic-tab"; 5 | import { useTheme } from "@/common/theme"; 6 | import { i18n } from "@/translations"; 7 | import { localeVar } from "@/common/vars"; 8 | import { useReactiveVar } from "@apollo/client"; 9 | 10 | export default function TabLayout() { 11 | const theme = useTheme().colorTheme; 12 | const locale = useReactiveVar(localeVar); 13 | const [tabTitles, setTabTitles] = useState({ 14 | home: i18n.t("home"), 15 | ledger: i18n.t("ledger"), 16 | journal: i18n.t("journal"), 17 | setting: i18n.t("setting"), 18 | }); 19 | 20 | useEffect(() => { 21 | setTabTitles({ 22 | home: i18n.t("home"), 23 | ledger: i18n.t("ledger"), 24 | journal: i18n.t("journal"), 25 | setting: i18n.t("setting"), 26 | }); 27 | }, [locale]); 28 | 29 | return ( 30 | 43 | ( 48 | 53 | ), 54 | }} 55 | /> 56 | ( 61 | 66 | ), 67 | }} 68 | /> 69 | ( 74 | 79 | ), 80 | }} 81 | /> 82 | ( 87 | 92 | ), 93 | }} 94 | /> 95 | 96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /src/common/providers/splash-provider/splash-provider.tsx: -------------------------------------------------------------------------------- 1 | import * as SplashScreen from "expo-splash-screen"; 2 | import { useEffect, memo, useState, useCallback } from "react"; 3 | import * as Font from "expo-font"; 4 | import { Ionicons } from "@expo/vector-icons"; 5 | import { View, StyleSheet } from "react-native"; 6 | import { loadLocale } from "@/common/vars/locale"; 7 | import { loadLedger, ledgerVar } from "@/common/vars/ledger"; 8 | import { loadTheme } from "@/common/vars/theme"; 9 | import { loadSession } from "@/common/vars/session"; 10 | import { i18n } from "@/translations"; 11 | import Constants from "expo-constants"; 12 | import { apolloClient } from "@/common/apollo/client"; 13 | import { GetLedgerDocument } from "@/generated-graphql/graphql"; 14 | 15 | SplashScreen.preventAutoHideAsync(); 16 | 17 | // Set the animation options. This is optional. 18 | if (Constants.executionEnvironment === "standalone") { 19 | SplashScreen.setOptions({ 20 | fade: false, 21 | duration: 0, 22 | }); 23 | } 24 | 25 | const styles = StyleSheet.create({ 26 | container: { 27 | flex: 1, 28 | }, 29 | }); 30 | 31 | const SplashProviderComponent = ({ 32 | children, 33 | }: { 34 | children: React.ReactNode; 35 | }) => { 36 | const [appIsReady, setAppIsReady] = useState(false); 37 | 38 | useEffect(() => { 39 | async function prepare() { 40 | try { 41 | // Pre-load fonts, make any API calls you need to do here 42 | await Font.loadAsync(Ionicons.font); 43 | const [locale, session, ledger] = await Promise.all([ 44 | loadLocale(), 45 | loadSession(), 46 | loadLedger(), 47 | loadTheme(), 48 | ]); 49 | if (locale) { 50 | i18n.locale = locale; 51 | } 52 | // Validate ledger if both session and ledger are not null 53 | if (session && ledger) { 54 | try { 55 | await apolloClient.query({ 56 | query: GetLedgerDocument, 57 | variables: { ledgerId: ledger }, 58 | fetchPolicy: "network-only", 59 | }); 60 | } catch (e) { 61 | // If GetLedger query fails, clear the ledgerVar 62 | console.warn("Failed to validate ledger, clearing ledgerVar:", e); 63 | ledgerVar(null); 64 | } 65 | } 66 | } catch (e) { 67 | console.warn(e); 68 | } finally { 69 | // Tell the application to render 70 | setAppIsReady(true); 71 | } 72 | } 73 | 74 | prepare(); 75 | }, []); 76 | 77 | const onLayout = useCallback(() => { 78 | if (appIsReady) { 79 | SplashScreen.hideAsync(); 80 | } 81 | }, [appIsReady]); 82 | 83 | if (!appIsReady) { 84 | return null; 85 | } 86 | 87 | return ( 88 | 89 | {children} 90 | 91 | ); 92 | }; 93 | 94 | export const SplashProvider = memo(SplashProviderComponent); 95 | 96 | SplashProvider.displayName = "SplashProvider"; 97 | -------------------------------------------------------------------------------- /src/screens/home-screen/selectors/__tests__/select-account-totals-zero-bug.test.ts: -------------------------------------------------------------------------------- 1 | import { getAccountTotals } from "../select-account-totals"; 2 | import { AccountHierarchyQuery } from "@/generated-graphql/graphql"; 3 | 4 | // Helper to create minimal test data matching GraphQL types 5 | type TestAccountBalance = { 6 | account: string; 7 | balance: number; 8 | balance_children: Record; 9 | children: TestAccountBalance[]; 10 | }; 11 | 12 | type TestHierarchyItem = { 13 | type: string; 14 | label: string; 15 | data: TestAccountBalance; 16 | }; 17 | 18 | type TestAccountHierarchyQuery = { 19 | accountHierarchy: { 20 | success: boolean; 21 | data: TestHierarchyItem[]; 22 | }; 23 | }; 24 | 25 | // Helper function to create test data 26 | function createTestData( 27 | items: Array<{ 28 | label: string; 29 | balance_children: Record; 30 | balance?: number; 31 | }>, 32 | ): TestAccountHierarchyQuery { 33 | return { 34 | accountHierarchy: { 35 | success: true, 36 | data: items.map((item) => ({ 37 | type: "account", 38 | label: item.label, 39 | data: { 40 | account: item.label, 41 | balance: item.balance || 0, 42 | balance_children: item.balance_children, 43 | children: [], 44 | }, 45 | })), 46 | }, 47 | }; 48 | } 49 | 50 | describe("getAccountTotals - Zero Currency Bug", () => { 51 | it("should return 0 when requested currency is 0, even if USD has value", () => { 52 | const data = createTestData([ 53 | { 54 | label: "Assets", 55 | balance_children: { EUR: 0, USD: 100.5 }, 56 | balance: 0, 57 | }, 58 | ]); 59 | 60 | const result = getAccountTotals( 61 | "EUR", 62 | data as unknown as AccountHierarchyQuery, 63 | ); 64 | 65 | // This should be "0.00" but due to the bug it will be "100.50" 66 | // After fixing the bug, this test should pass 67 | expect(result.assets).toBe("0.00"); 68 | }); 69 | 70 | it("should return 0 for negative zero balance when currency is 0", () => { 71 | const data = createTestData([ 72 | { 73 | label: "Income", 74 | balance_children: { EUR: 0, USD: -500.0 }, 75 | balance: 0, 76 | }, 77 | ]); 78 | 79 | const result = getAccountTotals( 80 | "EUR", 81 | data as unknown as AccountHierarchyQuery, 82 | ); 83 | 84 | expect(result.income).toBe("0.00"); 85 | }); 86 | 87 | it("should correctly handle when requested currency has value and USD is 0", () => { 88 | const data = createTestData([ 89 | { 90 | label: "Assets", 91 | balance_children: { EUR: 200.0, USD: 0 }, 92 | balance: 200.0, 93 | }, 94 | ]); 95 | 96 | const result = getAccountTotals( 97 | "EUR", 98 | data as unknown as AccountHierarchyQuery, 99 | ); 100 | 101 | expect(result.assets).toBe("200.00"); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /src/screens/referral-screen/components/gift-icon.tsx: -------------------------------------------------------------------------------- 1 | import { G, Path, Svg, Polygon } from "react-native-svg"; 2 | import * as React from "react"; 3 | import { useTheme } from "@/common/theme"; 4 | 5 | function GiftIconComponent(): JSX.Element { 6 | const { colorTheme: theme } = useTheme(); 7 | 8 | return ( 9 | 10 | 11 | 16 | 21 | 27 | 35 | 39 | 43 | 44 | 45 | 49 | 53 | 57 | 61 | 65 | 66 | 67 | 68 | 69 | ); 70 | } 71 | 72 | export const GiftIcon = GiftIconComponent; 73 | -------------------------------------------------------------------------------- /src/screens/home-screen/components/accounts-styled.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Text, View, StyleSheet } from "react-native"; 3 | import { contentPadding } from "@/common/screen-util"; 4 | import { i18n } from "@/translations"; 5 | import { ColorTheme } from "@/types/theme-props"; 6 | import { useThemeStyle } from "@/common/hooks/use-theme-style"; 7 | import { useTheme } from "@/common/theme"; 8 | 9 | const getStyles = (theme: ColorTheme) => 10 | StyleSheet.create({ 11 | text: { 12 | fontSize: 16, 13 | color: theme.text01, 14 | }, 15 | rowContainer: { 16 | flexDirection: "row", 17 | height: 42, 18 | alignItems: "center", 19 | paddingHorizontal: contentPadding, 20 | }, 21 | circle: { 22 | height: 12, 23 | width: 12, 24 | borderRadius: 6, 25 | marginRight: contentPadding, 26 | }, 27 | }); 28 | 29 | export function AccountsRow({ 30 | title, 31 | value, 32 | circleColor, 33 | }: { 34 | title: string; 35 | value: string; 36 | circleColor: string; 37 | }): JSX.Element { 38 | const styles = useThemeStyle(getStyles); 39 | return ( 40 | 41 | 42 | {title} 43 | 44 | {value} 45 | 46 | ); 47 | } 48 | 49 | export function AccountsStyled({ 50 | assets, 51 | liabilities, 52 | income, 53 | expenses, 54 | equity, 55 | }: { 56 | assets: string; 57 | liabilities: string; 58 | income: string; 59 | expenses: string; 60 | equity: string; 61 | }): JSX.Element { 62 | const styles = useThemeStyle((theme) => 63 | StyleSheet.create({ 64 | line: { 65 | height: StyleSheet.hairlineWidth, 66 | backgroundColor: theme.black20, 67 | }, 68 | }), 69 | ); 70 | const { colorTheme } = useTheme(); 71 | const rows = [ 72 | { 73 | title: i18n.t("assets"), 74 | value: assets, 75 | circleColor: colorTheme.success, 76 | }, 77 | { 78 | title: i18n.t("liabilities"), 79 | value: liabilities, 80 | circleColor: colorTheme.information, 81 | }, 82 | { 83 | title: i18n.t("income"), 84 | value: income, 85 | circleColor: colorTheme.warning, 86 | }, 87 | { 88 | title: i18n.t("expenses"), 89 | value: expenses, 90 | circleColor: colorTheme.error, 91 | }, 92 | { 93 | title: i18n.t("equity"), 94 | value: equity, 95 | circleColor: colorTheme.primary, 96 | }, 97 | ]; 98 | return ( 99 | 100 | {rows.map((row, index) => ( 101 | 102 | {index === 0 && } 103 | 108 | 109 | 110 | ))} 111 | 112 | ); 113 | } 114 | -------------------------------------------------------------------------------- /src/components/button/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useMemo } from "react"; 2 | import { 3 | Pressable, 4 | Text, 5 | StyleSheet, 6 | PressableStateCallbackType, 7 | StyleProp, 8 | ViewStyle, 9 | ActivityIndicator, 10 | } from "react-native"; 11 | import { useThemeStyle } from "@/common/hooks/use-theme-style"; 12 | import { ColorTheme } from "@/types/theme-props"; 13 | import { useTheme } from "@/common/theme"; 14 | 15 | type ButtonType = "primary" | "outline"; 16 | 17 | type ButtonProps = { 18 | type?: ButtonType; 19 | style?: StyleProp; 20 | children: React.ReactNode; 21 | loading?: boolean; 22 | onPress?: () => void; 23 | }; 24 | 25 | const getButtonStyles = (theme: ColorTheme) => { 26 | return StyleSheet.create({ 27 | buttonBase: { 28 | height: 44, 29 | borderRadius: 8, 30 | // flex: 1, 31 | alignItems: "center", 32 | justifyContent: "center", 33 | flexDirection: "row", 34 | }, 35 | buttonPrimary: { 36 | backgroundColor: theme.primary, 37 | }, 38 | buttonPrimaryPressed: { 39 | backgroundColor: theme.primaryDark, 40 | }, 41 | buttonPrimaryText: { 42 | color: theme.white, 43 | fontSize: 16, 44 | }, 45 | buttonOutline: { 46 | backgroundColor: theme.white, 47 | borderWidth: 1, 48 | borderColor: theme.primary, 49 | }, 50 | buttonOutlinePressed: { 51 | opacity: 0.6, 52 | }, 53 | buttonOutlineText: { 54 | color: theme.primary, 55 | fontSize: 16, 56 | }, 57 | buttonLoading: { 58 | marginRight: 8, 59 | }, 60 | }); 61 | }; 62 | 63 | export const Button = (props: ButtonProps) => { 64 | const type = props.type || "primary"; 65 | const styles = useThemeStyle(getButtonStyles); 66 | const pressableStyle = useCallback( 67 | ({ pressed }: PressableStateCallbackType) => { 68 | switch (type) { 69 | case "primary": 70 | return [ 71 | props.style, 72 | styles.buttonBase, 73 | styles.buttonPrimary, 74 | pressed && styles.buttonPrimaryPressed, 75 | ]; 76 | case "outline": 77 | return [ 78 | props.style, 79 | styles.buttonBase, 80 | styles.buttonOutline, 81 | pressed && styles.buttonOutlinePressed, 82 | ]; 83 | } 84 | }, 85 | [styles, type, props.style], 86 | ); 87 | 88 | const buttonTextStyle = useMemo(() => { 89 | switch (type) { 90 | case "primary": 91 | return styles.buttonPrimaryText; 92 | case "outline": 93 | return styles.buttonOutlineText; 94 | } 95 | }, [styles, type]); 96 | 97 | const theme = useTheme().colorTheme; 98 | 99 | return ( 100 | 105 | {props.loading ? ( 106 | 110 | ) : null} 111 | {props.children} 112 | 113 | ); 114 | }; 115 | -------------------------------------------------------------------------------- /src/components/picker/README.md: -------------------------------------------------------------------------------- 1 | # Picker Component 2 | 3 | A customizable picker component built with react-native-reanimated2 and native ScrollView that displays a modal from the bottom with a wheel picker interface. 4 | 5 | ## Features 6 | 7 | - ✅ Modal slides up from bottom with smooth animation 8 | - ✅ Shows maximum 5 items at a time (wheel effect) 9 | - ✅ Uses native ScrollView for smooth scrolling 10 | - ✅ Snap-to-item functionality 11 | - ✅ Visual selection indicator 12 | - ✅ Fade gradients for better UX 13 | - ✅ Theme-aware styling 14 | - ✅ TypeScript support 15 | 16 | ## Props 17 | 18 | ```typescript 19 | type PickerProps = { 20 | visible: boolean; // Controls modal visibility 21 | items: { label: string; value: string }[]; // Array of items to display 22 | onSelect: (item: { label: string; value: string }) => void; // Callback when item is selected 23 | onCancel: () => void; // Callback when picker is cancelled 24 | selectedValue?: string; // Currently selected value (optional) 25 | }; 26 | ``` 27 | 28 | ## Usage 29 | 30 | ```tsx 31 | import React, { useState } from "react"; 32 | import { View, TouchableOpacity, Text } from "react-native"; 33 | import { Picker } from "@/components"; 34 | 35 | const MyComponent = () => { 36 | const [visible, setVisible] = useState(false); 37 | const [selectedItem, setSelectedItem] = useState(null); 38 | 39 | const items = [ 40 | { label: "Option 1", value: "option1" }, 41 | { label: "Option 2", value: "option2" }, 42 | { label: "Option 3", value: "option3" }, 43 | // ... more items 44 | ]; 45 | 46 | const handleSelect = (item) => { 47 | setSelectedItem(item); 48 | setVisible(false); 49 | }; 50 | 51 | const handleCancel = () => { 52 | setVisible(false); 53 | }; 54 | 55 | return ( 56 | 57 | setVisible(true)}> 58 | Open Picker 59 | 60 | 61 | 68 | 69 | ); 70 | }; 71 | ``` 72 | 73 | ## Example 74 | 75 | See `example.tsx` for a complete working example. 76 | 77 | ## Styling 78 | 79 | The component automatically adapts to your app's theme using the `useTheme` hook. It supports both light and dark themes with appropriate colors for: 80 | 81 | - Background colors 82 | - Text colors 83 | - Selection indicator 84 | - Fade gradients 85 | - Button colors 86 | 87 | ## Technical Details 88 | 89 | - **Animation**: Uses react-native-reanimated2 for smooth 60fps animations 90 | - **ScrollView**: Native ScrollView with snap-to-interval for precise item selection 91 | - **Performance**: Optimized with useCallback and useMemo for smooth scrolling 92 | - **Accessibility**: Proper touch targets and keyboard navigation support 93 | - **Safe Area**: Accounts for home indicator on iOS devices 94 | 95 | ## Customization 96 | 97 | You can customize the appearance by modifying the `getStyles` function in the component. Key constants that can be adjusted: 98 | 99 | - `ITEM_HEIGHT`: Height of each picker item (default: 50) 100 | - `VISIBLE_ITEMS`: Number of items visible at once (default: 5) 101 | - `WHEEL_HEIGHT`: Total height of the wheel (calculated from above) 102 | -------------------------------------------------------------------------------- /src/components/text-input-screen/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { View, StyleSheet, TextInput, Pressable, Text } from "react-native"; 3 | import { useTheme } from "@/common/theme"; 4 | import { i18n } from "@/translations"; 5 | import { ColorTheme } from "@/types/theme-props"; 6 | import { router, Stack } from "expo-router"; 7 | import { SafeAreaView } from "react-native-safe-area-context"; 8 | import { usePageView } from "@/common/hooks/use-page-view"; 9 | import { analytics } from "@/common/analytics"; 10 | 11 | const getStyles = (theme: ColorTheme) => 12 | StyleSheet.create({ 13 | container: { 14 | backgroundColor: theme.white, 15 | flex: 1, 16 | }, 17 | inputContainer: { 18 | marginHorizontal: 16, 19 | marginTop: 16, 20 | borderBottomColor: theme.black40, 21 | borderBottomWidth: StyleSheet.hairlineWidth, 22 | }, 23 | input: { 24 | color: theme.text01, 25 | fontSize: 18, 26 | paddingVertical: 8, 27 | }, 28 | doneButton: { 29 | fontWeight: "bold", 30 | color: theme.primary, 31 | fontSize: 16, 32 | }, 33 | }); 34 | 35 | type TextInputScreenProps = { 36 | /** The initial value for the text input */ 37 | initialValue?: string; 38 | /** The title to display in the header */ 39 | headerTitle: string; 40 | /** The placeholder text for the input field */ 41 | placeholder?: string; 42 | /** Whether to allow multiline input */ 43 | multiline?: boolean; 44 | /** The analytics page name (will be prefixed with "page_view_") */ 45 | analyticsPageName: string; 46 | /** The analytics event name for saving (without "tap_" prefix) */ 47 | analyticsSaveEventName: string; 48 | /** Callback function when the user saves */ 49 | onSave?: (value: string) => void; 50 | }; 51 | 52 | /** 53 | * A reusable text input screen component. 54 | * Used for inputting simple text values like payee, narration, etc. 55 | */ 56 | export const TextInputScreen: React.FC = ({ 57 | initialValue = "", 58 | headerTitle, 59 | placeholder, 60 | multiline = false, 61 | analyticsPageName, 62 | analyticsSaveEventName, 63 | onSave, 64 | }) => { 65 | usePageView(analyticsPageName); 66 | 67 | const theme = useTheme().colorTheme; 68 | const styles = getStyles(theme); 69 | const [value, setValue] = useState(initialValue); 70 | 71 | const handleSave = async () => { 72 | onSave?.(value); 73 | await analytics.track(`tap_${analyticsSaveEventName}`, { 74 | value, 75 | }); 76 | router.back(); 77 | }; 78 | 79 | return ( 80 | 81 | ( 85 | 86 | {i18n.t("save")} 87 | 88 | ), 89 | }} 90 | /> 91 | 92 | 102 | 103 | 104 | ); 105 | }; 106 | -------------------------------------------------------------------------------- /src/common/theme/index.ts: -------------------------------------------------------------------------------- 1 | import { Appearance } from "react-native"; 2 | import { createTheming } from "@callstack/react-theme-provider"; 3 | import { ThemeProps, ColorTheme, AntdTheme } from "@/types/theme-props"; 4 | 5 | export const getSystemColorScheme = () => { 6 | const colorScheme = Appearance.getColorScheme(); 7 | return colorScheme === "dark" ? "dark" : "light"; 8 | }; 9 | 10 | const colorMode = getSystemColorScheme(); 11 | 12 | const lightTheme: ColorTheme = { 13 | overlay: "rgba(0, 0, 0, 0.5)", 14 | primary: "#6161e8", 15 | primaryLight: "#7a7aea", 16 | primaryDark: "#5252c3", 17 | secondary: "#0C8DE4", 18 | white: "#fff", 19 | 20 | black: "#000000", 21 | black90: "#333333", 22 | black80: "#999999", 23 | black60: "#CCCCCC", 24 | black40: "#E5E5E5", 25 | black20: "#F0F0F0", 26 | black10: "#F7F7F7", 27 | 28 | text01: "#4c4c4c", // Primary text, Body copy 29 | 30 | error: "#E54937", // Error 31 | success: "#07A35A", // Success 32 | warning: "#FFA000", // Warning 33 | information: "#5aaafa", // Information 34 | 35 | nav01: "#011627", // Global top bar 36 | nav02: "#20232a", // CTA footer 37 | 38 | tabIconDefault: "#ccc", 39 | tabIconSelected: "#2f95dc", 40 | activeTintColor: "#2f95dc", 41 | inactiveTintColor: "#ccc", 42 | activeBackgroundColor: "#fff", 43 | inactiveBackgroundColor: "#fff", 44 | navBg: "#fff", 45 | navText: "#000", 46 | }; 47 | 48 | const darkTheme: ColorTheme = { 49 | overlay: "rgba(255, 255, 255, 0.5)", 50 | primary: "#6161e8", 51 | primaryLight: "#7a7aea", 52 | primaryDark: "#5252c3", 53 | secondary: "#0C8DE4", 54 | white: "#000", 55 | 56 | black: "#FFF", 57 | black90: "#F7F7F7", 58 | black80: "#B3B3B3", 59 | black60: "#AAAAAA", 60 | black40: "#888888", 61 | black20: "#666666", 62 | black10: "#333333", 63 | 64 | text01: "#FFFFFF", // Primary text, Body copy 65 | 66 | error: "#E54937", // Error 67 | success: "#07A35A", // Success 68 | warning: "#FFA000", // Warning 69 | information: "#5aaafa", // Information 70 | 71 | nav01: "#000", // Global top bar 72 | nav02: "#000", // CTA footer 73 | 74 | tabIconDefault: "#ccc", 75 | tabIconSelected: "#2f95dc", 76 | activeTintColor: "#2f95dc", 77 | inactiveTintColor: "#CFCFCF", 78 | activeBackgroundColor: "#000", 79 | inactiveBackgroundColor: "#000", 80 | navBg: "#000", 81 | navText: "#fff", 82 | }; 83 | 84 | export const antdLightTheme: AntdTheme = { 85 | color_text_base: lightTheme.text01, 86 | brand_primary: lightTheme.primary, 87 | color_link: lightTheme.primary, 88 | primary_button_fill: lightTheme.primary, 89 | primary_button_fill_tap: lightTheme.primary, 90 | }; 91 | 92 | export const antdDarkTheme: AntdTheme = { 93 | color_text_base: darkTheme.text01, 94 | brand_primary: darkTheme.primary, 95 | color_link: darkTheme.primary, 96 | primary_button_fill: darkTheme.primary, 97 | primary_button_fill_tap: darkTheme.primary, 98 | }; 99 | 100 | export const themes: { [key: string]: ThemeProps } = { 101 | light: { 102 | name: "light", 103 | colorTheme: lightTheme, 104 | antdTheme: antdLightTheme, 105 | sizing: [2, 6, 8, 10, 16, 24, 32], 106 | }, 107 | dark: { 108 | name: "dark", 109 | colorTheme: darkTheme, 110 | antdTheme: antdDarkTheme, 111 | sizing: [2, 6, 8, 10, 16, 24, 32], 112 | }, 113 | }; 114 | 115 | const { ThemeProvider, withTheme, useTheme } = createTheming(themes[colorMode]); 116 | 117 | export { ThemeProvider, withTheme, useTheme, colorMode }; 118 | -------------------------------------------------------------------------------- /src/common/announcement.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { View, Text, StyleSheet, TouchableOpacity } from "react-native"; 3 | import AsyncStorage from "@react-native-async-storage/async-storage"; 4 | import { ColorTheme } from "@/types/theme-props"; 5 | import { contentPadding, ScreenWidth } from "@/common/screen-util"; 6 | import { useRouter } from "expo-router"; 7 | import { useThemeStyle } from "./hooks/use-theme-style"; 8 | 9 | type Props = { 10 | title: string; 11 | subtitle: string; 12 | icon: JSX.Element; 13 | }; 14 | 15 | const getStyles = (theme: ColorTheme) => 16 | StyleSheet.create({ 17 | container: { 18 | height: 120, 19 | backgroundColor: theme.white, 20 | paddingHorizontal: contentPadding, 21 | flexDirection: "row", 22 | borderRadius: 5, 23 | borderWidth: 1, 24 | borderColor: theme.black40, 25 | alignItems: "center", 26 | justifyContent: "space-between", 27 | marginBottom: contentPadding, 28 | }, 29 | titleContainer: { 30 | width: (ScreenWidth - 2 * contentPadding) * 0.6, 31 | }, 32 | iconContainer: { 33 | width: (ScreenWidth - 2 * contentPadding) * 0.3, 34 | }, 35 | subtitle: { 36 | color: theme.black, 37 | fontSize: 14, 38 | marginBottom: 10, 39 | }, 40 | title: { 41 | color: theme.black, 42 | fontSize: 20, 43 | fontWeight: "bold", 44 | lineHeight: 26, 45 | }, 46 | closeButton: { 47 | width: 20, 48 | height: 20, 49 | borderRadius: 10, 50 | backgroundColor: theme.black60, 51 | position: "absolute", 52 | top: 10, 53 | alignItems: "center", 54 | justifyContent: "center", 55 | right: 10, 56 | }, 57 | closeText: { 58 | color: theme.white, 59 | fontSize: 16, 60 | fontWeight: "bold", 61 | }, 62 | }); 63 | 64 | export function Announcement(props: Props): JSX.Element { 65 | const [hide, setHide] = React.useState(true); 66 | 67 | React.useEffect(() => { 68 | async function init() { 69 | try { 70 | const value = await AsyncStorage.getItem("@HideAnnouncement:key"); 71 | if (value !== null) { 72 | setHide(value === "true"); 73 | } else { 74 | setHide(false); 75 | } 76 | } catch (error) { 77 | console.error(`failed to get hide announcement value: ${error}`); 78 | } 79 | } 80 | init(); 81 | }, []); 82 | 83 | const router = useRouter(); 84 | 85 | const { title, subtitle, icon } = props; 86 | 87 | const styles = useThemeStyle(getStyles); 88 | 89 | if (hide) { 90 | return ; 91 | } 92 | 93 | return ( 94 | { 97 | router.navigate("/(app)/(tabs)/setting"); 98 | }} 99 | > 100 | 101 | {subtitle} 102 | 103 | {title} 104 | 105 | 106 | 107 | {icon} 108 | { 112 | setHide(true); 113 | try { 114 | await AsyncStorage.setItem("@HideAnnouncement:key", "true"); 115 | } catch (error) { 116 | console.error(`failed to set hide announcement value: ${error}`); 117 | } 118 | }} 119 | > 120 | 121 | 122 | 123 | ); 124 | } 125 | -------------------------------------------------------------------------------- /src/components/dashboard-webview/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useRef, 3 | useState, 4 | useEffect, 5 | forwardRef, 6 | useImperativeHandle, 7 | } from "react"; 8 | import { 9 | WebView, 10 | WebViewMessageEvent, 11 | WebViewProps, 12 | } from "react-native-webview"; 13 | import { useReactiveVar } from "@apollo/client"; 14 | import { localeVar } from "@/common/vars"; 15 | import { analytics } from "@/common/analytics"; 16 | import { ColorTheme } from "@/types/theme-props"; 17 | import { StyleSheet } from "react-native"; 18 | import { useThemeStyle } from "@/common/hooks/use-theme-style"; 19 | 20 | type SupportedLanguage = 21 | | "en" 22 | | "zh" 23 | | "es" 24 | | "fr" 25 | | "de" 26 | | "pt" 27 | | "ru" 28 | | "nl" 29 | | "bg" 30 | | "ca" 31 | | "fa" 32 | | "sk" 33 | | "uk"; 34 | 35 | interface BridgeMessage { 36 | type: string; 37 | data: unknown; 38 | } 39 | 40 | const getStyles = (theme: ColorTheme) => { 41 | return StyleSheet.create({ 42 | webView: { 43 | backgroundColor: theme.white, 44 | }, 45 | }); 46 | }; 47 | 48 | interface DashboardWebViewProps extends Omit { 49 | onMessage?: (event: WebViewMessageEvent) => void; 50 | scrollEnabled?: boolean; 51 | } 52 | 53 | export const DashboardWebView = forwardRef( 54 | ({ onMessage, scrollEnabled = false, ...webViewProps }, ref) => { 55 | const webViewRef = useRef(null); 56 | const [bridgeReady, setBridgeReady] = useState(false); 57 | const locale = useReactiveVar(localeVar); 58 | const styles = useThemeStyle(getStyles); 59 | 60 | // Expose WebView methods to parent via ref 61 | useImperativeHandle(ref, () => webViewRef.current as WebView); 62 | 63 | // Sync language to webview when bridge is ready or locale changes 64 | useEffect(() => { 65 | if (bridgeReady && locale) { 66 | changeLanguage(locale as SupportedLanguage); 67 | } 68 | }, [bridgeReady, locale]); 69 | 70 | const changeLanguage = (language: SupportedLanguage) => { 71 | if (!webViewRef.current) return; 72 | 73 | const script = ` 74 | window.dispatchEvent(new CustomEvent('rn:changeLanguage', { 75 | detail: { language: '${language}' } 76 | })); 77 | true; 78 | `; 79 | 80 | webViewRef.current.injectJavaScript(script); 81 | analytics.track("webview_language_sync", { language }); 82 | }; 83 | 84 | const handleMessage = (event: WebViewMessageEvent) => { 85 | try { 86 | const data: BridgeMessage = JSON.parse(event.nativeEvent.data); 87 | 88 | // Handle bridge ready message 89 | if (data.type === "bridgeReady") { 90 | setBridgeReady(true); 91 | } 92 | 93 | // Call custom onMessage handler if provided 94 | if (onMessage) { 95 | onMessage(event); 96 | } 97 | } catch (error) { 98 | console.error("Error handling webview message:", error); 99 | } 100 | }; 101 | 102 | return ( 103 | 119 | ); 120 | }, 121 | ); 122 | 123 | DashboardWebView.displayName = "DashboardWebView"; 124 | -------------------------------------------------------------------------------- /src/screens/account-picker-screen/account-picker-screen.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | View, 4 | Text, 5 | StyleSheet, 6 | ScrollView, 7 | TouchableOpacity, 8 | ActivityIndicator, 9 | } from "react-native"; 10 | import { useTheme } from "@/common/theme"; 11 | import { 12 | OptionTab, 13 | useLedgerMeta, 14 | } from "@/screens/add-transaction-screen/hooks/use-ledger-meta"; 15 | import { ColorTheme } from "@/types/theme-props"; 16 | import { useLocalSearchParams, useRouter } from "expo-router"; 17 | import { SelectedAssets, SelectedExpenses } from "@/common/globalFnFactory"; 18 | import { useSession } from "@/common/hooks/use-session"; 19 | import { Ionicons } from "@expo/vector-icons"; 20 | import { Tabs, FlexCenter } from "@/components"; 21 | import { analytics } from "@/common/analytics"; 22 | import { usePageView } from "@/common/hooks"; 23 | 24 | const getStyles = (theme: ColorTheme) => 25 | StyleSheet.create({ 26 | container: { 27 | flex: 1, 28 | backgroundColor: theme.white, 29 | }, 30 | listItem: { 31 | backgroundColor: theme.white, 32 | paddingVertical: 12, 33 | paddingHorizontal: 16, 34 | flexDirection: "row", 35 | justifyContent: "space-between", 36 | borderBottomWidth: StyleSheet.hairlineWidth, 37 | borderBottomColor: theme.black60, 38 | }, 39 | }); 40 | 41 | export function AccountPickerScreen(): JSX.Element { 42 | const router = useRouter(); 43 | const { userId } = useSession(); 44 | usePageView("account_picker"); 45 | const { type } = useLocalSearchParams<{ type: string }>(); 46 | const { assetsOptionTabs, expensesOptionTabs, loading } = useLedgerMeta( 47 | userId ?? "", 48 | ); 49 | 50 | const onSelected = 51 | type === "assets" ? SelectedAssets.getFn() : SelectedExpenses.getFn(); 52 | 53 | const optionTabs: OptionTab[] = 54 | type === "assets" ? assetsOptionTabs : expensesOptionTabs; 55 | 56 | const theme = useTheme().colorTheme; 57 | const styles = getStyles(theme); 58 | 59 | const renderOptionTab = (opt: OptionTab, index: number) => { 60 | return ( 61 | 66 | {opt.options.map((op, idx) => { 67 | return ( 68 | { 72 | await analytics.track("tap_account_picker_confirm", { 73 | selectedAccount: op, 74 | }); 75 | onSelected?.(op); 76 | router.back(); 77 | }} 78 | > 79 | 85 | {op} 86 | 87 | 88 | 89 | ); 90 | })} 91 | 92 | ); 93 | }; 94 | 95 | const tabsConfig = optionTabs.map((opt, index) => { 96 | return { 97 | title: opt.title, 98 | key: opt.title, 99 | component: renderOptionTab(opt, index), 100 | }; 101 | }); 102 | 103 | if (loading) { 104 | return ( 105 | 106 | 107 | 108 | ); 109 | } 110 | 111 | return ( 112 | 113 | 119 | 120 | ); 121 | } 122 | --------------------------------------------------------------------------------