This commit is contained in:
Arkaprabha Chakraborty
2026-02-18 13:16:51 +05:30
commit 53742d0134
102 changed files with 22090 additions and 0 deletions

4
.eslintrc.js Normal file
View File

@@ -0,0 +1,4 @@
module.exports = {
root: true,
extends: '@react-native',
};

View File

@@ -0,0 +1,139 @@
name: Build and Release Android APK
# Required repository secrets:
# RELEASE_KEYSTORE_BASE64 — base64-encoded release keystore file
# Generate: base64 -i my-release-key.keystore | pbcopy
# KEYSTORE_PASSWORD — keystore store password
# KEY_ALIAS — key alias inside the keystore
# KEY_PASSWORD — key password
on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
inputs:
version:
description: 'Version tag (e.g., v0.1.0-alpha)'
required: true
default: 'v0.1.0-alpha'
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Setup Java 17
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
# Cache Gradle wrapper + caches to dramatically speed up subsequent builds
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('android/**/*.gradle*', 'android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: gradle-${{ runner.os }}-
# Cache node_modules to skip re-installing unchanged deps
- name: Cache node_modules
uses: actions/cache@v4
with:
path: node_modules
key: bun-${{ runner.os }}-${{ hashFiles('bun.lockb') }}
restore-keys: bun-${{ runner.os }}-
- name: Install JS dependencies
run: bun install --frozen-lockfile
- name: Make gradlew executable
run: chmod +x android/gradlew
# Decode the base64 keystore secret and write keystore.properties so
# android/app/build.gradle can pick up the release signing credentials.
- name: Configure release signing
env:
RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }}
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: |
echo "$RELEASE_KEYSTORE_BASE64" | base64 --decode > android/app/expensso-release.keystore
{
echo "storeFile=$(pwd)/android/app/expensso-release.keystore"
echo "storePassword=$KEYSTORE_PASSWORD"
echo "keyAlias=$KEY_ALIAS"
echo "keyPassword=$KEY_PASSWORD"
} > android/keystore.properties
- name: Get version from package.json
id: pkg
run: |
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Build release APKs
run: bun run build:release
# ABI splits produce multiple APKs; rename them all for clarity.
# Outputs: Expensso-v0.1.0-alpha-arm64-v8a.apk, Expensso-v0.1.0-alpha-universal.apk, etc.
- name: Rename APKs
run: |
VERSION="${{ steps.pkg.outputs.version }}"
APK_DIR="android/app/build/outputs/apk/release"
for apk in "$APK_DIR"/app-*-release.apk; do
base=$(basename "$apk")
# Strip leading "app-" and trailing "-release.apk", add our prefix
abi="${base#app-}"
abi="${abi%-release.apk}"
mv "$apk" "$APK_DIR/Expensso-v${VERSION}-${abi}.apk"
done
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event.inputs.version || github.ref_name }}
name: Expensso v${{ steps.pkg.outputs.version }}
draft: false
prerelease: false
files: android/app/build/outputs/apk/release/Expensso-*.apk
body: |
## Expensso v${{ steps.pkg.outputs.version }}
### APK variants
| File | Recommended for |
|------|----------------|
| `Expensso-v${{ steps.pkg.outputs.version }}-universal.apk` | Any device (largest) |
| `Expensso-v${{ steps.pkg.outputs.version }}-arm64-v8a.apk` | Modern phones (64-bit) |
| `Expensso-v${{ steps.pkg.outputs.version }}-armeabi-v7a.apk` | Older phones (32-bit) |
| `Expensso-v${{ steps.pkg.outputs.version }}-x86_64.apk` | x86 emulators / Chromebooks |
### Installation
1. Download the APK for your device (universal if unsure).
2. Enable **Install from unknown sources** in device settings.
3. Open the downloaded APK to install.
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

117
.gitignore vendored Normal file
View File

@@ -0,0 +1,117 @@
# ===== macOS =====
.DS_Store
.AppleDouble
.LSOverride
Icon?
._*
.Spotlight-V100
.Trashes
# ===== Xcode =====
build/
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
xcuserdata
*.xccheckout
*.moved-aside
DerivedData
*.hmap
*.ipa
*.xcuserstate
**/.xcode.env.local
*.xcscmblueprint
*.xcworkspace
!*.xcodeproj/*.xcworkspace
!default.xcworkspace
*.origmeta
# ===== Android / IntelliJ =====
build/
.idea
.gradle
local.properties
*.iml
*.hprof
.cxx/
.kotlin/
# Keystores — never commit release keystores
*.keystore
!debug.keystore
# Signing credentials file (generated by CI or locally)
android/keystore.properties
# ===== Node.js =====
node_modules/
npm-debug.log
yarn-error.log
.npm/
# ===== Bun =====
# bun.lockb is intentionally committed for reproducible installs
# ===== Environment / Secrets =====
.env
.env.*
.env.local
.env.production
.env.staging
!.env.example
# ===== TypeScript =====
*.tsbuildinfo
dist/
out/
# ===== Fastlane =====
**/fastlane/report.xml
**/fastlane/Preview.html
**/fastlane/screenshots
**/fastlane/test_output
# ===== Bundle artifacts =====
*.jsbundle
# ===== Ruby / CocoaPods =====
**/Pods/
/vendor/bundle/
# Bundler local machine config (machine-specific gem paths, never commit)
.bundle/
# ===== Metro =====
.metro-health-check*
# ===== Testing =====
/coverage
# ===== Yarn =====
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# ===== Editor =====
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
!.vscode/launch.json
.idea/
*.swp
*.swo
*~
# ===== Logs =====
*.log
logs/
# ===== OS thumbnails =====
Thumbs.db
ehthumbs.db
Desktop.ini

5
.prettierrc.js Normal file
View File

@@ -0,0 +1,5 @@
module.exports = {
arrowParens: 'avoid',
singleQuote: true,
trailingComma: 'all',
};

1
.watchmanconfig Normal file
View File

@@ -0,0 +1 @@
{}

98
App.tsx Normal file
View File

@@ -0,0 +1,98 @@
/**
* Expensso — Expense & Net Worth Tracker
* React Native CLI (No Expo)
*/
import React from 'react';
import {
View,
Text,
StyleSheet,
ActivityIndicator,
StatusBar,
} from 'react-native';
import {SafeAreaProvider} from 'react-native-safe-area-context';
import {GestureHandlerRootView} from 'react-native-gesture-handler';
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
import './src/i18n'; // Initialize i18n
import AppNavigator from './src/navigation/AppNavigator';
import {useAppInit} from './src/hooks';
import {COLORS} from './src/constants';
import {ThemeProvider} from './src/theme';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 min
retry: 2,
},
},
});
const SplashScreen: React.FC<{error?: string | null}> = ({error}) => (
<View style={splashStyles.container}>
<StatusBar barStyle="dark-content" backgroundColor={COLORS.background} translucent />
<Text style={splashStyles.logo}>Expensso</Text>
<Text style={splashStyles.tagline}>Track. Plan. Grow.</Text>
{error ? (
<Text style={splashStyles.error}>{error}</Text>
) : (
<ActivityIndicator size="small" color={COLORS.primary} style={splashStyles.loader} />
)}
</View>
);
const splashStyles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: COLORS.background,
},
logo: {
fontSize: 42,
fontWeight: '800',
color: COLORS.primary,
letterSpacing: -1,
},
tagline: {
fontSize: 15,
color: COLORS.textSecondary,
marginTop: 4,
},
loader: {
marginTop: 32,
},
error: {
marginTop: 24,
fontSize: 14,
color: COLORS.danger,
textAlign: 'center',
paddingHorizontal: 32,
},
});
const App: React.FC = () => {
const {isReady, error} = useAppInit();
return (
<GestureHandlerRootView style={styles.root}>
<SafeAreaProvider>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
{isReady ? <AppNavigator /> : <SplashScreen error={error} />}
</ThemeProvider>
</QueryClientProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
);
};
const styles = StyleSheet.create({
root: {
flex: 1,
},
});
export default App;

16
Gemfile Normal file
View File

@@ -0,0 +1,16 @@
source 'https://rubygems.org'
# You may use http://rbenv.org/ or https://rvm.io/ to install and use this version
ruby ">= 2.6.10"
# Exclude problematic versions of cocoapods and activesupport that causes build failures.
gem 'cocoapods', '>= 1.13', '!= 1.15.0', '!= 1.15.1'
gem 'activesupport', '>= 6.1.7.5', '!= 7.1.0'
gem 'xcodeproj', '< 1.26.0'
gem 'concurrent-ruby', '< 1.3.4'
# Ruby 3.4.0 has removed some libraries from the standard library.
gem 'bigdecimal'
gem 'logger'
gem 'benchmark'
gem 'mutex_m'

97
README.md Normal file
View File

