├── .gitignore
├── .npmrc
├── .prettierignore
├── .svgrrc
├── .vscode
├── extensions.json
└── settings.json
├── README.md
├── app-env.d.ts
├── app.json
├── assets
├── adaptive-icon.png
├── favicon.png
├── icon.png
└── splash.png
├── babel.config.js
├── eas.json
├── eslint.config.js
├── git-hooks
└── pre-commit
├── global.css
├── metro.config.cjs
├── nativewind-env.d.ts
├── package.json
├── patches
└── metro.patch
├── pnpm-lock.yaml
├── prettier.config.js
├── src
├── app
│ ├── (app)
│ │ ├── (tabs)
│ │ │ ├── _layout.tsx
│ │ │ ├── index.tsx
│ │ │ └── two.tsx
│ │ └── _layout.tsx
│ ├── +html.tsx
│ ├── +not-found.tsx
│ ├── _layout.tsx
│ └── login.tsx
├── i18n
│ └── getLocale.tsx
├── lib
│ └── cx.tsx
├── setup.tsx
├── tests
│ └── App.test.tsx
├── ui
│ ├── BottomSheetModal.tsx
│ ├── Text.tsx
│ └── colors.ts
└── user
│ └── useViewerContext.tsx
├── tailwind.config.ts
├── translations
└── ja_JP.json
├── tsconfig.json
└── vitest.config.js
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | .env.local
3 | .eslintcache
4 | .expo/
5 | .metro-health-check*
6 | *.jks
7 | *.key
8 | *.mobileprovision
9 | *.orig.*
10 | *.p12
11 | *.p8
12 | *.swp
13 | android
14 | credentials.json
15 | dist/
16 | expo-env.d.ts
17 | ios
18 | node_modules/
19 | npm-debug.*
20 | tsconfig.tsbuildinfo
21 | web-build/
22 |
23 | # fbtee
24 | .enum_manifest.json
25 | .src_manifest.json
26 | source_strings.json
27 | src/translations/
28 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | resolution-mode=highest
2 | node-linker=hoisted
3 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | __generated__
2 | .enum_manifest.json
3 | .expo/
4 | .source_strings.json
5 | .src_manifest.json
6 | android
7 | coverage
8 | dist/
9 | ios
10 | patches/
11 | pnpm-lock.yaml
12 | web-build/
13 |
--------------------------------------------------------------------------------
/.svgrrc:
--------------------------------------------------------------------------------
1 | {
2 | "replaceAttrValues": {
3 | "currentColor": "{props.currentColor}"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "dbaeumer.vscode-eslint",
4 | "esbenp.prettier-vscode",
5 | "styled-components.vscode-styled-components",
6 | "sysoev.vscode-open-in-github",
7 | "usernamehw.errorlens",
8 | "wix.vscode-import-cost"
9 | ]
10 | }
11 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.formatOnSave": true,
3 | "files.insertFinalNewline": true,
4 | "files.trimFinalNewlines": true,
5 | "editor.codeActionsOnSave": {
6 | "source.fixAll.eslint": "explicit"
7 | },
8 | "[javascript]": {
9 | "editor.defaultFormatter": "esbenp.prettier-vscode"
10 | },
11 | "[typescriptreact]": {
12 | "editor.defaultFormatter": "esbenp.prettier-vscode"
13 | },
14 | "[typescript]": {
15 | "editor.defaultFormatter": "esbenp.prettier-vscode"
16 | },
17 | "[json]": {
18 | "editor.defaultFormatter": "esbenp.prettier-vscode"
19 | },
20 | "typescript.preferences.importModuleSpecifierEnding": "js",
21 | "typescript.reportStyleChecksAsWarnings": false,
22 | "typescript.updateImportsOnFileMove.enabled": "always",
23 | "typescript.tsdk": "node_modules/typescript/lib",
24 | "search.exclude": {
25 | "android/app/build/**": true,
26 | "ios/DerivedData/**": true
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # The React Native & Expo App Template
2 |
3 | This is the most modern and always up-to-date React Native & Expo app template. It comes with sensible defaults, a great developer experience and is optimized for performance. You can read more about the DevX setup in this [frontend tooling article](https://cpojer.net/posts/fastest-frontend-tooling-in-2022). Check out the corresponding [web app template](https://github.com/nkzw-tech/vite-ts-react-tailwind-template).
4 |
5 |
6 |
7 |
8 | ## Technologies
9 |
10 | You have to make a lot of decisions and install tons of packages every time you create a new React Native app. This template offers an opinionated starting point and includes the best options for various categories. Instead of spending hours on research and piecing together a setup that works, you can just copy this template and start right away. When you copy this template, you get full control to add or remove any third-party package to customize your app.
11 |
12 | - Expo 53 & React Native 0.79 with the New Architecture.
13 | - [Expo Router](https://docs.expo.dev/router/introduction/)
14 | - [NativeWind](https://www.nativewind.dev/) & [Tailwind CSS](https://tailwindcss.com/)
15 | - [`@gorhom/bottom-sheet`](https://github.com/gorhom/react-native-bottom-sheet), [Legend List](https://github.com/LegendApp/legend-list), [`react-native-svg`](https://github.com/software-mansion/react-native-svg) (+ `react-native-svg-transformer`), [`expo-linear-gradient`](https://docs.expo.dev/versions/latest/sdk/linear-gradient/).
16 | - [`fbtee`](https://github.com/nkzw-tech/fbtee) for i18n.
17 | - [TypeScript](https://www.typescriptlang.org)
18 | - [React Compiler](https://react.dev/learn/react-compiler)
19 | - [pnpm](https://pnpm.io/)
20 | - **ESM:** _It's 2025._ This template comes with `"type": "module"`.
21 |
22 | ## Getting Started
23 |
24 | Start here: [Create a new app using this template](https://github.com/new?template_name=expo-app-template&template_owner=nkzw-tech).
25 |
26 | After you created your repo, you can freely modify anything in this template.
27 |
28 | ### Prerequisites
29 |
30 | You'll need Node.js 22, pnpm 10+ and Cocoapods.
31 |
32 | ```bash
33 |
34 | brew install node pnpm cocoapods
35 | ```
36 |
37 | For building and running apps locally, follow the [Expo setup guides](https://docs.expo.dev/get-started/set-up-your-environment/?platform=ios&device=simulated).
38 |
39 | ### Installing Dependencies
40 |
41 | Run:
42 |
43 | ```bash
44 | pnpm install && pnpm dev:setup
45 | ```
46 |
47 | ### Running the iOS App in a simulator
48 |
49 | ```bash
50 | pnpm prebuild
51 | pnpm ios
52 | ```
53 |
54 | If you already have the app installed on your simulator, you can skip the above steps and simply run `pnpm dev` to start the development server.
55 |
56 | ## Contributing
57 |
58 | Feel free to open issues, initiate discussions and send PRs to improve the template.
59 |
--------------------------------------------------------------------------------
/app-env.d.ts:
--------------------------------------------------------------------------------
1 | // @ts-ignore
2 | ///
3 | ///
4 |
5 | declare module '*.svg' {
6 | import { FC } from 'react';
7 | import { SvgProps } from 'react-native-svg';
8 |
9 | const content: FC;
10 | export default content;
11 | }
12 |
--------------------------------------------------------------------------------
/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "NKZW App",
4 | "slug": "nkzw-app",
5 | "version": "1.0.0",
6 | "scheme": "nkzw-app",
7 | "web": {
8 | "bundler": "metro",
9 | "output": "static",
10 | "favicon": "./assets/favicon.png"
11 | },
12 | "plugins": [
13 | "expo-router",
14 | [
15 | "expo-dev-launcher",
16 | {
17 | "launchMode": "most-recent"
18 | }
19 | ],
20 | "expo-localization",
21 | "expo-font",
22 | "expo-web-browser",
23 | "react-native-edge-to-edge"
24 | ],
25 | "experiments": {
26 | "reactCanary": true,
27 | "reactCompiler": true,
28 | "buildCacheProvider": {
29 | "plugin": "expo-build-disk-cache"
30 | },
31 | "tsconfigPaths": true,
32 | "typedRoutes": true
33 | },
34 | "orientation": "portrait",
35 | "icon": "./assets/icon.png",
36 | "userInterfaceStyle": "light",
37 | "splash": {
38 | "image": "./assets/splash.png",
39 | "resizeMode": "contain",
40 | "backgroundColor": "#ffffff"
41 | },
42 | "assetBundlePatterns": ["**/*"],
43 | "ios": {
44 | "supportsTablet": true,
45 | "bundleIdentifier": "app.nkzw.www",
46 | "infoPlist": {
47 | "ITSAppUsesNonExemptEncryption": false
48 | }
49 | },
50 | "android": {
51 | "adaptiveIcon": {
52 | "foregroundImage": "./assets/adaptive-icon.png",
53 | "backgroundColor": "#ffffff"
54 | },
55 | "package": "app.nkzw.www"
56 | },
57 | "newArchEnabled": true,
58 | "extra": {
59 | "router": {
60 | "origin": false
61 | }
62 | }
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/assets/adaptive-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nkzw-tech/expo-app-template/650343429d93d95e41cc6d0149e53834d19c1b82/assets/adaptive-icon.png
--------------------------------------------------------------------------------
/assets/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nkzw-tech/expo-app-template/650343429d93d95e41cc6d0149e53834d19c1b82/assets/favicon.png
--------------------------------------------------------------------------------
/assets/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nkzw-tech/expo-app-template/650343429d93d95e41cc6d0149e53834d19c1b82/assets/icon.png
--------------------------------------------------------------------------------
/assets/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nkzw-tech/expo-app-template/650343429d93d95e41cc6d0149e53834d19c1b82/assets/splash.png
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | export default function (api) {
2 | api.cache(true);
3 |
4 | return {
5 | presets: [
6 | '@nkzw/babel-preset-fbtee',
7 | ['babel-preset-expo', { jsxImportSource: 'nativewind' }],
8 | 'nativewind/babel',
9 | ],
10 | };
11 | }
12 |
--------------------------------------------------------------------------------
/eas.json:
--------------------------------------------------------------------------------
1 | {
2 | "cli": {
3 | "version": ">= 16.1.0",
4 | "appVersionSource": "remote"
5 | },
6 | "build": {
7 | "development": {
8 | "developmentClient": true,
9 | "distribution": "internal"
10 | },
11 | "preview": {
12 | "distribution": "internal"
13 | },
14 | "production": {
15 | "autoIncrement": true
16 | }
17 | },
18 | "submit": {
19 | "production": {}
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import nkzw from '@nkzw/eslint-config';
2 | import fbtee from '@nkzw/eslint-plugin-fbtee';
3 |
4 | export default [
5 | ...nkzw,
6 | fbtee.configs.strict,
7 | {
8 | ignores: [
9 | '__generated__',
10 | '.expo',
11 | 'android/',
12 | 'dist/',
13 | 'ios/',
14 | 'vite.config.ts.timestamp-*',
15 | ],
16 | },
17 | {
18 | files: ['scripts/**/*.tsx'],
19 | rules: {
20 | 'no-console': 0,
21 | },
22 | },
23 | {
24 | files: ['metro.config.cjs'],
25 | rules: {
26 | '@typescript-eslint/no-require-imports': 0,
27 | },
28 | },
29 | {
30 | plugins: {
31 | '@nkzw/fbtee': fbtee,
32 | },
33 | rules: {
34 | '@nkzw/fbtee/no-untranslated-strings': 0,
35 | '@typescript-eslint/array-type': [2, { default: 'generic' }],
36 | '@typescript-eslint/no-restricted-imports': [
37 | 2,
38 | {
39 | paths: [
40 | {
41 | importNames: ['Text'],
42 | message:
43 | 'Please use the corresponding UI components from `src/ui/` instead.',
44 | name: 'react-native',
45 | },
46 | {
47 | importNames: ['ScrollView'],
48 | message:
49 | 'Please use the corresponding UI component from `react-native-gesture-handler` instead.',
50 | name: 'react-native',
51 | },
52 | {
53 | importNames: ['BottomSheetModal'],
54 | message:
55 | 'Please use the corresponding UI components from `src/ui/` instead.',
56 | name: '@gorhom/bottom-sheet',
57 | },
58 | ],
59 | },
60 | ],
61 | 'import-x/no-extraneous-dependencies': [
62 | 2,
63 | {
64 | devDependencies: [
65 | './eslint.config.js',
66 | './scripts/**.tsx',
67 | './tailwind.config.ts',
68 | './vitest.config.js',
69 | '**/*.test.tsx',
70 | ],
71 | },
72 | ],
73 | },
74 | settings: {
75 | 'import-x/resolver': {
76 | typescript: {
77 | project: './tsconfig.json',
78 | },
79 | },
80 | },
81 | },
82 | ];
83 |
--------------------------------------------------------------------------------
/git-hooks/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | FILES=$(git diff --cached --name-only --diff-filter=ACMR | sed 's| |\\ |g')
3 | [ -z "$FILES" ] && exit 0
4 | echo "$FILES" | xargs pnpm prettier --ignore-unknown --write
5 | echo "$FILES" | xargs git add
6 |
7 | exit 0
8 |
--------------------------------------------------------------------------------
/global.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
--------------------------------------------------------------------------------
/metro.config.cjs:
--------------------------------------------------------------------------------
1 | const { getDefaultConfig } = require('expo/metro-config');
2 | const { withNativeWind } = require('nativewind/metro');
3 |
4 | const config = getDefaultConfig(__dirname);
5 |
6 | module.exports = withNativeWind(
7 | {
8 | ...config,
9 | resolver: {
10 | ...config.resolver,
11 | assetExts: config.resolver.assetExts.filter((ext) => ext !== 'svg'),
12 | sourceExts: [...config.resolver.sourceExts, 'svg'],
13 | },
14 | transformer: {
15 | ...config.transformer,
16 | babelTransformerPath: require.resolve(
17 | 'react-native-svg-transformer/expo',
18 | ),
19 | },
20 | },
21 | {
22 | input: './global.css',
23 | },
24 | );
25 |
--------------------------------------------------------------------------------
/nativewind-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | // NOTE: This file should not be edited and should be committed with your source code. It is generated by NativeWind.
4 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nkzw-app",
3 | "version": "1.0.0",
4 | "private": true,
5 | "type": "module",
6 | "main": "expo-router/entry",
7 | "scripts": {
8 | "android": "expo run:android",
9 | "dev": "expo start",
10 | "dev:setup": "pnpm fbtee",
11 | "fbtee": "pnpm run fbtee:manifest && pnpm run fbtee:collect && pnpm run fbtee:translate",
12 | "fbtee:collect": "fbtee collect --manifest < .src_manifest.json > source_strings.json",
13 | "fbtee:manifest": "fbtee manifest --src src",
14 | "fbtee:translate": "fbtee translate --source-strings source_strings.json --translations translations/*.json --jenkins --output-dir src/translations/",
15 | "format": "prettier --write .",
16 | "format-graphql": "./scripts/format-graphql-schema.tsx",
17 | "preinstall": "command -v git >/dev/null 2>&1 && git config core.hooksPath git-hooks || exit 0",
18 | "ios": "expo run:ios --device 'iPhone 16 Pro'",
19 | "lint": "eslint --cache .",
20 | "lint:format": "prettier --cache --check .",
21 | "prebuild": "expo prebuild",
22 | "start": "expo start --dev-client",
23 | "test": "NODE_OPTIONS='--no-experimental-detect-module' npm-run-all --parallel tsc:check vitest:run lint lint:format",
24 | "tsc:check": "tsc",
25 | "vitest:run": "vitest run",
26 | "web": "expo start --web"
27 | },
28 | "eslintConfig": {
29 | "extends": "universe/native",
30 | "root": true
31 | },
32 | "dependencies": {
33 | "@expo/vector-icons": "^14.1.0",
34 | "@gorhom/bottom-sheet": "^5.1.4",
35 | "@legendapp/list": "^1.0.14",
36 | "@nkzw/core": "^1.2.1",
37 | "@nkzw/create-context-hook": "^1.1.0",
38 | "@react-native-async-storage/async-storage": "^2.1.2",
39 | "@react-navigation/native": "^7.1.9",
40 | "@react-navigation/stack": "^7.3.2",
41 | "babel-plugin-react-compiler": "19.1.0-rc.2",
42 | "classnames": "^2.5.1",
43 | "expo": "53.0.9",
44 | "expo-constants": "~17.1.6",
45 | "expo-dev-client": "~5.1.8",
46 | "expo-font": "^13.3.1",
47 | "expo-linear-gradient": "^14.1.4",
48 | "expo-linking": "~7.1.5",
49 | "expo-localization": "^16.1.5",
50 | "expo-router": "5.0.7",
51 | "expo-system-ui": "~5.0.7",
52 | "expo-web-browser": "~14.1.6",
53 | "fbtee": "^0.2.2",
54 | "nativewind": "^4.1.23",
55 | "react": "^19.1.0",
56 | "react-dom": "^19.1.0",
57 | "react-native": "~0.79.2",
58 | "react-native-edge-to-edge": "^1.6.0",
59 | "react-native-gesture-handler": "^2.25.0",
60 | "react-native-reanimated": "^3.17.5",
61 | "react-native-safe-area-context": "^5.4.1",
62 | "react-native-screens": "4.11.0-beta.2",
63 | "react-native-svg": "^15.12.0",
64 | "react-native-web": "~0.20.0"
65 | },
66 | "devDependencies": {
67 | "@ianvs/prettier-plugin-sort-imports": "^4.4.1",
68 | "@nkzw/babel-preset-fbtee": "^0.2.2",
69 | "@nkzw/eslint-config": "^3.0.0",
70 | "@nkzw/eslint-plugin-fbtee": "^0.2.2",
71 | "@react-native/metro-babel-transformer": "^0.79.2",
72 | "@types/react": "~19.1.5",
73 | "@vitejs/plugin-react": "^4.5.0",
74 | "eslint": "^9.27.0",
75 | "expo-build-disk-cache": "^0.4.4",
76 | "npm-run-all2": "^8.0.4",
77 | "prettier": "4.0.0-alpha.12",
78 | "prettier-plugin-packagejson": "^2.5.14",
79 | "prettier-plugin-tailwindcss": "^0.6.11",
80 | "react-native-svg-transformer": "^1.5.1",
81 | "tailwindcss": "^3.4.0",
82 | "typescript": "^5.8.3",
83 | "vitest": "^3.1.4",
84 | "vitest-react-native": "^0.1.5"
85 | },
86 | "pnpm": {
87 | "updateConfig": {
88 | "ignoreDependencies": [
89 | "tailwindcss"
90 | ]
91 | },
92 | "patchedDependencies": {
93 | "metro": "patches/metro.patch"
94 | },
95 | "ignorePatchFailures": false
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/patches/metro.patch:
--------------------------------------------------------------------------------
1 | diff --git a/src/lib/logToConsole.js b/src/lib/logToConsole.js
2 | index 5a51d4ba0bb6cc26cc880f666c29b015acd6eeee..28c67b88624b0fed01338b172416a78dde023778 100644
3 | --- a/src/lib/logToConsole.js
4 | +++ b/src/lib/logToConsole.js
5 | @@ -39,10 +39,8 @@ module.exports = (terminal, level, mode, ...data) => {
6 | if (typeof lastItem === "string") {
7 | data[data.length - 1] = lastItem.trimEnd();
8 | }
9 | - const modePrefix =
10 | - !mode || mode == "BRIDGE" ? "" : `(${mode.toUpperCase()}) `;
11 | terminal.log(
12 | - color.bold(` ${modePrefix}${logFunction.toUpperCase()} `) +
13 | + color.bold(` ${logFunction.toUpperCase()} `) +
14 | "".padEnd(groupStack.length * 2, " "),
15 | util.format(...data)
16 | );
17 |
--------------------------------------------------------------------------------
/prettier.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | importOrderParserPlugins: ['importAssertions', 'typescript', 'jsx'],
3 | plugins: [
4 | '@ianvs/prettier-plugin-sort-imports',
5 | 'prettier-plugin-packagejson',
6 | // The order of plugins matters, and Tailwind CSS must be the last one.
7 | 'prettier-plugin-tailwindcss',
8 | ],
9 | singleQuote: true,
10 | tailwindAttributes: ['className'],
11 | tailwindFunctions: ['cx'],
12 | };
13 |
--------------------------------------------------------------------------------
/src/app/(app)/(tabs)/_layout.tsx:
--------------------------------------------------------------------------------
1 | import _AntDesign from '@expo/vector-icons/AntDesign.js';
2 | import { Tabs } from 'expo-router';
3 | import { fbs } from 'fbtee';
4 | import { FC } from 'react';
5 | import { Pressable, View } from 'react-native';
6 | import getLocale from 'src/i18n/getLocale.tsx';
7 | import colors from 'src/ui/colors.ts';
8 | import Text from 'src/ui/Text.tsx';
9 | import useViewerContext from 'src/user/useViewerContext.tsx';
10 |
11 | // Types in `@expo/vector-icons` do not currently work correctly in `"type": "module"` packages.
12 | const AntDesign = _AntDesign as unknown as FC<{
13 | color: string;
14 | name: string;
15 | size: number;
16 | }>;
17 |
18 | export default function TabLayout() {
19 | const { locale, setLocale } = useViewerContext();
20 |
21 | return (
22 |
30 | (
34 | setLocale(locale === 'ja_JP' ? 'en_US' : 'ja_JP')}
37 | >
38 | {({ pressed }) => (
39 |
44 | {getLocale().split('_')[0]}
45 |
46 | )}
47 |
48 | ),
49 | tabBarIcon: ({ focused }: { focused: boolean }) => (
50 |
55 | ),
56 | title: String(fbs('Home', 'Home tab title')),
57 | }}
58 | />
59 | (
63 |
68 | ),
69 | title: String(fbs('Two', 'Two tab title')),
70 | }}
71 | />
72 |
73 | );
74 | }
75 |
--------------------------------------------------------------------------------
/src/app/(app)/(tabs)/index.tsx:
--------------------------------------------------------------------------------
1 | import { Stack } from 'expo-router';
2 | import { fbs } from 'fbtee';
3 | import { View } from 'react-native';
4 | import Text from 'src/ui/Text.tsx';
5 |
6 | export default function Index() {
7 | return (
8 | <>
9 |
12 |
13 |
14 | Welcome
15 |
16 |
17 | Modern, sensible defaults, fast.
18 |
19 |
20 |
21 |
22 | Change{' '}
23 |
24 | src/app/(app)/(tabs)/index.tsx
25 | {' '}
26 | for live updates.
27 |
28 |
29 |
30 |
31 | >
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/src/app/(app)/(tabs)/two.tsx:
--------------------------------------------------------------------------------
1 | import { View } from 'react-native';
2 | import Text from 'src/ui/Text.tsx';
3 | import useViewerContext from 'src/user/useViewerContext.tsx';
4 |
5 | export default function Two() {
6 | const { logout } = useViewerContext();
7 |
8 | return (
9 |
10 |
11 | Logout
12 |
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/src/app/(app)/_layout.tsx:
--------------------------------------------------------------------------------
1 | import { BottomSheetModalProvider } from '@gorhom/bottom-sheet';
2 | import { Redirect, Stack } from 'expo-router';
3 | import { Fragment } from 'react/jsx-runtime';
4 | import useViewerContext from 'src/user/useViewerContext.tsx';
5 |
6 | export default function TabLayout() {
7 | const { isAuthenticated, locale } = useViewerContext();
8 |
9 | if (!isAuthenticated) {
10 | return ;
11 | }
12 |
13 | return (
14 |
15 |
16 |
17 |
26 |
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/src/app/+html.tsx:
--------------------------------------------------------------------------------
1 | import { ScrollViewStyleReset } from 'expo-router/html.js';
2 | import { ReactNode } from 'react';
3 |
4 | // This file is web-only and used to configure the root HTML for every
5 | // web page during static rendering.
6 | // The contents of this function only run in Node.js environments and
7 | // do not have access to the DOM or browser APIs.
8 | export default function Root({ children }: { children: ReactNode }) {
9 | return (
10 |
11 |
12 |
13 |
14 |
15 | {/*
16 | This viewport disables scaling which makes the mobile website act more like a native app.
17 | However this does reduce built-in accessibility. If you want to enable scaling, use this instead:
18 |
19 | */}
20 |
24 | {/*
25 | Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
26 | However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
27 | */}
28 |
29 |
30 | {/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
31 |
32 | {/* Add any additional elements that you want globally available on web... */}
33 |
34 | {children}
35 |
36 | );
37 | }
38 |
39 | const responsiveBackground = `
40 | body {
41 | background-color: #fff;
42 | }
43 | @media (prefers-color-scheme: dark) {
44 | body {
45 | background-color: #000;
46 | }
47 | }`;
48 |
--------------------------------------------------------------------------------
/src/app/+not-found.tsx:
--------------------------------------------------------------------------------
1 | import { Link, Stack } from 'expo-router';
2 | import { View } from 'react-native';
3 | import Text from 'src/ui/Text.tsx';
4 |
5 | export default function NotFoundScreen() {
6 | return (
7 | <>
8 |
9 |
10 |
11 |
12 | This screen doesn't exist.
13 |
14 |
15 |
16 |
17 | Go to home screen!
18 |
19 |
20 |
21 | >
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/app/_layout.tsx:
--------------------------------------------------------------------------------
1 | import 'src/setup.tsx';
2 | import 'global.css';
3 | import { Slot } from 'expo-router';
4 | import { View } from 'react-native';
5 | import { GestureHandlerRootView } from 'react-native-gesture-handler';
6 | import { ViewerContext } from 'src/user/useViewerContext.tsx';
7 |
8 | export const unstable_settings = {
9 | initialRouteName: '(app)',
10 | };
11 |
12 | export default function RootLayout() {
13 | return (
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/src/app/login.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'expo-router';
2 | import { useCallback } from 'react';
3 | import { SafeAreaView, View } from 'react-native';
4 | import Text from 'src/ui/Text.tsx';
5 | import useViewerContext from 'src/user/useViewerContext.tsx';
6 |
7 | export default function Login() {
8 | const router = useRouter();
9 | const { login } = useViewerContext();
10 |
11 | const onPress = useCallback(async () => {
12 | await login();
13 | router.replace('/');
14 | }, [login, router]);
15 |
16 | return (
17 |
18 |
19 |
20 | Login
21 |
22 |
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/src/i18n/getLocale.tsx:
--------------------------------------------------------------------------------
1 | import isPresent from '@nkzw/core/isPresent.js';
2 | import { getLocales as getDeviceLocales } from 'expo-localization';
3 | import { TranslationDictionary, TranslationTable } from 'fbtee';
4 |
5 | const AvailableLanguages = new Map([
6 | ['en_US', 'English'],
7 | ['ja_JP', '日本語 (Japanese)'],
8 | ] as const);
9 |
10 | type LocaleLoaderFn = (
11 | locale: string,
12 | ) => Promise<{ [hashKey: string]: TranslationTable }>;
13 |
14 | const _defaultLanguage = 'en_US';
15 | const availableLocales = new Map();
16 | const translations: TranslationDictionary = { [_defaultLanguage]: {} };
17 |
18 | for (const [locale] of AvailableLanguages) {
19 | availableLocales.set(locale, locale);
20 | availableLocales.set(locale.split('_')[0], locale);
21 | }
22 |
23 | export async function setClientLocale(
24 | locale: string,
25 | loadLocale: LocaleLoaderFn,
26 | ) {
27 | if (availableLocales.has(locale)) {
28 | await maybeLoadLocale(locale, loadLocale);
29 | if (locale !== currentLanguage) {
30 | currentLanguage = locale;
31 | }
32 | }
33 | }
34 |
35 | export function getLocales({
36 | fallback = _defaultLanguage,
37 | } = {}): ReadonlyArray {
38 | return Array.from(
39 | new Set(
40 | [...getDeviceLocales().map(({ languageTag }) => languageTag), fallback]
41 | .flatMap((locale: string) => {
42 | if (!locale) {
43 | return null;
44 | }
45 | const [first = '', second] = locale.split(/-|_/);
46 | return [
47 | `${first.toLowerCase()}${second ? `_${second.toUpperCase()}` : ''}`,
48 | first.toLowerCase(),
49 | ];
50 | })
51 | .filter(isPresent),
52 | ),
53 | );
54 | }
55 |
56 | let currentLanguage: string | null;
57 |
58 | export default function getLocale(defaultLanguage = _defaultLanguage): string {
59 | if (currentLanguage) {
60 | return currentLanguage;
61 | }
62 |
63 | for (const locale of getLocales()) {
64 | const localeName = availableLocales.get(locale);
65 | if (localeName) {
66 | currentLanguage = localeName;
67 | return localeName;
68 | }
69 | }
70 | currentLanguage = defaultLanguage;
71 | return defaultLanguage;
72 | }
73 |
74 | export function getTranslationsObject() {
75 | return translations;
76 | }
77 |
78 | export async function maybeLoadLocale(
79 | locale: string,
80 | loadLocale: LocaleLoaderFn,
81 | ) {
82 | if (
83 | availableLocales.has(locale) &&
84 | !translations[locale] &&
85 | locale !== _defaultLanguage
86 | ) {
87 | translations[locale] = await loadLocale(locale);
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/src/lib/cx.tsx:
--------------------------------------------------------------------------------
1 | export { default as cx } from 'classnames';
2 |
--------------------------------------------------------------------------------
/src/setup.tsx:
--------------------------------------------------------------------------------
1 | import { IntlVariations, setupFbtee } from 'fbtee';
2 | import getLocale, {
3 | getTranslationsObject,
4 | setClientLocale,
5 | } from './i18n/getLocale.tsx';
6 | import ja_JP from './translations/ja_JP.json' with { type: 'json' };
7 |
8 | setupFbtee({
9 | hooks: {
10 | getViewerContext: () => ({
11 | GENDER: IntlVariations.GENDER_UNKNOWN,
12 | locale: getLocale(),
13 | }),
14 | },
15 | translations: getTranslationsObject(),
16 | });
17 |
18 | setClientLocale('ja_JP', async (locale) => {
19 | if (locale === 'ja_JP') {
20 | return ja_JP.ja_JP;
21 | }
22 | return {};
23 | });
24 |
--------------------------------------------------------------------------------
/src/tests/App.test.tsx:
--------------------------------------------------------------------------------
1 | import { expect, test } from 'vitest';
2 |
3 | test('App', () => {
4 | expect('apple').not.toBe('banana');
5 | });
6 |
--------------------------------------------------------------------------------
/src/ui/BottomSheetModal.tsx:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line @typescript-eslint/no-restricted-imports
2 | import { BottomSheetModal as OriginalBottomSheetModal } from '@gorhom/bottom-sheet';
3 | import { cssInterop } from 'nativewind';
4 |
5 | export const BottomSheetModal = cssInterop(OriginalBottomSheetModal, {
6 | className: {
7 | target: 'style',
8 | },
9 | });
10 |
11 | export type BottomSheetModal = OriginalBottomSheetModal;
12 |
--------------------------------------------------------------------------------
/src/ui/Text.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from 'react';
2 | // eslint-disable-next-line @typescript-eslint/no-restricted-imports
3 | import { Text as ReactNativeText, TextProps } from 'react-native';
4 | import { cx } from 'src/lib/cx.tsx';
5 |
6 | export default function Text({
7 | children,
8 | className,
9 | style,
10 | ...props
11 | }: {
12 | children: ReactNode;
13 | className?: string;
14 | } & TextProps) {
15 | return (
16 |
21 | {children}
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/src/ui/colors.ts:
--------------------------------------------------------------------------------
1 | const colors = {
2 | black: '#111',
3 | grey: '#ededed',
4 | purple: '#7e22ce',
5 | white: '#fff',
6 | };
7 |
8 | export type ColorName = keyof typeof colors;
9 |
10 | export default colors;
11 |
--------------------------------------------------------------------------------
/src/user/useViewerContext.tsx:
--------------------------------------------------------------------------------
1 | import createContextHook from '@nkzw/create-context-hook';
2 | import UntypedAsyncStorage from '@react-native-async-storage/async-storage';
3 | import { useRouter } from 'expo-router';
4 | import { useCallback, useState } from 'react';
5 | import getLocale, { setClientLocale } from 'src/i18n/getLocale.tsx';
6 |
7 | // The type of AsyncStorage is not correctly exported when using `"type": "module"` 🤷♂️.
8 | const AsyncStorage = UntypedAsyncStorage as unknown as Readonly<{
9 | getItem: (key: string) => Promise;
10 | setItem: (key: string, value: string) => Promise;
11 | }>;
12 |
13 | type LocalSettings = Readonly<{
14 | localSettingExample: string | null;
15 | }>;
16 |
17 | type ViewerContext = Readonly<{
18 | user: Readonly<{
19 | id: string;
20 | }>;
21 | }>;
22 |
23 | const getLocalStorageKey = (userID: string) =>
24 | `$userData${userID}$localSettings`;
25 |
26 | const initialLocalSettings = {
27 | localSettingExample: null,
28 | } as const;
29 |
30 | const [ViewerContext, useViewerContext] = createContextHook(() => {
31 | const router = useRouter();
32 |
33 | const [viewerContext, setViewerContext] = useState(
34 | null,
35 | );
36 |
37 | const user = viewerContext?.user;
38 |
39 | const [locale, _setLocale] = useState(getLocale);
40 |
41 | const setLocale = useCallback((locale: string) => {
42 | setClientLocale(locale, async () => ({}));
43 | _setLocale(locale);
44 | }, []);
45 |
46 | const [localSettings, setLocalSettings] =
47 | useState(initialLocalSettings);
48 |
49 | const updateLocalSettings = useCallback(
50 | (settings: Partial) => {
51 | const newSettings = {
52 | ...localSettings,
53 | ...settings,
54 | };
55 |
56 | setLocalSettings(newSettings);
57 |
58 | if (user?.id) {
59 | AsyncStorage.setItem(
60 | getLocalStorageKey(user.id),
61 | JSON.stringify(newSettings),
62 | );
63 | }
64 | },
65 | [localSettings, user],
66 | );
67 |
68 | const login = useCallback(async () => {
69 | // Implement your login logic here.
70 | setViewerContext({
71 | user: { id: '4' },
72 | });
73 | router.replace('/');
74 | }, [router]);
75 |
76 | const logout = useCallback(async () => {
77 | // Implement your logout logic here.
78 | setViewerContext(null);
79 | router.replace('/');
80 | }, [router]);
81 |
82 | return {
83 | isAuthenticated: !!user,
84 | locale,
85 | localSettings,
86 | login,
87 | logout,
88 | setLocale,
89 | updateLocalSettings,
90 | user,
91 | };
92 | });
93 |
94 | export function useLocalSettings() {
95 | const { localSettings, updateLocalSettings } = useViewerContext();
96 | return [localSettings, updateLocalSettings] as const;
97 | }
98 |
99 | export { ViewerContext };
100 | export default useViewerContext;
101 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | // @ts-expect-error This file does not have type definitions.
2 | import nativewindPreset from 'nativewind/dist/tailwind/index.js';
3 | import type { Config } from 'tailwindcss';
4 | import type { CSSRuleObject } from 'tailwindcss/types/config.d.ts';
5 | import colors from './src/ui/colors.ts';
6 |
7 | const variables: { [key: string]: string } = {};
8 | const colorMap: { [key: string]: string } = {};
9 |
10 | for (const [name, color] of Object.entries(colors)) {
11 | variables[`--${name}`] = color;
12 | colorMap[name] = `var(--${name})`;
13 | }
14 |
15 | export default {
16 | content: ['./src/**/*.{js,ts,tsx}'],
17 | plugins: [
18 | ({
19 | addBase,
20 | }: {
21 | addBase: (base: CSSRuleObject | Array) => void;
22 | }) =>
23 | addBase({
24 | ':root': variables,
25 | }),
26 | ],
27 | presets: [nativewindPreset],
28 | theme: {
29 | colors: colorMap,
30 | },
31 | } satisfies Config;
32 |
--------------------------------------------------------------------------------
/translations/ja_JP.json:
--------------------------------------------------------------------------------
1 | {
2 | "fb-locale": "ja_JP",
3 | "translations": {
4 | "UlDdQS10S2ZoiDzl5NopHg==": {
5 | "tokens": [],
6 | "types": [],
7 | "translations": [
8 | {
9 | "translation": "ホーム",
10 | "variations": []
11 | }
12 | ]
13 | },
14 | "D3/NjeAAjEGGNX8v0HM2dA==": {
15 | "tokens": [],
16 | "types": [],
17 | "translations": [
18 | {
19 | "translation": "ホーム",
20 | "variations": []
21 | }
22 | ]
23 | },
24 | "y7ytpzjv1/Y+s3HivTUHMQ==": {
25 | "tokens": [],
26 | "types": [],
27 | "translations": [
28 | {
29 | "translation": "ようこそ",
30 | "variations": []
31 | }
32 | ]
33 | },
34 | "EzI08DzuAz+m8GpFANRzGg==": {
35 | "tokens": [],
36 | "types": [],
37 | "translations": [
38 | {
39 | "translation": "モダンで、賢明なデフォルト、高速。",
40 | "variations": []
41 | }
42 | ]
43 | },
44 | "0aaRPLpuUIudmtHBdXuxvQ==": {
45 | "tokens": [],
46 | "types": [],
47 | "translations": [
48 | {
49 | "translation": "ライブ更新が反映されるように、{=src/app/(app)/(tabs)/index.tsx} を変更してください。",
50 | "variations": []
51 | }
52 | ]
53 | },
54 | "F3p8DbdliugG4CTKslAYqA==": {
55 | "tokens": [],
56 | "types": [],
57 | "translations": [
58 | {
59 | "translation": "ログイン",
60 | "variations": []
61 | }
62 | ]
63 | },
64 | "PkKwcnNiHG2aiPl+1FyNPw==": {
65 | "tokens": [],
66 | "types": [],
67 | "translations": [
68 | {
69 | "translation": "ログアウト",
70 | "variations": []
71 | }
72 | ]
73 | },
74 | "lHXO08CtjrF6j/7VvIlkRQ==": {
75 | "tokens": [],
76 | "types": [],
77 | "translations": [
78 | {
79 | "translation": "ログアウト",
80 | "variations": []
81 | }
82 | ]
83 | },
84 | "9wXUM5wXf6pOsxVkd/dkTw==": {
85 | "tokens": [],
86 | "types": [],
87 | "translations": [
88 | {
89 | "translation": "二",
90 | "variations": []
91 | }
92 | ]
93 | }
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "expo/tsconfig.base",
3 | "compilerOptions": {
4 | "allowImportingTsExtensions": true,
5 | "allowJs": true,
6 | "baseUrl": ".",
7 | "checkJs": true,
8 | "esModuleInterop": true,
9 | "forceConsistentCasingInFileNames": true,
10 | "incremental": true,
11 | "isolatedModules": true,
12 | "jsx": "react-jsx",
13 | "module": "nodenext",
14 | "moduleResolution": "nodenext",
15 | "noEmit": true,
16 | "noImplicitOverride": true,
17 | "noUnusedLocals": true,
18 | "resolveJsonModule": true,
19 | "skipLibCheck": true,
20 | "strict": true,
21 | "target": "es2022"
22 | },
23 | "exclude": ["node_modules"],
24 | "include": [
25 | "**/*.ts",
26 | "**/*.tsx",
27 | ".expo/types/**/*.ts",
28 | "expo-env.d.ts",
29 | "nativewind-env.d.ts"
30 | ]
31 | }
32 |
--------------------------------------------------------------------------------
/vitest.config.js:
--------------------------------------------------------------------------------
1 | // this is needed for react jsx support
2 | import fbteePreset from '@nkzw/babel-preset-fbtee';
3 | import react from '@vitejs/plugin-react';
4 | import reactNative from 'vitest-react-native';
5 |
6 | export default {
7 | plugins: [
8 | reactNative(),
9 | react({
10 | babel: {
11 | presets: [fbteePreset],
12 | },
13 | }),
14 | ],
15 | };
16 |
--------------------------------------------------------------------------------