diff --git a/.github/workflows/mobile-lint.yml b/.github/workflows/mobile-lint.yml new file mode 100644 index 0000000000..b8be43d5a1 --- /dev/null +++ b/.github/workflows/mobile-lint.yml @@ -0,0 +1,40 @@ +name: Run Lint + +on: + push: + branches: + - main + paths: + - apps/mobile/** + - packages/store/** + pull_request: + paths: + - apps/mobile/** + - packages/store/** + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + # Checkout the code + - name: Checkout code + uses: actions/checkout@v4 + + - name: Enable Corepack + run: corepack enable + + # Set up Node.js + - uses: actions/setup-node@v4 + with: + node-version: '22.11.0' # jod + cache: 'yarn' + + # Install dependencies + - name: Install dependencies + run: yarn install --immutable + + # Run tests with coverage + - name: Run lint + run: | + yarn workspace @safe-global/mobile run lint diff --git a/.github/workflows/mobile-unit-tests.yml b/.github/workflows/mobile-unit-tests.yml new file mode 100644 index 0000000000..3c7ae75b81 --- /dev/null +++ b/.github/workflows/mobile-unit-tests.yml @@ -0,0 +1,45 @@ +name: Run Tests and Coverage + +on: + push: + branches: + - main + paths: + - apps/mobile/** + pull_request: + paths: + - apps/mobile/** + +jobs: + test-and-coverage: + runs-on: ubuntu-latest + + steps: + # Checkout the code + - name: Checkout code + uses: actions/checkout@v4 + + - name: Enable Corepack + run: corepack enable + + # Set up Node.js + - uses: actions/setup-node@v4 + with: + node-version: '22.11.0' # jod + cache: 'yarn' + + # Install dependencies + - name: Install dependencies + run: yarn install --immutable + + # Run tests with coverage + - name: Run Jest tests with coverage + run: | + yarn workspace @safe-global/mobile test:coverage --coverageReporters=text --coverageReporters=json-summary | tee ./coverage.txt && exit ${PIPESTATUS[0]} + + - name: Jest Coverage Comment + uses: MishaKav/jest-coverage-comment@v1 + with: + coverage-summary-path: ./coverage/coverage-summary.json + coverage-title: Coverage + coverage-path: ./coverage.txt diff --git a/.gitignore b/.gitignore index ade4b1731e..a098bc6160 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,3 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - # dependencies /node_modules /.pnp @@ -23,6 +21,24 @@ *.pem .idea + + +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +# expo +**/.expo/* + +# next.js +**/.next/* + +# tamagui +**/.tamagui/* + # debug npm-debug.log* yarn-debug.log* @@ -30,7 +46,15 @@ yarn-error.log* .pnpm-debug.log* # local env files + .env*.local +.env +.env.local +.env.local* +.env.development.local +.env.test.local +.env.production.local +.env.production # vercel .vercel @@ -41,21 +65,11 @@ yarn-error.log* # yalc .yalc yalc.lock -.env -/cypress/videos -/cypress/screenshots -/cypress/downloads - -/public/sw.js -/public/sw.js.map -/public/worker-*.js -/public/workbox-*.js -/public/workbox-*.js.map -/public/fallback* -/public/*.js.LICENSE.txt certificates *storybook.log -# Yarn v4 -.yarn/* +THUMBS_DB +thumbs.db + +**/node_modules/* diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000000..2312dc587f --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged diff --git a/.lintstagedrc.json b/.lintstagedrc.json new file mode 100644 index 0000000000..87ca0645ac --- /dev/null +++ b/.lintstagedrc.json @@ -0,0 +1,8 @@ +{ + "*.ts": ["yarn prettier:fix"], + "*.tsx": ["yarn prettier:fix"], + "apps/mobile/assets/fonts/safe-icons/selection.json": [ + "node ./apps/mobile/scripts/generateIconTypes.js", + "git add ./apps/mobile/src/types/iconTypes.ts" + ] +} diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000000..bd82d1908e --- /dev/null +++ b/.prettierignore @@ -0,0 +1,10 @@ +# Ignore artifacts: +build +coverage +node_modules +html +ios +android +/assets + +apps/mobile/assets diff --git a/apps/mobile/.eas/build/build-and-maestro-test.yml b/apps/mobile/.eas/build/build-and-maestro-test.yml new file mode 100644 index 0000000000..0ec7551af4 --- /dev/null +++ b/apps/mobile/.eas/build/build-and-maestro-test.yml @@ -0,0 +1,38 @@ +build: + name: Create a build and run Maestro tests on it + steps: + - eas/checkout + + - run: + name: Enable corepack + command: corepack enable + + # if you are not interested in using custom .npmrc config you can skip it + - eas/use_npm_token + + - eas/install_node_modules + + - eas/resolve_build_config + + - eas/prebuild + + - run: + name: Install pods + working_directory: ./ios + command: pod install + + # if you are not using EAS Update you can remove this step from your config + # https://docs.expo.dev/eas-update/introduction/ + - eas/configure_eas_update: + inputs: + throw_if_not_configured: false + + - eas/generate_gymfile_from_template + + - eas/run_fastlane + + - eas/find_and_upload_build_artifacts + - eas/maestro_test: + inputs: + flow_path: | + e2e/flow.yml diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore new file mode 100644 index 0000000000..4453806e56 --- /dev/null +++ b/apps/mobile/.gitignore @@ -0,0 +1,37 @@ +node_modules/ +.expo/ +dist/ +npm-debug.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision +*.orig.* +web-build/ + +# Auto generated storybook file +.storybook/storybook.requires.ts + +# From jest +html +coverage + +# macOS +.DS_Store + +# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb +# The following patterns were generated by expo-cli + +expo-env.d.ts +# @end expo-cli +/.idea +# Tamagui UI generates a lot of cache files +.tamagui + +*storybook.log +/storybook-static + +# Android and iOS build files +/android/* +/ios/* diff --git a/apps/mobile/.storybook/index.ts b/apps/mobile/.storybook/index.ts new file mode 100644 index 0000000000..977e3d5151 --- /dev/null +++ b/apps/mobile/.storybook/index.ts @@ -0,0 +1,11 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { view } from './storybook.requires' + +const StorybookUIRoot = view.getStorybookUI({ + storage: { + getItem: AsyncStorage.getItem, + setItem: AsyncStorage.setItem, + }, +}) + +export default StorybookUIRoot diff --git a/apps/mobile/.storybook/main.ts b/apps/mobile/.storybook/main.ts new file mode 100644 index 0000000000..48923c9f74 --- /dev/null +++ b/apps/mobile/.storybook/main.ts @@ -0,0 +1,52 @@ +import type { StorybookConfig as WebStorybookConfig } from '@storybook/react-webpack5' +import type { StorybookConfig as RNStorybookConfig } from '@storybook/react-native' + +const isWeb = process.env.STORYBOOK_WEB +import path from 'path' +import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin' + +let config: WebStorybookConfig | RNStorybookConfig + +if (isWeb) { + config = { + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + addons: [ + '@storybook/addon-essentials', + '@storybook/addon-interactions', + { + name: '@storybook/addon-react-native-web', + options: { + projectRoot: '../', + modulesToTranspile: [], + }, + }, + '@storybook/addon-webpack5-compiler-babel', + ], + framework: { + name: '@storybook/react-webpack5', + options: {}, + }, + webpackFinal: async (config) => { + if (config.resolve) { + config.resolve.plugins = [ + ...(config.resolve.plugins || []), + new TsconfigPathsPlugin({ + extensions: config.resolve.extensions, + }), + ] + + config.resolve.alias = { + ...config.resolve.alias, + '@': path.resolve(__dirname, '../'), + } + } + return config + }, + } as WebStorybookConfig +} else { + config = { + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + addons: ['@storybook/addon-ondevice-controls', '@storybook/addon-ondevice-actions'], + } as RNStorybookConfig +} +export default config diff --git a/apps/mobile/.storybook/preview.tsx b/apps/mobile/.storybook/preview.tsx new file mode 100644 index 0000000000..86f9a42021 --- /dev/null +++ b/apps/mobile/.storybook/preview.tsx @@ -0,0 +1,40 @@ +import type { Preview } from '@storybook/react' +import { NavigationIndependentTree } from '@react-navigation/native' +import { SafeThemeProvider } from '@/src/theme/provider/safeTheme' +import { View } from 'react-native' +import { SafeToastProvider } from '@/src/theme/provider/toastProvider' +import { SafeAreaProvider } from 'react-native-safe-area-context' +import { PortalProvider } from 'tamagui' + +const preview: Preview = { + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, + }, + tags: ['autodocs'], + decorators: [ + (Story) => { + return ( + + + + + + + + + + + + + + ) + }, + ], +} + +export default preview diff --git a/apps/mobile/.storybook/tsconfig.json b/apps/mobile/.storybook/tsconfig.json new file mode 100644 index 0000000000..ee8b2aa390 --- /dev/null +++ b/apps/mobile/.storybook/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "module": "commonjs" + }, + "include": ["../src/**/*", "./**/*"] +} diff --git a/apps/mobile/CONTRIBUTING.md b/apps/mobile/CONTRIBUTING.md new file mode 100644 index 0000000000..691c29345b --- /dev/null +++ b/apps/mobile/CONTRIBUTING.md @@ -0,0 +1,69 @@ +# React Native Code Guidelines + +## Code Structure + +### General Components + +- Components that are used across multiple features should reside in the `src/components/` folder. +- Each component should have its own folder, structured as follows: + ``` + Alert/ + - Alert.tsx + - Alert.test.tsx + - Alert.stories.tsx + - index.tsx + ``` +- The main component implementation should be in a named file (e.g., `Alert.tsx`), and `index.tsx` should only be used for exporting the component. +- **Reason**: Using `index.tsx` allows for cleaner imports, e.g., + ``` + import { Alert } from 'src/components/Alert'; + ``` + instead of: + ``` + import { Alert } from 'src/components/Alert/Alert'; + ``` + +### Exporting Components + +- **Always prefer named exports over default exports.** + - Named exports make it easier to refactor and identify exports in a codebase. + +### Features and Screens + +- Feature-specific components and screens should be implemented inside the `src/features/` folder. + +#### Example: Feature File Structure + +For a feature called **Assets**, the file structure might look like this: + +``` +// src/features/Assets +- Assets.container.tsx +- index.tsx +``` + +- `index.tsx` should only export the **Assets** component/container. + +#### Subcomponents for Features + +- If a feature depends on multiple subcomponents unique to that feature, place them in a `components` subfolder. For example: + +``` +// src/features/Assets/components/AssetHeader +- AssetHeader.tsx +- AssetHeader.container.tsx +- index.tsx +``` + +### Presentation vs. Container Components + +- **Presentation Components**: + + - Responsible only for rendering the UI. + - Receive data and callbacks via props. + - Avoid direct manipulation of business logic. + - Simple business logic can be included but should generally be extracted into hooks. + +- **Container Components**: + - Handle business logic (e.g., state management, API calls, etc.). + - Pass necessary data and callbacks to the corresponding Presentation component. diff --git a/apps/mobile/README.md b/apps/mobile/README.md new file mode 100644 index 0000000000..d787ce30a2 --- /dev/null +++ b/apps/mobile/README.md @@ -0,0 +1,168 @@ +# Safe Mobile App 📱 + +This project is now part of the **@safe-global/safe-wallet** monorepo! The monorepo setup allows centralized management of multiple +applications and shared libraries. This workspace (`apps/mobile`) contains the Safe Mobile App. + +You can run commands for this workspace in two ways: + +1. **From the root of the monorepo using `yarn workspace` commands** +2. **From within the `apps/mobile` directory** + +## Prerequisites + +In the addition to the monorepo prerequisites, the mobile app requires the following: + +- Expo CLI +- iOS/Android Development Tools +- [Maestro](https://maestro.mobile.dev/) if you want to run E2E tests + +You can follow the [expo documentation](https://docs.expo.dev/get-started/set-up-your-environment/) to install the CLI and set up your development environment. + +Follow the [Maestro](https://maestro.mobile.dev/) documentation to install the tool for E2E testing. + +## Setup the Project + +1. Install all dependencies from the **root of the monorepo**: + +```bash +yarn install +``` + +## Running the App + +### Running on iOS + +From the root of the monorepo: + +```bash +yarn workspace @safe-global/mobile start:ios +``` + +Or directly from the `apps/mobile` directory: + +```bash +yarn start:ios +``` + +> [!NOTE] +> +> From now on for brevity we will only show the command to run from the root of the monorepo. You can always run the command from the `apps/mobile` directory you just need to omit the `workspace @safe-global/mobile`. + +### Running on Android + +From the root of the monorepo: + +```bash +yarn workspace @safe-global/mobile start:android +``` + +### How to Open the Custom DevTools Menu + +The app supports **Redux**, **RTK Query**, and **React DevTools**. To access these tools: + +1. Run the app. +2. In the terminal where the Expo server is running, press `Shift + M`. +3. Select the desired DevTools option for debugging. Happy debugging! 👨‍💻👩‍💻 + +## Running the Storybook + +### Running in the browse + +Run the storybook command from the root: + +```bash +yarn workspace @safe-global/mobile storybook:web +``` + +### Running on a mobile device + +To run the storybook on a mobile device: + +```bash +yarn workspace @safe-global/mobile storybook:[ios|android] +``` + +To View stories press `i` on iOS or `a` on Android. + +## How to Run the E2E Tests + +We use [Maestro](https://maestro.mobile.dev/) for E2E testing. Before running tests, install Maestro following the +documentation for your OS. + +### Run a Dev Build and E2E Tests + +To build the app for tests: + +#### For iOS: + +```bash +yarn workspace @safe-global/mobile e2e:metro-ios +``` + +#### For Android: + +```bash +yarn workspace @safe-global/mobile e2e:metro-android +``` + +These commands include `.e2e.ts|.e2e.tsx` files for mocking services or adding test-specific code. + +### Run the Tests + +In a second terminal run: + +```bash +yarn workspace @safe-global/mobile e2e:run +``` + +### Use Maestro Studio to Write Tests + +To write tests with Maestro Studio, run: + +```bash +maestro studio +``` + +Export the generated YAML file to the `e2e` folder and include it in the test suite. + +### Running E2E Tests in CI + +To run tests in CI, add the `eas-build-ios:build-and-maestro-test` label to a PR. This triggers the Expo CI pipeline to +execute the tests. + +## Unit Tests + +We use **Jest** and the [React Native Testing Library](https://callstack.github.io/react-native-testing-library/) for +unit, component, and hook tests. + +Run tests: + +```bash +yarn workspace @safe-global/mobile test +``` + +Run in watch mode: + +```bash +yarn workspace @safe-global/mobile test:watch +``` + +Check coverage: + +```bash +yarn workspace @safe-global/mobile test +``` + +Navigate to the `coverage` folder and open `index.html` in your browser. + +## Running ESLint & Prettier + +This project uses ESLint, Prettier, and TypeScript for linting and formatting. + +Run linting from the root: + +```bash +yarn workspace @safe-global/mobile lint +``` + +This command validates files with TypeScript, ESLint, and Prettier configurations. diff --git a/apps/mobile/__mocks__/fileMock.js b/apps/mobile/__mocks__/fileMock.js new file mode 100644 index 0000000000..0e56c5b5f7 --- /dev/null +++ b/apps/mobile/__mocks__/fileMock.js @@ -0,0 +1 @@ +module.exports = 'test-file-stub' diff --git a/apps/mobile/app.config.js b/apps/mobile/app.config.js new file mode 100644 index 0000000000..99c83043b9 --- /dev/null +++ b/apps/mobile/app.config.js @@ -0,0 +1,67 @@ +/* eslint-disable no-undef */ +const IS_DEV = process.env.APP_VARIANT === 'development' + +export default { + expo: { + name: IS_DEV ? 'Safe{Wallet} MVP - Development' : 'Safe{Wallet} MVP', + slug: 'safe-mobileapp', + owner: 'safeglobal', + version: '1.0.0', + extra: { + storybookEnabled: process.env.STORYBOOK_ENABLED, + eas: { + projectId: '27e9e907-8675-474d-99ee-6c94e7b83a5c', + }, + }, + orientation: 'portrait', + icon: './assets/images/icon.png', + scheme: 'myapp', + userInterfaceStyle: 'automatic', + newArchEnabled: true, + ios: { + config: { + usesNonExemptEncryption: false, + }, + supportsTablet: true, + appleTeamId: 'MXRS32BBL4', + bundleIdentifier: IS_DEV ? 'global.safe.mobileapp.dev' : 'global.safe.mobileapp', + }, + android: { + adaptiveIcon: { + foregroundImage: './assets/images/adaptive-icon.png', + backgroundColor: '#000000', + }, + package: IS_DEV ? 'global.safe.mobileapp.dev' : 'global.safe.mobileapp', + }, + web: { + bundler: 'metro', + output: 'static', + favicon: './assets/images/favicon.png', + }, + plugins: [ + 'expo-router', + [ + 'expo-font', + { + fonts: ['./assets/fonts/safe-icons/safe-icons.ttf'], + }, + ], + [ + 'expo-splash-screen', + { + image: './assets/images/splash.png', + enableFullScreenImage_legacy: true, + backgroundColor: '#000000', + dark: { + image: './assets/images/splash.png', + backgroundColor: '#000000', + }, + }, + ], + ['./expo-plugins/withDrawableAssets.js', './assets/android/drawable'], + ], + experiments: { + typedRoutes: true, + }, + }, +} diff --git a/apps/mobile/app/(tabs)/_layout.tsx b/apps/mobile/app/(tabs)/_layout.tsx new file mode 100644 index 0000000000..9810e0b327 --- /dev/null +++ b/apps/mobile/app/(tabs)/_layout.tsx @@ -0,0 +1,42 @@ +import { Tabs } from 'expo-router' +import React from 'react' +import { TabBarIcon } from '@/src/components/navigation/TabBarIcon' +import { Navbar as AssetsNavbar } from '@/src/features/Assets/components/Navbar/Navbar' + +export default function TabLayout() { + return ( + + , + }} + /> + + , + }} + /> + + { + return { + title: 'Settings', + headerShown: false, + tabBarButtonTestID: 'tabSettings', + tabBarIcon: ({ color }) => , + } + }} + /> + + ) +} diff --git a/apps/mobile/app/(tabs)/index.tsx b/apps/mobile/app/(tabs)/index.tsx new file mode 100644 index 0000000000..d160099bc8 --- /dev/null +++ b/apps/mobile/app/(tabs)/index.tsx @@ -0,0 +1,8 @@ +import React from 'react' +import { AssetsContainer } from '@/src/features/Assets' + +const HomeScreen = () => { + return +} + +export default HomeScreen diff --git a/apps/mobile/app/(tabs)/settings.tsx b/apps/mobile/app/(tabs)/settings.tsx new file mode 100644 index 0000000000..413651cba9 --- /dev/null +++ b/apps/mobile/app/(tabs)/settings.tsx @@ -0,0 +1,5 @@ +import { SettingsContainer } from '@/src/features/Settings' + +export default function SettingsScreen() { + return +} diff --git a/apps/mobile/app/(tabs)/transactions/(tabs)/_layout.tsx b/apps/mobile/app/(tabs)/transactions/(tabs)/_layout.tsx new file mode 100644 index 0000000000..35623ec2b2 --- /dev/null +++ b/apps/mobile/app/(tabs)/transactions/(tabs)/_layout.tsx @@ -0,0 +1,54 @@ +import React from 'react' + +import { + MaterialTopTabNavigationEventMap, + MaterialTopTabNavigationOptions, + createMaterialTopTabNavigator, +} from '@react-navigation/material-top-tabs' +import { withLayoutContext } from 'expo-router' +import { ParamListBase, TabNavigationState } from '@react-navigation/native' +import { useTheme } from 'tamagui' + +const { Navigator } = createMaterialTopTabNavigator() + +export const MaterialTopTabs = withLayoutContext< + MaterialTopTabNavigationOptions, + typeof Navigator, + TabNavigationState, + MaterialTopTabNavigationEventMap +>(Navigator) + +export default function TransactionsLayout() { + const theme = useTheme() + + return ( + + + + + ) +} diff --git a/apps/mobile/app/(tabs)/transactions/(tabs)/index.tsx b/apps/mobile/app/(tabs)/transactions/(tabs)/index.tsx new file mode 100644 index 0000000000..a7053dae7c --- /dev/null +++ b/apps/mobile/app/(tabs)/transactions/(tabs)/index.tsx @@ -0,0 +1,10 @@ +import { TxHistoryContainer } from '@/src/features/TxHistory' +import { View } from 'react-native' + +export default function TransactionsScreen() { + return ( + + + + ) +} diff --git a/apps/mobile/app/(tabs)/transactions/(tabs)/messages.tsx b/apps/mobile/app/(tabs)/transactions/(tabs)/messages.tsx new file mode 100644 index 0000000000..1748ff3e15 --- /dev/null +++ b/apps/mobile/app/(tabs)/transactions/(tabs)/messages.tsx @@ -0,0 +1,41 @@ +import { SafeFontIcon } from '@/src/components/SafeFontIcon/SafeFontIcon' +import { SafeListItem } from '@/src/components/SafeListItem' +import { formatWithSchema } from '@/src/utils/date' +import React from 'react' +import { ScrollView, Text, View } from 'tamagui' +import { SafeAreaView } from 'react-native-safe-area-context' +import { StyleSheet } from 'react-native' + +function Messages() { + return ( + + + + + + + + } + rightNode={ + + Success + + } + /> + + + + ) +} + +const styles = StyleSheet.create({ + wrapper: { + flex: 1, + flexDirection: 'column', + }, +}) + +export default Messages diff --git a/apps/mobile/app/(tabs)/transactions/_layout.tsx b/apps/mobile/app/(tabs)/transactions/_layout.tsx new file mode 100644 index 0000000000..21fc117898 --- /dev/null +++ b/apps/mobile/app/(tabs)/transactions/_layout.tsx @@ -0,0 +1,32 @@ +import { Stack } from 'expo-router' +import React from 'react' +import type { Route } from '@react-navigation/routers' + +import { getFocusedRouteNameFromRoute } from '@react-navigation/native' + +const getHeaderTitle = (route: Partial>) => { + const routeName = getFocusedRouteNameFromRoute(route) ?? 'index' + const name = { + ['index']: 'Transactions', + ['messages']: 'Messages', + }[routeName] + return name || 'Transactions' +} + +export default function TransactionsLayout() { + return ( + + ({ + headerTitle: getHeaderTitle(route), + })} + /> + + ) +} diff --git a/apps/mobile/app/+html.tsx b/apps/mobile/app/+html.tsx new file mode 100644 index 0000000000..3665df8e87 --- /dev/null +++ b/apps/mobile/app/+html.tsx @@ -0,0 +1,39 @@ +import { ScrollViewStyleReset } from 'expo-router/html' +import { type PropsWithChildren } from 'react' + +/** + * This file is web-only and used to configure the root HTML for every web page during static rendering. + * The contents of this function only run in Node.js environments and do not have access to the DOM or browser APIs. + */ +export default function Root({ children }: PropsWithChildren) { + return ( + + + + + + + {/* + Disable body scrolling on web. This makes ScrollView components work closer to how they do on native. + However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line. + */} + + + {/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */} +