@@ -0,0 +1,97 @@
This is a new [**React Native**](https://reactnative.dev) project, bootstrapped using [`@react-native-community/cli`](https://github.com/react-native-community/cli).
# Getting Started
> **Note**: Make sure you have completed the [Set Up Your Environment](https://reactnative.dev/docs/set-up-your-environment) guide before proceeding.
## Step 1: Start Metro
First, you will need to run **Metro**, the JavaScript build tool for React Native.
To start the Metro dev server, run the following command from the root of your React Native project:
```sh
# Using npm
npm start
# OR using Yarn
yarn start
```
## Step 2: Build and run your app
With Metro running, open a new terminal window/pane from the root of your React Native project, and use one of the following commands to build and run your Android or iOS app:
### Android
```sh
# Using npm
npm run android
# OR using Yarn
yarn android
```
### iOS
For iOS, remember to install CocoaPods dependencies (this only needs to be run on first clone or after updating native deps).
The first time you create a new project, run the Ruby bundler to install CocoaPods itself:
```sh
bundle install
```
Then, and every time you update your native dependencies, run:
```sh
bundle exec pod install
```
For more information, please visit [CocoaPods Getting Started guide](https://guides.cocoapods.org/using/getting-started.html).
```sh
# Using npm
npm run ios
# OR using Yarn
yarn ios
```
If everything is set up correctly, you should see your new app running in the Android Emulator, iOS Simulator, or your connected device.
This is one way to run your app — you can also build it directly from Android Studio or Xcode.
## Step 3: Modify your app
Now that you have successfully run the app, let's make changes!
Open `App.tsx` in your text editor of choice and make some changes. When you save, your app will automatically update and reflect these changes — this is powered by [Fast Refresh](https://reactnative.dev/docs/fast-refresh).
When you want to forcefully reload, for example to reset the state of your app, you can perform a full reload:
- **Android**: Press the <kbd>R</kbd> key twice or select **"Reload"** from the **Dev Menu**, accessed via <kbd>Ctrl</kbd> + <kbd>M</kbd> (Windows/Linux) or <kbd>Cmd ⌘</kbd> + <kbd>M</kbd> (macOS).
- **iOS**: Press <kbd>R</kbd> in iOS Simulator.
## Congratulations! :tada:
You've successfully run and modified your React Native App. :partying_face:
### Now what?
- If you want to add this new React Native code to an existing application, check out the [Integration guide](https://reactnative.dev/docs/integration-with-existing-apps).
- If you're curious to learn more about React Native, check out the [docs](https://reactnative.dev/docs/getting-started).
# Troubleshooting
If you're having issues getting the above steps to work, see the [Troubleshooting](https://reactnative.dev/docs/troubleshooting) page.
# Learn More
To learn more about React Native, take a look at the following resources:
- [React Native Website](https://reactnative.dev) - learn more about React Native.
- [Getting Started](https://reactnative.dev/docs/environment-setup) - an **overview** of React Native and how setup your environment.
- [Learn the Basics](https://reactnative.dev/docs/getting-started) - a **guided tour** of the React Native **basics**.
- [Blog](https://reactnative.dev/blog) - read the latest official React Native **Blog** posts.
- [`@facebook/react-native`](https://github.com/facebook/react-native) - the Open Source; GitHub **repository** for React Native.

13
__tests__/App.test.tsx Normal file
View File

@@ -0,0 +1,13 @@
/**
* @format
*/
import React from 'react';
import ReactTestRenderer from 'react-test-renderer';
import App from '../App';
test('renders correctly', async () => {
await ReactTestRenderer.act(() => {
ReactTestRenderer.create(<App />);
});
});

151
android/app/build.gradle Normal file
View File

@@ -0,0 +1,151 @@
apply plugin: "com.android.application"
apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "com.facebook.react"
apply from: file("../../node_modules/react-native-vector-icons/fonts.gradle")
// Load keystore signing credentials from android/keystore.properties (never committed).
// Generate this file locally or let CI create it from secrets.
def keystorePropertiesFile = rootProject.file("keystore.properties")
def keystoreProperties = new Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
/**
* This is the configuration block to customize your React Native Android app.
* By default you don't need to apply any configuration, just uncomment the lines you need.
*/
react {
/* Folders */
// The root of your project, i.e. where "package.json" lives. Default is '../..'
// root = file("../../")
// The folder where the react-native NPM package is. Default is ../../node_modules/react-native
// reactNativeDir = file("../../node_modules/react-native")
// The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen
// codegenDir = file("../../node_modules/@react-native/codegen")
// The cli.js file which is the React Native CLI entrypoint. Default is ../../node_modules/react-native/cli.js
// cliFile = file("../../node_modules/react-native/cli.js")
/* Variants */
// The list of variants to that are debuggable. For those we're going to
// skip the bundling of the JS bundle and the assets. Default is "debug", "debugOptimized".
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
// debuggableVariants = ["liteDebug", "liteDebugOptimized", "prodDebug", "prodDebugOptimized"]
/* Bundling */
// A list containing the node command and its flags. Default is just 'node'.
// nodeExecutableAndArgs = ["node"]
//
// The command to run when bundling. By default is 'bundle'
// bundleCommand = "ram-bundle"
//
// The path to the CLI configuration file. Default is empty.
// bundleConfig = file(../rn-cli.config.js)
//
// The name of the generated asset file containing your JS bundle
// bundleAssetName = "MyApplication.android.bundle"
//
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
// entryFile = file("../js/MyApplication.android.js")
//
// A list of extra flags to pass to the 'bundle' commands.
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
// extraPackagerArgs = []
/* Hermes Commands */
// The hermes compiler command to run. By default it is 'hermesc'
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
//
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
// hermesFlags = ["-O", "-output-source-map"]
/* Autolinking */
autolinkLibrariesWithApp()
}
/**
* Set this to true to Run Proguard on Release builds to minify the Java bytecode.
*/
def enableProguardInReleaseBuilds = true
/**
* The preferred build flavor of JavaScriptCore (JSC)
*
* For example, to use the international variant, you can use:
* `def jscFlavor = io.github.react-native-community:jsc-android-intl:2026004.+`
*
* The international variant includes ICU i18n library and necessary data
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
* give correct results when using with locales other than en-US. Note that
* this variant is about 6MiB larger per architecture than default.
*/
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
android {
ndkVersion rootProject.ext.ndkVersion
buildToolsVersion rootProject.ext.buildToolsVersion
compileSdk rootProject.ext.compileSdkVersion
namespace "com.expensso"
defaultConfig {
applicationId "com.expensso"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
}
signingConfigs {
debug {
storeFile file('debug.keystore')
storePassword 'android'
keyAlias 'androiddebugkey'
keyPassword 'android'
}
release {
if (keystorePropertiesFile.exists()) {
storeFile file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
} else {
// Fallback: unsigned local builds. Install via adb only.
storeFile file('debug.keystore')
storePassword 'android'
keyAlias 'androiddebugkey'
keyPassword 'android'
}
}
}
buildTypes {
debug {
signingConfig signingConfigs.debug
}
release {
signingConfig signingConfigs.release
minifyEnabled enableProguardInReleaseBuilds
shrinkResources enableProguardInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
}
}
// Generate per-ABI APKs to reduce download size
splits {
abi {
enable true
reset()
include "armeabi-v7a", "arm64-v8a", "x86", "x86_64"
universalApk true
}
}
}
dependencies {
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")
if (hermesEnabled.toBoolean()) {
implementation("com.facebook.react:hermes-android")
} else {
implementation jscFlavor
}
}

BIN
android/app/debug.keystore Normal file

Binary file not shown.

47
android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,47 @@
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the proguardFiles
# directive in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# Add any project specific keep options here:
# ─── React Native ─────────────────────────────────────────────────────
-keep class com.facebook.hermes.** { *; }
-keep class com.facebook.jni.** { *; }
-keep class com.facebook.react.** { *; }
-keepclassmembers class * {
@com.facebook.react.uimanager.annotations.ReactProp <methods>;
}
# ─── React Native Reanimated ─────────────────────────────────────────
-keep class com.swmansion.reanimated.** { *; }
-keep class com.facebook.react.turbomodule.** { *; }
# ─── React Native Gesture Handler ────────────────────────────────────
-keep class com.swmansion.gesturehandler.** { *; }
# ─── React Native SQLite Storage ─────────────────────────────────────
-keep class org.pgsqlite.** { *; }
# ─── React Native MMKV ───────────────────────────────────────────────
-keep class com.tencent.mmkv.** { *; }
# ─── React Native Screens ────────────────────────────────────────────
-keep class com.swmansion.rnscreens.** { *; }
# ─── React Native SVG ────────────────────────────────────────────────
-keep class com.horcrux.svg.** { *; }
# ─── React Native Vector Icons ───────────────────────────────────────
-keep class com.oblador.vectoricons.** { *; }
# ─── General Android optimisation ────────────────────────────────────
-dontwarn javax.annotation.**
-dontwarn sun.misc.**
-keepattributes *Annotation*
-keepattributes Signature
-keepattributes SourceFile,LineNumberTable

View File

@@ -0,0 +1,27 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".MainApplication"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:allowBackup="false"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="${usesCleartextTraffic}"
android:supportsRtl="true">
<activity
android:name=".MainActivity"
android:label="@string/app_name"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,22 @@
package com.expensso
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
import com.facebook.react.defaults.DefaultReactActivityDelegate
class MainActivity : ReactActivity() {
/**
* Returns the name of the main component registered from JavaScript. This is used to schedule
* rendering of the component.
*/
override fun getMainComponentName(): String = "Expensso"
/**
* Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
*/
override fun createReactActivityDelegate(): ReactActivityDelegate =
DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)
}

View File

@@ -0,0 +1,27 @@
package com.expensso
import android.app.Application
import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
import com.facebook.react.ReactHost
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
class MainApplication : Application(), ReactApplication {
override val reactHost: ReactHost by lazy {
getDefaultReactHost(
context = applicationContext,
packageList =
PackageList(this).packages.apply {
// Packages that cannot be autolinked yet can be added manually here, for example:
// add(MyReactNativePackage())
},
)
}
override fun onCreate() {
super.onCreate()
loadReactNative(this)
}
}

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2014 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:insetLeft="@dimen/abc_edit_text_inset_horizontal_material"
android:insetRight="@dimen/abc_edit_text_inset_horizontal_material"
android:insetTop="@dimen/abc_edit_text_inset_top_material"
android:insetBottom="@dimen/abc_edit_text_inset_bottom_material"
>
<selector>
<!--
This file is a copy of abc_edit_text_material (https://bit.ly/3k8fX7I).
The item below with state_pressed="false" and state_focused="false" causes a NullPointerException.
NullPointerException:tempt to invoke virtual method 'android.graphics.drawable.Drawable android.graphics.drawable.Drawable$ConstantState.newDrawable(android.content.res.Resources)'
<item android:state_pressed="false" android:state_focused="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
For more info, see https://bit.ly/3CdLStv (react-native/pull/29452) and https://bit.ly/3nxOMoR.
-->
<item android:state_enabled="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
<item android:drawable="@drawable/abc_textfield_activated_mtrl_alpha"/>
</selector>
</inset>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Expensso</string>
</resources>

View File

@@ -0,0 +1,9 @@
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<!-- Customize your theme here. -->
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
</style>
</resources>

21
android/build.gradle Normal file
View File

@@ -0,0 +1,21 @@
buildscript {
ext {
buildToolsVersion = "36.0.0"
minSdkVersion = 24
compileSdkVersion = 36
targetSdkVersion = 36
ndkVersion = "27.1.12297006"
kotlinVersion = "2.1.20"
}
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle")
classpath("com.facebook.react:react-native-gradle-plugin")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin")
}
}
apply plugin: "com.facebook.react.rootproject"

44
android/gradle.properties Normal file
View File

@@ -0,0 +1,44 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Use this property to specify which architecture you want to build.
# You can also override it from the CLI using
# ./gradlew <task> -PreactNativeArchitectures=x86_64
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
# Use this property to enable support to the new architecture.
# This will allow you to use TurboModules and the Fabric render in
# your application. You should enable this flag either if you want
# to write custom TurboModules/Fabric components OR use libraries that
# are providing them.
newArchEnabled=true
# Use this property to enable or disable the Hermes JS engine.
# If set to false, you will be using JSC instead.
hermesEnabled=true
# Use this property to enable edge-to-edge display support.
# This allows your app to draw behind system bars for an immersive UI.
# Note: Only works with ReactActivity and should not be used with custom Activity.
edgeToEdgeEnabled=false

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
android/gradlew vendored Executable file
View File

@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

99
android/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,99 @@
@REM Copyright (c) Meta Platforms, Inc. and affiliates.
@REM
@REM This source code is licensed under the MIT license found in the
@REM LICENSE file in the root directory of this source tree.
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

6
android/settings.gradle Normal file
View File

@@ -0,0 +1,6 @@
pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") }
plugins { id("com.facebook.react.settings") }
extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() }
rootProject.name = 'Expensso'
include ':app'
includeBuild('../node_modules/@react-native/gradle-plugin')

4
app.json Normal file
View File

@@ -0,0 +1,4 @@
{
"name": "Expensso",
"displayName": "Expensso"
}

BIN
assets/app-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

6
babel.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
presets: ['module:@react-native/babel-preset'],
plugins: [
'react-native-reanimated/plugin', // Must be last
],
};

9
index.js Normal file
View File

@@ -0,0 +1,9 @@
/**
* @format
*/
import { AppRegistry } from 'react-native';
import App from './App';
import { name as appName } from './app.json';
AppRegistry.registerComponent(appName, () => App);

11
ios/.xcode.env Normal file
View File

@@ -0,0 +1,11 @@
# This `.xcode.env` file is versioned and is used to source the environment
# used when running script phases inside Xcode.
# To customize your local environment, you can create an `.xcode.env.local`
# file that is not versioned.
# NODE_BINARY variable contains the PATH to the node executable.
#
# Customize the NODE_BINARY variable here.
# For example, to use nvm with brew, add the following line
# . "$(brew --prefix nvm)/nvm.sh" --no-use
export NODE_BINARY=$(command -v node)

View File

@@ -0,0 +1,475 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
0C80B921A6F3F58F76C31292 /* libPods-Expensso.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5DCACB8F33CDC322A6C60F78 /* libPods-Expensso.a */; };
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; };
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
13B07F961A680F5B00A75B9A /* Expensso.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Expensso.app; sourceTree = BUILT_PRODUCTS_DIR; };
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Expensso/Images.xcassets; sourceTree = "<group>"; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Expensso/Info.plist; sourceTree = "<group>"; };
13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = Expensso/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
3B4392A12AC88292D35C810B /* Pods-Expensso.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Expensso.debug.xcconfig"; path = "Target Support Files/Pods-Expensso/Pods-Expensso.debug.xcconfig"; sourceTree = "<group>"; };
5709B34CF0A7D63546082F79 /* Pods-Expensso.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Expensso.release.xcconfig"; path = "Target Support Files/Pods-Expensso/Pods-Expensso.release.xcconfig"; sourceTree = "<group>"; };
5DCACB8F33CDC322A6C60F78 /* libPods-Expensso.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Expensso.a"; sourceTree = BUILT_PRODUCTS_DIR; };
761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = Expensso/AppDelegate.swift; sourceTree = "<group>"; };
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = Expensso/LaunchScreen.storyboard; sourceTree = "<group>"; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
13B07F8C1A680F5B00A75B9A /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
0C80B921A6F3F58F76C31292 /* libPods-Expensso.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
13B07FAE1A68108700A75B9A /* Expensso */ = {
isa = PBXGroup;
children = (
13B07FB51A68108700A75B9A /* Images.xcassets */,
761780EC2CA45674006654EE /* AppDelegate.swift */,
13B07FB61A68108700A75B9A /* Info.plist */,
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */,
13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */,
);
name = Expensso;
sourceTree = "<group>";
};
2D16E6871FA4F8E400B85C8A /* Frameworks */ = {
isa = PBXGroup;
children = (
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
5DCACB8F33CDC322A6C60F78 /* libPods-Expensso.a */,
);
name = Frameworks;
sourceTree = "<group>";
};
832341AE1AAA6A7D00B99B32 /* Libraries */ = {
isa = PBXGroup;
children = (
);
name = Libraries;
sourceTree = "<group>";
};
83CBB9F61A601CBA00E9B192 = {
isa = PBXGroup;
children = (
13B07FAE1A68108700A75B9A /* Expensso */,
832341AE1AAA6A7D00B99B32 /* Libraries */,
83CBBA001A601CBA00E9B192 /* Products */,
2D16E6871FA4F8E400B85C8A /* Frameworks */,
BBD78D7AC51CEA395F1C20DB /* Pods */,
);
indentWidth = 2;
sourceTree = "<group>";
tabWidth = 2;
usesTabs = 0;
};
83CBBA001A601CBA00E9B192 /* Products */ = {
isa = PBXGroup;
children = (
13B07F961A680F5B00A75B9A /* Expensso.app */,
);
name = Products;
sourceTree = "<group>";
};
BBD78D7AC51CEA395F1C20DB /* Pods */ = {
isa = PBXGroup;
children = (
3B4392A12AC88292D35C810B /* Pods-Expensso.debug.xcconfig */,
5709B34CF0A7D63546082F79 /* Pods-Expensso.release.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
13B07F861A680F5B00A75B9A /* Expensso */ = {
isa = PBXNativeTarget;
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Expensso" */;
buildPhases = (
C38B50BA6285516D6DCD4F65 /* [CP] Check Pods Manifest.lock */,
13B07F871A680F5B00A75B9A /* Sources */,
13B07F8C1A680F5B00A75B9A /* Frameworks */,
13B07F8E1A680F5B00A75B9A /* Resources */,
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
00EEFC60759A1932668264C0 /* [CP] Embed Pods Frameworks */,
E235C05ADACE081382539298 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
dependencies = (
);
name = Expensso;
productName = Expensso;
productReference = 13B07F961A680F5B00A75B9A /* Expensso.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
83CBB9F71A601CBA00E9B192 /* Project object */ = {
isa = PBXProject;
attributes = {
LastUpgradeCheck = 1210;
TargetAttributes = {
13B07F861A680F5B00A75B9A = {
LastSwiftMigration = 1120;
};
};
};
buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Expensso" */;
compatibilityVersion = "Xcode 12.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 83CBB9F61A601CBA00E9B192;
productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
13B07F861A680F5B00A75B9A /* Expensso */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
13B07F8E1A680F5B00A75B9A /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */,
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"$(SRCROOT)/.xcode.env.local",
"$(SRCROOT)/.xcode.env",
);
name = "Bundle React Native code and images";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "set -e\n\nWITH_ENVIRONMENT=\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"$REACT_NATIVE_PATH/scripts/react-native-xcode.sh\"\n\n/bin/sh -c \"\\\"$WITH_ENVIRONMENT\\\" \\\"$REACT_NATIVE_XCODE\\\"\"\n";
};
00EEFC60759A1932668264C0 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Expensso/Pods-Expensso-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Expensso/Pods-Expensso-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Expensso/Pods-Expensso-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
C38B50BA6285516D6DCD4F65 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Expensso-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
E235C05ADACE081382539298 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Expensso/Pods-Expensso-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Expensso/Pods-Expensso-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Expensso/Pods-Expensso-resources.sh\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
13B07F871A680F5B00A75B9A /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
761780ED2CA45674006654EE /* AppDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
13B07F941A680F5B00A75B9A /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 3B4392A12AC88292D35C810B /* Pods-Expensso.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 1;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Expensso/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
"-lc++",
);
PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)";
PRODUCT_NAME = Expensso;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
13B07F951A680F5B00A75B9A /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 5709B34CF0A7D63546082F79 /* Pods-Expensso.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 1;
INFOPLIST_FILE = Expensso/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
"-lc++",
);
PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)";
PRODUCT_NAME = Expensso;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
83CBBA201A601CBA00E9B192 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_CXX_LANGUAGE_STANDARD = "c++20";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_SYMBOLS_PRIVATE_EXTERN = NO;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = (
/usr/lib/swift,
"$(inherited)",
);
LIBRARY_SEARCH_PATHS = (
"\"$(SDKROOT)/usr/lib/swift\"",
"\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"",
"\"$(inherited)\"",
);
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
OTHER_CPLUSPLUSFLAGS = (
"$(OTHER_CFLAGS)",
"-DFOLLY_NO_CONFIG",
"-DFOLLY_MOBILE=1",
"-DFOLLY_USE_LIBCPP=1",
"-DFOLLY_CFG_NO_COROUTINES=1",
"-DFOLLY_HAVE_CLOCK_GETTIME=1",
);
SDKROOT = iphoneos;
};
name = Debug;
};
83CBBA211A601CBA00E9B192 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_CXX_LANGUAGE_STANDARD = "c++20";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = YES;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = (
/usr/lib/swift,
"$(inherited)",
);
LIBRARY_SEARCH_PATHS = (
"\"$(SDKROOT)/usr/lib/swift\"",
"\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"",
"\"$(inherited)\"",
);
MTL_ENABLE_DEBUG_INFO = NO;
OTHER_CPLUSPLUSFLAGS = (
"$(OTHER_CFLAGS)",
"-DFOLLY_NO_CONFIG",
"-DFOLLY_MOBILE=1",
"-DFOLLY_USE_LIBCPP=1",
"-DFOLLY_CFG_NO_COROUTINES=1",
"-DFOLLY_HAVE_CLOCK_GETTIME=1",
);
SDKROOT = iphoneos;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Expensso" */ = {
isa = XCConfigurationList;
buildConfigurations = (
13B07F941A680F5B00A75B9A /* Debug */,
13B07F951A680F5B00A75B9A /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Expensso" */ = {
isa = XCConfigurationList;
buildConfigurations = (
83CBBA201A601CBA00E9B192 /* Debug */,
83CBBA211A601CBA00E9B192 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */;
}

View File

@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1210"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "Expensso.app"
BlueprintName = "Expensso"
ReferencedContainer = "container:Expensso.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "00E356ED1AD99517003FC87E"
BuildableName = "ExpenssoTests.xctest"
BlueprintName = "ExpenssoTests"
ReferencedContainer = "container:Expensso.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "Expensso.app"
BlueprintName = "Expensso"
ReferencedContainer = "container:Expensso.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "Expensso.app"
BlueprintName = "Expensso"
ReferencedContainer = "container:Expensso.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,48 @@
import UIKit
import React
import React_RCTAppDelegate
import ReactAppDependencyProvider
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
var reactNativeDelegate: ReactNativeDelegate?
var reactNativeFactory: RCTReactNativeFactory?
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
let delegate = ReactNativeDelegate()
let factory = RCTReactNativeFactory(delegate: delegate)
delegate.dependencyProvider = RCTAppDependencyProvider()
reactNativeDelegate = delegate
reactNativeFactory = factory
window = UIWindow(frame: UIScreen.main.bounds)
factory.startReactNative(
withModuleName: "Expensso",
in: window,
launchOptions: launchOptions
)
return true
}
}
class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
override func sourceURL(for bridge: RCTBridge) -> URL? {
self.bundleURL()
}
override func bundleURL() -> URL? {
#if DEBUG
RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
#else
Bundle.main.url(forResource: "main", withExtension: "jsbundle")
#endif
}
}

View File

@@ -0,0 +1,62 @@
{
"images" : [
{
"filename" : "Icon-20@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "20x20"
},
{
"filename" : "Icon-20@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "20x20"
},
{
"filename" : "Icon-29@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "Icon-29@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "29x29"
},
{
"filename" : "Icon-40@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "40x40"
},
{
"filename" : "Icon-40@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "40x40"
},
{
"filename" : "Icon-60@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "60x60"
},
{
"filename" : "Icon-60@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "60x60"
},
{
"filename" : "Icon-1024.png",
"idiom" : "ios-marketing",
"scale" : "1x",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
}
}

59
ios/Expensso/Info.plist Normal file
View File

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Expensso</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<!-- Do not change NSAllowsArbitraryLoads to true, or you will risk app rejection! -->
<key>NSAllowsArbitraryLoads</key>
<false/>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSLocationWhenInUseUsageDescription</key>
<string></string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="15702" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15704"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Expensso" textAlignment="center" lineBreakMode="middleTruncation" baselineAdjustment="alignBaselines" minimumFontSize="18" translatesAutoresizingMaskIntoConstraints="NO" id="GJd-Yh-RWb">
<rect key="frame" x="0.0" y="202" width="375" height="43"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="36"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Powered by React Native" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumFontSize="9" translatesAutoresizingMaskIntoConstraints="NO" id="MN2-I3-ftu">
<rect key="frame" x="0.0" y="626" width="375" height="21"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<constraints>
<constraint firstItem="Bcu-3y-fUS" firstAttribute="bottom" secondItem="MN2-I3-ftu" secondAttribute="bottom" constant="20" id="OZV-Vh-mqD"/>
<constraint firstItem="Bcu-3y-fUS" firstAttribute="centerX" secondItem="GJd-Yh-RWb" secondAttribute="centerX" id="Q3B-4B-g5h"/>
<constraint firstItem="MN2-I3-ftu" firstAttribute="centerX" secondItem="Bcu-3y-fUS" secondAttribute="centerX" id="akx-eg-2ui"/>
<constraint firstItem="MN2-I3-ftu" firstAttribute="leading" secondItem="Bcu-3y-fUS" secondAttribute="leading" id="i1E-0Y-4RG"/>
<constraint firstItem="GJd-Yh-RWb" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="bottom" multiplier="1/3" constant="1" id="moa-c2-u7t"/>
<constraint firstItem="GJd-Yh-RWb" firstAttribute="leading" secondItem="Bcu-3y-fUS" secondAttribute="leading" symbolic="YES" id="x7j-FC-K8j"/>
</constraints>
<viewLayoutGuide key="safeArea" id="Bcu-3y-fUS"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="52.173913043478265" y="375"/>
</scene>
</scenes>
</document>

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
</array>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyTracking</key>
<false/>
</dict>
</plist>

34
ios/Podfile Normal file
View File

@@ -0,0 +1,34 @@
# Resolve react_native_pods.rb with node to allow for hoisting
require Pod::Executable.execute_command('node', ['-p',
'require.resolve(
"react-native/scripts/react_native_pods.rb",
{paths: [process.argv[1]]},
)', __dir__]).strip
platform :ios, min_ios_version_supported
prepare_react_native_project!
linkage = ENV['USE_FRAMEWORKS']
if linkage != nil
Pod::UI.puts "Configuring Pod with #{linkage}ally linked Frameworks".green
use_frameworks! :linkage => linkage.to_sym
end
target 'Expensso' do
config = use_native_modules!
use_react_native!(
:path => config[:reactNativePath],
# An absolute path to your application root.
:app_path => "#{Pod::Config.instance.installation_root}/.."
)
post_install do |installer|
react_native_post_install(
installer,
config[:reactNativePath],
:mac_catalyst_enabled => false,
# :ccache_enabled => true
)
end
end

3
jest.config.js Normal file
View File

@@ -0,0 +1,3 @@
module.exports = {
preset: 'react-native',
};

11
metro.config.js Normal file
View File

@@ -0,0 +1,11 @@
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
/**
* Metro configuration
* https://reactnative.dev/docs/metro
*
* @type {import('@react-native/metro-config').MetroConfig}
*/
const config = {};
module.exports = mergeConfig(getDefaultConfig(__dirname), config);

13673
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

70
package.json Normal file
View File

@@ -0,0 +1,70 @@
{
"name": "Expensso",
"version": "0.0.1",
"private": true,
"scripts": {
"android": "react-native run-android",
"ios": "react-native run-ios",
"lint": "eslint .",
"start": "react-native start",
"test": "jest",
"build:release": "cd android && ./gradlew assembleRelease --no-daemon",
"build:release:aab": "cd android && ./gradlew bundleRelease --no-daemon"
},
"dependencies": {
"@gorhom/bottom-sheet": "^5.2.8",
"@react-native/new-app-screen": "0.84.0",
"@react-navigation/bottom-tabs": "^7.14.0",
"@react-navigation/native": "^7.1.28",
"@react-navigation/stack": "^7.7.2",
"@tanstack/react-query": "^5.90.21",
"dayjs": "^1.11.19",
"i18next": "^25.8.10",
"nativewind": "^4.2.1",
"react": "19.2.3",
"react-i18next": "^16.5.4",
"react-native": "0.84.0",
"react-native-gesture-handler": "^2.30.0",
"react-native-gifted-charts": "^1.4.74",
"react-native-haptic-feedback": "^2.3.3",
"react-native-mmkv": "^4.1.2",
"react-native-nitro-modules": "^0.33.9",
"react-native-paper": "^5.15.0",
"react-native-reanimated": "^4.2.2",
"react-native-safe-area-context": "^5.6.2",
"react-native-screens": "^4.23.0",
"react-native-sqlite-storage": "^6.0.1",
"react-native-svg": "^15.15.3",
"react-native-vector-icons": "^10.3.0",
"react-native-worklets": "^0.7.4",
"uuid": "^13.0.0",
"zustand": "^5.0.11"
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.3",
"@babel/runtime": "^7.25.0",
"@react-native-community/cli": "20.1.0",
"@react-native-community/cli-platform-android": "20.1.0",
"@react-native-community/cli-platform-ios": "20.1.0",
"@react-native/babel-preset": "0.84.0",
"@react-native/eslint-config": "0.84.0",
"@react-native/metro-config": "0.84.0",
"@react-native/typescript-config": "0.84.0",
"@types/jest": "^29.5.13",
"@types/react": "^19.2.0",
"@types/react-native-sqlite-storage": "^6.0.5",
"@types/react-native-vector-icons": "^6.4.18",
"@types/react-test-renderer": "^19.1.0",
"@types/uuid": "^10.0.0",
"eslint": "^8.19.0",
"jest": "^29.6.3",
"prettier": "2.8.8",
"react-test-renderer": "19.2.3",
"tailwindcss": "^3.4.19",
"typescript": "^5.8.3"
},
"engines": {
"node": ">= 22.11.0"
}
}

View File

@@ -0,0 +1,433 @@
/**
* CustomBottomSheet — replaces ALL system Alert/Modal patterns.
*
* Built on @gorhom/bottom-sheet with MD3 styling, drag-handle,
* scrim overlay, and smooth reanimated transitions.
*
* Usage:
* <CustomBottomSheet ref={sheetRef} snapPoints={['50%']}>
* <BottomSheetContent />
* </CustomBottomSheet>
*
* Imperative API:
* sheetRef.current?.present() open
* sheetRef.current?.dismiss() close
*/
import React, {
forwardRef,
useCallback,
useImperativeHandle,
useMemo,
useRef,
} from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Pressable,
} from 'react-native';
import BottomSheet, {
BottomSheetBackdrop,
BottomSheetScrollView,
BottomSheetView,
type BottomSheetBackdropProps,
} from '@gorhom/bottom-sheet';
import ReactNativeHapticFeedback from 'react-native-haptic-feedback';
import {useTheme} from '../theme';
import type {MD3Theme} from '../theme';
const hapticOptions = {enableVibrateFallback: true, ignoreAndroidSystemSettings: false};
export const triggerHaptic = (type: 'impactLight' | 'impactMedium' | 'selection' | 'notificationSuccess' = 'impactLight') =>
ReactNativeHapticFeedback.trigger(type, hapticOptions);
// ─── Public Handle ───────────────────────────────────────────────────
export interface CustomBottomSheetHandle {
present: () => void;
dismiss: () => void;
}
// ─── Props ───────────────────────────────────────────────────────────
export interface CustomBottomSheetProps {
/** Title displayed in the sheet header */
title?: string;
/** Snap-point percentages or absolute values */
snapPoints?: (string | number)[];
/** Callback when the sheet is fully closed */
onDismiss?: () => void;
/** Whether to wrap children in a ScrollView (default: true) */
scrollable?: boolean;
/** Header left action (e.g. Cancel) */
headerLeft?: {label: string; onPress: () => void};
/** Header right action (e.g. Save) */
headerRight?: {label: string; onPress: () => void; color?: string};
/** Content */
children: React.ReactNode;
/** Enable dynamic sizing instead of snapPoints */
enableDynamicSizing?: boolean;
}
// ─── Component ───────────────────────────────────────────────────────
const CustomBottomSheetInner = forwardRef<CustomBottomSheetHandle, CustomBottomSheetProps>(
(
{
title,
snapPoints: snapPointsProp,
onDismiss,
scrollable = true,
headerLeft,
headerRight,
children,
enableDynamicSizing = false,
},
ref,
) => {
const theme = useTheme();
const s = makeStyles(theme);
const sheetRef = useRef<BottomSheet>(null);
const snapPoints = useMemo(() => snapPointsProp ?? ['60%'], [snapPointsProp]);
// Imperative handle
useImperativeHandle(ref, () => ({
present: () => {
triggerHaptic('impactMedium');
sheetRef.current?.snapToIndex(0);
},
dismiss: () => {
triggerHaptic('impactLight');
sheetRef.current?.close();
},
}));
// Backdrop with scrim
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.4}
pressBehavior="close"
/>
),
[],
);
// Handle indicator
const renderHandle = useCallback(
() => (
<View style={s.handleContainer}>
<View style={s.handle} />
</View>
),
[s],
);
const Wrapper = scrollable ? BottomSheetScrollView : BottomSheetView;
return (
<BottomSheet
ref={sheetRef}
index={-1}
snapPoints={enableDynamicSizing ? undefined : snapPoints}
enableDynamicSizing={enableDynamicSizing}
enablePanDownToClose
onClose={onDismiss}
backdropComponent={renderBackdrop}
handleComponent={renderHandle}
backgroundStyle={s.background}
style={s.container}
keyboardBehavior="interactive"
keyboardBlurBehavior="restore"
android_keyboardInputMode="adjustResize">
{/* Sheet Header */}
{(title || headerLeft || headerRight) && (
<View style={s.header}>
{headerLeft ? (
<TouchableOpacity onPress={headerLeft.onPress} hitSlop={8}>
<Text style={s.headerLeftText}>{headerLeft.label}</Text>
</TouchableOpacity>
) : (
<View style={s.headerPlaceholder} />
)}
{title ? <Text style={s.headerTitle}>{title}</Text> : <View />}
{headerRight ? (
<TouchableOpacity onPress={headerRight.onPress} hitSlop={8}>
<Text
style={[
s.headerRightText,
headerRight.color ? {color: headerRight.color} : undefined,
]}>
{headerRight.label}
</Text>
</TouchableOpacity>
) : (
<View style={s.headerPlaceholder} />
)}
</View>
)}
{/* Sheet Body */}
<Wrapper
style={s.body}
contentContainerStyle={s.bodyContent}
showsVerticalScrollIndicator={false}>
{children}
</Wrapper>
</BottomSheet>
);
},
);
CustomBottomSheetInner.displayName = 'CustomBottomSheet';
export const CustomBottomSheet = CustomBottomSheetInner;
// ─── Styles ──────────────────────────────────────────────────────────
function makeStyles(theme: MD3Theme) {
const {colors, typography, shape, spacing} = theme;
return StyleSheet.create({
container: {
zIndex: 999,
},
background: {
backgroundColor: colors.surfaceContainerLow,
borderTopLeftRadius: shape.extraLarge,
borderTopRightRadius: shape.extraLarge,
},
handleContainer: {
alignItems: 'center',
paddingTop: spacing.sm,
paddingBottom: spacing.xs,
},
handle: {
width: 32,
height: 4,
borderRadius: 2,
backgroundColor: colors.onSurfaceVariant,
opacity: 0.4,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.xl,
paddingTop: spacing.sm,
paddingBottom: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.outlineVariant,
},
headerTitle: {
...typography.titleMedium,
color: colors.onSurface,
fontWeight: '600',
},
headerLeftText: {
...typography.labelLarge,
color: colors.onSurfaceVariant,
},
headerRightText: {
...typography.labelLarge,
color: colors.primary,
fontWeight: '600',
},
headerPlaceholder: {
width: 48,
},
body: {
flex: 1,
},
bodyContent: {
paddingHorizontal: spacing.xl,
paddingTop: spacing.lg,
paddingBottom: spacing.xxxl + 20,
},
});
}
// ─── Convenience Sub-Components ──────────────────────────────────────
/**
* MD3-styled text input for use inside bottom sheets.
*/
export interface BottomSheetInputProps {
label: string;
value: string;
onChangeText: (text: string) => void;
placeholder?: string;
keyboardType?: 'default' | 'decimal-pad' | 'number-pad' | 'email-address';
error?: string;
multiline?: boolean;
prefix?: string;
autoFocus?: boolean;
}
import {TextInput} from 'react-native';
export const BottomSheetInput: React.FC<BottomSheetInputProps> = ({
label,
value,
onChangeText,
placeholder,
keyboardType = 'default',
error,
multiline = false,
prefix,
autoFocus = false,
}) => {
const theme = useTheme();
const {colors, typography, shape, spacing} = theme;
const [focused, setFocused] = React.useState(false);
const borderColor = error
? colors.error
: focused
? colors.primary
: colors.outline;
return (
<View style={{marginBottom: spacing.lg}}>
<Text
style={{
...typography.bodySmall,
color: error ? colors.error : focused ? colors.primary : colors.onSurfaceVariant,
marginBottom: spacing.xs,
}}>
{label}
</Text>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
borderWidth: focused ? 2 : 1,
borderColor,
borderRadius: shape.small,
backgroundColor: colors.surfaceContainerLowest,
paddingHorizontal: spacing.md,
minHeight: multiline ? 80 : 48,
}}>
{prefix && (
<Text
style={{
...typography.bodyLarge,
color: colors.onSurfaceVariant,
marginRight: spacing.xs,
}}>
{prefix}
</Text>
)}
<TextInput
style={{
flex: 1,
...typography.bodyLarge,
color: colors.onSurface,
paddingVertical: spacing.sm,
textAlignVertical: multiline ? 'top' : 'center',
}}
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor={colors.onSurfaceVariant + '80'}
keyboardType={keyboardType}
multiline={multiline}
autoFocus={autoFocus}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
/>
</View>
{error && (
<Text
style={{
...typography.bodySmall,
color: colors.error,
marginTop: spacing.xs,
}}>
{error}
</Text>
)}
</View>
);
};
/**
* MD3 chip selector row for use inside bottom sheets.
*/
export interface BottomSheetChipSelectorProps<T extends string> {
label?: string;
options: {value: T; label: string; icon?: string}[];
selected: T;
onSelect: (value: T) => void;
}
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
export function BottomSheetChipSelector<T extends string>({
label,
options,
selected,
onSelect,
}: BottomSheetChipSelectorProps<T>) {
const theme = useTheme();
const {colors, typography, shape, spacing} = theme;
return (
<View style={{marginBottom: spacing.lg}}>
{label && (
<Text
style={{
...typography.bodySmall,
color: colors.onSurfaceVariant,
marginBottom: spacing.sm,
}}>
{label}
</Text>
)}
<View style={{flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm}}>
{options.map(opt => {
const isSelected = opt.value === selected;
return (
<Pressable
key={opt.value}
onPress={() => {
triggerHaptic('selection');
onSelect(opt.value);
}}
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: shape.small,
borderWidth: 1,
borderColor: isSelected ? colors.primary : colors.outlineVariant,
backgroundColor: isSelected
? colors.primaryContainer
: colors.surfaceContainerLowest,
}}>
{opt.icon && (
<Icon
name={opt.icon}
size={16}
color={isSelected ? colors.primary : colors.onSurfaceVariant}
style={{marginRight: spacing.xs}}
/>
)}
<Text
style={{
...typography.labelMedium,
color: isSelected ? colors.onPrimaryContainer : colors.onSurfaceVariant,
}}>
{opt.label}
</Text>
</Pressable>
);
})}
</View>
</View>
);
}

View File

@@ -0,0 +1,43 @@
import React from 'react';
import {View, Text, StyleSheet} from 'react-native';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import {useTheme} from '../theme';
interface EmptyStateProps {
icon: string;
title: string;
subtitle?: string;
}
export const EmptyState: React.FC<EmptyStateProps> = ({icon, title, subtitle}) => {
const {colors} = useTheme();
return (
<View style={styles.container}>
<Icon name={icon} size={56} color={colors.onSurfaceVariant} />
<Text style={[styles.title, {color: colors.onSurfaceVariant}]}>{title}</Text>
{subtitle ? <Text style={[styles.subtitle, {color: colors.onSurfaceVariant}]}>{subtitle}</Text> : null}
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 60,
paddingHorizontal: 32,
},
title: {
fontSize: 17,
fontWeight: '600',
marginTop: 16,
textAlign: 'center',
},
subtitle: {
fontSize: 14,
marginTop: 6,
textAlign: 'center',
lineHeight: 20,
},
});

View File

@@ -0,0 +1,54 @@
import React from 'react';
import {View, Text, StyleSheet, Pressable, ViewStyle} from 'react-native';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import {useTheme} from '../theme';
interface SectionHeaderProps {
title: string;
actionLabel?: string;
onAction?: () => void;
style?: ViewStyle;
}
export const SectionHeader: React.FC<SectionHeaderProps> = ({
title,
actionLabel,
onAction,
style,
}) => {
const {colors} = useTheme();
return (
<View style={[styles.container, style]}>
<Text style={[styles.title, {color: colors.onSurface}]}>{title}</Text>
{actionLabel && onAction ? (
<Pressable onPress={onAction} style={styles.action} hitSlop={{top: 8, bottom: 8, left: 8, right: 8}}>
<Text style={[styles.actionLabel, {color: colors.primary}]}>{actionLabel}</Text>
<Icon name="chevron-right" size={16} color={colors.primary} />
</Pressable>
) : null}
</View>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 20,
paddingTop: 24,
paddingBottom: 12,
},
title: {
fontSize: 18,
fontWeight: '700',
},
action: {
flexDirection: 'row',
alignItems: 'center',
},
actionLabel: {
fontSize: 15,
fontWeight: '600',
},
});

View File

@@ -0,0 +1,69 @@
import React from 'react';
import {View, Text, StyleSheet, ViewStyle} from 'react-native';
import {useTheme} from '../theme';
interface SummaryCardProps {
title: string;
value: string;
subtitle?: string;
icon?: React.ReactNode;
valueColor?: string;
style?: ViewStyle;
}
export const SummaryCard: React.FC<SummaryCardProps> = ({
title,
value,
subtitle,
icon,
valueColor,
style,
}) => {
const {colors, typography, elevation, shape, spacing} = useTheme();
return (
<View style={[styles.card, {backgroundColor: colors.surfaceContainerLow, ...elevation.level1}, style]}>
<View style={styles.cardHeader}>
{icon && <View style={styles.iconContainer}>{icon}</View>}
<Text style={[styles.cardTitle, {color: colors.onSurfaceVariant}]}>{title}</Text>
</View>
<Text style={[styles.cardValue, {color: colors.onSurface}, valueColor ? {color: valueColor} : undefined]} numberOfLines={1} adjustsFontSizeToFit>
{value}
</Text>
{subtitle ? <Text style={[styles.cardSubtitle, {color: colors.onSurfaceVariant}]}>{subtitle}</Text> : null}
</View>
);
};
const styles = StyleSheet.create({
card: {
borderRadius: 16,
padding: 16,
shadowColor: '#000',
shadowOffset: {width: 0, height: 2},
shadowOpacity: 0.06,
shadowRadius: 8,
elevation: 3,
},
cardHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
},
iconContainer: {
marginRight: 8,
},
cardTitle: {
fontSize: 14,
fontWeight: '500',
letterSpacing: 0.2,
},
cardValue: {
fontSize: 26,
fontWeight: '700',
marginBottom: 2,
},
cardSubtitle: {
fontSize: 13,
marginTop: 2,
},
});

