├── .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 | 
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 | 
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 |
44 |
45 |
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 |
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 |
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 |
--------------------------------------------------------------------------------