View File

@@ -0,0 +1,106 @@
import React from 'react';
import {View, Text, StyleSheet, Pressable} from 'react-native';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import {Transaction} from '../types';
import {formatCurrency} from '../utils';
import {useSettingsStore} from '../store';
import {useTheme} from '../theme';
interface TransactionItemProps {
transaction: Transaction;
onPress?: (transaction: Transaction) => void;
}
export const TransactionItem: React.FC<TransactionItemProps> = ({
transaction,
onPress,
}) => {
const baseCurrency = useSettingsStore(s => s.baseCurrency);
const {colors} = useTheme();
const isExpense = transaction.type === 'expense';
return (
<Pressable
style={[styles.container, {backgroundColor: colors.surface}]}
onPress={() => onPress?.(transaction)}
android_ripple={{color: colors.primary + '12'}}>
<View
style={[
styles.iconCircle,
{backgroundColor: (transaction.categoryColor || '#95A5A6') + '18'},
]}>
<Icon
name={transaction.categoryIcon || 'dots-horizontal'}
size={22}
color={transaction.categoryColor || '#95A5A6'}
/>
</View>
<View style={styles.details}>
<Text style={[styles.categoryName, {color: colors.onSurface}]} numberOfLines={1}>
{transaction.categoryName || 'Uncategorized'}
</Text>
<Text style={[styles.meta, {color: colors.onSurfaceVariant}]} numberOfLines={1}>
{transaction.paymentMethod}
{transaction.note ? ` · ${transaction.note}` : ''}
</Text>
</View>
<View style={styles.amountContainer}>
<Text
style={[
styles.amount,
{color: isExpense ? colors.error : colors.success},
]}>
{isExpense ? '-' : '+'}
{formatCurrency(transaction.amount, baseCurrency)}
</Text>
<Text style={[styles.date, {color: colors.onSurfaceVariant}]}>
{new Date(transaction.date).toLocaleDateString('en-IN', {
day: 'numeric',
month: 'short',
})}
</Text>
</View>
</Pressable>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 14,
paddingHorizontal: 18,
},
iconCircle: {
width: 44,
height: 44,
borderRadius: 22,
justifyContent: 'center',
alignItems: 'center',
},
details: {
flex: 1,
marginLeft: 12,
},
categoryName: {
fontSize: 16,
fontWeight: '600',
},
meta: {
fontSize: 13,
marginTop: 2,
},
amountContainer: {
alignItems: 'flex-end',
},
amount: {
fontSize: 16,
fontWeight: '700',
},
date: {
fontSize: 12,
marginTop: 2,
},
});

View File

@@ -0,0 +1,164 @@
/**
* AssetChipRow — Horizontal scrolling chip/card row
* showing quick totals for Bank, Stocks, Gold, Debt, etc.
*/
import React from 'react';
import {View, Text, StyleSheet, ScrollView} from 'react-native';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import Animated, {FadeInRight} from 'react-native-reanimated';
import {useTheme} from '../../theme';
import type {MD3Theme} from '../../theme';
import {formatCompact} from '../../utils';
import type {Asset, Liability, Currency} from '../../types';
interface AssetChipRowProps {
assets: Asset[];
liabilities: Liability[];
currency: Currency;
}
const ASSET_ICONS: Record<string, {icon: string; color: string}> = {
Bank: {icon: 'bank', color: '#1E88E5'},
Stocks: {icon: 'chart-line', color: '#7E57C2'},
Gold: {icon: 'gold', color: '#D4AF37'},
EPF: {icon: 'shield-account', color: '#00ACC1'},
'Real Estate': {icon: 'home-city', color: '#8D6E63'},
'Mutual Funds': {icon: 'chart-areaspline', color: '#26A69A'},
'Fixed Deposit': {icon: 'safe', color: '#3949AB'},
PPF: {icon: 'piggy-bank', color: '#43A047'},
Other: {icon: 'dots-horizontal', color: '#78909C'},
};
export const AssetChipRow: React.FC<AssetChipRowProps> = ({
assets,
liabilities,
currency,
}) => {
const theme = useTheme();
const s = makeStyles(theme);
// Group assets by type and sum values
const groupedAssets = assets.reduce<Record<string, number>>((acc, a) => {
acc[a.type] = (acc[a.type] || 0) + a.currentValue;
return acc;
}, {});
// Sum total liabilities
const totalDebt = liabilities.reduce((sum, l) => sum + l.outstandingAmount, 0);
const chips: {label: string; value: number; icon: string; iconColor: string; isDebt?: boolean}[] = [];
Object.entries(groupedAssets).forEach(([type, value]) => {
const visual = ASSET_ICONS[type] || ASSET_ICONS.Other;
chips.push({
label: type,
value,
icon: visual.icon,
iconColor: visual.color,
});
});
if (totalDebt > 0) {
chips.push({
label: 'Debt',
value: totalDebt,
icon: 'credit-card-clock',
iconColor: theme.colors.error,
isDebt: true,
});
}
if (chips.length === 0) return null;
return (
<View style={s.container}>
<Text style={s.sectionLabel}>Portfolio Breakdown</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={s.scrollContent}>
{chips.map((chip, idx) => (
<Animated.View
key={chip.label}
entering={FadeInRight.delay(idx * 80).duration(400).springify()}>
<View
style={[
s.chip,
chip.isDebt && {borderColor: theme.colors.errorContainer},
]}>
<View
style={[
s.chipIconBg,
{backgroundColor: chip.iconColor + '1A'},
]}>
<Icon name={chip.icon} size={18} color={chip.iconColor} />
</View>
<Text style={s.chipLabel} numberOfLines={1}>
{chip.label}
</Text>
<Text
style={[
s.chipValue,
chip.isDebt && {color: theme.colors.error},
]}
numberOfLines={1}>
{chip.isDebt ? '-' : ''}
{formatCompact(chip.value, currency)}
</Text>
</View>
</Animated.View>
))}
</ScrollView>
</View>
);
};
function makeStyles(theme: MD3Theme) {
const {colors, typography, shape, spacing} = theme;
return StyleSheet.create({
container: {
marginTop: spacing.xl,
},
sectionLabel: {
...typography.labelMedium,
color: colors.onSurfaceVariant,
letterSpacing: 0.8,
textTransform: 'uppercase',
marginLeft: spacing.xl,
marginBottom: spacing.sm,
},
scrollContent: {
paddingHorizontal: spacing.xl,
gap: spacing.sm,
},
chip: {
backgroundColor: colors.surfaceContainerLow,
borderRadius: shape.medium,
borderWidth: 1,
borderColor: colors.outlineVariant,
padding: spacing.md,
minWidth: 110,
alignItems: 'flex-start',
},
chipIconBg: {
width: 32,
height: 32,
borderRadius: shape.small,
justifyContent: 'center',
alignItems: 'center',
marginBottom: spacing.sm,
},
chipLabel: {
...typography.labelSmall,
color: colors.onSurfaceVariant,
marginBottom: 2,
},
chipValue: {
...typography.titleSmall,
color: colors.onSurface,
fontWeight: '700',
},
});
}

View File

@@ -0,0 +1,277 @@
/**
* FinancialHealthGauges — Monthly Budget vs. Spent progress bars
* and savings rate indicator.
*/
import React from 'react';
import {View, Text, StyleSheet} from 'react-native';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import Animated, {FadeInUp} from 'react-native-reanimated';
import {useTheme} from '../../theme';
import type {MD3Theme} from '../../theme';
import {formatCompact} from '../../utils';
import type {Currency} from '../../types';
interface FinancialHealthGaugesProps {
monthlyIncome: number;
monthlyExpense: number;
currency: Currency;
/** Optional monthly budget target. Defaults to income. */
monthlyBudget?: number;
}
export const FinancialHealthGauges: React.FC<FinancialHealthGaugesProps> = ({
monthlyIncome,
monthlyExpense,
currency,
monthlyBudget,
}) => {
const theme = useTheme();
const s = makeStyles(theme);
const budget = monthlyBudget || monthlyIncome;
const savingsRate =
monthlyIncome > 0
? (((monthlyIncome - monthlyExpense) / monthlyIncome) * 100).toFixed(0)
: '0';
const spentPercent = budget > 0 ? Math.min((monthlyExpense / budget) * 100, 100) : 0;
const remaining = Math.max(budget - monthlyExpense, 0);
const spentColor =
spentPercent > 90
? theme.colors.error
: spentPercent > 70
? theme.colors.warning
: theme.colors.success;
return (
<Animated.View entering={FadeInUp.duration(500).delay(400)} style={s.container}>
<Text style={s.sectionTitle}>Financial Health</Text>
<View style={s.cardsRow}>
{/* Budget Gauge */}
<View style={s.gaugeCard}>
<View style={s.gaugeHeader}>
<Icon name="chart-donut" size={18} color={spentColor} />
<Text style={s.gaugeLabel}>Budget</Text>
</View>
{/* Progress Bar */}
<View style={s.progressTrack}>
<View
style={[
s.progressFill,
{
width: `${spentPercent}%`,
backgroundColor: spentColor,
},
]}
/>
</View>
<View style={s.gaugeFooter}>
<Text style={s.gaugeSpent}>
{formatCompact(monthlyExpense, currency)} spent
</Text>
<Text style={s.gaugeRemaining}>
{formatCompact(remaining, currency)} left
</Text>
</View>
</View>
{/* Savings Rate Gauge */}
<View style={s.gaugeCard}>
<View style={s.gaugeHeader}>
<Icon
name="piggy-bank-outline"
size={18}
color={
Number(savingsRate) >= 20
? theme.colors.success
: theme.colors.warning
}
/>
<Text style={s.gaugeLabel}>Savings</Text>
</View>
<Text
style={[
s.savingsRate,
{
color:
Number(savingsRate) >= 20
? theme.colors.success
: Number(savingsRate) >= 0
? theme.colors.warning
: theme.colors.error,
},
]}>
{savingsRate}%
</Text>
<Text style={s.savingsSub}>of income saved</Text>
</View>
</View>
{/* Income vs Expense Bar */}
<View style={s.comparisonCard}>
<View style={s.comparisonRow}>
<View style={s.comparisonItem}>
<View style={[s.comparisonDot, {backgroundColor: theme.colors.success}]} />
<Text style={s.comparisonLabel}>Income</Text>
<Text style={[s.comparisonValue, {color: theme.colors.success}]}>
{formatCompact(monthlyIncome, currency)}
</Text>
</View>
<View style={s.comparisonItem}>
<View style={[s.comparisonDot, {backgroundColor: theme.colors.error}]} />
<Text style={s.comparisonLabel}>Expense</Text>
<Text style={[s.comparisonValue, {color: theme.colors.error}]}>
{formatCompact(monthlyExpense, currency)}
</Text>
</View>
</View>
{/* Dual progress bar */}
<View style={s.dualTrack}>
{monthlyIncome > 0 && (
<View
style={[
s.dualFill,
{
flex: monthlyIncome,
backgroundColor: theme.colors.success,
borderTopLeftRadius: 4,
borderBottomLeftRadius: 4,
},
]}
/>
)}
{monthlyExpense > 0 && (
<View
style={[
s.dualFill,
{
flex: monthlyExpense,
backgroundColor: theme.colors.error,
borderTopRightRadius: 4,
borderBottomRightRadius: 4,
},
]}
/>
)}
</View>
</View>
</Animated.View>
);
};
function makeStyles(theme: MD3Theme) {
const {colors, typography, elevation, shape, spacing} = theme;
return StyleSheet.create({
container: {
marginTop: spacing.xl,
marginHorizontal: spacing.xl,
},
sectionTitle: {
...typography.titleSmall,
color: colors.onSurface,
fontWeight: '600',
marginBottom: spacing.md,
},
cardsRow: {
flexDirection: 'row',
gap: spacing.md,
},
gaugeCard: {
flex: 1,
backgroundColor: colors.surfaceContainerLow,
borderRadius: shape.medium,
padding: spacing.lg,
...elevation.level1,
},
gaugeHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
marginBottom: spacing.md,
},
gaugeLabel: {
...typography.labelMedium,
color: colors.onSurfaceVariant,
},
progressTrack: {
height: 8,
backgroundColor: colors.surfaceContainerHighest,
borderRadius: 4,
overflow: 'hidden',
marginBottom: spacing.sm,
},
progressFill: {
height: 8,
borderRadius: 4,
},
gaugeFooter: {
flexDirection: 'row',
justifyContent: 'space-between',
},
gaugeSpent: {
...typography.labelSmall,
color: colors.onSurfaceVariant,
},
gaugeRemaining: {
...typography.labelSmall,
color: colors.onSurfaceVariant,
},
savingsRate: {
...typography.headlineMedium,
fontWeight: '700',
marginBottom: 2,
},
savingsSub: {
...typography.labelSmall,
color: colors.onSurfaceVariant,
},
comparisonCard: {
marginTop: spacing.md,
backgroundColor: colors.surfaceContainerLow,
borderRadius: shape.medium,
padding: spacing.lg,
...elevation.level1,
},
comparisonRow: {
flexDirection: 'row',
justifyContent: 'space-around',
marginBottom: spacing.md,
},
comparisonItem: {
alignItems: 'center',
gap: 2,
},
comparisonDot: {
width: 8,
height: 8,
borderRadius: 4,
marginBottom: 2,
},
comparisonLabel: {
...typography.labelSmall,
color: colors.onSurfaceVariant,
},
comparisonValue: {
...typography.titleSmall,
fontWeight: '700',
},
dualTrack: {
height: 8,
flexDirection: 'row',
borderRadius: 4,
overflow: 'hidden',
backgroundColor: colors.surfaceContainerHighest,
gap: 2,
},
dualFill: {
height: 8,
},
});
}

View File

@@ -0,0 +1,200 @@
/**
* NetWorthHeroCard — Sophisticated header showing total net worth
* with a sparkline trend overlaying the background.
*/
import React, {useMemo} from 'react';
import {View, Text, StyleSheet} from 'react-native';
import {LineChart} from 'react-native-gifted-charts';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import Animated, {FadeInDown} from 'react-native-reanimated';
import {useTheme} from '../../theme';
import type {MD3Theme} from '../../theme';
import {formatCurrency, formatCompact} from '../../utils';
import type {Currency, NetWorthSnapshot} from '../../types';
interface NetWorthHeroCardProps {
netWorth: number;
totalAssets: number;
totalLiabilities: number;
currency: Currency;
history: NetWorthSnapshot[];
}
export const NetWorthHeroCard: React.FC<NetWorthHeroCardProps> = ({
netWorth,
totalAssets,
totalLiabilities,
currency,
history,
}) => {
const theme = useTheme();
const s = makeStyles(theme);
const sparklineData = useMemo(() => {
if (history.length < 2) return [];
return history.map(snap => ({
value: snap.netWorth,
}));
}, [history]);
const isPositive = netWorth >= 0;
return (
<Animated.View entering={FadeInDown.duration(500).springify()} style={s.card}>
{/* Sparkline Background */}
{sparklineData.length >= 2 && (
<View style={s.sparklineContainer}>
<LineChart
data={sparklineData}
curved
areaChart
hideDataPoints
hideYAxisText
hideAxesAndRules
color={
theme.isDark
? theme.colors.primaryContainer + '40'
: theme.colors.primary + '25'
}
startFillColor={
theme.isDark
? theme.colors.primaryContainer + '20'
: theme.colors.primary + '12'
}
endFillColor="transparent"
thickness={2}
width={280}
height={100}
adjustToWidth
isAnimated
animationDuration={800}
initialSpacing={0}
endSpacing={0}
yAxisOffset={Math.min(...sparklineData.map(d => d.value)) * 0.95}
/>
</View>
)}
{/* Content Overlay */}
<View style={s.content}>
<View style={s.labelRow}>
<Icon
name="chart-line-variant"
size={16}
color={theme.colors.onSurfaceVariant}
/>
<Text style={s.label}>NET WORTH</Text>
</View>
<Text
style={[
s.value,
{color: isPositive ? theme.colors.success : theme.colors.error},
]}
numberOfLines={1}
adjustsFontSizeToFit>
{formatCurrency(netWorth, currency)}
</Text>
{/* Asset / Liability Split */}
<View style={s.splitRow}>
<View style={s.splitItem}>
<View style={[s.splitDot, {backgroundColor: theme.colors.success}]} />
<View>
<Text style={s.splitLabel}>Assets</Text>
<Text style={[s.splitValue, {color: theme.colors.success}]}>
{formatCompact(totalAssets, currency)}
</Text>
</View>
</View>
<View style={s.splitDivider} />
<View style={s.splitItem}>
<View style={[s.splitDot, {backgroundColor: theme.colors.error}]} />
<View>
<Text style={s.splitLabel}>Liabilities</Text>
<Text style={[s.splitValue, {color: theme.colors.error}]}>
{formatCompact(totalLiabilities, currency)}
</Text>
</View>
</View>
</View>
</View>
</Animated.View>
);
};
function makeStyles(theme: MD3Theme) {
const {colors, typography, elevation, shape, spacing} = theme;
return StyleSheet.create({
card: {
marginHorizontal: spacing.xl,
marginTop: spacing.md,
borderRadius: shape.extraLarge,
backgroundColor: colors.surfaceContainerLow,
overflow: 'hidden',
...elevation.level3,
},
sparklineContainer: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
opacity: 0.6,
overflow: 'hidden',
borderRadius: shape.extraLarge,
},
content: {
padding: spacing.xxl,
},
labelRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
marginBottom: spacing.sm,
},
label: {
...typography.labelSmall,
color: colors.onSurfaceVariant,
letterSpacing: 1.5,
},
value: {
...typography.displaySmall,
fontWeight: '700',
marginBottom: spacing.lg,
},
splitRow: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.surfaceContainer,
borderRadius: shape.medium,
padding: spacing.md,
},
splitItem: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
justifyContent: 'center',
},
splitDot: {
width: 8,
height: 8,
borderRadius: 4,
},
splitLabel: {
...typography.labelSmall,
color: colors.onSurfaceVariant,
},
splitValue: {
...typography.titleSmall,
fontWeight: '700',
},
splitDivider: {
width: 1,
height: 28,
backgroundColor: colors.outlineVariant,
},
});
}

View File

@@ -0,0 +1,223 @@
/**
* RecentActivityList — "Glassmorphism" elevated surface list of the
* last 5 transactions with high-quality category icons.
*/
import React from 'react';
import {View, Text, StyleSheet, Pressable} from 'react-native';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import Animated, {FadeInUp} from 'react-native-reanimated';
import {useTheme} from '../../theme';
import type {MD3Theme} from '../../theme';
import {formatCurrency} from '../../utils';
import type {Transaction, Currency} from '../../types';
// Map common categories to premium Material icons
const CATEGORY_ICONS: Record<string, string> = {
Groceries: 'cart',
Rent: 'home',
Fuel: 'gas-station',
'Domestic Help': 'account-group',
Tiffin: 'food',
Utilities: 'lightning-bolt',
'Mobile Recharge': 'cellphone',
Transport: 'bus',
Shopping: 'shopping',
Medical: 'hospital-box',
Education: 'school',
Entertainment: 'movie',
'Dining Out': 'silverware-fork-knife',
Subscriptions: 'television-play',
Insurance: 'shield-check',
Salary: 'briefcase',
Freelance: 'laptop',
Investments: 'chart-line',
'Rental Income': 'home-city',
Dividends: 'cash-multiple',
UPI: 'contactless-payment',
};
interface RecentActivityListProps {
transactions: Transaction[];
currency: Currency;
onViewAll?: () => void;
}
export const RecentActivityList: React.FC<RecentActivityListProps> = ({
transactions,
currency,
onViewAll,
}) => {
const theme = useTheme();
const s = makeStyles(theme);
if (transactions.length === 0) {
return (
<View style={s.emptyContainer}>
<Icon name="receipt" size={48} color={theme.colors.onSurfaceVariant + '40'} />
<Text style={s.emptyText}>No recent transactions</Text>
<Text style={s.emptySubtext}>Start tracking to see activity here</Text>
</View>
);
}
return (
<Animated.View entering={FadeInUp.duration(500).delay(300)} style={s.container}>
<View style={s.headerRow}>
<Text style={s.title}>Recent Activity</Text>
{onViewAll && (
<Pressable onPress={onViewAll} hitSlop={8}>
<Text style={s.viewAll}>View All</Text>
</Pressable>
)}
</View>
<View style={s.glassCard}>
{transactions.slice(0, 5).map((txn, idx) => {
const isExpense = txn.type === 'expense';
const iconName =
CATEGORY_ICONS[txn.categoryName || ''] ||
txn.categoryIcon ||
'dots-horizontal';
const iconColor = txn.categoryColor || theme.colors.onSurfaceVariant;
return (
<View
key={txn.id}
style={[
s.txnRow,
idx < Math.min(transactions.length, 5) - 1 && s.txnRowBorder,
]}>
<View style={[s.iconCircle, {backgroundColor: iconColor + '14'}]}>
<Icon name={iconName} size={20} color={iconColor} />
</View>
<View style={s.txnDetails}>
<Text style={s.txnCategory} numberOfLines={1}>
{txn.categoryName || 'Uncategorized'}
</Text>
<Text style={s.txnMeta} numberOfLines={1}>
{txn.paymentMethod}
{txn.note ? ` · ${txn.note}` : ''}
</Text>
</View>
<View style={s.txnAmountCol}>
<Text
style={[
s.txnAmount,
{
color: isExpense
? theme.colors.error
: theme.colors.success,
},
]}>
{isExpense ? '-' : '+'}
{formatCurrency(txn.amount, currency)}
</Text>
<Text style={s.txnDate}>
{new Date(txn.date).toLocaleDateString('en-IN', {
day: 'numeric',
month: 'short',
})}
</Text>
</View>
</View>
);
})}
</View>
</Animated.View>
);
};
function makeStyles(theme: MD3Theme) {
const {colors, typography, elevation, shape, spacing} = theme;
return StyleSheet.create({
container: {
marginHorizontal: spacing.xl,
marginTop: spacing.xl,
},
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.md,
},
title: {
...typography.titleSmall,
color: colors.onSurface,
fontWeight: '600',
},
viewAll: {
...typography.labelMedium,
color: colors.primary,
},
glassCard: {
backgroundColor: colors.surfaceContainerLow,
borderRadius: shape.large,
borderWidth: 1,
borderColor: colors.outlineVariant + '40',
overflow: 'hidden',
...elevation.level2,
},
txnRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.md,
paddingHorizontal: spacing.lg,
},
txnRowBorder: {
borderBottomWidth: 1,
borderBottomColor: colors.outlineVariant + '30',
},
iconCircle: {
width: 40,
height: 40,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
},
txnDetails: {
flex: 1,
marginLeft: spacing.md,
},
txnCategory: {
...typography.bodyMedium,
color: colors.onSurface,
fontWeight: '500',
},
txnMeta: {
...typography.bodySmall,
color: colors.onSurfaceVariant,
marginTop: 1,
},
txnAmountCol: {
alignItems: 'flex-end',
},
txnAmount: {
...typography.titleSmall,
fontWeight: '700',
},
txnDate: {
...typography.labelSmall,
color: colors.onSurfaceVariant,
marginTop: 1,
},
emptyContainer: {
alignItems: 'center',
paddingVertical: spacing.xxxl + 16,
marginHorizontal: spacing.xl,
},
emptyText: {
...typography.bodyLarge,
color: colors.onSurfaceVariant,
marginTop: spacing.md,
},
emptySubtext: {
...typography.bodySmall,
color: colors.onSurfaceVariant + '80',
marginTop: spacing.xs,
},
});
}

View File

@@ -0,0 +1,181 @@
/**
* WealthDistributionChart — Donut chart showing asset allocation:
* Liquid (Bank, FD) vs Equity (Stocks, MF) vs Fixed (Gold, RE, EPF, PPF)
*/
import React, {useCallback, useMemo} from 'react';
import {View, Text, StyleSheet} from 'react-native';
import {PieChart} from 'react-native-gifted-charts';
import Animated, {FadeInUp} from 'react-native-reanimated';
import {useTheme} from '../../theme';
import type {MD3Theme} from '../../theme';
import {formatCompact} from '../../utils';
import type {Asset, Currency} from '../../types';
interface WealthDistributionChartProps {
assets: Asset[];
currency: Currency;
}
const ALLOCATION_MAP: Record<string, string> = {
Bank: 'Liquid',
'Fixed Deposit': 'Liquid',
Stocks: 'Equity',
'Mutual Funds': 'Equity',
Gold: 'Fixed',
'Real Estate': 'Fixed',
EPF: 'Fixed',
PPF: 'Fixed',
Other: 'Other',
};
const ALLOCATION_COLORS: Record<string, {light: string; dark: string}> = {
Liquid: {light: '#1E88E5', dark: '#64B5F6'},
Equity: {light: '#7E57C2', dark: '#CE93D8'},
Fixed: {light: '#D4AF37', dark: '#FFD54F'},
Other: {light: '#78909C', dark: '#B0BEC5'},
};
export const WealthDistributionChart: React.FC<WealthDistributionChartProps> = ({
assets,
currency,
}) => {
const theme = useTheme();
const s = makeStyles(theme);
const {pieData, totalValue, segments} = useMemo(() => {
const groups: Record<string, number> = {};
let total = 0;
assets.forEach(a => {
const bucket = ALLOCATION_MAP[a.type] || 'Other';
groups[bucket] = (groups[bucket] || 0) + a.currentValue;
total += a.currentValue;
});
const segs = Object.entries(groups)
.filter(([_, v]) => v > 0)
.sort((a, b) => b[1] - a[1])
.map(([name, value]) => ({
name,
value,
percentage: total > 0 ? ((value / total) * 100).toFixed(1) : '0',
color:
ALLOCATION_COLORS[name]?.[theme.isDark ? 'dark' : 'light'] || '#78909C',
}));
const pie = segs.map((seg, idx) => ({
value: seg.value,
color: seg.color,
text: `${seg.percentage}%`,
focused: idx === 0,
}));
return {pieData: pie, totalValue: total, segments: segs};
}, [assets, theme.isDark]);
const CenterLabel = useCallback(() => (
<View style={s.centerLabel}>
<Text style={s.centerValue}>
{formatCompact(totalValue, currency)}
</Text>
<Text style={s.centerSubtitle}>Total</Text>
</View>
), [totalValue, currency, s]);
if (pieData.length === 0) return null;
return (
<Animated.View entering={FadeInUp.duration(500).delay(200)} style={s.card}>
<Text style={s.title}>Wealth Distribution</Text>
<View style={s.chartRow}>
<PieChart
data={pieData}
donut
innerRadius={48}
radius={72}
innerCircleColor={theme.colors.surfaceContainerLow}
centerLabelComponent={CenterLabel}
/>
<View style={s.legend}>
{segments.map(seg => (
<View key={seg.name} style={s.legendItem}>
<View style={[s.legendDot, {backgroundColor: seg.color}]} />
<View style={s.legendText}>
<Text style={s.legendName}>{seg.name}</Text>
<Text style={s.legendValue}>
{formatCompact(seg.value, currency)} · {seg.percentage}%
</Text>
</View>
</View>
))}
</View>
</View>
</Animated.View>
);
};
function makeStyles(theme: MD3Theme) {
const {colors, typography, elevation, shape, spacing} = theme;
return StyleSheet.create({
card: {
marginHorizontal: spacing.xl,
marginTop: spacing.xl,
backgroundColor: colors.surfaceContainerLow,
borderRadius: shape.large,
padding: spacing.xl,
...elevation.level1,
},
title: {
...typography.titleSmall,
color: colors.onSurface,
fontWeight: '600',
marginBottom: spacing.lg,
},
chartRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xl,
},
centerLabel: {
alignItems: 'center',
},
centerValue: {
...typography.titleSmall,
color: colors.onSurface,
fontWeight: '700',
},
centerSubtitle: {
...typography.labelSmall,
color: colors.onSurfaceVariant,
},
legend: {
flex: 1,
gap: spacing.md,
},
legendItem: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
legendDot: {
width: 10,
height: 10,
borderRadius: 5,
},
legendText: {
flex: 1,
},
legendName: {
...typography.labelMedium,
color: colors.onSurface,
},
legendValue: {
...typography.bodySmall,
color: colors.onSurfaceVariant,
},
});
}

View File

@@ -0,0 +1,5 @@
export {NetWorthHeroCard} from './NetWorthHeroCard';
export {AssetChipRow} from './AssetChipRow';
export {WealthDistributionChart} from './WealthDistributionChart';
export {RecentActivityList} from './RecentActivityList';
export {FinancialHealthGauges} from './FinancialHealthGauges';

11
src/components/index.ts Normal file
View File

@@ -0,0 +1,11 @@
export {SummaryCard} from './SummaryCard';
export {TransactionItem} from './TransactionItem';
export {EmptyState} from './EmptyState';
export {SectionHeader} from './SectionHeader';
export {
CustomBottomSheet,
BottomSheetInput,
BottomSheetChipSelector,
triggerHaptic,
} from './CustomBottomSheet';
export type {CustomBottomSheetHandle} from './CustomBottomSheet';

142
src/constants/index.ts Normal file
View File

@@ -0,0 +1,142 @@
import {Category, ExchangeRates, PaymentMethod} from '../types';
// ─── Default Expense Categories (Indian Context) ────────────────────
export const DEFAULT_EXPENSE_CATEGORIES: Omit<Category, 'id' | 'createdAt'>[] = [
{name: 'Rent', icon: 'home', color: '#E74C3C', type: 'expense', isDefault: true},
{name: 'Groceries', icon: 'cart', color: '#2ECC71', type: 'expense', isDefault: true},
{name: 'Fuel', icon: 'gas-station', color: '#F39C12', type: 'expense', isDefault: true},
{name: 'Domestic Help', icon: 'account-group', color: '#9B59B6', type: 'expense', isDefault: true},
{name: 'Tiffin', icon: 'food', color: '#E67E22', type: 'expense', isDefault: true},
{name: 'Utilities', icon: 'lightning-bolt', color: '#3498DB', type: 'expense', isDefault: true},
{name: 'Mobile Recharge', icon: 'cellphone', color: '#1ABC9C', type: 'expense', isDefault: true},
{name: 'Transport', icon: 'bus', color: '#34495E', type: 'expense', isDefault: true},
{name: 'Shopping', icon: 'shopping', color: '#E91E63', type: 'expense', isDefault: true},
{name: 'Medical', icon: 'hospital-box', color: '#F44336', type: 'expense', isDefault: true},
{name: 'Education', icon: 'school', color: '#673AB7', type: 'expense', isDefault: true},
{name: 'Entertainment', icon: 'movie', color: '#FF9800', type: 'expense', isDefault: true},
{name: 'Dining Out', icon: 'silverware-fork-knife', color: '#795548', type: 'expense', isDefault: true},
{name: 'Subscriptions', icon: 'television-play', color: '#607D8B', type: 'expense', isDefault: true},
{name: 'Insurance', icon: 'shield-check', color: '#4CAF50', type: 'expense', isDefault: true},
{name: 'Other', icon: 'dots-horizontal', color: '#95A5A6', type: 'expense', isDefault: true},
];
export const DEFAULT_INCOME_CATEGORIES: Omit<Category, 'id' | 'createdAt'>[] = [
{name: 'Salary', icon: 'briefcase', color: '#27AE60', type: 'income', isDefault: true},
{name: 'Freelance', icon: 'laptop', color: '#2980B9', type: 'income', isDefault: true},
{name: 'Investments', icon: 'chart-line', color: '#8E44AD', type: 'income', isDefault: true},
{name: 'Rental Income', icon: 'home-city', color: '#D35400', type: 'income', isDefault: true},
{name: 'Dividends', icon: 'cash-multiple', color: '#16A085', type: 'income', isDefault: true},
{name: 'Other', icon: 'dots-horizontal', color: '#7F8C8D', type: 'income', isDefault: true},
];
// ─── Payment Methods ─────────────────────────────────────────────────
export const PAYMENT_METHODS: PaymentMethod[] = [
'UPI',
'Cash',
'Credit Card',
'Debit Card',
'Digital Wallet',
'Net Banking',
'Other',
];
// ─── Static Exchange Rates (fallback) ────────────────────────────────
export const STATIC_EXCHANGE_RATES: ExchangeRates = {
INR: 1,
USD: 84.5, // 1 USD = 84.5 INR
EUR: 91.2, // 1 EUR = 91.2 INR
};
// ─── Currency Symbols ────────────────────────────────────────────────
export const CURRENCY_SYMBOLS: Record<string, string> = {
INR: '₹',
USD: '$',
EUR: '€',
};
// ─── Theme Colors ────────────────────────────────────────────────────
export const COLORS = {
primary: '#0A84FF',
primaryDark: '#0066CC',
secondary: '#5856D6',
success: '#34C759',
danger: '#FF3B30',
warning: '#FF9500',
info: '#5AC8FA',
background: '#F2F2F7',
surface: '#FFFFFF',
surfaceVariant: '#F8F9FA',
card: '#FFFFFF',
text: '#000000',
textSecondary: '#6B7280',
textTertiary: '#9CA3AF',
textInverse: '#FFFFFF',
border: '#E5E7EB',
borderLight: '#F3F4F6',
divider: '#E5E7EB',
income: '#34C759',
expense: '#FF3B30',
asset: '#0A84FF',
liability: '#FF9500',
chartColors: [
'#0A84FF', '#34C759', '#FF9500', '#FF3B30',
'#5856D6', '#AF52DE', '#FF2D55', '#5AC8FA',
'#FFCC00', '#64D2FF',
],
};
export const DARK_COLORS: typeof COLORS = {
primary: '#0A84FF',
primaryDark: '#409CFF',
secondary: '#5E5CE6',
success: '#30D158',
danger: '#FF453A',
warning: '#FF9F0A',
info: '#64D2FF',
background: '#000000',
surface: '#1C1C1E',
surfaceVariant: '#2C2C2E',
card: '#1C1C1E',
text: '#FFFFFF',
textSecondary: '#EBEBF5',
textTertiary: '#636366',
textInverse: '#000000',
border: '#38383A',
borderLight: '#2C2C2E',
divider: '#38383A',
income: '#30D158',
expense: '#FF453A',
asset: '#0A84FF',
liability: '#FF9F0A',
chartColors: [
'#0A84FF', '#30D158', '#FF9F0A', '#FF453A',
'#5E5CE6', '#BF5AF2', '#FF375F', '#64D2FF',
'#FFD60A', '#64D2FF',
],
};
// ─── Date Formats ────────────────────────────────────────────────────
export const DATE_FORMATS = {
display: 'DD MMM YYYY',
displayShort: 'DD MMM',
month: 'MMM YYYY',
iso: 'YYYY-MM-DD',
time: 'hh:mm A',
full: 'DD MMM YYYY, hh:mm A',
};

167
src/db/database.ts Normal file
View File

@@ -0,0 +1,167 @@
import SQLite, {
SQLiteDatabase,
Transaction as SQLTransaction,
ResultSet,
} from 'react-native-sqlite-storage';
// Enable promise-based API
SQLite.enablePromise(true);
const DATABASE_NAME = 'expensso.db';
const DATABASE_VERSION = '1.0';
const DATABASE_DISPLAY_NAME = 'Expensso Database';
const DATABASE_SIZE = 200000;
let db: SQLiteDatabase | null = null;
// ─── Open / Get Database ─────────────────────────────────────────────
export async function getDatabase(): Promise<SQLiteDatabase> {
if (db) {
return db;
}
db = await SQLite.openDatabase({
name: DATABASE_NAME,
location: 'default',
});
await createTables(db);
return db;
}
// ─── Schema Creation ─────────────────────────────────────────────────
async function createTables(database: SQLiteDatabase): Promise<void> {
await database.transaction(async (tx: SQLTransaction) => {
// Categories table
tx.executeSql(`
CREATE TABLE IF NOT EXISTS categories (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT 'dots-horizontal',
color TEXT NOT NULL DEFAULT '#95A5A6',
type TEXT NOT NULL CHECK(type IN ('income', 'expense')),
is_default INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
// Transactions table (the ledger)
tx.executeSql(`
CREATE TABLE IF NOT EXISTS transactions (
id TEXT PRIMARY KEY,
amount REAL NOT NULL,
currency TEXT NOT NULL DEFAULT 'INR',
type TEXT NOT NULL CHECK(type IN ('income', 'expense')),
category_id TEXT NOT NULL,
payment_method TEXT NOT NULL DEFAULT 'UPI',
note TEXT DEFAULT '',
date TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
);
`);
// Transaction impacts table (links transactions to net-worth entries)
tx.executeSql(`
CREATE TABLE IF NOT EXISTS transaction_impacts (
transaction_id TEXT PRIMARY KEY,
target_type TEXT NOT NULL CHECK(target_type IN ('asset', 'liability')),
target_id TEXT NOT NULL,
operation TEXT NOT NULL CHECK(operation IN ('add', 'subtract')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE CASCADE
);
`);
// Assets table
tx.executeSql(`
CREATE TABLE IF NOT EXISTS assets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
current_value REAL NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT 'INR',
note TEXT DEFAULT '',
last_updated TEXT NOT NULL DEFAULT (datetime('now')),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
// Liabilities table
tx.executeSql(`
CREATE TABLE IF NOT EXISTS liabilities (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
outstanding_amount REAL NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT 'INR',
interest_rate REAL NOT NULL DEFAULT 0,
emi_amount REAL NOT NULL DEFAULT 0,
note TEXT DEFAULT '',
last_updated TEXT NOT NULL DEFAULT (datetime('now')),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
// Net-worth snapshots for historical tracking
tx.executeSql(`
CREATE TABLE IF NOT EXISTS net_worth_snapshots (
id TEXT PRIMARY KEY,
total_assets REAL NOT NULL DEFAULT 0,
total_liabilities REAL NOT NULL DEFAULT 0,
net_worth REAL NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT 'INR',
snapshot_date TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
// Indexes for performance
tx.executeSql(`
CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(date);
`);
tx.executeSql(`
CREATE INDEX IF NOT EXISTS idx_transactions_type ON transactions(type);
`);
tx.executeSql(`
CREATE INDEX IF NOT EXISTS idx_transactions_category ON transactions(category_id);
`);
tx.executeSql(`
CREATE INDEX IF NOT EXISTS idx_transaction_impacts_target ON transaction_impacts(target_type, target_id);
`);
tx.executeSql(`
CREATE INDEX IF NOT EXISTS idx_snapshots_date ON net_worth_snapshots(snapshot_date);
`);
});
}
// ─── Generic Helpers ─────────────────────────────────────────────────
export async function executeSql(
sql: string,
params: any[] = [],
): Promise<ResultSet> {
const database = await getDatabase();
const [result] = await database.executeSql(sql, params);
return result;
}
export function rowsToArray<T>(result: ResultSet): T[] {
const rows: T[] = [];
for (let i = 0; i < result.rows.length; i++) {
rows.push(result.rows.item(i) as T);
}
return rows;
}
// ─── Close Database ──────────────────────────────────────────────────
export async function closeDatabase(): Promise<void> {
if (db) {
await db.close();
db = null;
}
}

2
src/db/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export {getDatabase, executeSql, rowsToArray, closeDatabase} from './database';
export * from './queries';

445
src/db/queries.ts Normal file
View File

@@ -0,0 +1,445 @@
import {executeSql, rowsToArray} from './database';
import {
Category,
Transaction,
Asset,
Liability,
NetWorthSnapshot,
TransactionImpact,
NetWorthTargetType,
ImpactOperation,
} from '../types';
import {generateId} from '../utils';
import {DEFAULT_EXPENSE_CATEGORIES, DEFAULT_INCOME_CATEGORIES} from '../constants';
// ═══════════════════════════════════════════════════════════════════════
// CATEGORIES
// ═══════════════════════════════════════════════════════════════════════
export async function seedDefaultCategories(): Promise<void> {
const result = await executeSql('SELECT COUNT(*) as count FROM categories');
const count = result.rows.item(0).count;
if (count > 0) {return;}
const allCategories = [...DEFAULT_EXPENSE_CATEGORIES, ...DEFAULT_INCOME_CATEGORIES];
for (const cat of allCategories) {
await executeSql(
'INSERT INTO categories (id, name, icon, color, type, is_default) VALUES (?, ?, ?, ?, ?, ?)',
[generateId(), cat.name, cat.icon, cat.color, cat.type, cat.isDefault ? 1 : 0],
);
}
}
export async function getCategories(type?: 'income' | 'expense'): Promise<Category[]> {
let sql = 'SELECT id, name, icon, color, type, is_default as isDefault, created_at as createdAt FROM categories';
const params: any[] = [];
if (type) {
sql += ' WHERE type = ?';
params.push(type);
}
sql += ' ORDER BY is_default DESC, name ASC';
const result = await executeSql(sql, params);
return rowsToArray<Category>(result);
}
export async function insertCategory(cat: Omit<Category, 'id' | 'createdAt'>): Promise<string> {
const id = generateId();
await executeSql(
'INSERT INTO categories (id, name, icon, color, type, is_default) VALUES (?, ?, ?, ?, ?, ?)',
[id, cat.name, cat.icon, cat.color, cat.type, cat.isDefault ? 1 : 0],
);
return id;
}
// ═══════════════════════════════════════════════════════════════════════
// TRANSACTIONS
// ═══════════════════════════════════════════════════════════════════════
export async function getTransactions(options?: {
type?: 'income' | 'expense';
fromDate?: string;
toDate?: string;
categoryId?: string;
limit?: number;
offset?: number;
}): Promise<Transaction[]> {
let sql = `
SELECT
t.id, t.amount, t.currency, t.type, t.category_id as categoryId,
t.payment_method as paymentMethod, t.note, t.date,
t.created_at as createdAt, t.updated_at as updatedAt,
c.name as categoryName, c.icon as categoryIcon, c.color as categoryColor
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
WHERE 1=1
`;
const params: any[] = [];
if (options?.type) {
sql += ' AND t.type = ?';
params.push(options.type);
}
if (options?.fromDate) {
sql += ' AND t.date >= ?';
params.push(options.fromDate);
}
if (options?.toDate) {
sql += ' AND t.date <= ?';
params.push(options.toDate);
}
if (options?.categoryId) {
sql += ' AND t.category_id = ?';
params.push(options.categoryId);
}
sql += ' ORDER BY t.date DESC, t.created_at DESC';
if (options?.limit) {
sql += ' LIMIT ?';
params.push(options.limit);
}
if (options?.offset) {
sql += ' OFFSET ?';
params.push(options.offset);
}
const result = await executeSql(sql, params);
return rowsToArray<Transaction>(result);
}
export async function getTransactionById(id: string): Promise<Transaction | null> {
const result = await executeSql(
`SELECT
t.id, t.amount, t.currency, t.type, t.category_id as categoryId,
t.payment_method as paymentMethod, t.note, t.date,
t.created_at as createdAt, t.updated_at as updatedAt,
c.name as categoryName, c.icon as categoryIcon, c.color as categoryColor
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
WHERE t.id = ?`,
[id],
);
if (result.rows.length === 0) {
return null;
}
return result.rows.item(0) as Transaction;
}
export async function insertTransaction(
txn: Omit<Transaction, 'id' | 'createdAt' | 'updatedAt' | 'categoryName' | 'categoryIcon' | 'categoryColor'>,
): Promise<string> {
const id = generateId();
await executeSql(
`INSERT INTO transactions (id, amount, currency, type, category_id, payment_method, note, date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[id, txn.amount, txn.currency, txn.type, txn.categoryId, txn.paymentMethod, txn.note, txn.date],
);
return id;
}
export async function updateTransaction(
id: string,
txn: Partial<Omit<Transaction, 'id' | 'createdAt'>>,
): Promise<void> {
const fields: string[] = [];
const params: any[] = [];
if (txn.amount !== undefined) { fields.push('amount = ?'); params.push(txn.amount); }
if (txn.currency) { fields.push('currency = ?'); params.push(txn.currency); }
if (txn.type) { fields.push('type = ?'); params.push(txn.type); }
if (txn.categoryId) { fields.push('category_id = ?'); params.push(txn.categoryId); }
if (txn.paymentMethod) { fields.push('payment_method = ?'); params.push(txn.paymentMethod); }
if (txn.note !== undefined) { fields.push('note = ?'); params.push(txn.note); }
if (txn.date) { fields.push('date = ?'); params.push(txn.date); }
fields.push("updated_at = datetime('now')");
params.push(id);
await executeSql(`UPDATE transactions SET ${fields.join(', ')} WHERE id = ?`, params);
}
export async function deleteTransaction(id: string): Promise<void> {
await executeSql('DELETE FROM transactions WHERE id = ?', [id]);
}
export async function saveTransactionImpact(input: TransactionImpact): Promise<void> {
await executeSql(
`INSERT OR REPLACE INTO transaction_impacts (transaction_id, target_type, target_id, operation)
VALUES (?, ?, ?, ?)`,
[input.transactionId, input.targetType, input.targetId, input.operation],
);
}
export async function getTransactionImpact(transactionId: string): Promise<TransactionImpact | null> {
const result = await executeSql(
`SELECT transaction_id as transactionId, target_type as targetType, target_id as targetId, operation
FROM transaction_impacts
WHERE transaction_id = ?`,
[transactionId],
);
if (result.rows.length === 0) {
return null;
}
return result.rows.item(0) as TransactionImpact;
}
export async function deleteTransactionImpact(transactionId: string): Promise<void> {
await executeSql('DELETE FROM transaction_impacts WHERE transaction_id = ?', [transactionId]);
}
export async function applyTargetImpact(
targetType: NetWorthTargetType,
targetId: string,
operation: ImpactOperation,
amount: number,
): Promise<void> {
if (amount <= 0) {
return;
}
if (targetType === 'asset') {
if (operation === 'add') {
await executeSql(
`UPDATE assets
SET current_value = current_value + ?,
last_updated = datetime('now')
WHERE id = ?`,
[amount, targetId],
);
return;
}
await executeSql(
`UPDATE assets
SET current_value = MAX(current_value - ?, 0),
last_updated = datetime('now')
WHERE id = ?`,
[amount, targetId],
);
return;
}
if (operation === 'add') {
await executeSql(
`UPDATE liabilities
SET outstanding_amount = outstanding_amount + ?,
last_updated = datetime('now')
WHERE id = ?`,
[amount, targetId],
);
return;
}
await executeSql(
`UPDATE liabilities
SET outstanding_amount = MAX(outstanding_amount - ?, 0),
last_updated = datetime('now')
WHERE id = ?`,
[amount, targetId],
);
}
export async function reverseTargetImpact(impact: TransactionImpact, amount: number): Promise<void> {
const reverseOperation: ImpactOperation = impact.operation === 'add' ? 'subtract' : 'add';
await applyTargetImpact(impact.targetType, impact.targetId, reverseOperation, amount);
}
export async function getMonthlyTotals(
type: 'income' | 'expense',
year: number,
month: number,
): Promise<number> {
const startDate = `${year}-${String(month).padStart(2, '0')}-01`;
const endDate =
month === 12
? `${year + 1}-01-01`
: `${year}-${String(month + 1).padStart(2, '0')}-01`;
const result = await executeSql(
'SELECT COALESCE(SUM(amount), 0) as total FROM transactions WHERE type = ? AND date >= ? AND date < ?',
[type, startDate, endDate],
);
return result.rows.item(0).total;
}
export async function getSpendingByCategory(
fromDate: string,
toDate: string,
): Promise<{categoryName: string; categoryColor: string; categoryIcon: string; total: number}[]> {
const result = await executeSql(
`SELECT c.name as categoryName, c.color as categoryColor, c.icon as categoryIcon,
SUM(t.amount) as total
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
WHERE t.type = 'expense' AND t.date >= ? AND t.date < ?
GROUP BY t.category_id
ORDER BY total DESC`,
[fromDate, toDate],
);
return rowsToArray(result);
}
export async function getMonthlySpendingTrend(months: number = 6): Promise<{month: string; total: number}[]> {
const result = await executeSql(
`SELECT strftime('%Y-%m', date) as month, SUM(amount) as total
FROM transactions
WHERE type = 'expense'
AND date >= date('now', '-' || ? || ' months')
GROUP BY strftime('%Y-%m', date)
ORDER BY month ASC`,
[months],
);
return rowsToArray(result);
}
// ═══════════════════════════════════════════════════════════════════════
// ASSETS
// ═══════════════════════════════════════════════════════════════════════
export async function getAssets(): Promise<Asset[]> {
const result = await executeSql(
`SELECT id, name, type, current_value as currentValue, currency,
note, last_updated as lastUpdated, created_at as createdAt
FROM assets
ORDER BY current_value DESC`,
);
return rowsToArray<Asset>(result);
}
export async function insertAsset(asset: Omit<Asset, 'id' | 'createdAt' | 'lastUpdated'>): Promise<string> {
const id = generateId();
await executeSql(
'INSERT INTO assets (id, name, type, current_value, currency, note) VALUES (?, ?, ?, ?, ?, ?)',
[id, asset.name, asset.type, asset.currentValue, asset.currency, asset.note],
);
return id;
}
export async function updateAsset(id: string, asset: Partial<Omit<Asset, 'id' | 'createdAt'>>): Promise<void> {
const fields: string[] = [];
const params: any[] = [];
if (asset.name) { fields.push('name = ?'); params.push(asset.name); }
if (asset.type) { fields.push('type = ?'); params.push(asset.type); }
if (asset.currentValue !== undefined) { fields.push('current_value = ?'); params.push(asset.currentValue); }
if (asset.currency) { fields.push('currency = ?'); params.push(asset.currency); }
if (asset.note !== undefined) { fields.push('note = ?'); params.push(asset.note); }
fields.push("last_updated = datetime('now')");
params.push(id);
await executeSql(`UPDATE assets SET ${fields.join(', ')} WHERE id = ?`, params);
}
export async function deleteAsset(id: string): Promise<void> {
await executeSql('DELETE FROM assets WHERE id = ?', [id]);
}
export async function getTotalAssets(): Promise<number> {
const result = await executeSql('SELECT COALESCE(SUM(current_value), 0) as total FROM assets');
return result.rows.item(0).total;
}
// ═══════════════════════════════════════════════════════════════════════
// LIABILITIES
// ═══════════════════════════════════════════════════════════════════════
export async function getLiabilities(): Promise<Liability[]> {
const result = await executeSql(
`SELECT id, name, type, outstanding_amount as outstandingAmount, currency,
interest_rate as interestRate, emi_amount as emiAmount,
note, last_updated as lastUpdated, created_at as createdAt
FROM liabilities
ORDER BY outstanding_amount DESC`,
);
return rowsToArray<Liability>(result);
}
export async function insertLiability(
liability: Omit<Liability, 'id' | 'createdAt' | 'lastUpdated'>,
): Promise<string> {
const id = generateId();
await executeSql(
`INSERT INTO liabilities (id, name, type, outstanding_amount, currency, interest_rate, emi_amount, note)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
id, liability.name, liability.type, liability.outstandingAmount,
liability.currency, liability.interestRate, liability.emiAmount, liability.note,
],
);
return id;
}
export async function updateLiability(
id: string,
liability: Partial<Omit<Liability, 'id' | 'createdAt'>>,
): Promise<void> {
const fields: string[] = [];
const params: any[] = [];
if (liability.name) { fields.push('name = ?'); params.push(liability.name); }
if (liability.type) { fields.push('type = ?'); params.push(liability.type); }
if (liability.outstandingAmount !== undefined) { fields.push('outstanding_amount = ?'); params.push(liability.outstandingAmount); }
if (liability.currency) { fields.push('currency = ?'); params.push(liability.currency); }
if (liability.interestRate !== undefined) { fields.push('interest_rate = ?'); params.push(liability.interestRate); }
if (liability.emiAmount !== undefined) { fields.push('emi_amount = ?'); params.push(liability.emiAmount); }
if (liability.note !== undefined) { fields.push('note = ?'); params.push(liability.note); }
fields.push("last_updated = datetime('now')");
params.push(id);
await executeSql(`UPDATE liabilities SET ${fields.join(', ')} WHERE id = ?`, params);
}
export async function deleteLiability(id: string): Promise<void> {
await executeSql('DELETE FROM liabilities WHERE id = ?', [id]);
}
export async function getTotalLiabilities(): Promise<number> {
const result = await executeSql('SELECT COALESCE(SUM(outstanding_amount), 0) as total FROM liabilities');
return result.rows.item(0).total;
}
// ═══════════════════════════════════════════════════════════════════════
// NET WORTH SNAPSHOTS
// ═══════════════════════════════════════════════════════════════════════
export async function saveNetWorthSnapshot(
totalAssets: number,
totalLiabilities: number,
currency: string,
): Promise<string> {
const id = generateId();
const netWorth = totalAssets - totalLiabilities;
const today = new Date().toISOString().split('T')[0];
// Upsert: delete any existing snapshot for today then insert
await executeSql('DELETE FROM net_worth_snapshots WHERE snapshot_date = ?', [today]);
await executeSql(
`INSERT INTO net_worth_snapshots (id, total_assets, total_liabilities, net_worth, currency, snapshot_date)
VALUES (?, ?, ?, ?, ?, ?)`,
[id, totalAssets, totalLiabilities, netWorth, currency, today],
);
return id;
}
export async function getNetWorthHistory(months: number = 12): Promise<NetWorthSnapshot[]> {
const result = await executeSql(
`SELECT id, total_assets as totalAssets, total_liabilities as totalLiabilities,
net_worth as netWorth, currency, snapshot_date as snapshotDate,
created_at as createdAt
FROM net_worth_snapshots
WHERE snapshot_date >= date('now', '-' || ? || ' months')
ORDER BY snapshot_date ASC`,
[months],
);
return rowsToArray<NetWorthSnapshot>(result);
}

2
src/hooks/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export {useAppInit} from './useAppInit';
export {useThemeColors, useIsDarkTheme} from './useThemeColors';

53
src/hooks/useAppInit.ts Normal file
View File

@@ -0,0 +1,53 @@
import {useEffect, useState} from 'react';
import {getDatabase} from '../db/database';
import {seedDefaultCategories} from '../db/queries';
import {useSettingsStore} from '../store/settingsStore';
import {useExpenseStore} from '../store/expenseStore';
/**
* Hook to initialize the app: open DB, seed categories, hydrate settings.
* Returns `isReady` when all initialization is complete.
*/
export function useAppInit(): {isReady: boolean; error: string | null} {
const [isReady, setIsReady] = useState(false);
const [error, setError] = useState<string | null>(null);
const hydrate = useSettingsStore(s => s.hydrate);
const initialize = useExpenseStore(s => s.initialize);
useEffect(() => {
let mounted = true;
async function init() {
try {
// 1. Open SQLite database (creates tables if needed)
await getDatabase();
// 2. Seed default categories
await seedDefaultCategories();
// 3. Hydrate settings from MMKV
hydrate();
// 4. Initialize expense store (load categories)
await initialize();
if (mounted) {
setIsReady(true);
}
} catch (err: any) {
console.error('App initialization failed:', err);
if (mounted) {
setError(err.message || 'Initialization failed');
}
}
}
init();
return () => {
mounted = false;
};
}, [hydrate, initialize]);
return {isReady, error};
}

View File

@@ -0,0 +1,32 @@
import {useColorScheme} from 'react-native';
import {useSettingsStore} from '../store';
import {COLORS, DARK_COLORS} from '../constants';
/**
* Returns the appropriate color palette based on the user's theme setting.
* - 'light' → always light
* - 'dark' → always dark
* - 'system' → follows the device setting
*/
export const useThemeColors = (): typeof COLORS => {
const theme = useSettingsStore(s => s.theme);
const systemScheme = useColorScheme(); // 'light' | 'dark' | null
if (theme === 'dark') return DARK_COLORS;
if (theme === 'light') return COLORS;
// system
return systemScheme === 'dark' ? DARK_COLORS : COLORS;
};
/**
* Returns true when the resolved theme is dark.
*/
export const useIsDarkTheme = (): boolean => {
const theme = useSettingsStore(s => s.theme);
const systemScheme = useColorScheme();
if (theme === 'dark') return true;
if (theme === 'light') return false;
return systemScheme === 'dark';
};

99
src/i18n/en.ts Normal file
View File

@@ -0,0 +1,99 @@
export default {
translation: {
// ─── Common ───
common: {
save: 'Save',
cancel: 'Cancel',
delete: 'Delete',
edit: 'Edit',
add: 'Add',
done: 'Done',
confirm: 'Confirm',
loading: 'Loading...',
noData: 'No data available',
error: 'Something went wrong',
retry: 'Retry',
},
// ─── Tabs ───
tabs: {
dashboard: 'Dashboard',
expenses: 'Expenses',
netWorth: 'Net Worth',
settings: 'Settings',
},
// ─── Dashboard ───
dashboard: {
title: 'Dashboard',
netWorth: 'Net Worth',
totalAssets: 'Total Assets',
totalLiabilities: 'Total Liabilities',
monthlySpending: 'Monthly Spending',
monthlyIncome: 'Monthly Income',
recentTransactions: 'Recent Transactions',
spendingTrends: 'Spending Trends',
viewAll: 'View All',
thisMonth: 'This Month',
lastMonth: 'Last Month',
},
// ─── Expenses ───
expenses: {
title: 'Expenses',
addExpense: 'Add Expense',
addIncome: 'Add Income',
amount: 'Amount',
category: 'Category',
paymentMethod: 'Payment Method',
date: 'Date',
note: 'Note (optional)',
noTransactions: 'No transactions yet',
startTracking: 'Start tracking your expenses',
},
// ─── Net Worth ───
netWorth: {
title: 'Net Worth',
assets: 'Assets',
liabilities: 'Liabilities',
addAsset: 'Add Asset',
addLiability: 'Add Liability',
assetName: 'Asset Name',
assetType: 'Asset Type',
currentValue: 'Current Value',
liabilityName: 'Liability Name',
liabilityType: 'Liability Type',
outstandingAmount: 'Outstanding Amount',
interestRate: 'Interest Rate (%)',
emiAmount: 'EMI Amount',
growth: 'Growth',
noAssets: 'No assets added yet',
noLiabilities: 'No liabilities added yet',
},
// ─── Settings ───
settings: {
title: 'Settings',
general: 'General',
baseCurrency: 'Base Currency',
language: 'Language',
theme: 'Theme',
data: 'Data',
exportData: 'Export Data',
importData: 'Import Data',
clearData: 'Clear All Data',
clearDataConfirm: 'Are you sure? This action cannot be undone.',
about: 'About',
version: 'Version',
appName: 'Expensso',
},
// ─── Currency ───
currency: {
INR: 'Indian Rupee (₹)',
USD: 'US Dollar ($)',
EUR: 'Euro (€)',
},
},
};

17
src/i18n/index.ts Normal file
View File

@@ -0,0 +1,17 @@
import i18n from 'i18next';
import {initReactI18next} from 'react-i18next';
import en from './en';
i18n.use(initReactI18next).init({
compatibilityJSON: 'v4',
resources: {
en,
},
lng: 'en',
fallbackLng: 'en',
interpolation: {
escapeValue: false,
},
});
export default i18n;

View File

@@ -0,0 +1,93 @@
import React from 'react';
import {NavigationContainer, DefaultTheme as NavDefaultTheme, DarkTheme as NavDarkTheme} from '@react-navigation/native';
import {createBottomTabNavigator} from '@react-navigation/bottom-tabs';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import {useTranslation} from 'react-i18next';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {
ModernDashboard,
ExpensesScreen,
NetWorthScreen,
SettingsScreen,
} from '../screens';
import {useTheme} from '../theme';
const Tab = createBottomTabNavigator();
const TAB_ICONS: Record<string, {focused: string; unfocused: string}> = {
Dashboard: {focused: 'view-dashboard', unfocused: 'view-dashboard-outline'},
Expenses: {focused: 'receipt', unfocused: 'receipt'},
NetWorth: {focused: 'chart-line', unfocused: 'chart-line'},
Settings: {focused: 'cog', unfocused: 'cog-outline'},
};
const AppNavigator: React.FC = () => {
const {t} = useTranslation();
const theme = useTheme();
const {colors, isDark} = theme;
const insets = useSafeAreaInsets();
const baseNavTheme = isDark ? NavDarkTheme : NavDefaultTheme;
const navigationTheme = {
...baseNavTheme,
colors: {
...baseNavTheme.colors,
background: colors.background,
card: colors.surfaceContainerLow,
text: colors.onSurface,
border: colors.outlineVariant,
primary: colors.primary,
},
};
return (
<NavigationContainer theme={navigationTheme}>
<Tab.Navigator
screenOptions={({route}) => ({
headerShown: false,
tabBarIcon: ({focused, color, size}) => {
const icons = TAB_ICONS[route.name];
const iconName = focused ? icons.focused : icons.unfocused;
return <Icon name={iconName} size={size} color={color} />;
},
tabBarActiveTintColor: colors.primary,
tabBarInactiveTintColor: colors.onSurfaceVariant,
tabBarStyle: {
backgroundColor: colors.surfaceContainerLow,
borderTopColor: colors.outlineVariant + '40',
borderTopWidth: 1,
height: 60 + insets.bottom,
paddingBottom: insets.bottom,
},
tabBarLabelStyle: {
fontSize: 11,
fontWeight: '600',
},
})}>
<Tab.Screen
name="Dashboard"
component={ModernDashboard}
options={{tabBarLabel: t('tabs.dashboard')}}
/>
<Tab.Screen
name="Expenses"
component={ExpensesScreen}
options={{tabBarLabel: t('tabs.expenses')}}
/>
<Tab.Screen
name="NetWorth"
component={NetWorthScreen}
options={{tabBarLabel: t('tabs.netWorth')}}
/>
<Tab.Screen
name="Settings"
component={SettingsScreen}
options={{tabBarLabel: t('tabs.settings')}}
/>
</Tab.Navigator>
</NavigationContainer>
);
};
export default AppNavigator;

View File

@@ -0,0 +1,429 @@
import React, {useCallback, useEffect} from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
RefreshControl,
StatusBar,
} from 'react-native';
import {useFocusEffect} from '@react-navigation/native';
import {SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
import {useTranslation} from 'react-i18next';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import {BarChart, PieChart} from 'react-native-gifted-charts';
import {SummaryCard, SectionHeader, TransactionItem, EmptyState} from '../components';
import {useSettingsStore, useNetWorthStore, useExpenseStore} from '../store';
import {formatCurrency, formatCompact, percentageChange} from '../utils';
import {COLORS} from '../constants';
import {useThemeColors, useIsDarkTheme} from '../hooks';
const DashboardScreen: React.FC = () => {
const {t} = useTranslation();
const baseCurrency = useSettingsStore(s => s.baseCurrency);
const colors = useThemeColors();
const isDark = useIsDarkTheme();
const insets = useSafeAreaInsets();
const {
totalAssets,
totalLiabilities,
netWorth,
loadNetWorth,
isLoading: nwLoading,
} = useNetWorthStore();
const {
transactions,
monthlyExpense,
monthlyIncome,
spendingByCategory,
monthlyTrend,
loadTransactions,
loadMonthlyStats,
loadSpendingAnalytics,
isLoading: txLoading,
} = useExpenseStore();
const loadAll = useCallback(async () => {
await Promise.all([
loadNetWorth(),
loadTransactions({limit: 5}),
loadMonthlyStats(),
loadSpendingAnalytics(),
]);
}, [loadNetWorth, loadTransactions, loadMonthlyStats, loadSpendingAnalytics]);
// Reload on screen focus
useFocusEffect(
useCallback(() => {
loadAll();
}, [loadAll]),
);
const isLoading = nwLoading || txLoading;
// ─── Chart: Spending by Category (Pie) ─────────────────────────────
const pieData = spendingByCategory.slice(0, 6).map((item, idx) => ({
value: item.total,
text: formatCompact(item.total, baseCurrency),
color: item.categoryColor || colors.chartColors[idx % colors.chartColors.length],
focused: idx === 0,
}));
// ─── Chart: Monthly Trend (Bar) ────────────────────────────────────
const barData = monthlyTrend.map((item, idx) => ({
value: item.total,
label: item.month.slice(5), // "01", "02", etc.
frontColor: colors.chartColors[idx % colors.chartColors.length],
}));
// ─── Net Worth Calculation Breakdown ───────────────────────────────
// Net Worth = Total Assets - Total Liabilities
// This is already computed in the netWorthStore from live SQLite data.
return (
<SafeAreaView style={[styles.screen, {backgroundColor: colors.background}]} edges={['top', 'left', 'right']}>
<StatusBar barStyle={isDark ? 'light-content' : 'dark-content'} backgroundColor={colors.background} />
{/* Header */}
<View style={[styles.header, {backgroundColor: colors.background}]}>
<View>
<Text style={[styles.greeting, {color: colors.textSecondary}]}>Hello,</Text>
<Text style={[styles.headerTitle, {color: colors.text}]}>{t('dashboard.title')}</Text>
</View>
<View style={[styles.currencyBadge, {backgroundColor: colors.primary + '15'}]}>
<Text style={[styles.currencyText, {color: colors.primary}]}>{baseCurrency}</Text>
</View>
</View>
<ScrollView
style={styles.scrollView}
contentContainerStyle={[styles.scrollContent, {paddingBottom: 60 + insets.bottom}]}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl refreshing={isLoading} onRefresh={loadAll} />
}>
{/* ── Net Worth Hero Card ─────────────────────────────────── */}
<View style={[styles.heroCard, {backgroundColor: colors.surface}]}>
<Text style={[styles.heroLabel, {color: colors.textSecondary}]}>{t('dashboard.netWorth')}</Text>
<Text
style={[
styles.heroValue,
{color: netWorth >= 0 ? colors.success : colors.danger},
]}
numberOfLines={1}
adjustsFontSizeToFit>
{formatCurrency(netWorth, baseCurrency)}
</Text>
<View style={[styles.heroBreakdown, {borderTopColor: colors.borderLight}]}>
<View style={styles.heroBreakdownItem}>
<Icon name="trending-up" size={16} color={colors.asset} />
<Text style={[styles.heroBreakdownLabel, {color: colors.textTertiary}]}>
{t('dashboard.totalAssets')}
</Text>
<Text style={[styles.heroBreakdownValue, {color: colors.asset}]}>
{formatCompact(totalAssets, baseCurrency)}
</Text>
</View>
<View style={[styles.heroBreakdownDivider, {backgroundColor: colors.borderLight}]} />
<View style={styles.heroBreakdownItem}>
<Icon name="trending-down" size={16} color={colors.liability} />
<Text style={[styles.heroBreakdownLabel, {color: colors.textTertiary}]}>
{t('dashboard.totalLiabilities')}
</Text>
<Text
style={[styles.heroBreakdownValue, {color: colors.liability}]}>
{formatCompact(totalLiabilities, baseCurrency)}
</Text>
</View>
</View>
</View>
{/* ── Monthly Summary Cards ──────────────────────────────── */}
<View style={styles.cardRow}>
<SummaryCard
title={t('dashboard.monthlyIncome')}
value={formatCurrency(monthlyIncome, baseCurrency)}
valueColor={colors.income}
icon={<Icon name="arrow-down-circle" size={18} color={colors.income} />}
style={styles.halfCard}
/>
<SummaryCard
title={t('dashboard.monthlySpending')}
value={formatCurrency(monthlyExpense, baseCurrency)}
valueColor={colors.expense}
icon={<Icon name="arrow-up-circle" size={18} color={colors.expense} />}
style={styles.halfCard}
/>
</View>
{/* ── Spending by Category (Pie Chart) ───────────────────── */}
{pieData.length > 0 && (
<>
<SectionHeader title={t('dashboard.thisMonth')} />
<View style={[styles.chartCard, {backgroundColor: colors.surface}]}>
<PieChart
data={pieData}
donut
innerRadius={50}
radius={80}
innerCircleColor={colors.surface}
centerLabelComponent={() => (
<View style={styles.pieCenter}>
<Text style={[styles.pieCenterValue, {color: colors.text}]}>
{formatCompact(monthlyExpense, baseCurrency)}
</Text>
<Text style={[styles.pieCenterLabel, {color: colors.textTertiary}]}>Spent</Text>
</View>
)}
/>
<View style={styles.legendContainer}>
{spendingByCategory.slice(0, 5).map((item, idx) => (
<View key={idx} style={styles.legendItem}>
<View
style={[
styles.legendDot,
{backgroundColor: item.categoryColor},
]}
/>
<Text style={[styles.legendText, {color: colors.text}]} numberOfLines={1}>
{item.categoryName}
</Text>
<Text style={[styles.legendValue, {color: colors.textSecondary}]}>
{formatCompact(item.total, baseCurrency)}
</Text>
</View>
))}
</View>
</View>
</>
)}
{/* ── Spending Trends (Bar Chart) ────────────────────────── */}
{barData.length > 0 && (
<>
<SectionHeader title={t('dashboard.spendingTrends')} />
<View style={[styles.chartCard, {backgroundColor: colors.surface}]}>
<BarChart
data={barData}
barWidth={28}
spacing={18}
roundedTop
roundedBottom
noOfSections={4}
yAxisThickness={0}
xAxisThickness={0}
yAxisTextStyle={[styles.chartAxisText, {color: colors.textTertiary}]}
xAxisLabelTextStyle={[styles.chartAxisText, {color: colors.textTertiary}]}
hideRules
isAnimated
barBorderRadius={6}
/>
</View>
</>
)}
{/* ── Recent Transactions ─────────────────────────────────── */}
<SectionHeader title={t('dashboard.recentTransactions')} />
<View style={[styles.transactionsList, {backgroundColor: colors.surface}]}>
{transactions.length > 0 ? (
transactions.map(txn => (
<TransactionItem key={txn.id} transaction={txn} />
))
) : (
<EmptyState
icon="receipt"
title={t('expenses.noTransactions')}
subtitle={t('expenses.startTracking')}
/>
)}
</View>
<View style={styles.bottomSpacer} />
</ScrollView>
</SafeAreaView>
);
};
export default DashboardScreen;
// ─── Styles ────────────────────────────────────────────────────────────
const styles = StyleSheet.create({
screen: {
flex: 1,
backgroundColor: COLORS.background,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 20,
paddingTop: 16,
paddingBottom: 8,
backgroundColor: COLORS.background,
},
greeting: {
fontSize: 14,
color: COLORS.textSecondary,
},
headerTitle: {
fontSize: 28,
fontWeight: '800',
color: COLORS.text,
},
currencyBadge: {
backgroundColor: COLORS.primary + '15',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 20,
},
currencyText: {
fontSize: 13,
fontWeight: '700',
color: COLORS.primary,
},
scrollView: {
flex: 1,
},
scrollContent: {},
// Hero card
heroCard: {
backgroundColor: COLORS.surface,
margin: 20,
marginTop: 12,
borderRadius: 20,
padding: 24,
shadowColor: '#000',
shadowOffset: {width: 0, height: 4},
shadowOpacity: 0.08,
shadowRadius: 12,
elevation: 5,
},
heroLabel: {
fontSize: 13,
fontWeight: '600',
color: COLORS.textSecondary,
textTransform: 'uppercase',
letterSpacing: 1,
},
heroValue: {
fontSize: 36,
fontWeight: '800',
marginTop: 4,
marginBottom: 16,
},
heroBreakdown: {
flexDirection: 'row',
alignItems: 'center',
borderTopWidth: 1,
borderTopColor: COLORS.borderLight,
paddingTop: 16,
},
heroBreakdownItem: {
flex: 1,
alignItems: 'center',
},
heroBreakdownDivider: {
width: 1,
height: 36,
backgroundColor: COLORS.borderLight,
},
heroBreakdownLabel: {
fontSize: 11,
color: COLORS.textTertiary,
marginTop: 4,
},
heroBreakdownValue: {
fontSize: 16,
fontWeight: '700',
marginTop: 2,
},
// Monthly cards
cardRow: {
flexDirection: 'row',
paddingHorizontal: 20,
gap: 12,
},
halfCard: {
flex: 1,
},
// Charts
chartCard: {
backgroundColor: COLORS.surface,
marginHorizontal: 20,
borderRadius: 16,
padding: 20,
alignItems: 'center',
shadowColor: '#000',
shadowOffset: {width: 0, height: 2},
shadowOpacity: 0.04,
shadowRadius: 8,
elevation: 2,
},
chartAxisText: {
fontSize: 10,
color: COLORS.textTertiary,
},
pieCenter: {
alignItems: 'center',
},
pieCenterValue: {
fontSize: 16,
fontWeight: '700',
color: COLORS.text,
},
pieCenterLabel: {
fontSize: 11,
color: COLORS.textTertiary,
},
legendContainer: {
width: '100%',
marginTop: 16,
},
legendItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 4,
},
legendDot: {
width: 10,
height: 10,
borderRadius: 5,
marginRight: 8,
},
legendText: {
flex: 1,
fontSize: 13,
color: COLORS.text,
},
legendValue: {
fontSize: 13,
fontWeight: '600',
color: COLORS.textSecondary,
},
// Transactions
transactionsList: {
backgroundColor: COLORS.surface,
marginHorizontal: 20,
borderRadius: 16,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: {width: 0, height: 2},
shadowOpacity: 0.04,
shadowRadius: 8,
elevation: 2,
},
bottomSpacer: {
height: 40,
},
});

View File

@@ -0,0 +1,552 @@
/**
* ExpensesScreen — MD3 refactored.
* Replaces the system Modal with CustomBottomSheet.
* Uses MD3 theme tokens (useTheme), Reanimated animations, and haptic feedback.
*/
import React, {useCallback, useRef, useState} from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
ScrollView,
Pressable,
Alert,
StatusBar,
} from 'react-native';
import {useFocusEffect} from '@react-navigation/native';
import {SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
import {useTranslation} from 'react-i18next';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import Animated, {FadeIn, FadeInDown} from 'react-native-reanimated';
import dayjs from 'dayjs';
import {
TransactionItem,
EmptyState,
CustomBottomSheet,
BottomSheetInput,
BottomSheetChipSelector,
triggerHaptic,
} from '../components';
import type {CustomBottomSheetHandle} from '../components';
import {useExpenseStore, useSettingsStore, useNetWorthStore} from '../store';
import {PAYMENT_METHODS} from '../constants';
import {formatCurrency} from '../utils';
import {useTheme} from '../theme';
import type {MD3Theme} from '../theme';
import {
TransactionType,
PaymentMethod,
Category,
Transaction,
NetWorthTargetType,
AssetType,
LiabilityType,
} from '../types';
const ASSET_TYPES: AssetType[] = [
'Bank', 'Stocks', 'Gold', 'EPF', 'Mutual Funds', 'Fixed Deposit', 'PPF', 'Real Estate', 'Other',
];
const LIABILITY_TYPES: LiabilityType[] = [
'Home Loan', 'Car Loan', 'Personal Loan', 'Education Loan', 'Credit Card', 'Other',
];
const ExpensesScreen: React.FC = () => {
const {t} = useTranslation();
const baseCurrency = useSettingsStore(s => s.baseCurrency);
const theme = useTheme();
const s = makeStyles(theme);
const {colors, spacing} = theme;
const insets = useSafeAreaInsets();
const sheetRef = useRef<CustomBottomSheetHandle>(null);
const {
transactions,
categories,
monthlyExpense,
monthlyIncome,
loadTransactions,
loadMonthlyStats,
addTransaction,
removeTransaction,
} = useExpenseStore();
const {assets, liabilities, loadNetWorth} = useNetWorthStore();
const [txnType, setTxnType] = useState<TransactionType>('expense');
const [amount, setAmount] = useState('');
const [selectedCategory, setSelectedCategory] = useState<Category | null>(null);
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod>('UPI');
const [note, setNote] = useState('');
const [targetType, setTargetType] = useState<NetWorthTargetType>('asset');
const [targetMode, setTargetMode] = useState<'existing' | 'new'>('existing');
const [selectedTargetId, setSelectedTargetId] = useState('');
const [newTargetName, setNewTargetName] = useState('');
const [newAssetType, setNewAssetType] = useState<AssetType>('Bank');
const [newLiabilityType, setNewLiabilityType] = useState<LiabilityType>('Home Loan');
useFocusEffect(
useCallback(() => {
loadTransactions({limit: 100});
loadMonthlyStats();
loadNetWorth();
}, [loadTransactions, loadMonthlyStats, loadNetWorth]),
);
const filteredCategories = categories.filter(c => c.type === txnType);
const selectedTargets = targetType === 'asset' ? assets : liabilities;
const resetForm = () => {
setAmount('');
setSelectedCategory(null);
setPaymentMethod('UPI');
setNote('');
setTargetType('asset');
setTargetMode('existing');
setSelectedTargetId('');
setNewTargetName('');
setNewAssetType('Bank');
setNewLiabilityType('Home Loan');
};
const handleSave = async () => {
const parsed = parseFloat(amount);
if (isNaN(parsed) || parsed <= 0) {
Alert.alert('Invalid Amount', 'Please enter a valid amount.');
return;
}
if (!selectedCategory) {
Alert.alert('Select Category', 'Please select a category.');
return;
}
if (targetMode === 'existing' && !selectedTargetId) {
Alert.alert('Select Entry', 'Please select an existing asset or liability entry.');
return;
}
if (targetMode === 'new' && !newTargetName.trim()) {
Alert.alert('Entry Name Required', 'Please enter a name for the new net worth entry.');
return;
}
const operation =
txnType === 'income'
? targetType === 'asset' ? 'add' : 'subtract'
: targetType === 'asset' ? 'subtract' : 'add';
await addTransaction({
amount: parsed,
currency: baseCurrency,
type: txnType,
categoryId: selectedCategory.id,
paymentMethod,
note,
date: dayjs().format('YYYY-MM-DD'),
impact: {
targetType,
targetId: targetMode === 'existing' ? selectedTargetId : undefined,
operation,
createNew:
targetMode === 'new'
? {
name: newTargetName.trim(),
type: targetType === 'asset' ? newAssetType : newLiabilityType,
note: '',
}
: undefined,
},
});
triggerHaptic('notificationSuccess');
resetForm();
sheetRef.current?.dismiss();
loadNetWorth();
};
const handleDelete = (txn: Transaction) => {
Alert.alert('Delete Transaction', 'Are you sure you want to delete this?', [
{text: 'Cancel', style: 'cancel'},
{
text: 'Delete',
style: 'destructive',
onPress: () => {
triggerHaptic('impactMedium');
removeTransaction(txn.id);
},
},
]);
};
// ── List Header ─────────────────────────────────────────────────────
const renderHeader = () => (
<Animated.View entering={FadeInDown.duration(400)} style={s.headerSection}>
<View style={s.summaryRow}>
<View style={[s.summaryItem, {backgroundColor: colors.success + '12'}]}>
<Icon name="arrow-down-circle" size={20} color={colors.success} />
<Text style={s.summaryLabel}>Income</Text>
<Text style={[s.summaryValue, {color: colors.success}]}>
{formatCurrency(monthlyIncome, baseCurrency)}
</Text>
</View>
<View style={[s.summaryItem, {backgroundColor: colors.error + '12'}]}>
<Icon name="arrow-up-circle" size={20} color={colors.error} />
<Text style={s.summaryLabel}>Expense</Text>
<Text style={[s.summaryValue, {color: colors.error}]}>
{formatCurrency(monthlyExpense, baseCurrency)}
</Text>
</View>
</View>
</Animated.View>
);
// ── Category chip options ───────────────────────────────────────────
const categoryOptions = filteredCategories.map(c => ({
value: c.id,
label: c.name,
icon: c.icon,
}));
const paymentOptions = PAYMENT_METHODS.map(m => ({value: m, label: m}));
// ── Render ──────────────────────────────────────────────────────────
return (
<SafeAreaView style={s.screen} edges={['top', 'left', 'right']}>
<StatusBar
barStyle={theme.isDark ? 'light-content' : 'dark-content'}
backgroundColor={colors.background}
/>
<Animated.View entering={FadeIn.duration(300)} style={s.header}>
<Text style={s.headerTitle}>{t('expenses.title')}</Text>
</Animated.View>
<FlatList
data={transactions}
keyExtractor={item => item.id}
renderItem={({item}) => (
<TransactionItem transaction={item} onPress={handleDelete} />
)}
ListHeaderComponent={renderHeader}
ListEmptyComponent={
<EmptyState
icon="receipt"
title={t('expenses.noTransactions')}
subtitle={t('expenses.startTracking')}
/>
}
ItemSeparatorComponent={() => (
<View style={[s.separator, {backgroundColor: colors.outlineVariant + '30'}]} />
)}
contentContainerStyle={{paddingBottom: 80 + insets.bottom}}
showsVerticalScrollIndicator={false}
/>
{/* FAB */}
<Pressable
style={[s.fab, {bottom: 70 + insets.bottom}]}
onPress={() => {
resetForm();
triggerHaptic('impactMedium');
sheetRef.current?.present();
}}>
<Icon name="plus" size={28} color={colors.onPrimary} />
</Pressable>
{/* ── Add Transaction Bottom Sheet ──────────────────────── */}
<CustomBottomSheet
ref={sheetRef}
title={txnType === 'expense' ? t('expenses.addExpense') : t('expenses.addIncome')}
snapPoints={['92%']}
headerLeft={{label: t('common.cancel'), onPress: () => sheetRef.current?.dismiss()}}
headerRight={{label: t('common.save'), onPress: handleSave}}>
{/* Expense / Income Toggle */}
<View style={s.typeToggle}>
<Pressable
style={[s.typeBtn, txnType === 'expense' && {backgroundColor: colors.errorContainer}]}
onPress={() => {
triggerHaptic('selection');
setTxnType('expense');
setSelectedCategory(null);
}}>
<Text style={[s.typeBtnText, txnType === 'expense' && {color: colors.onErrorContainer}]}>
Expense
</Text>
</Pressable>
<Pressable
style={[s.typeBtn, txnType === 'income' && {backgroundColor: colors.primaryContainer}]}
onPress={() => {
triggerHaptic('selection');
setTxnType('income');
setSelectedCategory(null);
}}>
<Text style={[s.typeBtnText, txnType === 'income' && {color: colors.onPrimaryContainer}]}>
Income
</Text>
</Pressable>
</View>
{/* Amount */}
<BottomSheetInput
label={t('expenses.amount')}
value={amount}
onChangeText={setAmount}
placeholder="0"
keyboardType="decimal-pad"
prefix={baseCurrency === 'INR' ? '\u20B9' : baseCurrency === 'USD' ? '$' : '\u20AC'}
autoFocus
/>
{/* Category */}
<BottomSheetChipSelector
label={t('expenses.category')}
options={categoryOptions}
selected={selectedCategory?.id ?? ''}
onSelect={id => {
const cat = filteredCategories.find(c => c.id === id) ?? null;
setSelectedCategory(cat);
}}
/>
{/* Payment Method */}
<BottomSheetChipSelector
label={t('expenses.paymentMethod')}
options={paymentOptions}
selected={paymentMethod}
onSelect={m => setPaymentMethod(m as PaymentMethod)}
/>
{/* Net Worth Impact */}
<Text style={s.sectionLabel}>Net Worth Entry</Text>
<View style={s.typeToggle}>
<Pressable
style={[s.typeBtn, targetType === 'asset' && {backgroundColor: colors.primaryContainer}]}
onPress={() => {
triggerHaptic('selection');
setTargetType('asset');
setSelectedTargetId('');
}}>
<Text style={[s.typeBtnText, targetType === 'asset' && {color: colors.onPrimaryContainer}]}>
Asset
</Text>
</Pressable>
<Pressable
style={[s.typeBtn, targetType === 'liability' && {backgroundColor: colors.errorContainer}]}
onPress={() => {
triggerHaptic('selection');
setTargetType('liability');
setSelectedTargetId('');
}}>
<Text style={[s.typeBtnText, targetType === 'liability' && {color: colors.onErrorContainer}]}>
Liability
</Text>
</Pressable>
</View>
<View style={[s.typeToggle, {marginBottom: spacing.lg}]}>
<Pressable
style={[s.typeBtn, targetMode === 'existing' && {backgroundColor: colors.primaryContainer}]}
onPress={() => {
triggerHaptic('selection');
setTargetMode('existing');
}}>
<Text style={[s.typeBtnText, targetMode === 'existing' && {color: colors.onPrimaryContainer}]}>
Existing
</Text>
</Pressable>
<Pressable
style={[s.typeBtn, targetMode === 'new' && {backgroundColor: colors.primaryContainer}]}
onPress={() => {
triggerHaptic('selection');
setTargetMode('new');
setSelectedTargetId('');
}}>
<Text style={[s.typeBtnText, targetMode === 'new' && {color: colors.onPrimaryContainer}]}>
New
</Text>
</Pressable>
</View>
{targetMode === 'existing' ? (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{marginBottom: spacing.lg}}>
{selectedTargets.map(entry => {
const active = selectedTargetId === entry.id;
return (
<Pressable
key={entry.id}
style={[
s.chip,
active && {backgroundColor: colors.primaryContainer, borderColor: colors.primary},
]}
onPress={() => {
triggerHaptic('selection');
setSelectedTargetId(entry.id);
}}>
<Text style={[s.chipText, active && {color: colors.onPrimaryContainer}]}>
{entry.name}
</Text>
</Pressable>
);
})}
</ScrollView>
) : (
<View style={{marginBottom: spacing.lg}}>
<BottomSheetInput
label={targetType === 'asset' ? 'Asset Name' : 'Liability Name'}
value={newTargetName}
onChangeText={setNewTargetName}
placeholder={targetType === 'asset' ? 'New asset name' : 'New liability name'}
/>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
{(targetType === 'asset' ? ASSET_TYPES : LIABILITY_TYPES).map(entryType => {
const active = (targetType === 'asset' ? newAssetType : newLiabilityType) === entryType;
return (
<Pressable
key={entryType}
style={[
s.chip,
active && {backgroundColor: colors.primaryContainer, borderColor: colors.primary},
]}
onPress={() => {
triggerHaptic('selection');
if (targetType === 'asset') setNewAssetType(entryType as AssetType);
else setNewLiabilityType(entryType as LiabilityType);
}}>
<Text style={[s.chipText, active && {color: colors.onPrimaryContainer}]}>
{entryType}
</Text>
</Pressable>
);
})}
</ScrollView>
</View>
)}
<Text style={s.operationHint}>
{txnType === 'income'
? targetType === 'asset'
? 'Income will add to this asset.'
: 'Income will reduce this liability.'
: targetType === 'asset'
? 'Expense will reduce this asset.'
: 'Expense will increase this liability.'}
</Text>
{/* Note */}
<BottomSheetInput
label={t('expenses.note')}
value={note}
onChangeText={setNote}
placeholder="Add a note..."
multiline
/>
</CustomBottomSheet>
</SafeAreaView>
);
};
export default ExpensesScreen;
// ─── Styles ────────────────────────────────────────────────────────────
function makeStyles(theme: MD3Theme) {
const {colors, typography, elevation, shape, spacing} = theme;
return StyleSheet.create({
screen: {flex: 1, backgroundColor: colors.background},
header: {
paddingHorizontal: spacing.xl,
paddingTop: spacing.lg,
paddingBottom: spacing.sm,
},
headerTitle: {
...typography.headlineMedium,
color: colors.onSurface,
fontWeight: '700',
},
headerSection: {
paddingHorizontal: spacing.xl,
paddingBottom: spacing.md,
},
summaryRow: {
flexDirection: 'row',
gap: spacing.md,
marginTop: spacing.sm,
marginBottom: spacing.lg,
},
summaryItem: {
flex: 1,
borderRadius: shape.large,
padding: spacing.lg,
alignItems: 'center',
},
summaryLabel: {
...typography.bodySmall,
color: colors.onSurfaceVariant,
marginTop: spacing.xs,
},
summaryValue: {
...typography.titleMedium,
fontWeight: '700',
marginTop: 2,
},
separator: {height: 1, marginLeft: 72},
fab: {
position: 'absolute',
right: spacing.xl,
width: 56,
height: 56,
borderRadius: shape.large,
backgroundColor: colors.primary,
justifyContent: 'center',
alignItems: 'center',
...elevation.level3,
},
typeToggle: {
flexDirection: 'row',
backgroundColor: colors.surfaceContainerLow,
borderRadius: shape.medium,
padding: 4,
marginBottom: spacing.xl,
},
typeBtn: {
flex: 1,
paddingVertical: spacing.md,
borderRadius: shape.small,
alignItems: 'center',
},
typeBtnText: {
...typography.labelLarge,
color: colors.onSurfaceVariant,
},
chip: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: shape.full,
borderWidth: 1,
borderColor: colors.outlineVariant,
backgroundColor: colors.surfaceContainerLowest,
marginRight: spacing.sm,
},
chipText: {
...typography.labelMedium,
color: colors.onSurfaceVariant,
},
sectionLabel: {
...typography.bodySmall,
color: colors.onSurfaceVariant,
marginBottom: spacing.sm,
},
operationHint: {
...typography.bodySmall,
color: colors.onSurfaceVariant,
marginBottom: spacing.lg,
fontStyle: 'italic',
},
});
}

View File

@@ -0,0 +1,213 @@
/**
* ModernDashboard — Full MD3 redesign of the dashboard screen.
*
* Sections:
* 1. Net Worth "Hero" Card with sparkline trend
* 2. Asset vs. Liability chip row
* 3. Wealth Distribution donut chart
* 4. Financial Health gauges (budget vs. spent)
* 5. Recent Activity glassmorphism list
*/
import React, {useCallback, useState} from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
RefreshControl,
StatusBar,
Pressable,
} from 'react-native';
import {useFocusEffect, useNavigation} from '@react-navigation/native';
import {SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
import {useTranslation} from 'react-i18next';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import Animated, {FadeIn} from 'react-native-reanimated';
import {useTheme} from '../theme';
import type {MD3Theme} from '../theme';
import {useSettingsStore, useNetWorthStore, useExpenseStore} from '../store';
import {getNetWorthHistory} from '../db';
import type {NetWorthSnapshot} from '../types';
import {
NetWorthHeroCard,
AssetChipRow,
WealthDistributionChart,
RecentActivityList,
FinancialHealthGauges,
} from '../components/dashboard';
const ModernDashboard: React.FC = () => {
const {t} = useTranslation();
const theme = useTheme();
const s = makeStyles(theme);
const insets = useSafeAreaInsets();
const navigation = useNavigation<any>();
const baseCurrency = useSettingsStore(ss => ss.baseCurrency);
const {
totalAssets,
totalLiabilities,
netWorth,
assets,
liabilities,
loadNetWorth,
isLoading: nwLoading,
} = useNetWorthStore();
const {
transactions,
monthlyExpense,
monthlyIncome,
loadTransactions,
loadMonthlyStats,
loadSpendingAnalytics,
isLoading: txLoading,
} = useExpenseStore();
const [history, setHistory] = useState<NetWorthSnapshot[]>([]);
const loadAll = useCallback(async () => {
await Promise.all([
loadNetWorth(),
loadTransactions({limit: 5}),
loadMonthlyStats(),
loadSpendingAnalytics(),
]);
const hist = await getNetWorthHistory(12);
setHistory(hist);
}, [loadNetWorth, loadTransactions, loadMonthlyStats, loadSpendingAnalytics]);
useFocusEffect(
useCallback(() => {
loadAll();
}, [loadAll]),
);
const isLoading = nwLoading || txLoading;
return (
<SafeAreaView style={s.screen} edges={['top', 'left', 'right']}>
<StatusBar
barStyle={theme.isDark ? 'light-content' : 'dark-content'}
backgroundColor={theme.colors.background}
/>
{/* ── Header ──────────────────────────────────────────────── */}
<Animated.View entering={FadeIn.duration(400)} style={s.header}>
<View>
<Text style={s.greeting}>Hello,</Text>
<Text style={s.headerTitle}>{t('dashboard.title')}</Text>
</View>
<Pressable style={s.currencyBadge}>
<Icon name="swap-horizontal" size={14} color={theme.colors.primary} />
<Text style={s.currencyText}>{baseCurrency}</Text>
</Pressable>
</Animated.View>
{/* ── Scrollable Content ──────────────────────────────────── */}
<ScrollView
style={s.scrollView}
contentContainerStyle={{paddingBottom: 80 + insets.bottom}}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={isLoading}
onRefresh={loadAll}
tintColor={theme.colors.primary}
colors={[theme.colors.primary]}
/>
}>
{/* 1. Net Worth Hero Card */}
<NetWorthHeroCard
netWorth={netWorth}
totalAssets={totalAssets}
totalLiabilities={totalLiabilities}
currency={baseCurrency}
history={history}
/>
{/* 2. Asset vs. Liability Chip Row */}
<AssetChipRow
assets={assets}
liabilities={liabilities}
currency={baseCurrency}
/>
{/* 3. Wealth Distribution Donut */}
<WealthDistributionChart assets={assets} currency={baseCurrency} />
{/* 4. Financial Health Gauges */}
<FinancialHealthGauges
monthlyIncome={monthlyIncome}
monthlyExpense={monthlyExpense}
currency={baseCurrency}
/>
{/* 5. Recent Activity */}
<RecentActivityList
transactions={transactions}
currency={baseCurrency}
onViewAll={() => navigation.navigate('Expenses')}
/>
<View style={s.bottomSpacer} />
</ScrollView>
</SafeAreaView>
);
};
export default ModernDashboard;
// ─── Styles ────────────────────────────────────────────────────────────
function makeStyles(theme: MD3Theme) {
const {colors, typography, spacing} = theme;
return StyleSheet.create({
screen: {
flex: 1,
backgroundColor: colors.background,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: spacing.xl,
paddingTop: spacing.lg,
paddingBottom: spacing.sm,
},
greeting: {
...typography.bodyMedium,
color: colors.onSurfaceVariant,
},
headerTitle: {
...typography.headlineMedium,
color: colors.onSurface,
fontWeight: '700',
},
currencyBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
backgroundColor: colors.primaryContainer,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: 20,
},
currencyText: {
...typography.labelMedium,
color: colors.onPrimaryContainer,
fontWeight: '600',
},
scrollView: {
flex: 1,
},
bottomSpacer: {
height: 20,
},
});
}

View File

@@ -0,0 +1,633 @@
/**
* NetWorthScreen — MD3 refactored.
* Replaces system Modals with CustomBottomSheet.
* Uses MD3 theme tokens (useTheme), Reanimated animations, and haptic feedback.
*/
import React, {useCallback, useRef, useState} from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
Pressable,
Alert,
RefreshControl,
StatusBar,
} from 'react-native';
import {useFocusEffect} from '@react-navigation/native';
import {SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
import {useTranslation} from 'react-i18next';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import {LineChart} from 'react-native-gifted-charts';
import Animated, {FadeIn, FadeInDown, FadeInUp} from 'react-native-reanimated';
import {
SectionHeader,
EmptyState,
CustomBottomSheet,
BottomSheetInput,
BottomSheetChipSelector,
triggerHaptic,
} from '../components';
import type {CustomBottomSheetHandle} from '../components';
import {useNetWorthStore, useSettingsStore} from '../store';
import {getNetWorthHistory} from '../db';
import {formatCurrency, formatCompact} from '../utils';
import {useTheme} from '../theme';
import type {MD3Theme} from '../theme';
import {Asset, AssetType, Liability, LiabilityType, NetWorthSnapshot} from '../types';
const ASSET_TYPES: AssetType[] = [
'Bank', 'Stocks', 'Gold', 'EPF', 'Mutual Funds', 'Fixed Deposit', 'PPF', 'Real Estate', 'Other',
];
const LIABILITY_TYPES: LiabilityType[] = [
'Home Loan', 'Car Loan', 'Personal Loan', 'Education Loan', 'Credit Card', 'Other',
];
const ASSET_ICONS: Record<AssetType, string> = {
Bank: 'bank',
Stocks: 'chart-line',
Gold: 'gold',
EPF: 'shield-account',
'Real Estate': 'home-city',
'Mutual Funds': 'chart-areaspline',
'Fixed Deposit': 'safe',
PPF: 'piggy-bank',
Other: 'dots-horizontal',
};
const ASSET_ICON_COLORS: Record<AssetType, string> = {
Bank: '#1E88E5',
Stocks: '#7E57C2',
Gold: '#D4AF37',
EPF: '#00ACC1',
'Real Estate': '#8D6E63',
'Mutual Funds': '#26A69A',
'Fixed Deposit': '#3949AB',
PPF: '#43A047',
Other: '#78909C',
};
const LIABILITY_ICON_COLORS: Record<LiabilityType, {icon: string; color: string}> = {
'Home Loan': {icon: 'home-city', color: '#EF6C00'},
'Car Loan': {icon: 'car', color: '#5E35B1'},
'Personal Loan': {icon: 'account-cash', color: '#E53935'},
'Education Loan': {icon: 'school', color: '#1E88E5'},
'Credit Card': {icon: 'credit-card', color: '#D81B60'},
Other: {icon: 'alert-circle-outline', color: '#757575'},
};
const NetWorthScreen: React.FC = () => {
const {t} = useTranslation();
const baseCurrency = useSettingsStore(s => s.baseCurrency);
const theme = useTheme();
const s = makeStyles(theme);
const {colors, spacing, shape} = theme;
const insets = useSafeAreaInsets();
const assetSheetRef = useRef<CustomBottomSheetHandle>(null);
const liabilitySheetRef = useRef<CustomBottomSheetHandle>(null);
const {
assets,
liabilities,
totalAssets,
totalLiabilities,
netWorth,
isLoading,
loadNetWorth,
addAsset,
removeAsset,
editAsset,
addLiability,
removeLiability,
editLiability,
takeSnapshot,
} = useNetWorthStore();
const [history, setHistory] = useState<NetWorthSnapshot[]>([]);
const [editingAsset, setEditingAsset] = useState<Asset | null>(null);
const [editingLiability, setEditingLiability] = useState<Liability | null>(null);
// Asset form
const [assetName, setAssetName] = useState('');
const [assetType, setAssetType] = useState<AssetType>('Bank');
const [assetValue, setAssetValue] = useState('');
const [assetNote, setAssetNote] = useState('');
// Liability form
const [liabName, setLiabName] = useState('');
const [liabType, setLiabType] = useState<LiabilityType>('Home Loan');
const [liabAmount, setLiabAmount] = useState('');
const [liabRate, setLiabRate] = useState('');
const [liabEmi, setLiabEmi] = useState('');
const [liabNote, setLiabNote] = useState('');
const loadAll = useCallback(async () => {
await loadNetWorth();
const hist = await getNetWorthHistory(12);
setHistory(hist);
}, [loadNetWorth]);
useFocusEffect(useCallback(() => { loadAll(); }, [loadAll]));
// Chart data
const lineData = history.map(snap => ({
value: snap.netWorth,
label: snap.snapshotDate.slice(5, 10),
dataPointText: formatCompact(snap.netWorth, baseCurrency),
}));
// Asset type chip options
const assetTypeOptions = ASSET_TYPES.map(at => ({
value: at,
label: at,
icon: ASSET_ICONS[at],
}));
const liabTypeOptions = LIABILITY_TYPES.map(lt => ({
value: lt,
label: lt,
}));
// ── Save Asset ──────────────────────────────────────────────────
const handleSaveAsset = async () => {
const val = parseFloat(assetValue);
if (!assetName.trim() || isNaN(val) || val <= 0) {
Alert.alert('Invalid', 'Please enter a valid name and value.');
return;
}
if (editingAsset) {
await editAsset(editingAsset.id, {
name: assetName.trim(),
type: assetType,
currentValue: val,
note: assetNote,
});
} else {
await addAsset({
name: assetName.trim(),
type: assetType,
currentValue: val,
currency: baseCurrency,
note: assetNote,
});
}
triggerHaptic('notificationSuccess');
await takeSnapshot(baseCurrency);
setAssetName(''); setAssetValue(''); setAssetNote(''); setEditingAsset(null);
assetSheetRef.current?.dismiss();
loadAll();
};
// ── Save Liability ──────────────────────────────────────────────
const handleSaveLiability = async () => {
const amt = parseFloat(liabAmount);
if (!liabName.trim() || isNaN(amt) || amt <= 0) {
Alert.alert('Invalid', 'Please enter a valid name and amount.');
return;
}
if (editingLiability) {
await editLiability(editingLiability.id, {
name: liabName.trim(),
type: liabType,
outstandingAmount: amt,
interestRate: parseFloat(liabRate) || 0,
emiAmount: parseFloat(liabEmi) || 0,
note: liabNote,
});
} else {
await addLiability({
name: liabName.trim(),
type: liabType,
outstandingAmount: amt,
currency: baseCurrency,
interestRate: parseFloat(liabRate) || 0,
emiAmount: parseFloat(liabEmi) || 0,
note: liabNote,
});
}
triggerHaptic('notificationSuccess');
await takeSnapshot(baseCurrency);
setLiabName(''); setLiabAmount(''); setLiabRate('');
setLiabEmi(''); setLiabNote(''); setEditingLiability(null);
liabilitySheetRef.current?.dismiss();
loadAll();
};
// ── Delete / Edit handlers ──────────────────────────────────────
const handleDeleteAsset = (asset: Asset) => {
Alert.alert('Delete Asset', `Remove "${asset.name}"?`, [
{text: 'Cancel', style: 'cancel'},
{text: 'Delete', style: 'destructive', onPress: () => {
triggerHaptic('impactMedium');
removeAsset(asset.id);
}},
]);
};
const handleDeleteLiability = (liab: Liability) => {
Alert.alert('Delete Liability', `Remove "${liab.name}"?`, [
{text: 'Cancel', style: 'cancel'},
{text: 'Delete', style: 'destructive', onPress: () => {
triggerHaptic('impactMedium');
removeLiability(liab.id);
}},
]);
};
const handleEditAsset = (asset: Asset) => {
setEditingAsset(asset);
setAssetName(asset.name);
setAssetType(asset.type as AssetType);
setAssetValue(asset.currentValue.toString());
setAssetNote(asset.note || '');
assetSheetRef.current?.present();
};
const handleEditLiability = (liab: Liability) => {
setEditingLiability(liab);
setLiabName(liab.name);
setLiabType(liab.type as LiabilityType);
setLiabAmount(liab.outstandingAmount.toString());
setLiabRate(liab.interestRate.toString());
setLiabEmi(liab.emiAmount.toString());
setLiabNote(liab.note || '');
liabilitySheetRef.current?.present();
};
const handleOpenAddAsset = () => {
setEditingAsset(null);
setAssetName(''); setAssetType('Bank'); setAssetValue(''); setAssetNote('');
assetSheetRef.current?.present();
};
const handleOpenAddLiability = () => {
setEditingLiability(null);
setLiabName(''); setLiabType('Home Loan'); setLiabAmount('');
setLiabRate(''); setLiabEmi(''); setLiabNote('');
liabilitySheetRef.current?.present();
};
// ── Render ──────────────────────────────────────────────────────
return (
<SafeAreaView style={s.screen} edges={['top', 'left', 'right']}>
<StatusBar
barStyle={theme.isDark ? 'light-content' : 'dark-content'}
backgroundColor={colors.background}
/>
<Animated.View entering={FadeIn.duration(300)} style={s.header}>
<Text style={s.headerTitle}>{t('netWorth.title')}</Text>
</Animated.View>
<ScrollView
style={{flex: 1}}
contentContainerStyle={{paddingBottom: 60 + insets.bottom}}
showsVerticalScrollIndicator={false}
refreshControl={<RefreshControl refreshing={isLoading} onRefresh={loadAll} />}>
{/* Hero Card */}
<Animated.View entering={FadeInDown.springify().damping(18)} style={s.heroCard}>
<Text style={s.heroLabel}>{t('dashboard.netWorth')}</Text>
<Text
style={[s.heroValue, {color: netWorth >= 0 ? colors.success : colors.error}]}
numberOfLines={1}
adjustsFontSizeToFit>
{formatCurrency(netWorth, baseCurrency)}
</Text>
<View style={s.heroSplit}>
<View style={s.heroSplitItem}>
<Icon name="trending-up" size={16} color={colors.success} />
<Text style={[s.heroSplitLabel, {color: colors.onSurfaceVariant}]}>Assets</Text>
<Text style={[s.heroSplitValue, {color: colors.success}]}>
{formatCurrency(totalAssets, baseCurrency)}
</Text>
</View>
<View style={[s.heroSplitDivider, {backgroundColor: colors.outlineVariant}]} />
<View style={s.heroSplitItem}>
<Icon name="trending-down" size={16} color={colors.error} />
<Text style={[s.heroSplitLabel, {color: colors.onSurfaceVariant}]}>Liabilities</Text>
<Text style={[s.heroSplitValue, {color: colors.error}]}>
{formatCurrency(totalLiabilities, baseCurrency)}
</Text>
</View>
</View>
</Animated.View>
{/* Chart */}
{lineData.length > 1 && (
<Animated.View entering={FadeInUp.duration(400).delay(100)}>
<SectionHeader title={t('netWorth.growth')} />
<View style={s.chartCard}>
<LineChart
data={lineData}
curved
color={colors.primary}
thickness={2}
dataPointsColor={colors.primary}
startFillColor={colors.primary}
endFillColor={colors.primary + '05'}
areaChart
yAxisTextStyle={{fontSize: 11, color: colors.onSurfaceVariant}}
xAxisLabelTextStyle={{fontSize: 11, color: colors.onSurfaceVariant}}
hideRules
yAxisThickness={0}
xAxisThickness={0}
noOfSections={4}
isAnimated
/>
</View>
</Animated.View>
)}
{/* Assets List */}
<Animated.View entering={FadeInUp.duration(400).delay(200)}>
<SectionHeader
title={t('netWorth.assets')}
actionLabel={t('common.add')}
onAction={handleOpenAddAsset}
/>
<View style={s.listCard}>
{assets.length > 0 ? (
assets.map((asset, idx) => {
const iconColor = ASSET_ICON_COLORS[asset.type as AssetType] || colors.primary;
const iconName = ASSET_ICONS[asset.type as AssetType] || 'dots-horizontal';
return (
<Pressable
key={asset.id}
style={[s.listItem, idx < assets.length - 1 && s.listItemBorder]}
onPress={() => handleEditAsset(asset)}
onLongPress={() => handleDeleteAsset(asset)}
android_ripple={{color: colors.primary + '12'}}>
<View style={[s.listIcon, {backgroundColor: iconColor + '1A'}]}>
<Icon name={iconName} size={20} color={iconColor} />
</View>
<View style={s.listDetails}>
<Text style={s.listName}>{asset.name}</Text>
<Text style={s.listType}>{asset.type}</Text>
</View>
<Text style={[s.listValue, {color: colors.success}]}>
{formatCurrency(asset.currentValue, baseCurrency)}
</Text>
</Pressable>
);
})
) : (
<EmptyState icon="wallet-plus" title={t('netWorth.noAssets')} />
)}
</View>
</Animated.View>
{/* Liabilities List */}
<Animated.View entering={FadeInUp.duration(400).delay(300)}>
<SectionHeader
title={t('netWorth.liabilities')}
actionLabel={t('common.add')}
onAction={handleOpenAddLiability}
/>
<View style={s.listCard}>
{liabilities.length > 0 ? (
liabilities.map((liab, idx) => {
const liabVisual = LIABILITY_ICON_COLORS[liab.type as LiabilityType] || LIABILITY_ICON_COLORS.Other;
return (
<Pressable
key={liab.id}
style={[s.listItem, idx < liabilities.length - 1 && s.listItemBorder]}
onPress={() => handleEditLiability(liab)}
onLongPress={() => handleDeleteLiability(liab)}
android_ripple={{color: colors.primary + '12'}}>
<View style={[s.listIcon, {backgroundColor: liabVisual.color + '1A'}]}>
<Icon name={liabVisual.icon} size={20} color={liabVisual.color} />
</View>
<View style={s.listDetails}>
<Text style={s.listName}>{liab.name}</Text>
<Text style={s.listType}>
{liab.type} · {liab.interestRate}% · EMI {formatCompact(liab.emiAmount, baseCurrency)}
</Text>
</View>
<Text style={[s.listValue, {color: colors.error}]}>
{formatCurrency(liab.outstandingAmount, baseCurrency)}
</Text>
</Pressable>
);
})
) : (
<EmptyState icon="credit-card-off" title={t('netWorth.noLiabilities')} />
)}
</View>
</Animated.View>
</ScrollView>
{/* ── Asset Bottom Sheet ─────────────────────────────────────── */}
<CustomBottomSheet
ref={assetSheetRef}
title={editingAsset ? 'Edit Asset' : t('netWorth.addAsset')}
snapPoints={['80%']}
headerLeft={{label: t('common.cancel'), onPress: () => assetSheetRef.current?.dismiss()}}
headerRight={{label: t('common.save'), onPress: handleSaveAsset}}>
<BottomSheetInput
label={t('netWorth.assetName')}
value={assetName}
onChangeText={setAssetName}
placeholder="e.g. HDFC Savings"
/>
<BottomSheetChipSelector
label={t('netWorth.assetType')}
options={assetTypeOptions}
selected={assetType}
onSelect={at => setAssetType(at as AssetType)}
/>
<BottomSheetInput
label={t('netWorth.currentValue')}
value={assetValue}
onChangeText={setAssetValue}
placeholder="0"
keyboardType="decimal-pad"
prefix={baseCurrency === 'INR' ? '\u20B9' : baseCurrency === 'USD' ? '$' : '\u20AC'}
/>
<BottomSheetInput
label="Note"
value={assetNote}
onChangeText={setAssetNote}
placeholder="Optional note"
/>
</CustomBottomSheet>
{/* ── Liability Bottom Sheet ─────────────────────────────────── */}
<CustomBottomSheet
ref={liabilitySheetRef}
title={editingLiability ? 'Edit Liability' : t('netWorth.addLiability')}
snapPoints={['90%']}
headerLeft={{label: t('common.cancel'), onPress: () => liabilitySheetRef.current?.dismiss()}}
headerRight={{label: t('common.save'), onPress: handleSaveLiability}}>
<BottomSheetInput
label={t('netWorth.liabilityName')}
value={liabName}
onChangeText={setLiabName}
placeholder="e.g. SBI Home Loan"
/>
<BottomSheetChipSelector
label={t('netWorth.liabilityType')}
options={liabTypeOptions}
selected={liabType}
onSelect={lt => setLiabType(lt as LiabilityType)}
/>
<BottomSheetInput
label={t('netWorth.outstandingAmount')}
value={liabAmount}
onChangeText={setLiabAmount}
placeholder="0"
keyboardType="decimal-pad"
prefix={baseCurrency === 'INR' ? '\u20B9' : baseCurrency === 'USD' ? '$' : '\u20AC'}
/>
<BottomSheetInput
label={t('netWorth.interestRate')}
value={liabRate}
onChangeText={setLiabRate}
placeholder="e.g. 8.5"
keyboardType="decimal-pad"
prefix="%"
/>
<BottomSheetInput
label={t('netWorth.emiAmount')}
value={liabEmi}
onChangeText={setLiabEmi}
placeholder="0"
keyboardType="decimal-pad"
prefix={baseCurrency === 'INR' ? '\u20B9' : baseCurrency === 'USD' ? '$' : '\u20AC'}
/>
<BottomSheetInput
label="Note"
value={liabNote}
onChangeText={setLiabNote}
placeholder="Optional note"
/>
</CustomBottomSheet>
</SafeAreaView>
);
};
export default NetWorthScreen;
// ─── Styles ────────────────────────────────────────────────────────────
function makeStyles(theme: MD3Theme) {
const {colors, typography, elevation, shape, spacing} = theme;
return StyleSheet.create({
screen: {flex: 1, backgroundColor: colors.background},
header: {
paddingHorizontal: spacing.xl,
paddingTop: spacing.lg,
paddingBottom: spacing.sm,
},
headerTitle: {
...typography.headlineMedium,
color: colors.onSurface,
fontWeight: '700',
},
heroCard: {
backgroundColor: colors.surfaceContainerLow,
marginHorizontal: spacing.xl,
marginTop: spacing.md,
borderRadius: shape.extraLarge,
padding: spacing.xl,
...elevation.level2,
},
heroLabel: {
...typography.labelSmall,
color: colors.onSurfaceVariant,
textTransform: 'uppercase',
letterSpacing: 1,
},
heroValue: {
...typography.displaySmall,
fontWeight: '800',
marginTop: spacing.xs,
},
heroSplit: {
flexDirection: 'row',
marginTop: spacing.lg,
paddingTop: spacing.lg,
borderTopWidth: 1,
borderTopColor: colors.outlineVariant + '40',
},
heroSplitItem: {
flex: 1,
alignItems: 'center',
gap: 2,
},
heroSplitDivider: {
width: 1,
marginHorizontal: spacing.md,
},
heroSplitLabel: {
...typography.bodySmall,
},
heroSplitValue: {
...typography.titleSmall,
fontWeight: '700',
},
chartCard: {
backgroundColor: colors.surfaceContainerLow,
marginHorizontal: spacing.xl,
borderRadius: shape.large,
padding: spacing.xl,
alignItems: 'center',
...elevation.level1,
},
listCard: {
backgroundColor: colors.surfaceContainerLow,
marginHorizontal: spacing.xl,
borderRadius: shape.large,
overflow: 'hidden',
...elevation.level1,
},
listItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.lg,
paddingHorizontal: spacing.lg,
},
listItemBorder: {
borderBottomWidth: 1,
borderBottomColor: colors.outlineVariant + '30',
},
listIcon: {
width: 42,
height: 42,
borderRadius: 21,
justifyContent: 'center',
alignItems: 'center',
},
listDetails: {flex: 1, marginLeft: spacing.md},
listName: {
...typography.bodyLarge,
color: colors.onSurface,
fontWeight: '600',
},
listType: {
...typography.bodySmall,
color: colors.onSurfaceVariant,
marginTop: 2,
},
listValue: {
...typography.titleSmall,
fontWeight: '700',
},
});
}

View File

@@ -0,0 +1,379 @@
/**
* SettingsScreen — Refactored with MD3 theme and CustomBottomSheet
* for selection dialogs (replaces system Alert-based selectors).
*/
import React, {useRef} from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
Pressable,
Alert,
StatusBar,
} from 'react-native';
import {SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
import {useTranslation} from 'react-i18next';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import Animated, {FadeIn, FadeInDown} from 'react-native-reanimated';
import {
CustomBottomSheet,
triggerHaptic,
} from '../components';
import type {CustomBottomSheetHandle} from '../components';
import {useSettingsStore} from '../store';
import {useTheme} from '../theme';
import type {MD3Theme} from '../theme';
import {Currency} from '../types';
const CURRENCIES: {label: string; value: Currency; icon: string}[] = [
{label: 'Indian Rupee (\u20B9)', value: 'INR', icon: 'currency-inr'},
{label: 'US Dollar ($)', value: 'USD', icon: 'currency-usd'},
{label: 'Euro (\u20AC)', value: 'EUR', icon: 'currency-eur'},
{label: 'British Pound (\u00A3)', value: 'GBP', icon: 'currency-gbp'},
];
const THEMES: {label: string; value: 'light' | 'dark' | 'system'; icon: string}[] = [
{label: 'Light', value: 'light', icon: 'white-balance-sunny'},
{label: 'Dark', value: 'dark', icon: 'moon-waning-crescent'},
{label: 'System', value: 'system', icon: 'theme-light-dark'},
];
// ── Extracted SettingsRow for lint compliance ──────────────────────
interface SettingsRowProps {
icon: string;
iconColor?: string;
label: string;
value?: string;
onPress?: () => void;
destructive?: boolean;
theme: MD3Theme;
}
const SettingsRow: React.FC<SettingsRowProps> = ({
icon,
iconColor,
label,
value,
onPress,
destructive,
theme: thm,
}) => {
const {colors, typography, shape, spacing} = thm;
return (
<Pressable
style={{
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.lg,
paddingHorizontal: spacing.lg,
}}
onPress={onPress}
disabled={!onPress}
android_ripple={{color: colors.primary + '12'}}>
<View
style={{
width: 36,
height: 36,
borderRadius: shape.small,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: (iconColor || colors.primary) + '14',
marginRight: spacing.md,
}}>
<Icon name={icon} size={20} color={iconColor || colors.primary} />
</View>
<Text
style={{
flex: 1,
...typography.bodyLarge,
color: destructive ? colors.error : colors.onSurface,
}}>
{label}
</Text>
{value ? (
<Text
style={{
...typography.bodyMedium,
color: colors.onSurfaceVariant,
marginRight: spacing.xs,
}}>
{value}
</Text>
) : null}
{onPress ? (
<Icon name="chevron-right" size={20} color={colors.onSurfaceVariant} />
) : null}
</Pressable>
);
};
// ── Main Component ────────────────────────────────────────────────
const SettingsScreen: React.FC = () => {
const {t} = useTranslation();
const {baseCurrency, setBaseCurrency, theme: themeSetting, setTheme} = useSettingsStore();
const theme = useTheme();
const s = makeStyles(theme);
const {colors} = theme;
const insets = useSafeAreaInsets();
const currencySheetRef = useRef<CustomBottomSheetHandle>(null);
const themeSheetRef = useRef<CustomBottomSheetHandle>(null);
const handleClearData = () => {
Alert.alert(
t('settings.clearData'),
t('settings.clearDataConfirm'),
[
{text: t('common.cancel'), style: 'cancel'},
{
text: t('common.confirm'),
style: 'destructive',
onPress: async () => {
triggerHaptic('impactMedium');
Alert.alert('Done', 'All data has been cleared.');
},
},
],
);
};
return (
<SafeAreaView style={s.screen} edges={['top', 'left', 'right']}>
<StatusBar
barStyle={theme.isDark ? 'light-content' : 'dark-content'}
backgroundColor={colors.background}
/>
<Animated.View entering={FadeIn.duration(300)} style={s.header}>
<Text style={s.headerTitle}>{t('settings.title')}</Text>
</Animated.View>
<ScrollView
style={s.scrollView}
contentContainerStyle={{paddingBottom: 60 + insets.bottom}}
showsVerticalScrollIndicator={false}>
{/* General */}
<Animated.View entering={FadeInDown.duration(400).delay(100)}>
<Text style={s.sectionTitle}>{t('settings.general')}</Text>
<View style={s.sectionCard}>
<SettingsRow
theme={theme}
icon="currency-inr"
label={t('settings.baseCurrency')}
value={baseCurrency}
onPress={() => currencySheetRef.current?.present()}
/>
<View style={s.divider} />
<SettingsRow
theme={theme}
icon="translate"
iconColor="#7E57C2"
label={t('settings.language')}
value="English"
/>
<View style={s.divider} />
<SettingsRow
theme={theme}
icon="theme-light-dark"
iconColor="#E65100"
label={t('settings.theme')}
value={themeSetting.charAt(0).toUpperCase() + themeSetting.slice(1)}
onPress={() => themeSheetRef.current?.present()}
/>
</View>
</Animated.View>
{/* Data */}
<Animated.View entering={FadeInDown.duration(400).delay(200)}>
<Text style={s.sectionTitle}>{t('settings.data')}</Text>
<View style={s.sectionCard}>
<SettingsRow
theme={theme}
icon="export"
iconColor={colors.success}
label={t('settings.exportData')}
onPress={() => Alert.alert('Coming Soon', 'Export functionality will be available in a future release.')}
/>
<View style={s.divider} />
<SettingsRow
theme={theme}
icon="import"
iconColor="#1E88E5"
label={t('settings.importData')}
onPress={() => Alert.alert('Coming Soon', 'Import functionality will be available in a future release.')}
/>
<View style={s.divider} />
<SettingsRow
theme={theme}
icon="delete-forever"
iconColor={colors.error}
label={t('settings.clearData')}
onPress={handleClearData}
destructive
/>
</View>
</Animated.View>
{/* About */}
<Animated.View entering={FadeInDown.duration(400).delay(300)}>
<Text style={s.sectionTitle}>{t('settings.about')}</Text>
<View style={s.sectionCard}>
<SettingsRow
theme={theme}
icon="information"
iconColor="#7E57C2"
label={t('settings.version')}
value="0.1.0 Alpha"
/>
</View>
</Animated.View>
<Text style={s.footer}>
{t('settings.appName')} - Made by WebArk
</Text>
</ScrollView>
{/* Currency Selection Bottom Sheet */}
<CustomBottomSheet
ref={currencySheetRef}
title={t('settings.baseCurrency')}
enableDynamicSizing
snapPoints={['40%']}>
{CURRENCIES.map(c => (
<Pressable
key={c.value}
style={[
s.selectionRow,
c.value === baseCurrency && {backgroundColor: colors.primaryContainer},
]}
onPress={() => {
triggerHaptic('selection');
setBaseCurrency(c.value);
currencySheetRef.current?.dismiss();
}}>
<Icon
name={c.icon}
size={20}
color={c.value === baseCurrency ? colors.primary : colors.onSurfaceVariant}
/>
<Text
style={[
s.selectionLabel,
c.value === baseCurrency && {color: colors.onPrimaryContainer, fontWeight: '600'},
]}>
{c.label}
</Text>
{c.value === baseCurrency && (
<Icon name="check" size={20} color={colors.primary} />
)}
</Pressable>
))}
</CustomBottomSheet>
{/* Theme Selection Bottom Sheet */}
<CustomBottomSheet
ref={themeSheetRef}
title={t('settings.theme')}
enableDynamicSizing
snapPoints={['35%']}>
{THEMES.map(th => (
<Pressable
key={th.value}
style={[
s.selectionRow,
th.value === themeSetting && {backgroundColor: colors.primaryContainer},
]}
onPress={() => {
triggerHaptic('selection');
setTheme(th.value);
themeSheetRef.current?.dismiss();
}}>
<Icon
name={th.icon}
size={20}
color={th.value === themeSetting ? colors.primary : colors.onSurfaceVariant}
/>
<Text
style={[
s.selectionLabel,
th.value === themeSetting && {color: colors.onPrimaryContainer, fontWeight: '600'},
]}>
{th.label}
</Text>
{th.value === themeSetting && (
<Icon name="check" size={20} color={colors.primary} />
)}
</Pressable>
))}
</CustomBottomSheet>
</SafeAreaView>
);
};
export default SettingsScreen;
// ─── Styles ────────────────────────────────────────────────────────────
function makeStyles(theme: MD3Theme) {
const {colors, typography, elevation, shape, spacing} = theme;
return StyleSheet.create({
screen: {flex: 1, backgroundColor: colors.background},
header: {
paddingHorizontal: spacing.xl,
paddingTop: spacing.lg,
paddingBottom: spacing.sm,
},
headerTitle: {
...typography.headlineMedium,
color: colors.onSurface,
fontWeight: '700',
},
scrollView: {flex: 1, paddingHorizontal: spacing.xl},
sectionTitle: {
...typography.labelSmall,
color: colors.onSurfaceVariant,
textTransform: 'uppercase',
letterSpacing: 0.8,
marginTop: spacing.xxl,
marginBottom: spacing.sm,
marginLeft: spacing.xs,
},
sectionCard: {
backgroundColor: colors.surfaceContainerLow,
borderRadius: shape.large,
overflow: 'hidden',
...elevation.level1,
},
divider: {
height: 1,
backgroundColor: colors.outlineVariant + '30',
marginLeft: 64,
},
footer: {
textAlign: 'center',
...typography.bodySmall,
color: colors.onSurfaceVariant,
marginTop: spacing.xxxl,
marginBottom: spacing.xxxl + 8,
},
selectionRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.lg,
paddingHorizontal: spacing.lg,
borderRadius: shape.medium,
marginBottom: spacing.xs,
},
selectionLabel: {
flex: 1,
...typography.bodyLarge,
color: colors.onSurface,
},
});
}

5
src/screens/index.ts Normal file
View File

@@ -0,0 +1,5 @@
export {default as DashboardScreen} from './DashboardScreen';
export {default as ModernDashboard} from './ModernDashboard';
export {default as ExpensesScreen} from './ExpensesScreen';
export {default as NetWorthScreen} from './NetWorthScreen';
export {default as SettingsScreen} from './SettingsScreen';

257
src/store/expenseStore.ts Normal file
View File

@@ -0,0 +1,257 @@
import {create} from 'zustand';
import {
Transaction,
Category,
Currency,
TransactionType,
PaymentMethod,
NetWorthTargetType,
ImpactOperation,
} from '../types';
import {
getTransactions,
getTransactionById,
insertTransaction,
updateTransaction as updateTxnDb,
deleteTransaction as deleteTxnDb,
getMonthlyTotals,
getCategories,
seedDefaultCategories,
getSpendingByCategory,
getMonthlySpendingTrend,
insertAsset,
insertLiability,
saveTransactionImpact,
getTransactionImpact,
deleteTransactionImpact,
applyTargetImpact,
reverseTargetImpact,
} from '../db';
interface TransactionImpactInput {
targetType: NetWorthTargetType;
targetId?: string;
operation: ImpactOperation;
createNew?: {
name: string;
type: string;
note?: string;
};
}
interface ExpenseState {
transactions: Transaction[];
categories: Category[];
monthlyExpense: number;
monthlyIncome: number;
isLoading: boolean;
spendingByCategory: {categoryName: string; categoryColor: string; categoryIcon: string; total: number}[];
monthlyTrend: {month: string; total: number}[];
// Actions
initialize: () => Promise<void>;
loadTransactions: (options?: {
type?: TransactionType;
fromDate?: string;
toDate?: string;
limit?: number;
}) => Promise<void>;
addTransaction: (txn: {
amount: number;
currency: Currency;
type: TransactionType;
categoryId: string;
paymentMethod: PaymentMethod;
note: string;
date: string;
impact?: TransactionImpactInput;
}) => Promise<void>;
editTransaction: (id: string, txn: Partial<Transaction>, impact?: TransactionImpactInput) => Promise<void>;
removeTransaction: (id: string) => Promise<void>;
loadMonthlyStats: () => Promise<void>;
loadSpendingAnalytics: () => Promise<void>;
}
export const useExpenseStore = create<ExpenseState>((set, get) => ({
transactions: [],
categories: [],
monthlyExpense: 0,
monthlyIncome: 0,
isLoading: false,
spendingByCategory: [],
monthlyTrend: [],
initialize: async () => {
set({isLoading: true});
try {
await seedDefaultCategories();
const categories = await getCategories();
set({categories, isLoading: false});
} catch (error) {
console.error('Failed to initialize expense store:', error);
set({isLoading: false});
}
},
loadTransactions: async (options) => {
set({isLoading: true});
try {
const transactions = await getTransactions(options);
set({transactions, isLoading: false});
} catch (error) {
console.error('Failed to load transactions:', error);
set({isLoading: false});
}
},
addTransaction: async (txn) => {
const {impact, ...transactionPayload} = txn;
const transactionId = await insertTransaction(transactionPayload);
if (impact) {
let targetId = impact.targetId;
if (!targetId && impact.createNew) {
if (impact.targetType === 'asset') {
targetId = await insertAsset({
name: impact.createNew.name,
type: impact.createNew.type as any,
currentValue: 0,
currency: txn.currency,
note: impact.createNew.note || '',
});
} else {
targetId = await insertLiability({
name: impact.createNew.name,
type: impact.createNew.type as any,
outstandingAmount: 0,
currency: txn.currency,
interestRate: 0,
emiAmount: 0,
note: impact.createNew.note || '',
});
}
}
if (targetId) {
await applyTargetImpact(impact.targetType, targetId, impact.operation, txn.amount);
await saveTransactionImpact({
transactionId,
targetType: impact.targetType,
targetId,
operation: impact.operation,
});
}
}
// Reload current transactions and monthly stats
await get().loadTransactions({limit: 50});
await get().loadMonthlyStats();
},
editTransaction: async (id, txn, impact) => {
const oldTransaction = await getTransactionById(id);
const oldImpact = await getTransactionImpact(id);
if (oldTransaction && oldImpact) {
await reverseTargetImpact(oldImpact, oldTransaction.amount);
}
await updateTxnDb(id, txn);
const updatedTransaction = await getTransactionById(id);
if (updatedTransaction) {
if (impact) {
let targetId = impact.targetId;
if (!targetId && impact.createNew) {
if (impact.targetType === 'asset') {
targetId = await insertAsset({
name: impact.createNew.name,
type: impact.createNew.type as any,
currentValue: 0,
currency: updatedTransaction.currency,
note: impact.createNew.note || '',
});
} else {
targetId = await insertLiability({
name: impact.createNew.name,
type: impact.createNew.type as any,
outstandingAmount: 0,
currency: updatedTransaction.currency,
interestRate: 0,
emiAmount: 0,
note: impact.createNew.note || '',
});
}
}
if (targetId) {
await applyTargetImpact(impact.targetType, targetId, impact.operation, updatedTransaction.amount);
await saveTransactionImpact({
transactionId: id,
targetType: impact.targetType,
targetId,
operation: impact.operation,
});
}
} else if (oldImpact) {
await applyTargetImpact(oldImpact.targetType, oldImpact.targetId, oldImpact.operation, updatedTransaction.amount);
await saveTransactionImpact({
transactionId: id,
targetType: oldImpact.targetType,
targetId: oldImpact.targetId,
operation: oldImpact.operation,
});
}
}
await get().loadTransactions({limit: 50});
await get().loadMonthlyStats();
},
removeTransaction: async (id) => {
const transaction = await getTransactionById(id);
const impact = await getTransactionImpact(id);
if (transaction && impact) {
await reverseTargetImpact(impact, transaction.amount);
await deleteTransactionImpact(id);
}
await deleteTxnDb(id);
await get().loadTransactions({limit: 50});
await get().loadMonthlyStats();
},
loadMonthlyStats: async () => {
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
const [expense, income] = await Promise.all([
getMonthlyTotals('expense', year, month),
getMonthlyTotals('income', year, month),
]);
set({monthlyExpense: expense, monthlyIncome: income});
},
loadSpendingAnalytics: async () => {
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
const startDate = `${year}-${String(month).padStart(2, '0')}-01`;
const endDate =
month === 12
? `${year + 1}-01-01`
: `${year}-${String(month + 1).padStart(2, '0')}-01`;
const [byCategory, trend] = await Promise.all([
getSpendingByCategory(startDate, endDate),
getMonthlySpendingTrend(6),
]);
set({spendingByCategory: byCategory, monthlyTrend: trend});
},
}));

4
src/store/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export {useSettingsStore} from './settingsStore';
export {useNetWorthStore} from './netWorthStore';
export {useExpenseStore} from './expenseStore';
export {mmkvStorage} from './mmkv';

72
src/store/mmkv.ts Normal file
View File

@@ -0,0 +1,72 @@
import {createMMKV, type MMKV} from 'react-native-mmkv';
import {AppSettings, Currency} from '../types';
let _storage: MMKV | null = null;
function getStorage(): MMKV {
if (!_storage) {
_storage = createMMKV({id: 'expensso-settings'});
}
return _storage;
}
// ─── Keys ────────────────────────────────────────────────────────────
const KEYS = {
BASE_CURRENCY: 'settings.baseCurrency',
LOCALE: 'settings.locale',
THEME: 'settings.theme',
BIOMETRIC: 'settings.biometricEnabled',
ONBOARDING: 'settings.onboardingComplete',
} as const;
// ─── Typed Getters / Setters ─────────────────────────────────────────
export const mmkvStorage = {
getBaseCurrency: (): Currency => {
return (getStorage().getString(KEYS.BASE_CURRENCY) as Currency) || 'INR';
},
setBaseCurrency: (currency: Currency) => {
getStorage().set(KEYS.BASE_CURRENCY, currency);
},
getLocale: (): string => {
return getStorage().getString(KEYS.LOCALE) || 'en';
},
setLocale: (locale: string) => {
getStorage().set(KEYS.LOCALE, locale);
},
getTheme: (): 'light' | 'dark' | 'system' => {
return (getStorage().getString(KEYS.THEME) as AppSettings['theme']) || 'light';
},
setTheme: (theme: AppSettings['theme']) => {
getStorage().set(KEYS.THEME, theme);
},
getBiometric: (): boolean => {
return getStorage().getBoolean(KEYS.BIOMETRIC) || false;
},
setBiometric: (enabled: boolean) => {
getStorage().set(KEYS.BIOMETRIC, enabled);
},
getOnboardingComplete: (): boolean => {
return getStorage().getBoolean(KEYS.ONBOARDING) || false;
},
setOnboardingComplete: (complete: boolean) => {
getStorage().set(KEYS.ONBOARDING, complete);
},
getAllSettings: (): AppSettings => ({
baseCurrency: mmkvStorage.getBaseCurrency(),
locale: mmkvStorage.getLocale(),
theme: mmkvStorage.getTheme(),
biometricEnabled: mmkvStorage.getBiometric(),
onboardingComplete: mmkvStorage.getOnboardingComplete(),
}),
clearAll: () => {
getStorage().clearAll();
},
};

101
src/store/netWorthStore.ts Normal file
View File

@@ -0,0 +1,101 @@
import {create} from 'zustand';
import {Asset, Liability, Currency} from '../types';
import {
getAssets,
getLiabilities,
insertAsset,
insertLiability,
updateAsset as updateAssetDb,
updateLiability as updateLiabilityDb,
deleteAsset as deleteAssetDb,
deleteLiability as deleteLiabilityDb,
getTotalAssets,
getTotalLiabilities,
saveNetWorthSnapshot,
} from '../db';
interface NetWorthState {
assets: Asset[];
liabilities: Liability[];
totalAssets: number;
totalLiabilities: number;
netWorth: number;
isLoading: boolean;
// Actions
loadNetWorth: () => Promise<void>;
addAsset: (asset: Omit<Asset, 'id' | 'createdAt' | 'lastUpdated'>) => Promise<void>;
editAsset: (id: string, asset: Partial<Omit<Asset, 'id' | 'createdAt'>>) => Promise<void>;
removeAsset: (id: string) => Promise<void>;
addLiability: (liability: Omit<Liability, 'id' | 'createdAt' | 'lastUpdated'>) => Promise<void>;
editLiability: (id: string, liability: Partial<Omit<Liability, 'id' | 'createdAt'>>) => Promise<void>;
removeLiability: (id: string) => Promise<void>;
takeSnapshot: (currency: Currency) => Promise<void>;
}
export const useNetWorthStore = create<NetWorthState>((set, get) => ({
assets: [],
liabilities: [],
totalAssets: 0,
totalLiabilities: 0,
netWorth: 0,
isLoading: false,
loadNetWorth: async () => {
set({isLoading: true});
try {
const [assets, liabilities, totalA, totalL] = await Promise.all([
getAssets(),
getLiabilities(),
getTotalAssets(),
getTotalLiabilities(),
]);
set({
assets,
liabilities,
totalAssets: totalA,
totalLiabilities: totalL,
netWorth: totalA - totalL,
isLoading: false,
});
} catch (error) {
console.error('Failed to load net worth:', error);
set({isLoading: false});
}
},
addAsset: async (asset) => {
await insertAsset(asset);
await get().loadNetWorth();
},
editAsset: async (id, asset) => {
await updateAssetDb(id, asset);
await get().loadNetWorth();
},
removeAsset: async (id) => {
await deleteAssetDb(id);
await get().loadNetWorth();
},
addLiability: async (liability) => {
await insertLiability(liability);
await get().loadNetWorth();
},
editLiability: async (id, liability) => {
await updateLiabilityDb(id, liability);
await get().loadNetWorth();
},
removeLiability: async (id) => {
await deleteLiabilityDb(id);
await get().loadNetWorth();
},
takeSnapshot: async (currency) => {
const {totalAssets, totalLiabilities} = get();
await saveNetWorthSnapshot(totalAssets, totalLiabilities, currency);
},
}));

View File

@@ -0,0 +1,53 @@
import {create} from 'zustand';
import {AppSettings, Currency} from '../types';
import {mmkvStorage} from './mmkv';
interface SettingsState extends AppSettings {
// Actions
setBaseCurrency: (currency: Currency) => void;
setLocale: (locale: string) => void;
setTheme: (theme: AppSettings['theme']) => void;
setBiometric: (enabled: boolean) => void;
setOnboardingComplete: (complete: boolean) => void;
hydrate: () => void;
}
export const useSettingsStore = create<SettingsState>((set) => ({
// Default state (hydrated from MMKV on app load)
baseCurrency: 'INR',
locale: 'en',
theme: 'light',
biometricEnabled: false,
onboardingComplete: false,
// Actions persist to MMKV and update zustand state simultaneously
setBaseCurrency: (currency: Currency) => {
mmkvStorage.setBaseCurrency(currency);
set({baseCurrency: currency});
},
setLocale: (locale: string) => {
mmkvStorage.setLocale(locale);
set({locale});
},
setTheme: (theme: AppSettings['theme']) => {
mmkvStorage.setTheme(theme);
set({theme});
},
setBiometric: (enabled: boolean) => {
mmkvStorage.setBiometric(enabled);
set({biometricEnabled: enabled});
},
setOnboardingComplete: (complete: boolean) => {
mmkvStorage.setOnboardingComplete(complete);
set({onboardingComplete: complete});
},
hydrate: () => {
const settings = mmkvStorage.getAllSettings();
set(settings);
},
}));

View File

@@ -0,0 +1,36 @@
/**
* MD3 ThemeProvider — React Context bridge between zustand settings and MD3 tokens.
*
* Wraps the app and exposes `useTheme()` which returns the fully resolved
* MD3Theme object (colors, typography, elevation, shape, spacing).
*/
import React, {createContext, useContext, useMemo} from 'react';
import {useColorScheme} from 'react-native';
import {useSettingsStore} from '../store/settingsStore';
import {LightTheme, DarkTheme} from './md3';
import type {MD3Theme} from './md3';
const ThemeContext = createContext<MD3Theme>(LightTheme);
export const ThemeProvider: React.FC<{children: React.ReactNode}> = ({children}) => {
const themeSetting = useSettingsStore(s => s.theme);
const systemScheme = useColorScheme();
const resolvedTheme = useMemo<MD3Theme>(() => {
if (themeSetting === 'dark') return DarkTheme;
if (themeSetting === 'light') return LightTheme;
return systemScheme === 'dark' ? DarkTheme : LightTheme;
}, [themeSetting, systemScheme]);
return (
<ThemeContext.Provider value={resolvedTheme}>
{children}
</ThemeContext.Provider>
);
};
/**
* Hook to access the full MD3 theme from anywhere in the tree.
*/
export const useTheme = (): MD3Theme => useContext(ThemeContext);

12
src/theme/index.ts Normal file
View File

@@ -0,0 +1,12 @@
export {
MD3LightColors,
MD3DarkColors,
MD3Typography,
MD3Elevation,
MD3Shape,
Spacing,
LightTheme,
DarkTheme,
} from './md3';
export type {MD3Theme, MD3ColorScheme} from './md3';
export {ThemeProvider, useTheme} from './ThemeProvider';

380
src/theme/md3.ts Normal file
View File

@@ -0,0 +1,380 @@
/**
* Material Design 3 Theme System for Expensso
*
* Implements full MD3 color roles, typography scales, elevation,
* and shape tokens with "Material You"style palette.
*/
// ─── MD3 Color Palette ───────────────────────────────────────────────
export const MD3LightColors = {
// Primary
primary: '#6750A4',
onPrimary: '#FFFFFF',
primaryContainer: '#EADDFF',
onPrimaryContainer: '#21005D',
// Secondary
secondary: '#625B71',
onSecondary: '#FFFFFF',
secondaryContainer: '#E8DEF8',
onSecondaryContainer: '#1D192B',
// Tertiary (Fintech Teal)
tertiary: '#00897B',
onTertiary: '#FFFFFF',
tertiaryContainer: '#A7F3D0',
onTertiaryContainer: '#00382E',
// Error
error: '#B3261E',
onError: '#FFFFFF',
errorContainer: '#F9DEDC',
onErrorContainer: '#410E0B',
// Success (custom MD3 extension)
success: '#1B873B',
onSuccess: '#FFFFFF',
successContainer: '#D4EDDA',
onSuccessContainer: '#0A3D1B',
// Warning (custom MD3 extension)
warning: '#E65100',
onWarning: '#FFFFFF',
warningContainer: '#FFE0B2',
onWarningContainer: '#3E2723',
// Surface
background: '#FFFBFE',
onBackground: '#1C1B1F',
surface: '#FFFBFE',
onSurface: '#1C1B1F',
surfaceVariant: '#E7E0EC',
onSurfaceVariant: '#49454F',
surfaceDim: '#DED8E1',
surfaceBright: '#FFF8FF',
surfaceContainerLowest: '#FFFFFF',
surfaceContainerLow: '#F7F2FA',
surfaceContainer: '#F3EDF7',
surfaceContainerHigh: '#ECE6F0',
surfaceContainerHighest: '#E6E0E9',
// Outline
outline: '#79747E',
outlineVariant: '#CAC4D0',
// Inverse
inverseSurface: '#313033',
inverseOnSurface: '#F4EFF4',
inversePrimary: '#D0BCFF',
// Scrim & Shadow
scrim: '#000000',
shadow: '#000000',
// ─── App-Specific Semantic Colors ─────────────────────────────
income: '#1B873B',
expense: '#B3261E',
asset: '#6750A4',
liability: '#E65100',
// Chart palette (MD3 tonal)
chartColors: [
'#6750A4', '#00897B', '#1E88E5', '#E65100',
'#8E24AA', '#00ACC1', '#43A047', '#F4511E',
'#5C6BC0', '#FFB300',
],
};
export const MD3DarkColors: typeof MD3LightColors = {
// Primary
primary: '#D0BCFF',
onPrimary: '#381E72',
primaryContainer: '#4F378B',
onPrimaryContainer: '#EADDFF',
// Secondary
secondary: '#CCC2DC',
onSecondary: '#332D41',
secondaryContainer: '#4A4458',
onSecondaryContainer: '#E8DEF8',
// Tertiary
tertiary: '#4DB6AC',
onTertiary: '#003730',
tertiaryContainer: '#005048',
onTertiaryContainer: '#A7F3D0',
// Error
error: '#F2B8B5',
onError: '#601410',
errorContainer: '#8C1D18',
onErrorContainer: '#F9DEDC',
// Success
success: '#81C784',
onSuccess: '#0A3D1B',
successContainer: '#1B5E20',
onSuccessContainer: '#D4EDDA',
// Warning
warning: '#FFB74D',
onWarning: '#3E2723',
warningContainer: '#BF360C',
onWarningContainer: '#FFE0B2',
// Surface
background: '#141218',
onBackground: '#E6E0E9',
surface: '#141218',
onSurface: '#E6E0E9',
surfaceVariant: '#49454F',
onSurfaceVariant: '#CAC4D0',
surfaceDim: '#141218',
surfaceBright: '#3B383E',
surfaceContainerLowest: '#0F0D13',
surfaceContainerLow: '#1D1B20',
surfaceContainer: '#211F26',
surfaceContainerHigh: '#2B2930',
surfaceContainerHighest: '#36343B',
// Outline
outline: '#938F99',
outlineVariant: '#49454F',
// Inverse
inverseSurface: '#E6E0E9',
inverseOnSurface: '#313033',
inversePrimary: '#6750A4',
// Scrim & Shadow
scrim: '#000000',
shadow: '#000000',
// App-Specific
income: '#81C784',
expense: '#F2B8B5',
asset: '#D0BCFF',
liability: '#FFB74D',
chartColors: [
'#D0BCFF', '#4DB6AC', '#64B5F6', '#FFB74D',
'#CE93D8', '#4DD0E1', '#81C784', '#FF8A65',
'#9FA8DA', '#FFD54F',
],
};
// ─── MD3 Typography Scale ────────────────────────────────────────────
const fontFamily = 'System'; // Falls back to Roboto on Android, SF Pro on iOS
export const MD3Typography = {
displayLarge: {
fontFamily,
fontSize: 57,
fontWeight: '400' as const,
lineHeight: 64,
letterSpacing: -0.25,
},
displayMedium: {
fontFamily,
fontSize: 45,
fontWeight: '400' as const,
lineHeight: 52,
letterSpacing: 0,
},
displaySmall: {
fontFamily,
fontSize: 36,
fontWeight: '400' as const,
lineHeight: 44,
letterSpacing: 0,
},
headlineLarge: {
fontFamily,
fontSize: 32,
fontWeight: '400' as const,
lineHeight: 40,
letterSpacing: 0,
},
headlineMedium: {
fontFamily,
fontSize: 28,
fontWeight: '400' as const,
lineHeight: 36,
letterSpacing: 0,
},
headlineSmall: {
fontFamily,
fontSize: 24,
fontWeight: '400' as const,
lineHeight: 32,
letterSpacing: 0,
},
titleLarge: {
fontFamily,
fontSize: 22,
fontWeight: '500' as const,
lineHeight: 28,
letterSpacing: 0,
},
titleMedium: {
fontFamily,
fontSize: 16,
fontWeight: '500' as const,
lineHeight: 24,
letterSpacing: 0.15,
},
titleSmall: {
fontFamily,
fontSize: 14,
fontWeight: '500' as const,
lineHeight: 20,
letterSpacing: 0.1,
},
bodyLarge: {
fontFamily,
fontSize: 16,
fontWeight: '400' as const,
lineHeight: 24,
letterSpacing: 0.5,
},
bodyMedium: {
fontFamily,
fontSize: 14,
fontWeight: '400' as const,
lineHeight: 20,
letterSpacing: 0.25,
},
bodySmall: {
fontFamily,
fontSize: 12,
fontWeight: '400' as const,
lineHeight: 16,
letterSpacing: 0.4,
},
labelLarge: {
fontFamily,
fontSize: 14,
fontWeight: '500' as const,
lineHeight: 20,
letterSpacing: 0.1,
},
labelMedium: {
fontFamily,
fontSize: 12,
fontWeight: '500' as const,
lineHeight: 16,
letterSpacing: 0.5,
},
labelSmall: {
fontFamily,
fontSize: 11,
fontWeight: '500' as const,
lineHeight: 16,
letterSpacing: 0.5,
},
};
// ─── MD3 Elevation (Shadow Presets) ──────────────────────────────────
export const MD3Elevation = {
level0: {
shadowColor: 'transparent',
shadowOffset: {width: 0, height: 0},
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
},
level1: {
shadowColor: '#000',
shadowOffset: {width: 0, height: 1},
shadowOpacity: 0.05,
shadowRadius: 3,
elevation: 1,
},
level2: {
shadowColor: '#000',
shadowOffset: {width: 0, height: 2},
shadowOpacity: 0.08,
shadowRadius: 6,
elevation: 3,
},
level3: {
shadowColor: '#000',
shadowOffset: {width: 0, height: 4},
shadowOpacity: 0.11,
shadowRadius: 8,
elevation: 6,
},
level4: {
shadowColor: '#000',
shadowOffset: {width: 0, height: 6},
shadowOpacity: 0.14,
shadowRadius: 10,
elevation: 8,
},
level5: {
shadowColor: '#000',
shadowOffset: {width: 0, height: 8},
shadowOpacity: 0.17,
shadowRadius: 12,
elevation: 12,
},
};
// ─── MD3 Shape (Corner Radii) ────────────────────────────────────────
export const MD3Shape = {
none: 0,
extraSmall: 4,
small: 8,
medium: 12,
large: 16,
extraLarge: 28,
full: 9999,
};
// ─── Spacing Scale ───────────────────────────────────────────────────
export const Spacing = {
xs: 4,
sm: 8,
md: 12,
lg: 16,
xl: 20,
xxl: 24,
xxxl: 32,
};
// ─── Composite Theme Object ─────────────────────────────────────────
export type MD3ColorScheme = typeof MD3LightColors;
export interface MD3Theme {
colors: MD3ColorScheme;
typography: typeof MD3Typography;
elevation: typeof MD3Elevation;
shape: typeof MD3Shape;
spacing: typeof Spacing;
isDark: boolean;
}
export const LightTheme: MD3Theme = {
colors: MD3LightColors,
typography: MD3Typography,
elevation: MD3Elevation,
shape: MD3Shape,
spacing: Spacing,
isDark: false,
};
export const DarkTheme: MD3Theme = {
colors: MD3DarkColors,
typography: MD3Typography,
elevation: MD3Elevation,
shape: MD3Shape,
spacing: Spacing,
isDark: true,
};

118
src/types/index.ts Normal file
View File

@@ -0,0 +1,118 @@
// ─── Core Domain Types ───────────────────────────────────────────────
export type Currency = 'INR' | 'USD' | 'EUR' | 'GBP';
export type PaymentMethod = 'UPI' | 'Cash' | 'Credit Card' | 'Debit Card' | 'Digital Wallet' | 'Net Banking' | 'Other';
export type AssetType = 'Bank' | 'Stocks' | 'Gold' | 'EPF' | 'Real Estate' | 'Mutual Funds' | 'Fixed Deposit' | 'PPF' | 'Other';
export type LiabilityType = 'Home Loan' | 'Car Loan' | 'Personal Loan' | 'Education Loan' | 'Credit Card' | 'Other';
export type TransactionType = 'income' | 'expense';
export type NetWorthTargetType = 'asset' | 'liability';
export type ImpactOperation = 'add' | 'subtract';
// ─── Database Models ─────────────────────────────────────────────────
export interface Category {
id: string;
name: string;
icon: string;
color: string;
type: TransactionType;
isDefault: boolean;
createdAt: string;
}
export interface Transaction {
id: string;
amount: number;
currency: Currency;
type: TransactionType;
categoryId: string;
categoryName?: string;
categoryIcon?: string;
categoryColor?: string;
paymentMethod: PaymentMethod;
note: string;
date: string; // ISO string
createdAt: string;
updatedAt: string;
}
export interface TransactionImpact {
transactionId: string;
targetType: NetWorthTargetType;
targetId: string;
operation: ImpactOperation;
}
export interface Asset {
id: string;
name: string;
type: AssetType;
currentValue: number;
currency: Currency;
note: string;
lastUpdated: string;
createdAt: string;
}
export interface Liability {
id: string;
name: string;
type: LiabilityType;
outstandingAmount: number;
currency: Currency;
interestRate: number;
emiAmount: number;
note: string;
lastUpdated: string;
createdAt: string;
}
// ─── Net Worth ───────────────────────────────────────────────────────
export interface NetWorthSnapshot {
id: string;
totalAssets: number;
totalLiabilities: number;
netWorth: number;
currency: Currency;
snapshotDate: string;
createdAt: string;
}
// ─── Settings ────────────────────────────────────────────────────────
export interface AppSettings {
baseCurrency: Currency;
locale: string;
theme: 'light' | 'dark' | 'system';
biometricEnabled: boolean;
onboardingComplete: boolean;
}
// ─── Chart Data ──────────────────────────────────────────────────────
export interface ChartDataPoint {
value: number;
label: string;
frontColor?: string;
}
export interface PieDataPoint {
value: number;
text: string;
color: string;
focused?: boolean;
}
// ─── Exchange Rates ──────────────────────────────────────────────────
export interface ExchangeRates {
INR: number;
USD: number;
EUR: number;
GBP: number;
}

122
src/utils/formatting.ts Normal file
View File

@@ -0,0 +1,122 @@
import {Currency} from '../types';
import {CURRENCY_SYMBOLS, STATIC_EXCHANGE_RATES} from '../constants';
/**
* Formats a number in Indian Lakh/Crore notation.
* e.g., 1500000 → "15,00,000"
*/
export function formatIndianNumber(num: number): string {
const isNegative = num < 0;
const absNum = Math.abs(num);
const [intPart, decPart] = absNum.toFixed(2).split('.');
if (intPart.length <= 3) {
const formatted = intPart + (decPart && decPart !== '00' ? '.' + decPart : '');
return isNegative ? '-' + formatted : formatted;
}
// Last 3 digits
const lastThree = intPart.slice(-3);
const remaining = intPart.slice(0, -3);
// Group remaining digits in pairs (Indian system)
const pairs = remaining.replace(/\B(?=(\d{2})+(?!\d))/g, ',');
const formatted =
pairs + ',' + lastThree + (decPart && decPart !== '00' ? '.' + decPart : '');
return isNegative ? '-' + formatted : formatted;
}
/**
* Formats a number in Western notation (comma every 3 digits).
*/
export function formatWesternNumber(num: number): string {
const isNegative = num < 0;
const absNum = Math.abs(num);
const [intPart, decPart] = absNum.toFixed(2).split('.');
const formatted =
intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',') +
(decPart && decPart !== '00' ? '.' + decPart : '');
return isNegative ? '-' + formatted : formatted;
}
/**
* Formats amount with currency symbol and locale-aware grouping.
*/
export function formatCurrency(amount: number, currency: Currency = 'INR'): string {
const symbol = CURRENCY_SYMBOLS[currency] || '₹';
const formatted =
currency === 'INR'
? formatIndianNumber(amount)
: formatWesternNumber(amount);
return `${symbol}${formatted}`;
}
/**
* Compact formatting for large values on charts/cards.
* e.g., 1500000 → "₹15L", 25000000 → "₹2.5Cr"
*/
export function formatCompact(amount: number, currency: Currency = 'INR'): string {
const symbol = CURRENCY_SYMBOLS[currency] || '₹';
const abs = Math.abs(amount);
const sign = amount < 0 ? '-' : '';
if (currency === 'INR') {
if (abs >= 1_00_00_000) {
return `${sign}${symbol}${(abs / 1_00_00_000).toFixed(1).replace(/\.0$/, '')}Cr`;
}
if (abs >= 1_00_000) {
return `${sign}${symbol}${(abs / 1_00_000).toFixed(1).replace(/\.0$/, '')}L`;
}
if (abs >= 1_000) {
return `${sign}${symbol}${(abs / 1_000).toFixed(1).replace(/\.0$/, '')}K`;
}
return `${sign}${symbol}${abs}`;
}
// Western compact
if (abs >= 1_000_000_000) {
return `${sign}${symbol}${(abs / 1_000_000_000).toFixed(1).replace(/\.0$/, '')}B`;
}
if (abs >= 1_000_000) {
return `${sign}${symbol}${(abs / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`;
}
if (abs >= 1_000) {
return `${sign}${symbol}${(abs / 1_000).toFixed(1).replace(/\.0$/, '')}K`;
}
return `${sign}${symbol}${abs}`;
}
/**
* Convert an amount from one currency to another using static rates
* (all rates are relative to INR as base).
*/
export function convertCurrency(
amount: number,
from: Currency,
to: Currency,
): number {
if (from === to) {return amount;}
// Convert to INR first, then to target
const amountInINR = amount * STATIC_EXCHANGE_RATES[from];
return amountInINR / STATIC_EXCHANGE_RATES[to];
}
/**
* Calculate percentage change between two values.
*/
export function percentageChange(current: number, previous: number): number {
if (previous === 0) {return current > 0 ? 100 : 0;}
return ((current - previous) / Math.abs(previous)) * 100;
}
/**
* Generate a simple UUID v4 string.
*/
export function generateId(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}

Some files were not shown because too many files have changed in this diff Show More