template_0205

This commit is contained in:
Sofio
2026-02-05 13:16:05 +08:00
commit d93e4d9c9f
197 changed files with 52810 additions and 0 deletions

View File

@@ -0,0 +1,195 @@
const metroConfig = require("./metro.config.js")
const platforms = ["ios", "android", "web", "native"]
const extensions = metroConfig?.resolver?.sourceExts.flatMap((pExt) =>
platforms.map((platform) => `.${platform}.${pExt}`).concat(`.${pExt}`),
)
/** @type {import('dependency-cruiser').IConfiguration} */
module.exports = {
forbidden: [
{
name: "no-circular",
severity: "warn",
comment:
"This dependency is part of a circular relationship. You might want to revise " +
"your solution (i.e. use dependency inversion, make sure the modules have a single responsibility) ",
from: {},
to: {
circular: true,
},
},
{
name: "no-orphans",
comment:
"This is an orphan module - it's likely not used (anymore?). Either use it or " +
"remove it. If it's logical this module is an orphan (i.e. it's a config file), " +
"add an exception for it in your dependency-cruiser configuration. By default " +
"this rule does not scrutinize dot-files (e.g. .eslintrc.js), TypeScript declaration " +
"files (.d.ts), tsconfig.json and some of the babel and webpack configs.",
severity: "warn",
from: {
orphan: true,
pathNot: [
"(^|/)\\.[^/]+\\.(js|cjs|mjs|ts|json)$",
"\\.d\\.ts$",
"(^|/)tsconfig\\.json$",
"(^|/)(babel|webpack)\\.config\\.(js|cjs|mjs|ts|json)$",
"crashReporting\\.ts$", // Boilerplate file for future crash reporting setup
"utils/delay\\.ts$", // Utility function for delaying execution
],
},
to: {},
},
{
name: "no-deprecated-core",
comment:
"A module depends on a node core module that has been deprecated. Find an alternative - these are " +
"bound to exist - node doesn't deprecate lightly.",
severity: "warn",
from: {},
to: {
dependencyTypes: ["core"],
path: ["^(punycode)$", "^(domain)$", "^(constants)$", "^(sys)$"],
},
},
{
name: "not-to-deprecated",
comment:
"This module uses a (version of an) npm module that has been deprecated. Either upgrade to a later " +
"version of that module, or find an alternative. Deprecated modules are a security risk.",
severity: "warn",
from: {},
to: {
dependencyTypes: ["deprecated"],
},
},
{
name: "no-non-package-json",
severity: "error",
comment:
"This module depends on an npm package that isn't in the 'dependencies' section of your package.json. " +
"That's problematic as the package either (1) won't be available on live (2) will be " +
"available on live with an non-guaranteed version. Fix it by adding the package to the dependencies " +
"in your package.json.",
from: {},
to: {
dependencyTypes: ["npm-no-pkg", "npm-unknown"],
},
},
{
name: "not-to-unresolvable",
comment:
"This module depends on a module that cannot be found ('resolved to disk'). If it's an npm " +
"module: add it to your package.json. In all other cases you likely already know what to do.",
severity: "error",
from: {},
to: {
couldNotResolve: true,
},
},
{
name: "no-duplicate-dep-types",
comment:
"Likely this module depends on an external ('npm') package that occurs more than once " +
"in your package.json i.e. bot as a devDependencies and in dependencies. This will cause " +
"maintenance problems later on.",
severity: "warn",
from: {},
to: {
moreThanOneDependencyType: true,
dependencyTypesNot: ["type-only"],
},
},
{
name: "not-to-spec",
comment:
"This module depends on a spec (test) file. The sole responsibility of a spec file is to test code. " +
"If there's something in a spec that's of use to other modules, it doesn't have that single " +
"responsibility anymore. Factor it out into (e.g.) a separate utility/ helper or a mock.",
severity: "error",
from: {
pathNot: "\\.(spec|test)\\.(js|mjs|cjs|ts|tsx)$",
},
to: {
path: "\\.(spec|test)\\.(js|mjs|cjs|ts|tsx)$",
},
},
{
name: "not-to-dev-dep",
severity: "error",
comment:
"This module depends on an npm package from the 'devDependencies' section of your " +
"package.json. It looks like something that ships to production, though. To prevent problems " +
"with npm packages that aren't there on production declare it (only!) in the 'dependencies'" +
"section of your package.json. If this module is development only - add it to the " +
"from.pathNot re of the not-to-dev-dep rule in the dependency-cruiser configuration",
from: {
path: "^(app|src)",
pathNot: "\\.(spec|test)\\.(js|mjs|cjs|ts|tsx)$",
},
to: {
dependencyTypes: ["npm-dev"],
pathNot: ["node_modules/@types/"],
exoticRequireNot: [
"react-native/Libraries/Utilities/codegenNativeComponent",
"react-native/Libraries/Utilities/codegenNativeCommands",
],
},
},
{
name: "optional-deps-used",
severity: "info",
comment:
"This module depends on an npm package that is declared as an optional dependency " +
"in your package.json. As this makes sense in limited situations only, it's flagged here. " +
"If you're using an optional dependency here by design - add an exception to your" +
"dependency-cruiser configuration.",
from: {},
to: {
dependencyTypes: ["npm-optional"],
},
},
{
name: "peer-deps-used",
comment:
"This module depends on an npm package that is declared as a peer dependency " +
"in your package.json. This makes sense if your package is e.g. a plugin, but in " +
"other cases - maybe not so much. If the use of a peer dependency is intentional " +
"add an exception to your dependency-cruiser configuration.",
severity: "warn",
from: {},
to: {
dependencyTypes: ["npm-peer"],
},
},
],
options: {
doNotFollow: {
path: "node_modules",
},
tsPreCompilationDeps: true,
tsConfig: {
fileName: "tsconfig.json",
},
enhancedResolveOptions: {
exportsFields: ["exports"],
conditionNames: ["import", "require", "node", "default"],
// React Native / Metro bundler support for platform-specific extensions
// See: https://reactnative.dev/docs/platform-specific-code
// See: https://github.com/sverweij/dependency-cruiser/issues/511
extensions,
},
reporterOptions: {
dot: {
collapsePattern: "node_modules/(@[^/]+/[^/]+|[^/]+)",
},
archi: {
collapsePattern: "^(app|src|test)/[^/]+",
},
text: {
highlightFocused: true,
},
},
},
}

6
RN_TEMPLATE/.env Normal file
View File

@@ -0,0 +1,6 @@
API_URL=https://auth.upay01.com
GOOGLE_WEB_CLIENT_ID=500211604129-4c5ij6e87jhlitoaclf8gfkco8ca6t9k.apps.googleusercontent.com
GOOGLE_IOS_CLIENT_ID=500211604129-uiln0fhiaj05jjg256oahkdcucpi6cqb.apps.googleusercontent.com
GOOGLE_ANDROID_CLIENT_ID=500211604129-f5rsg6e1bi7300i0goii8ckl9ld5jk3r.apps.googleusercontent.com
# iOS URL Scheme = reversed iOS Client ID (required for Google Sign-In callback)
GOOGLE_IOS_URL_SCHEME=com.googleusercontent.apps.500211604129-uiln0fhiaj05jjg256oahkdcucpi6cqb

View File

@@ -0,0 +1,8 @@
node_modules
ios
android
.expo
.vscode
ignite/ignite.json
package.json
.eslintignore

105
RN_TEMPLATE/.eslintrc.js Normal file
View File

@@ -0,0 +1,105 @@
// https://docs.expo.dev/guides/using-eslint/
module.exports = {
root: true,
extends: [
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react-native/all",
// `expo` must come after `standard` or its globals configuration will be overridden
"expo",
// `jsx-runtime` must come after `expo` or it will be overridden
"plugin:react/jsx-runtime",
"prettier",
],
plugins: ["reactotron", "prettier"],
rules: {
"prettier/prettier": "error",
// typescript-eslint
"@typescript-eslint/array-type": 0,
"@typescript-eslint/ban-ts-comment": 0,
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
},
],
"@typescript-eslint/no-var-requires": 0,
"@typescript-eslint/no-require-imports": 0,
"@typescript-eslint/no-empty-object-type": 0,
// eslint
"no-use-before-define": 0,
"no-restricted-imports": [
"error",
{
paths: [
// Prefer named exports from 'react' instead of importing `React`
{
name: "react",
importNames: ["default"],
message: "Import named exports from 'react' instead.",
},
{
name: "react-native",
importNames: ["SafeAreaView"],
message: "Use the SafeAreaView from 'react-native-safe-area-context' instead.",
},
{
name: "react-native",
importNames: ["Text", "Button", "TextInput"],
message: "Use the custom wrapper component from '@/components'.",
},
],
},
],
// react
"react/prop-types": 0,
// react-native
"react-native/no-raw-text": 0,
// reactotron
"reactotron/no-tron-in-production": "error",
// eslint-config-standard overrides
"comma-dangle": 0,
"no-global-assign": 0,
"quotes": 0,
"space-before-function-paren": 0,
// eslint-import
"import/order": [
"error",
{
"alphabetize": {
order: "asc",
caseInsensitive: true,
},
"newlines-between": "always",
"groups": [["builtin", "external"], "internal", "unknown", ["parent", "sibling"], "index"],
"distinctGroup": false,
"pathGroups": [
{
pattern: "react",
group: "external",
position: "before",
},
{
pattern: "react-native",
group: "external",
position: "before",
},
{
pattern: "expo{,-*}",
group: "external",
position: "before",
},
{
pattern: "@/**",
group: "unknown",
position: "after",
},
],
"pathGroupsExcludedImportTypes": ["react", "react-native", "expo", "expo-*"],
},
],
"import/newline-after-import": 1,
},
}

92
RN_TEMPLATE/.gitignore vendored Normal file
View File

@@ -0,0 +1,92 @@
# OSX
#
.DS_Store
# Xcode
#
build/
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
xcuserdata
*.xccheckout
*.moved-aside
DerivedData
*.hmap
*.ipa
*.xcuserstate
ios/.xcode.env.local
# Android/IntelliJ
#
build/
.idea
.gradle
local.properties
*.iml
*.hprof
.cxx/
# node.js
#
node_modules/
npm-debug.log
# BUCK
buck-out/
\.buckd/
*.keystore
!debug.keystore
# fastlane
#
# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the
# screenshots whenever they are needed.
# For more information about the recommended setup visit:
# https://docs.fastlane.tools/best-practices/source-control/
**/fastlane/report.xml
**/fastlane/Preview.html
**/fastlane/screenshots
**/fastlane/test_output
# Bundle artifact
*.jsbundle
# Ignite-specific items below
# You can safely replace everything above this comment with whatever is
# in the default .gitignore generated by React-Native CLI
# VS Code
.vscode
# Expo
.expo/*
bin/Exponent.app
/android
/ios
expo-env.d.ts
## Secrets
npm-debug.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
web-build/
# Configurations
!env.js
/coverage
# Dependency Graph Visualizations
app-dependency-graph.svg
app-dependency-graph.png

View File

@@ -0,0 +1,37 @@
# flow: run the login flow and then navigate to the demo podcast list screen, favorite a podcast, and then switch the list to only be favorites.
appId: ${MAESTRO_APP_ID}
env:
FAVORITES_TEXT: "Switch on to only show favorites" # en.demoPodcastListScreen.accessibility.switch
onFlowStart:
- runFlow: ../shared/_OnFlowStart.yaml
---
- runFlow: ../shared/_Login.yaml
- tapOn: "Podcast"
- assertVisible: "React Native Radio episodes"
- tapOn:
text: ${FAVORITES_TEXT}
- assertVisible: "This looks a bit empty"
- tapOn:
text: ${FAVORITES_TEXT}
# https://maestro.mobile.dev/troubleshooting/known-issues#android-accidental-double-tap
retryTapIfNoChange: false
- repeat:
times: 2
commands:
- scroll
- copyTextFrom:
text: "RNR .*" # assumes all podcast titles start with RNR
index: 2 # grab the third one, others might not be fully visible
- longPressOn: ${maestro.copiedText}
- scrollUntilVisible:
element:
text: ${FAVORITES_TEXT}
direction: UP
timeout: 50000
speed: 90
visibilityPercentage: 100
- tapOn:
text: ${FAVORITES_TEXT}
- assertVisible: ${maestro.copiedText}

View File

@@ -0,0 +1,13 @@
#flow: Login
#intent:
# Open up our app and use the default credentials to login
# and navigate to the demo screen
appId: ${MAESTRO_APP_ID} # the app id of the app we want to test
# You can find the appId of an Ignite app in the `app.json` file
# as the "package" under the "android" section and "bundleIdentifier" under the "ios" section
onFlowStart:
- runFlow: ../shared/_OnFlowStart.yaml
---
- runFlow: ../shared/_Login.yaml

View File

@@ -0,0 +1,12 @@
#flow: Shared _Login
#intent: shared login flow for any flow that needs to start with a log in.
appId: ${MAESTRO_APP_ID}
---
- assertVisible: "Log In"
- tapOn:
text: "Tap to Log in!"
- assertVisible: "Your app, almost ready for launch!"
- tapOn:
text: "Let's go!"
- assertVisible: "Components to jump start your project!"

View File

@@ -0,0 +1,39 @@
# flow: Shared _OnFlowStart
#intent:
# launch the app with a completely clear state, wait for animations to settle,
# and click through the expo dev screens if needed.
# These conditionals slow the app launch down a little but are necessary because the expo
# dev server and launch screen are only shown when the new architecture is turned off in expo 53.
# So we check to see if we need to connect to the metro server... that loads the app and then we
# check if the dev menu is showing and dismiss it if necessary.
# Then the app is then launched and ready for the maestro tests to run.
#
# This flow should be included in every maestro test header as `onFlowStart` to ensure expo screens
# are bypassed if necessary. Example:
#
# appId: ${MAESTRO_APP_ID}
# onFlowStart:
# - runFlow: ../shared/_OnFlowStart.yaml
# ---
# [your maestro flow]
#
appId: ${MAESTRO_APP_ID}
---
# launch the app with a clean slate
- launchApp:
clearState: true
clearKeychain: true
stopApp: true
- waitForAnimationToEnd
# conditionally run the dev client flow if the words "Development servers" is present.
# this uses the default maestro timeout and moves on if it doesn't see the text.
- runFlow:
when:
visible: 'Development servers'
commands:
# this regex allows for different hosts and ports
- tapOn: "http://.*:.*"
- waitForAnimationToEnd
- tapOn: "Close" # dismiss the bottom sheet

2
RN_TEMPLATE/.npmrc Normal file
View File

@@ -0,0 +1,2 @@
undefined
node-linker=hoisted

View File

@@ -0,0 +1,9 @@
node_modules
ios
android
.expo
.vscode
ignite/ignite.json
package.json
.eslintignore
*.ejs

7
RN_TEMPLATE/.prettierrc Normal file
View File

@@ -0,0 +1,7 @@
{
"printWidth": 100,
"semi": false,
"singleQuote": false,
"trailingComma": "all",
"quoteProps": "consistent"
}

314
RN_TEMPLATE/CLAUDE.md Normal file
View File

@@ -0,0 +1,314 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## user rools
每次开头请叫我哥哥
请严格遵守ui设计规范使用响应式布局设计不要硬编码
请使用项目里面封装好的ui组件如果没有请提醒我
## Project Overview
React Native + Expo mobile application built on **Ignite CLI boilerplate** (v11.4.0). Uses TypeScript, React Navigation 7, and NativeWind (Tailwind CSS).
## Common Commands
```bash
pnpm install # Install dependencies
pnpm run start # Start Expo dev client
pnpm run android # Run on Android (expo run:android)
pnpm run ios # Run on iOS (expo run:ios)
pnpm run compile # TypeScript type check (tsc --noEmit)
pnpm run lint # ESLint with auto-fix
pnpm run lint:check # ESLint without fix
pnpm run test # Run Jest tests
pnpm run test:watch # Run Jest in watch mode
# EAS Builds (requires expo-cli)
pnpm run build:ios:sim # iOS simulator build
pnpm run build:android:sim # Android emulator build
pnpm run prebuild:clean # Regenerate native projects
```
## Architecture
### Directory Structure
- `app/` - Main application source
- `components/` - 12个基础 UI 组件(详见下方组件列表)
- `screens/` - Screen components connected to navigation
- `screens/DemoShowroomScreen/demos/` - 组件演示页面(展示组件用法,非实际组件)
- `navigators/` - React Navigation configuration (AppNavigator, DemoNavigator)
- `context/` - React Context providers (AuthContext, ThemeContext, ToastContext)
- `services/api/` - API layer using Apisauce (`authApi.ts` for auth, `index.ts` for base API)
- `theme/` - Design tokens (colors, spacing, typography) and theme system
- `i18n/` - Internationalization with i18next (8 languages: en, zh, ar, es, fr, hi, ja, ko)
- `utils/` - Utilities including MMKV storage wrapper
- `config/` - Environment-based configuration (dev/prod)
- `devtools/` - Reactotron configuration
### Key Patterns
**State Management**: React Context + MMKV for persistence. No Redux.
**Theming**: Use `useAppTheme()` hook and `ThemedStyle<T>` pattern:
```typescript
const $container: ThemedStyle<ViewStyle> = ({ colors, spacing }) => ({
backgroundColor: colors.background,
padding: spacing.lg,
})
```
**Navigation**: Two-layer structure - AppStack (auth/main screens) and DemoNavigator (bottom tabs). Type-safe routes defined in `navigationTypes.ts`. Auth screens: AuthWelcome, Login, Register, ForgotPassword. Main screens: Welcome, Profile, Settings, Security, Theme, Language, About, etc.
**API Layer**: Singleton `Api` class in `services/api/index.ts` using Apisauce with typed responses.
**Styling**: NativeWind (Tailwind CSS) configured. Use `className` prop for Tailwind styles.
**Authentication**: `AuthContext` provides full auth flow with step-based state machines for login, register, and password reset. Supports email/password and Google Sign-In. Uses JWT tokens stored in MMKV.
## UI 设计规范
### 参考文件
| 类型 | 文件路径 | 说明 |
|------|----------|------|
| **设计令牌** | `app/theme/colors.ts` | 颜色系统(浅色主题) |
| | `app/theme/colorsDark.ts` | 颜色系统(深色主题) |
| | `app/theme/spacing.ts` | 间距系统 |
| | `app/theme/typography.ts` | 字体排版 |
| | `app/theme/styles.ts` | 全局样式($styles.container 等) |
| **UI 参考页面** | `app/screens/DemoCommunityScreen.tsx` | **最佳参考** - 标准页面结构、ListItem 用法、Section 标题 |
| | `app/screens/WelcomeScreen.tsx` | useHeader hook 用法 |
| | `app/screens/ProfileScreen.tsx` | 设置页面、用户卡片 |
| **组件演示** | `app/screens/DemoShowroomScreen/demos/` | 所有组件的用法示例(类似 Storybook |
### 设计令牌
**颜色语义**(定义在 `app/theme/colors.ts`:
```typescript
text: palette.neutral800 // 主要文本
textDim: palette.neutral600 // 次要文本
background: palette.neutral200 // 屏幕背景
border: palette.neutral400 // 边框
tint: palette.primary500 // 主色调(按钮、链接)
separator: palette.neutral300 // 分隔线
error: palette.angry500 // 错误
```
**间距系统**`app/theme/spacing.ts`:
```typescript
xxxs: 2, xxs: 4, xs: 8, sm: 12, md: 16, lg: 24, xl: 32, xxl: 48, xxxl: 64
```
**字体大小**`app/components/Text.tsx`:
```typescript
xxl: 36, xl: 24, lg: 20, md: 18, sm: 16(默认), xs: 14, xxs: 12
```
### Header 使用规范
**必须使用 `useHeader` hook**,不要直接在 Screen 内部放置 `<Header>` 组件。
```typescript
// ✅ 正确写法
import { useHeader } from "@/utils/useHeader"
useHeader({
title: translate("screenName:title"),
leftIcon: "back",
onLeftPress: () => navigation.goBack(),
}, [])
return (
<Screen safeAreaEdges={["bottom"]} contentContainerStyle={[$styles.container, themed($container)]}>
{/* 内容 */}
</Screen>
)
const $container: ThemedStyle<ViewStyle> = ({ spacing }) => ({
paddingTop: spacing.lg, // 覆盖 $styles.container 的 56px
})
```
**原因**: Header 组件默认添加顶部安全区域 padding直接放在 Screen 内容中会导致 Header 上方出现额外空白。`useHeader` 通过 `navigation.setOptions()` 将 Header 设置为导航层的一部分。
### 容器间距规范
`$styles.container`(定义在 `app/theme/styles.ts`:
```typescript
container: {
paddingTop: spacing.lg + spacing.xl, // = 56px为无 Header 的 Tab 页面设计)
paddingHorizontal: spacing.lg, // = 24px
}
```
**使用 useHeader 的页面**需在 `$container` 中覆盖 paddingTop 为 `spacing.lg` (24px)。
**不要**在页面内容中再添加额外的 paddingTop 或 marginTop避免重复间距。
### ListItem 使用规范
**参考**: `app/screens/DemoCommunityScreen.tsx`
**默认无分隔线**,保持简洁风格:
```typescript
<ListItem
tx="setting:option1"
leftIcon="settings"
rightIcon="caretRight"
onPress={handlePress}
/>
```
只有**连续列表项**需要视觉分隔时,才使用 `bottomSeparator`,且**最后一项不需要**:
```typescript
<ListItem text="Item 1" bottomSeparator />
<ListItem text="Item 2" bottomSeparator />
<ListItem text="Item 3" />
```
### Section 标题规范
**参考**: `app/screens/DemoCommunityScreen.tsx``$sectionTitle` 样式
使用 `preset="subheading"` 作为 section 标题:
```typescript
<Text preset="subheading" tx="section:title" style={themed($sectionTitle)} />
const $sectionTitle: ThemedStyle<TextStyle> = ({ spacing }) => ({
marginTop: spacing.xxl, // 48px
})
```
### 页面结构模板
**有 Header 的页面**(如设置、详情页):
```typescript
import { useHeader } from "@/utils/useHeader"
export const ExampleScreen: FC<Props> = ({ navigation }) => {
const { themed } = useAppTheme()
useHeader({
title: translate("exampleScreen:title"),
leftIcon: "back",
onLeftPress: () => navigation.goBack(),
}, [])
return (
<Screen
preset="scroll"
safeAreaEdges={["bottom"]}
contentContainerStyle={[$styles.container, themed($container)]}
>
{/* 内容 */}
</Screen>
)
}
const $container: ThemedStyle<ViewStyle> = ({ spacing }) => ({
paddingTop: spacing.lg,
})
```
**无 Header 的 Tab 页面**(参考 `DemoCommunityScreen.tsx`:
```typescript
export const ExampleTabScreen: FC<Props> = () => {
const { themed } = useAppTheme()
return (
<Screen preset="scroll" contentContainerStyle={$styles.container}>
<Text preset="heading" tx="screen:title" style={themed($title)} />
<Text tx="screen:description" style={themed($description)} />
<Text preset="subheading" tx="screen:section1" style={themed($sectionTitle)} />
<ListItem ... />
<ListItem ... />
</Screen>
)
}
const $title: ThemedStyle<TextStyle> = ({ spacing }) => ({
marginBottom: spacing.sm,
})
const $description: ThemedStyle<TextStyle> = ({ spacing }) => ({
marginBottom: spacing.lg,
})
const $sectionTitle: ThemedStyle<TextStyle> = ({ spacing }) => ({
marginTop: spacing.xxl,
})
```
### 组件列表 (`app/components/`)
| 组件 | 用途 |
|------|------|
| `Screen.tsx` | 屏幕容器SafeArea + 键盘适配 + 滚动) |
| `Header.tsx` | 页面头部导航栏 |
| `Card.tsx` | 卡片容器 |
| `TextField.tsx` | 文本输入框 |
| `Button.tsx` | 按钮default/filled/reversed 预设) |
| `EmptyState.tsx` | 空状态占位 |
| `ListItem.tsx` | 列表项 |
| `Icon.tsx` | 图标 |
| `Text.tsx` | 文本heading/subheading/bold 等预设) |
| `AutoImage.tsx` | 自适应图片 |
| `Toggle/` | 开关/复选框/单选框 |
| `Avatar.tsx` | 用户头像(使用 expo-image 缓存) |
| `Modal.tsx` | 模态框bottom/center 预设) |
| `Dialog.tsx` | 对话框(确认/提示) |
**注意**: `screens/DemoShowroomScreen/demos/` 目录下的 `Demo*.tsx` 文件是组件**演示页面**,用于展示组件的各种用法和变体,类似 Storybook不是实际组件。
### 自适应布局
**Screen 组件预设**:
- `preset="fixed"` - 固定布局,不可滚动
- `preset="scroll"` - 始终可滚动
- `preset="auto"` - 智能判断:内容超出屏幕时才启用滚动
**安全区域**: 使用 `useSafeAreaInsetsStyle` hook 处理刘海屏
```typescript
<Screen safeAreaEdges={["top", "bottom"]}>
{/* 内容自动避开刘海和底部指示条 */}
</Screen>
```
**间距系统** (`app/theme/spacing.ts`):
```typescript
xxxs: 2, xxs: 4, xs: 8, sm: 12, md: 16, lg: 24, xl: 32, xxl: 48, xxxl: 64
```
**字体大小** (`app/components/Text.tsx`) - 固定像素值,无响应式:
```typescript
xxl: 36, xl: 24, lg: 20, md: 18, sm: 16(默认), xs: 14, xxs: 12
```
### Path Aliases
- `@/*``app/*`
- `@assets/*``assets/*`
## Import Restrictions (ESLint enforced)
- Use `Text`, `Button`, `TextInput` from `@/components`, not from `react-native`
- Use `SafeAreaView` from `react-native-safe-area-context`, not from `react-native`
- Import named exports from `react` (not `import React from 'react'`)
## Testing
Jest with Testing Library for React Native. Test files use `.test.ts(x)` suffix. Setup in `test/setup.ts`.
Run single test:
```bash
pnpm run test -- --testPathPattern="storage"
```
## Web Support
This app runs on web via Expo. Use `pnpm run start` and press `w` to open web. Note: React Navigation's `headerRight` may not work reliably on web - consider placing header elements directly inside Screen components if targeting web.
## Running on Physical Devices

77
RN_TEMPLATE/README.md Normal file
View File

@@ -0,0 +1,77 @@
# Welcome to your new ignited app!
> The latest and greatest boilerplate for Infinite Red opinions
This is the boilerplate that [Infinite Red](https://infinite.red) uses as a way to test bleeding-edge changes to our React Native stack.
- [Quick start documentation](https://github.com/infinitered/ignite/blob/master/docs/boilerplate/Boilerplate.md)
- [Full documentation](https://github.com/infinitered/ignite/blob/master/docs/README.md)
## Getting Started
```bash
pnpm install
pnpm run start
```
To make things work on your local simulator, or on your phone, you need first to [run `eas build`](https://github.com/infinitered/ignite/blob/master/docs/expo/EAS.md). We have many shortcuts on `package.json` to make it easier:
```bash
pnpm run build:ios:sim # build for ios simulator
pnpm run build:ios:device # build for ios device
pnpm run build:ios:prod # build for ios device
```
### `./assets`
This directory is designed to organize and store various assets, making it easy for you to manage and use them in your application. The assets are further categorized into subdirectories, including `icons` and `images`:
```tree
assets
├── icons
└── images
```
**icons**
This is where your icon assets will live. These icons can be used for buttons, navigation elements, or any other UI components. The recommended format for icons is PNG, but other formats can be used as well.
Ignite comes with a built-in `Icon` component. You can find detailed usage instructions in the [docs](https://github.com/infinitered/ignite/blob/master/docs/boilerplate/app/components/Icon.md).
**images**
This is where your images will live, such as background images, logos, or any other graphics. You can use various formats such as PNG, JPEG, or GIF for your images.
Another valuable built-in component within Ignite is the `AutoImage` component. You can find detailed usage instructions in the [docs](https://github.com/infinitered/ignite/blob/master/docs/Components-AutoImage.md).
How to use your `icon` or `image` assets:
```typescript
import { Image } from 'react-native';
const MyComponent = () => {
return (
<Image source={require('assets/images/my_image.png')} />
);
};
```
## Running Maestro end-to-end tests
Follow our [Maestro Setup](https://ignitecookbook.com/docs/recipes/MaestroSetup) recipe.
## Next Steps
### Ignite Cookbook
[Ignite Cookbook](https://ignitecookbook.com/) is an easy way for developers to browse and share code snippets (or “recipes”) that actually work.
### Upgrade Ignite boilerplate
Read our [Upgrade Guide](https://ignitecookbook.com/docs/recipes/UpdatingIgnite) to learn how to upgrade your Ignite project.
## Community
⭐️ Help us out by [starring on GitHub](https://github.com/infinitered/ignite), filing bug reports in [issues](https://github.com/infinitered/ignite/issues) or [ask questions](https://github.com/infinitered/ignite/discussions).
💬 Join us on [Slack](https://join.slack.com/t/infiniteredcommunity/shared_invite/zt-1f137np4h-zPTq_CbaRFUOR_glUFs2UA) to discuss.
📰 Make our Editor-in-chief happy by [reading the React Native Newsletter](https://reactnativenewsletter.com/).

50
RN_TEMPLATE/app.config.ts Normal file
View File

@@ -0,0 +1,50 @@
import { ExpoConfig, ConfigContext } from "@expo/config"
/**
* Use tsx/cjs here so we can use TypeScript for our Config Plugins
* and not have to compile them to JavaScript.
*
* See https://docs.expo.dev/config-plugins/plugins/#add-typescript-support-and-convert-to-dynamic-app-config
*/
import "tsx/cjs"
/**
* @param config ExpoConfig coming from the static config app.json if it exists
*
* You can read more about Expo's Configuration Resolution Rules here:
* https://docs.expo.dev/workflow/configuration/#configuration-resolution-rules
*/
module.exports = ({ config }: ConfigContext): Partial<ExpoConfig> => {
const existingPlugins = config.plugins ?? []
return {
...config,
ios: {
...config.ios,
// This privacyManifests is to get you started.
// See Expo's guide on apple privacy manifests here:
// https://docs.expo.dev/guides/apple-privacy/
// You may need to add more privacy manifests depending on your app's usage of APIs.
// More details and a list of "required reason" APIs can be found in the Apple Developer Documentation.
// https://developer.apple.com/documentation/bundleresources/privacy-manifest-files
privacyManifests: {
NSPrivacyAccessedAPITypes: [
{
NSPrivacyAccessedAPIType: "NSPrivacyAccessedAPICategoryUserDefaults",
NSPrivacyAccessedAPITypeReasons: ["CA92.1"], // CA92.1 = "Access info from same app, per documentation"
},
],
},
},
plugins: [
...existingPlugins,
[
"@react-native-google-signin/google-signin",
{
// iOS URL Scheme = reversed iOS Client ID (for Google Sign-In callback)
iosUrlScheme: "com.googleusercontent.apps.500211604129-uiln0fhiaj05jjg256oahkdcucpi6cqb",
},
],
],
}
}

71
RN_TEMPLATE/app.json Normal file
View File

@@ -0,0 +1,71 @@
{
"name": "upay",
"slug": "test",
"scheme": "upay",
"version": "1.0.0",
"orientation": "portrait",
"userInterfaceStyle": "automatic",
"icon": "./assets/images/app-icon-all.png",
"updates": {
"fallbackToCacheTimeout": 0
},
"newArchEnabled": true,
"jsEngine": "hermes",
"assetBundlePatterns": [
"**/*"
],
"android": {
"icon": "./assets/images/app-icon-android-legacy.png",
"package": "com.upay",
"adaptiveIcon": {
"foregroundImage": "./assets/images/app-icon-android-adaptive-foreground.png",
"backgroundImage": "./assets/images/app-icon-android-adaptive-background.png"
},
"allowBackup": false,
"edgeToEdgeEnabled": true
},
"ios": {
"icon": "./assets/images/app-icon-ios.png",
"supportsTablet": true,
"bundleIdentifier": "com.upay"
},
"web": {
"favicon": "./assets/images/app-icon-web-favicon.png",
"bundler": "metro"
},
"plugins": [
"expo-localization",
"expo-font",
[
"expo-splash-screen",
{
"image": "./assets/images/app-icon-android-adaptive-foreground.png",
"imageWidth": 300,
"resizeMode": "contain",
"backgroundColor": "#191015"
}
],
[
"react-native-edge-to-edge",
{
"android": {
"parentTheme": "Light",
"enforceNavigationBarContrast": false
}
}
],
"expo-build-properties"
],
"experiments": {
"tsconfigPaths": true
},
"extra": {
"ignite": {
"version": "11.4.0"
},
"eas": {
"projectId": "03377418-ceb0-4ec9-8dce-7605150d5225"
}
},
"owner": "sofio"
}

117
RN_TEMPLATE/app/app.tsx Normal file
View File

@@ -0,0 +1,117 @@
/* eslint-disable import/first */
/**
* Welcome to the main entry point of the app. In this file, we'll
* be kicking off our app.
*
* Most of this file is boilerplate and you shouldn't need to modify
* it very often. But take some time to look through and understand
* what is going on here.
*
* The app navigation resides in ./app/navigators, so head over there
* if you're interested in adding screens and navigators.
*/
if (__DEV__) {
// Load Reactotron in development only.
// Note that you must be using metro's `inlineRequires` for this to work.
// If you turn it off in metro.config.js, you'll have to manually import it.
require("./devtools/ReactotronConfig.ts")
}
import "./utils/gestureHandler"
import { useEffect, useState } from "react"
import { useFonts } from "expo-font"
import * as Linking from "expo-linking"
import { GestureHandlerRootView } from "react-native-gesture-handler"
import { KeyboardProvider } from "react-native-keyboard-controller"
import { initialWindowMetrics, SafeAreaProvider } from "react-native-safe-area-context"
import { AuthProvider } from "./context/AuthContext"
import { ToastProvider } from "./context/ToastContext"
import { initI18n } from "./i18n"
import { AppNavigator } from "./navigators/AppNavigator"
import { useNavigationPersistence } from "./navigators/navigationUtilities"
import { ThemeProvider } from "./theme/context"
import { customFontsToLoad } from "./theme/typography"
import { loadDateFnsLocale } from "./utils/formatDate"
import * as storage from "./utils/storage"
export const NAVIGATION_PERSISTENCE_KEY = "NAVIGATION_STATE"
// Web linking configuration
const prefix = Linking.createURL("/")
const config = {
screens: {
Login: {
path: "",
},
Welcome: "welcome",
Demo: {
screens: {
DemoShowroom: {
path: "showroom/:queryIndex?/:itemIndex?",
},
DemoDebug: "debug",
DemoPodcastList: "podcast",
DemoCommunity: "community",
},
},
},
}
/**
* This is the root component of our app.
* @param {AppProps} props - The props for the `App` component.
* @returns {JSX.Element} The rendered `App` component.
*/
export function App() {
const {
initialNavigationState,
onNavigationStateChange,
isRestored: isNavigationStateRestored,
} = useNavigationPersistence(storage, NAVIGATION_PERSISTENCE_KEY)
const [areFontsLoaded, fontLoadError] = useFonts(customFontsToLoad)
const [isI18nInitialized, setIsI18nInitialized] = useState(false)
useEffect(() => {
initI18n()
.then(() => setIsI18nInitialized(true))
.then(() => loadDateFnsLocale())
}, [])
// Before we show the app, we have to wait for our state to be ready.
// In the meantime, don't render anything. This will be the background
// color set in native by rootView's background color.
// In iOS: application:didFinishLaunchingWithOptions:
// In Android: https://stackoverflow.com/a/45838109/204044
// You can replace with your own loading component if you wish.
if (!isNavigationStateRestored || !isI18nInitialized || (!areFontsLoaded && !fontLoadError)) {
return null
}
const linking = {
prefixes: [prefix],
config,
}
// otherwise, we're ready to render the app
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<KeyboardProvider>
<ThemeProvider>
<ToastProvider>
<AuthProvider>
<AppNavigator
linking={linking}
initialState={initialNavigationState}
onStateChange={onNavigationStateChange}
/>
</AuthProvider>
</ToastProvider>
</ThemeProvider>
</KeyboardProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
)
}

View File

@@ -0,0 +1,89 @@
import { useLayoutEffect, useState } from "react"
import { Image, ImageProps, ImageURISource, Platform, PixelRatio } from "react-native"
export interface AutoImageProps extends ImageProps {
/**
* How wide should the image be?
*/
maxWidth?: number
/**
* How tall should the image be?
*/
maxHeight?: number
headers?: {
[key: string]: string
}
}
/**
* A hook that will return the scaled dimensions of an image based on the
* provided dimensions' aspect ratio. If no desired dimensions are provided,
* it will return the original dimensions of the remote image.
*
* How is this different from `resizeMode: 'contain'`? Firstly, you can
* specify only one side's size (not both). Secondly, the image will scale to fit
* the desired dimensions instead of just being contained within its image-container.
* @param {number} remoteUri - The URI of the remote image.
* @param {number} dimensions - The desired dimensions of the image. If not provided, the original dimensions will be returned.
* @returns {[number, number]} - The scaled dimensions of the image.
*/
export function useAutoImage(
remoteUri: string,
headers?: {
[key: string]: string
},
dimensions?: [maxWidth?: number, maxHeight?: number],
): [width: number, height: number] {
const [[remoteWidth, remoteHeight], setRemoteImageDimensions] = useState([0, 0])
const remoteAspectRatio = remoteWidth / remoteHeight
const [maxWidth, maxHeight] = dimensions ?? []
useLayoutEffect(() => {
if (!remoteUri) return
if (!headers) {
Image.getSize(remoteUri, (w, h) => setRemoteImageDimensions([w, h]))
} else {
Image.getSizeWithHeaders(remoteUri, headers, (w, h) => setRemoteImageDimensions([w, h]))
}
}, [remoteUri, headers])
if (Number.isNaN(remoteAspectRatio)) return [0, 0]
if (maxWidth && maxHeight) {
const aspectRatio = Math.min(maxWidth / remoteWidth, maxHeight / remoteHeight)
return [
PixelRatio.roundToNearestPixel(remoteWidth * aspectRatio),
PixelRatio.roundToNearestPixel(remoteHeight * aspectRatio),
]
} else if (maxWidth) {
return [maxWidth, PixelRatio.roundToNearestPixel(maxWidth / remoteAspectRatio)]
} else if (maxHeight) {
return [PixelRatio.roundToNearestPixel(maxHeight * remoteAspectRatio), maxHeight]
} else {
return [remoteWidth, remoteHeight]
}
}
/**
* An Image component that automatically sizes a remote or data-uri image.
* @see [Documentation and Examples]{@link https://docs.infinite.red/ignite-cli/boilerplate/app/components/AutoImage/}
* @param {AutoImageProps} props - The props for the `AutoImage` component.
* @returns {JSX.Element} The rendered `AutoImage` component.
*/
export function AutoImage(props: AutoImageProps) {
const { maxWidth, maxHeight, ...ImageProps } = props
const source = props.source as ImageURISource
const headers = source?.headers
const [width, height] = useAutoImage(
Platform.select({
web: (source?.uri as string) ?? (source as string),
default: source?.uri as string,
}),
headers,
[maxWidth, maxHeight],
)
return <Image {...ImageProps} style={[{ width, height }, props.style]} />
}

View File

@@ -0,0 +1,71 @@
import { StyleProp, TextStyle, View, ViewStyle } from "react-native"
import { Image, ImageStyle } from "expo-image"
import { useAppTheme } from "@/theme/context"
import type { ThemedStyle } from "@/theme/types"
import { s } from "@/utils/responsive"
import { Text } from "./Text"
export interface AvatarProps {
/**
* The image URI to display
*/
uri?: string | null
/**
* Fallback text to display when no image (usually first letter of name)
*/
fallback?: string
/**
* Size of the avatar (width and height)
* @default 80
*/
size?: number
/**
* Optional style override for the container
*/
style?: StyleProp<ViewStyle>
}
/**
* A reusable Avatar component with expo-image for efficient caching.
* Displays an image or a fallback letter when no image is available.
*/
export function Avatar(props: AvatarProps) {
const { uri, fallback = "U", size = s(80), style } = props
const { themed, theme } = useAppTheme()
const borderRadius = size / 2
const fontSize = size * 0.4
if (uri) {
return (
<Image
source={{ uri }}
style={[$image, { width: size, height: size, borderRadius }, style as ImageStyle]}
contentFit="cover"
cachePolicy="memory-disk"
transition={200}
placeholder={{ blurhash: "L6PZfSi_.AyE_3t7t7R**0o#DgR4" }}
/>
)
}
return (
<View style={[themed($placeholder), { width: size, height: size, borderRadius }, style]}>
<Text style={{ color: theme.colors.palette.neutral100, fontSize, fontWeight: "bold" }}>
{fallback.charAt(0).toUpperCase()}
</Text>
</View>
)
}
const $image: ImageStyle = {
backgroundColor: "#e1e1e1",
}
const $placeholder: ThemedStyle<ViewStyle> = ({ colors }) => ({
backgroundColor: colors.tint,
justifyContent: "center",
alignItems: "center",
})

View File

@@ -0,0 +1,349 @@
import { ComponentType, useEffect, useRef } from "react"
import {
Animated,
Pressable,
PressableProps,
PressableStateCallbackType,
StyleProp,
TextStyle,
View,
ViewStyle,
} from "react-native"
import { useAppTheme } from "@/theme/context"
import { $styles } from "@/theme/styles"
import type { ThemedStyle, ThemedStyleArray } from "@/theme/types"
import { s, fs } from "@/utils/responsive"
import { Text, TextProps } from "./Text"
/**
* Three-dot loading animation component
*/
function LoadingDots({ color }: { color: string }) {
const dot1 = useRef(new Animated.Value(0)).current
const dot2 = useRef(new Animated.Value(0)).current
const dot3 = useRef(new Animated.Value(0)).current
useEffect(() => {
const animateDot = (dot: Animated.Value, delay: number) => {
return Animated.loop(
Animated.sequence([
Animated.delay(delay),
Animated.timing(dot, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(dot, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}),
]),
)
}
const animation = Animated.parallel([
animateDot(dot1, 0),
animateDot(dot2, 150),
animateDot(dot3, 300),
])
animation.start()
return () => animation.stop()
}, [dot1, dot2, dot3])
const dotStyle = (animatedValue: Animated.Value) => ({
width: s(8),
height: s(8),
borderRadius: s(4),
backgroundColor: color,
marginHorizontal: 3,
opacity: animatedValue.interpolate({
inputRange: [0, 1],
outputRange: [0.3, 1],
}),
transform: [
{
scale: animatedValue.interpolate({
inputRange: [0, 1],
outputRange: [0.8, 1.2],
}),
},
],
})
return (
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "center" }}>
<Animated.View style={dotStyle(dot1)} />
<Animated.View style={dotStyle(dot2)} />
<Animated.View style={dotStyle(dot3)} />
</View>
)
}
type Presets = "default" | "filled" | "reversed" | "link"
export interface ButtonAccessoryProps {
style: StyleProp<any>
pressableState: PressableStateCallbackType
disabled?: boolean
}
export interface ButtonProps extends PressableProps {
/**
* Text which is looked up via i18n.
*/
tx?: TextProps["tx"]
/**
* The text to display if not using `tx` or nested components.
*/
text?: TextProps["text"]
/**
* Optional options to pass to i18n. Useful for interpolation
* as well as explicitly setting locale or translation fallbacks.
*/
txOptions?: TextProps["txOptions"]
/**
* An optional style override useful for padding & margin.
*/
style?: StyleProp<ViewStyle>
/**
* An optional style override for the "pressed" state.
*/
pressedStyle?: StyleProp<ViewStyle>
/**
* An optional style override for the button text.
*/
textStyle?: StyleProp<TextStyle>
/**
* An optional style override for the button text when in the "pressed" state.
*/
pressedTextStyle?: StyleProp<TextStyle>
/**
* An optional style override for the button text when in the "disabled" state.
*/
disabledTextStyle?: StyleProp<TextStyle>
/**
* One of the different types of button presets.
*/
preset?: Presets
/**
* An optional component to render on the right side of the text.
* Example: `RightAccessory={(props) => <View {...props} />}`
*/
RightAccessory?: ComponentType<ButtonAccessoryProps>
/**
* An optional component to render on the left side of the text.
* Example: `LeftAccessory={(props) => <View {...props} />}`
*/
LeftAccessory?: ComponentType<ButtonAccessoryProps>
/**
* Children components.
*/
children?: React.ReactNode
/**
* disabled prop, accessed directly for declarative styling reasons.
* https://reactnative.dev/docs/pressable#disabled
*/
disabled?: boolean
/**
* An optional style override for the disabled state
*/
disabledStyle?: StyleProp<ViewStyle>
/**
* Show loading state with animated dots
*/
loading?: boolean
}
/**
* A component that allows users to take actions and make choices.
* Wraps the Text component with a Pressable component.
* @see [Documentation and Examples]{@link https://docs.infinite.red/ignite-cli/boilerplate/app/components/Button/}
* @param {ButtonProps} props - The props for the `Button` component.
* @returns {JSX.Element} The rendered `Button` component.
* @example
* <Button
* tx="common:ok"
* style={styles.button}
* textStyle={styles.buttonText}
* onPress={handleButtonPress}
* />
*/
export function Button(props: ButtonProps) {
const {
tx,
text,
txOptions,
style: $viewStyleOverride,
pressedStyle: $pressedViewStyleOverride,
textStyle: $textStyleOverride,
pressedTextStyle: $pressedTextStyleOverride,
disabledTextStyle: $disabledTextStyleOverride,
children,
RightAccessory,
LeftAccessory,
disabled,
disabledStyle: $disabledViewStyleOverride,
loading,
...rest
} = props
const {
themed,
theme: { colors },
} = useAppTheme()
const preset: Presets = props.preset ?? "default"
// Get loading dots color based on preset
const loadingDotsColor =
preset === "reversed" ? colors.palette.neutral100 : colors.palette.neutral800
/**
* @param {PressableStateCallbackType} root0 - The root object containing the pressed state.
* @param {boolean} root0.pressed - The pressed state.
* @returns {StyleProp<ViewStyle>} The view style based on the pressed state.
*/
function $viewStyle({ pressed }: PressableStateCallbackType): StyleProp<ViewStyle> {
return [
themed($viewPresets[preset]),
$viewStyleOverride,
!!pressed && themed([$pressedViewPresets[preset], $pressedViewStyleOverride]),
!!disabled && $disabledViewStyleOverride,
]
}
/**
* @param {PressableStateCallbackType} root0 - The root object containing the pressed state.
* @param {boolean} root0.pressed - The pressed state.
* @returns {StyleProp<TextStyle>} The text style based on the pressed state.
*/
function $textStyle({ pressed }: PressableStateCallbackType): StyleProp<TextStyle> {
return [
themed($textPresets[preset]),
$textStyleOverride,
!!pressed && themed([$pressedTextPresets[preset], $pressedTextStyleOverride]),
!!disabled && $disabledTextStyleOverride,
]
}
return (
<Pressable
style={$viewStyle}
accessibilityRole="button"
accessibilityState={{ disabled: !!disabled || !!loading }}
{...rest}
disabled={disabled || loading}
>
{(state) => (
<>
{!!LeftAccessory && !loading && (
<LeftAccessory style={$leftAccessoryStyle} pressableState={state} disabled={disabled} />
)}
{loading ? (
<LoadingDots color={loadingDotsColor} />
) : (
<Text tx={tx} text={text} txOptions={txOptions} style={$textStyle(state)}>
{children}
</Text>
)}
{!!RightAccessory && !loading && (
<RightAccessory
style={$rightAccessoryStyle}
pressableState={state}
disabled={disabled}
/>
)}
</>
)}
</Pressable>
)
}
const $baseViewStyle: ThemedStyle<ViewStyle> = ({ spacing }) => ({
minHeight: s(56),
borderRadius: s(4),
justifyContent: "center",
alignItems: "center",
paddingVertical: spacing.sm,
paddingHorizontal: spacing.sm,
overflow: "hidden",
})
const $baseTextStyle: ThemedStyle<TextStyle> = ({ typography }) => ({
fontSize: fs(16),
lineHeight: fs(20),
fontFamily: typography.primary.medium,
textAlign: "center",
flexShrink: 1,
flexGrow: 0,
zIndex: 2,
})
const $rightAccessoryStyle: ThemedStyle<ViewStyle> = ({ spacing }) => ({
marginStart: spacing.xs,
zIndex: 1,
})
const $leftAccessoryStyle: ThemedStyle<ViewStyle> = ({ spacing }) => ({
marginEnd: spacing.xs,
zIndex: 1,
})
const $viewPresets: Record<Presets, ThemedStyleArray<ViewStyle>> = {
default: [
$styles.row,
$baseViewStyle,
({ colors }) => ({
borderWidth: 1,
borderColor: colors.palette.neutral400,
backgroundColor: colors.palette.neutral100,
}),
],
filled: [
$styles.row,
$baseViewStyle,
({ colors }) => ({ backgroundColor: colors.palette.neutral300 }),
],
reversed: [
$styles.row,
$baseViewStyle,
({ colors }) => ({ backgroundColor: colors.palette.neutral800 }),
],
link: [
$styles.row,
() => ({
minHeight: 0,
paddingVertical: 0,
paddingHorizontal: 0,
backgroundColor: "transparent",
borderWidth: 0,
justifyContent: "center",
alignItems: "center",
}),
],
}
const $textPresets: Record<Presets, ThemedStyleArray<TextStyle>> = {
default: [$baseTextStyle],
filled: [$baseTextStyle],
reversed: [$baseTextStyle, ({ colors }) => ({ color: colors.palette.neutral100 })],
link: [({ colors }) => ({ color: colors.tint, fontSize: fs(14) })],
}
const $pressedViewPresets: Record<Presets, ThemedStyle<ViewStyle>> = {
default: ({ colors }) => ({ backgroundColor: colors.palette.neutral200 }),
filled: ({ colors }) => ({ backgroundColor: colors.palette.neutral400 }),
reversed: ({ colors }) => ({ backgroundColor: colors.palette.neutral700 }),
link: () => ({ opacity: 0.7 }),
}
const $pressedTextPresets: Record<Presets, ThemedStyle<TextStyle>> = {
default: () => ({ opacity: 0.9 }),
filled: () => ({ opacity: 0.9 }),
reversed: () => ({ opacity: 0.9 }),
link: () => ({}),
}

View File

@@ -0,0 +1,315 @@
import { ComponentType, Fragment, ReactElement } from "react"
import {
StyleProp,
TextStyle,
TouchableOpacity,
TouchableOpacityProps,
View,
ViewProps,
ViewStyle,
} from "react-native"
import { useAppTheme } from "@/theme/context"
import { $styles } from "@/theme/styles"
import type { ThemedStyle, ThemedStyleArray } from "@/theme/types"
import { s } from "@/utils/responsive"
import { Text, TextProps } from "./Text"
type Presets = "default" | "reversed"
interface CardProps extends TouchableOpacityProps {
/**
* One of the different types of text presets.
*/
preset?: Presets
/**
* How the content should be aligned vertically. This is especially (but not exclusively) useful
* when the card is a fixed height but the content is dynamic.
*
* `top` (default) - aligns all content to the top.
* `center` - aligns all content to the center.
* `space-between` - spreads out the content evenly.
* `force-footer-bottom` - aligns all content to the top, but forces the footer to the bottom.
*/
verticalAlignment?: "top" | "center" | "space-between" | "force-footer-bottom"
/**
* Custom component added to the left of the card body.
*/
LeftComponent?: ReactElement
/**
* Custom component added to the right of the card body.
*/
RightComponent?: ReactElement
/**
* The heading text to display if not using `headingTx`.
*/
heading?: TextProps["text"]
/**
* Heading text which is looked up via i18n.
*/
headingTx?: TextProps["tx"]
/**
* Optional heading options to pass to i18n. Useful for interpolation
* as well as explicitly setting locale or translation fallbacks.
*/
headingTxOptions?: TextProps["txOptions"]
/**
* Style overrides for heading text.
*/
headingStyle?: StyleProp<TextStyle>
/**
* Pass any additional props directly to the heading Text component.
*/
HeadingTextProps?: TextProps
/**
* Custom heading component.
* Overrides all other `heading*` props.
*/
HeadingComponent?: ReactElement
/**
* The content text to display if not using `contentTx`.
*/
content?: TextProps["text"]
/**
* Content text which is looked up via i18n.
*/
contentTx?: TextProps["tx"]
/**
* Optional content options to pass to i18n. Useful for interpolation
* as well as explicitly setting locale or translation fallbacks.
*/
contentTxOptions?: TextProps["txOptions"]
/**
* Style overrides for content text.
*/
contentStyle?: StyleProp<TextStyle>
/**
* Pass any additional props directly to the content Text component.
*/
ContentTextProps?: TextProps
/**
* Custom content component.
* Overrides all other `content*` props.
*/
ContentComponent?: ReactElement
/**
* The footer text to display if not using `footerTx`.
*/
footer?: TextProps["text"]
/**
* Footer text which is looked up via i18n.
*/
footerTx?: TextProps["tx"]
/**
* Optional footer options to pass to i18n. Useful for interpolation
* as well as explicitly setting locale or translation fallbacks.
*/
footerTxOptions?: TextProps["txOptions"]
/**
* Style overrides for footer text.
*/
footerStyle?: StyleProp<TextStyle>
/**
* Pass any additional props directly to the footer Text component.
*/
FooterTextProps?: TextProps
/**
* Custom footer component.
* Overrides all other `footer*` props.
*/
FooterComponent?: ReactElement
}
/**
* Cards are useful for displaying related information in a contained way.
* If a ListItem displays content horizontally, a Card can be used to display content vertically.
* @see [Documentation and Examples]{@link https://docs.infinite.red/ignite-cli/boilerplate/app/components/Card/}
* @param {CardProps} props - The props for the `Card` component.
* @returns {JSX.Element} The rendered `Card` component.
*/
export function Card(props: CardProps) {
const {
content,
contentTx,
contentTxOptions,
footer,
footerTx,
footerTxOptions,
heading,
headingTx,
headingTxOptions,
ContentComponent,
HeadingComponent,
FooterComponent,
LeftComponent,
RightComponent,
verticalAlignment = "top",
style: $containerStyleOverride,
contentStyle: $contentStyleOverride,
headingStyle: $headingStyleOverride,
footerStyle: $footerStyleOverride,
ContentTextProps,
HeadingTextProps,
FooterTextProps,
...WrapperProps
} = props
const {
themed,
theme: { spacing },
} = useAppTheme()
const preset: Presets = props.preset ?? "default"
const isPressable = !!WrapperProps.onPress
const isHeadingPresent = !!(HeadingComponent || heading || headingTx)
const isContentPresent = !!(ContentComponent || content || contentTx)
const isFooterPresent = !!(FooterComponent || footer || footerTx)
const Wrapper = (isPressable ? TouchableOpacity : View) as ComponentType<
TouchableOpacityProps | ViewProps
>
const HeaderContentWrapper = verticalAlignment === "force-footer-bottom" ? View : Fragment
const $containerStyle: StyleProp<ViewStyle> = [
themed($containerPresets[preset]),
$containerStyleOverride,
]
const $headingStyle = [
themed($headingPresets[preset]),
(isFooterPresent || isContentPresent) && { marginBottom: spacing.xxxs },
$headingStyleOverride,
HeadingTextProps?.style,
]
const $contentStyle = [
themed($contentPresets[preset]),
isHeadingPresent && { marginTop: spacing.xxxs },
isFooterPresent && { marginBottom: spacing.xxxs },
$contentStyleOverride,
ContentTextProps?.style,
]
const $footerStyle = [
themed($footerPresets[preset]),
(isHeadingPresent || isContentPresent) && { marginTop: spacing.xxxs },
$footerStyleOverride,
FooterTextProps?.style,
]
const $alignmentWrapperStyle = [
$alignmentWrapper,
{ justifyContent: $alignmentWrapperFlexOptions[verticalAlignment] },
LeftComponent && { marginStart: spacing.md },
RightComponent && { marginEnd: spacing.md },
]
return (
<Wrapper
style={$containerStyle}
activeOpacity={0.8}
accessibilityRole={isPressable ? "button" : undefined}
{...WrapperProps}
>
{LeftComponent}
<View style={$alignmentWrapperStyle}>
<HeaderContentWrapper>
{HeadingComponent ||
(isHeadingPresent && (
<Text
weight="bold"
text={heading}
tx={headingTx}
txOptions={headingTxOptions}
{...HeadingTextProps}
style={$headingStyle}
/>
))}
{ContentComponent ||
(isContentPresent && (
<Text
weight="normal"
text={content}
tx={contentTx}
txOptions={contentTxOptions}
{...ContentTextProps}
style={$contentStyle}
/>
))}
</HeaderContentWrapper>
{FooterComponent ||
(isFooterPresent && (
<Text
weight="normal"
size="xs"
text={footer}
tx={footerTx}
txOptions={footerTxOptions}
{...FooterTextProps}
style={$footerStyle}
/>
))}
</View>
{RightComponent}
</Wrapper>
)
}
const $containerBase: ThemedStyle<ViewStyle> = (theme) => ({
borderRadius: theme.spacing.md,
padding: theme.spacing.xs,
borderWidth: 1,
shadowColor: theme.colors.palette.neutral800,
shadowOffset: { width: 0, height: s(12) },
shadowOpacity: 0.08,
shadowRadius: s(12.81),
elevation: 16,
minHeight: s(96),
})
const $alignmentWrapper: ViewStyle = {
flex: 1,
alignSelf: "stretch",
}
const $alignmentWrapperFlexOptions = {
"top": "flex-start",
"center": "center",
"space-between": "space-between",
"force-footer-bottom": "space-between",
} as const
const $containerPresets: Record<Presets, ThemedStyleArray<ViewStyle>> = {
default: [
$styles.row,
$containerBase,
(theme) => ({
backgroundColor: theme.colors.palette.neutral100,
borderColor: theme.colors.palette.neutral300,
}),
],
reversed: [
$styles.row,
$containerBase,
(theme) => ({
backgroundColor: theme.colors.palette.neutral800,
borderColor: theme.colors.palette.neutral500,
}),
],
}
const $headingPresets: Record<Presets, ThemedStyleArray<TextStyle>> = {
default: [],
reversed: [(theme) => ({ color: theme.colors.palette.neutral100 })],
}
const $contentPresets: Record<Presets, ThemedStyleArray<TextStyle>> = {
default: [],
reversed: [(theme) => ({ color: theme.colors.palette.neutral100 })],
}
const $footerPresets: Record<Presets, ThemedStyleArray<TextStyle>> = {
default: [],
reversed: [(theme) => ({ color: theme.colors.palette.neutral100 })],
}

View File

@@ -0,0 +1,276 @@
import { Modal as RNModal, Pressable, StyleProp, TextStyle, View, ViewStyle } from "react-native"
import { useAppTheme } from "@/theme/context"
import type { ThemedStyle } from "@/theme/types"
import { s } from "@/utils/responsive"
import { Button } from "./Button"
import { Text, TextProps } from "./Text"
type Presets = "default" | "destructive"
export interface DialogProps {
/**
* Whether the dialog is visible.
*/
visible: boolean
/**
* Callback when the dialog is closed.
*/
onClose: () => void
/**
* Dialog preset style.
* - `default`: Normal dialog with filled confirm button
* - `destructive`: Dangerous action with red confirm button
* @default "default"
*/
preset?: Presets
/**
* Title text which is looked up via i18n.
*/
titleTx?: TextProps["tx"]
/**
* Title text to display if not using `titleTx`.
*/
title?: string
/**
* Optional title options to pass to i18n.
*/
titleTxOptions?: TextProps["txOptions"]
/**
* Message text which is looked up via i18n.
*/
messageTx?: TextProps["tx"]
/**
* Message text to display if not using `messageTx`.
*/
message?: string
/**
* Optional message options to pass to i18n.
*/
messageTxOptions?: TextProps["txOptions"]
/**
* Confirm button text which is looked up via i18n.
* @default "common:ok"
*/
confirmTx?: TextProps["tx"]
/**
* Confirm button text to display if not using `confirmTx`.
*/
confirmText?: string
/**
* Callback when confirm button is pressed.
*/
onConfirm?: () => void
/**
* Cancel button text which is looked up via i18n.
* @default "common:cancel"
*/
cancelTx?: TextProps["tx"]
/**
* Cancel button text to display if not using `cancelTx`.
*/
cancelText?: string
/**
* Callback when cancel button is pressed. If not provided, cancel button will not be shown.
*/
onCancel?: () => void
/**
* Whether to show the cancel button.
* @default true (if onCancel is provided)
*/
showCancel?: boolean
/**
* Whether the confirm button is in loading state.
*/
loading?: boolean
/**
* Whether to close the dialog when pressing the overlay.
* @default false
*/
closeOnOverlayPress?: boolean
/**
* Style overrides for the content container.
*/
contentStyle?: StyleProp<ViewStyle>
}
/**
* A simple dialog component for confirmations and alerts.
* Use this for simple confirm/cancel dialogs. For complex content, use Modal instead.
* @param {DialogProps} props - The props for the `Dialog` component.
* @returns {JSX.Element} The rendered `Dialog` component.
* @example
* // Simple alert
* <Dialog
* visible={showAlert}
* onClose={() => setShowAlert(false)}
* title="Success"
* message="Operation completed successfully."
* onConfirm={() => setShowAlert(false)}
* />
*
* @example
* // Destructive confirmation
* <Dialog
* visible={showConfirm}
* onClose={() => setShowConfirm(false)}
* preset="destructive"
* titleTx="common:confirm"
* messageTx="session:confirmLogoutMessage"
* confirmTx="session:logout"
* onConfirm={handleLogout}
* onCancel={() => setShowConfirm(false)}
* />
*/
export function Dialog(props: DialogProps) {
const {
visible,
onClose,
preset = "default",
titleTx,
title,
titleTxOptions,
messageTx,
message,
messageTxOptions,
confirmTx = "common:ok",
confirmText,
onConfirm,
cancelTx = "common:cancel",
cancelText,
onCancel,
showCancel = !!onCancel,
loading = false,
closeOnOverlayPress = false,
contentStyle: $contentStyleOverride,
} = props
const { themed, theme } = useAppTheme()
const isDestructive = preset === "destructive"
const handleConfirm = () => {
onConfirm?.()
}
const handleCancel = () => {
onCancel?.()
onClose()
}
const handleOverlayPress = () => {
if (closeOnOverlayPress) {
onClose()
}
}
const $confirmButtonStyle: StyleProp<ViewStyle> = [
themed($button),
isDestructive && themed($destructiveButton),
]
return (
<RNModal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
statusBarTranslucent
>
<Pressable style={themed($overlay)} onPress={handleOverlayPress}>
<Pressable
style={[themed($contentContainer), $contentStyleOverride]}
onPress={(e) => e.stopPropagation()}
>
{/* Title */}
{(titleTx || title) && (
<Text
preset="subheading"
tx={titleTx}
text={title}
txOptions={titleTxOptions}
style={themed($title)}
/>
)}
{/* Message */}
{(messageTx || message) && (
<Text
tx={messageTx}
text={message}
txOptions={messageTxOptions}
style={themed($message)}
/>
)}
{/* Buttons */}
<View style={themed($buttonContainer)}>
{showCancel && (
<Button
preset="default"
tx={cancelTx}
text={cancelText}
style={themed($button)}
onPress={handleCancel}
disabled={loading}
/>
)}
<Button
preset={isDestructive ? "default" : "filled"}
tx={confirmTx}
text={confirmText}
style={$confirmButtonStyle}
textStyle={isDestructive ? { color: theme.colors.error } : undefined}
onPress={handleConfirm}
loading={loading}
/>
</View>
</Pressable>
</Pressable>
</RNModal>
)
}
const $overlay: ThemedStyle<ViewStyle> = ({ colors, spacing }) => ({
flex: 1,
backgroundColor: colors.palette.overlay50,
justifyContent: "center",
alignItems: "center",
padding: spacing.lg,
})
const $contentContainer: ThemedStyle<ViewStyle> = ({ colors, spacing }) => ({
backgroundColor: colors.background,
borderRadius: s(12),
width: "100%",
maxWidth: s(320),
paddingHorizontal: spacing.lg,
paddingVertical: spacing.lg,
})
const $title: ThemedStyle<TextStyle> = ({ spacing }) => ({
textAlign: "center",
marginBottom: spacing.sm,
})
const $message: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
textAlign: "center",
color: colors.textDim,
marginBottom: spacing.lg,
})
const $buttonContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
flexDirection: "row",
justifyContent: "center",
gap: spacing.sm,
})
const $button: ThemedStyle<ViewStyle> = () => ({
flex: 1,
minWidth: s(100),
})
const $destructiveButton: ThemedStyle<ViewStyle> = ({ colors }) => ({
borderColor: colors.error,
})

View File

@@ -0,0 +1,249 @@
import { Image, ImageProps, ImageStyle, StyleProp, TextStyle, View, ViewStyle } from "react-native"
import { useTranslation } from "react-i18next"
import { useAppTheme } from "@/theme/context"
import type { ThemedStyle } from "@/theme/types"
import { Button, ButtonProps } from "./Button"
import { Text, TextProps } from "./Text"
const sadFace = require("@assets/images/sad-face.png")
interface EmptyStateProps {
/**
* An optional prop that specifies the text/image set to use for the empty state.
*/
preset?: "generic"
/**
* Style override for the container.
*/
style?: StyleProp<ViewStyle>
/**
* An Image source to be displayed above the heading.
*/
imageSource?: ImageProps["source"]
/**
* Style overrides for image.
*/
imageStyle?: StyleProp<ImageStyle>
/**
* Pass any additional props directly to the Image component.
*/
ImageProps?: Omit<ImageProps, "source">
/**
* The heading text to display if not using `headingTx`.
*/
heading?: TextProps["text"]
/**
* Heading text which is looked up via i18n.
*/
headingTx?: TextProps["tx"]
/**
* Optional heading options to pass to i18n. Useful for interpolation
* as well as explicitly setting locale or translation fallbacks.
*/
headingTxOptions?: TextProps["txOptions"]
/**
* Style overrides for heading text.
*/
headingStyle?: StyleProp<TextStyle>
/**
* Pass any additional props directly to the heading Text component.
*/
HeadingTextProps?: TextProps
/**
* The content text to display if not using `contentTx`.
*/
content?: TextProps["text"]
/**
* Content text which is looked up via i18n.
*/
contentTx?: TextProps["tx"]
/**
* Optional content options to pass to i18n. Useful for interpolation
* as well as explicitly setting locale or translation fallbacks.
*/
contentTxOptions?: TextProps["txOptions"]
/**
* Style overrides for content text.
*/
contentStyle?: StyleProp<TextStyle>
/**
* Pass any additional props directly to the content Text component.
*/
ContentTextProps?: TextProps
/**
* The button text to display if not using `buttonTx`.
*/
button?: TextProps["text"]
/**
* Button text which is looked up via i18n.
*/
buttonTx?: TextProps["tx"]
/**
* Optional button options to pass to i18n. Useful for interpolation
* as well as explicitly setting locale or translation fallbacks.
*/
buttonTxOptions?: TextProps["txOptions"]
/**
* Style overrides for button.
*/
buttonStyle?: ButtonProps["style"]
/**
* Style overrides for button text.
*/
buttonTextStyle?: ButtonProps["textStyle"]
/**
* Called when the button is pressed.
*/
buttonOnPress?: ButtonProps["onPress"]
/**
* Pass any additional props directly to the Button component.
*/
ButtonProps?: ButtonProps
}
interface EmptyStatePresetItem {
imageSource: ImageProps["source"]
heading: TextProps["text"]
content: TextProps["text"]
button: TextProps["text"]
}
/**
* A component to use when there is no data to display. It can be utilized to direct the user what to do next.
* @see [Documentation and Examples]{@link https://docs.infinite.red/ignite-cli/boilerplate/app/components/EmptyState/}
* @param {EmptyStateProps} props - The props for the `EmptyState` component.
* @returns {JSX.Element} The rendered `EmptyState` component.
*/
export function EmptyState(props: EmptyStateProps) {
const {
theme,
themed,
theme: { spacing },
} = useAppTheme()
const { t } = useTranslation()
const EmptyStatePresets = {
generic: {
imageSource: sadFace,
heading: t("emptyStateComponent:generic.heading"),
content: t("emptyStateComponent:generic.content"),
button: t("emptyStateComponent:generic.button"),
} as EmptyStatePresetItem,
} as const
const preset = EmptyStatePresets[props.preset ?? "generic"]
const {
button = preset.button,
buttonTx,
buttonOnPress,
buttonTxOptions,
content = preset.content,
contentTx,
contentTxOptions,
heading = preset.heading,
headingTx,
headingTxOptions,
imageSource = preset.imageSource,
style: $containerStyleOverride,
buttonStyle: $buttonStyleOverride,
buttonTextStyle: $buttonTextStyleOverride,
contentStyle: $contentStyleOverride,
headingStyle: $headingStyleOverride,
imageStyle: $imageStyleOverride,
ButtonProps,
ContentTextProps,
HeadingTextProps,
ImageProps,
} = props
const isImagePresent = !!imageSource
const isHeadingPresent = !!(heading || headingTx)
const isContentPresent = !!(content || contentTx)
const isButtonPresent = !!(button || buttonTx)
const $containerStyles = [$containerStyleOverride]
const $imageStyles = [
$image,
(isHeadingPresent || isContentPresent || isButtonPresent) && { marginBottom: spacing.xxxs },
$imageStyleOverride,
ImageProps?.style,
]
const $headingStyles = [
themed($heading),
isImagePresent && { marginTop: spacing.xxxs },
(isContentPresent || isButtonPresent) && { marginBottom: spacing.xxxs },
$headingStyleOverride,
HeadingTextProps?.style,
]
const $contentStyles = [
themed($content),
(isImagePresent || isHeadingPresent) && { marginTop: spacing.xxxs },
isButtonPresent && { marginBottom: spacing.xxxs },
$contentStyleOverride,
ContentTextProps?.style,
]
const $buttonStyles = [
(isImagePresent || isHeadingPresent || isContentPresent) && { marginTop: spacing.xl },
$buttonStyleOverride,
ButtonProps?.style,
]
return (
<View style={$containerStyles}>
{isImagePresent && (
<Image
source={imageSource}
{...ImageProps}
style={$imageStyles}
tintColor={theme.colors.palette.neutral900}
/>
)}
{isHeadingPresent && (
<Text
preset="subheading"
text={heading}
tx={headingTx}
txOptions={headingTxOptions}
{...HeadingTextProps}
style={$headingStyles}
/>
)}
{isContentPresent && (
<Text
text={content}
tx={contentTx}
txOptions={contentTxOptions}
{...ContentTextProps}
style={$contentStyles}
/>
)}
{isButtonPresent && (
<Button
onPress={buttonOnPress}
text={button}
tx={buttonTx}
txOptions={buttonTxOptions}
textStyle={$buttonTextStyleOverride}
{...ButtonProps}
style={$buttonStyles}
/>
)}
</View>
)
}
const $image: ImageStyle = { alignSelf: "center" }
const $heading: ThemedStyle<TextStyle> = ({ spacing }) => ({
textAlign: "center",
paddingHorizontal: spacing.lg,
})
const $content: ThemedStyle<TextStyle> = ({ spacing }) => ({
textAlign: "center",
paddingHorizontal: spacing.lg,
})

View File

@@ -0,0 +1,337 @@
import { ReactElement } from "react"
import {
StyleProp,
TextStyle,
TouchableOpacity,
TouchableOpacityProps,
View,
ViewStyle,
} from "react-native"
import { useTranslation } from "react-i18next"
import { isRTL } from "@/i18n"
import { useAppTheme } from "@/theme/context"
import { $styles } from "@/theme/styles"
import type { ThemedStyle } from "@/theme/types"
import { s } from "@/utils/responsive"
import { ExtendedEdge, useSafeAreaInsetsStyle } from "@/utils/useSafeAreaInsetsStyle"
import { IconTypes, PressableIcon } from "./Icon"
import { Text, TextProps } from "./Text"
export interface HeaderProps {
/**
* The layout of the title relative to the action components.
* - `center` will force the title to always be centered relative to the header. If the title or the action buttons are too long, the title will be cut off.
* - `flex` will attempt to center the title relative to the action buttons. If the action buttons are different widths, the title will be off-center relative to the header.
*/
titleMode?: "center" | "flex"
/**
* Optional title style override.
*/
titleStyle?: StyleProp<TextStyle>
/**
* Optional outer title container style override.
*/
titleContainerStyle?: StyleProp<ViewStyle>
/**
* Optional inner header wrapper style override.
*/
style?: StyleProp<ViewStyle>
/**
* Optional outer header container style override.
*/
containerStyle?: StyleProp<ViewStyle>
/**
* Background color
*/
backgroundColor?: string
/**
* Title text to display if not using `tx` or nested components.
*/
title?: TextProps["text"]
/**
* Title text which is looked up via i18n.
*/
titleTx?: TextProps["tx"]
/**
* Optional options to pass to i18n. Useful for interpolation
* as well as explicitly setting locale or translation fallbacks.
*/
titleTxOptions?: TextProps["txOptions"]
/**
* Icon that should appear on the left.
* Can be used with `onLeftPress`.
*/
leftIcon?: IconTypes
/**
* An optional tint color for the left icon
*/
leftIconColor?: string
/**
* Left action text to display if not using `leftTx`.
* Can be used with `onLeftPress`. Overrides `leftIcon`.
*/
leftText?: TextProps["text"]
/**
* Left action text text which is looked up via i18n.
* Can be used with `onLeftPress`. Overrides `leftIcon`.
*/
leftTx?: TextProps["tx"]
/**
* Left action custom ReactElement if the built in action props don't suffice.
* Overrides `leftIcon`, `leftTx` and `leftText`.
*/
LeftActionComponent?: ReactElement
/**
* Optional options to pass to i18n. Useful for interpolation
* as well as explicitly setting locale or translation fallbacks.
*/
leftTxOptions?: TextProps["txOptions"]
/**
* What happens when you press the left icon or text action.
*/
onLeftPress?: TouchableOpacityProps["onPress"]
/**
* Icon that should appear on the right.
* Can be used with `onRightPress`.
*/
rightIcon?: IconTypes
/**
* An optional tint color for the right icon
*/
rightIconColor?: string
/**
* Right action text to display if not using `rightTx`.
* Can be used with `onRightPress`. Overrides `rightIcon`.
*/
rightText?: TextProps["text"]
/**
* Right action text text which is looked up via i18n.
* Can be used with `onRightPress`. Overrides `rightIcon`.
*/
rightTx?: TextProps["tx"]
/**
* Right action custom ReactElement if the built in action props don't suffice.
* Overrides `rightIcon`, `rightTx` and `rightText`.
*/
RightActionComponent?: ReactElement
/**
* Optional options to pass to i18n. Useful for interpolation
* as well as explicitly setting locale or translation fallbacks.
*/
rightTxOptions?: TextProps["txOptions"]
/**
* What happens when you press the right icon or text action.
*/
onRightPress?: TouchableOpacityProps["onPress"]
/**
* Override the default edges for the safe area.
*/
safeAreaEdges?: ExtendedEdge[]
}
interface HeaderActionProps {
backgroundColor?: string
icon?: IconTypes
iconColor?: string
text?: TextProps["text"]
tx?: TextProps["tx"]
txOptions?: TextProps["txOptions"]
onPress?: TouchableOpacityProps["onPress"]
ActionComponent?: ReactElement
}
/**
* Header that appears on many screens. Will hold navigation buttons and screen title.
* The Header is meant to be used with the `screenOptions.header` option on navigators, routes, or screen components via `navigation.setOptions({ header })`.
* @see [Documentation and Examples]{@link https://docs.infinite.red/ignite-cli/boilerplate/app/components/Header/}
* @param {HeaderProps} props - The props for the `Header` component.
* @returns {JSX.Element} The rendered `Header` component.
*/
export function Header(props: HeaderProps) {
const {
theme: { colors },
themed,
} = useAppTheme()
const { t } = useTranslation()
const {
backgroundColor = colors.background,
LeftActionComponent,
leftIcon,
leftIconColor,
leftText,
leftTx,
leftTxOptions,
onLeftPress,
onRightPress,
RightActionComponent,
rightIcon,
rightIconColor,
rightText,
rightTx,
rightTxOptions,
safeAreaEdges = ["top"],
title,
titleMode = "center",
titleTx,
titleTxOptions,
titleContainerStyle: $titleContainerStyleOverride,
style: $styleOverride,
titleStyle: $titleStyleOverride,
containerStyle: $containerStyleOverride,
} = props
const $containerInsets = useSafeAreaInsetsStyle(safeAreaEdges)
const titleContent = titleTx ? t(titleTx, titleTxOptions) : title
return (
<View style={[$container, $containerInsets, { backgroundColor }, $containerStyleOverride]}>
<View style={[$styles.row, $wrapper, $styleOverride]}>
<HeaderAction
tx={leftTx}
text={leftText}
icon={leftIcon}
iconColor={leftIconColor}
onPress={onLeftPress}
txOptions={leftTxOptions}
backgroundColor={backgroundColor}
ActionComponent={LeftActionComponent}
/>
{!!titleContent && (
<View
style={[
$titleWrapperPointerEvents,
titleMode === "center" && themed($titleWrapperCenter),
titleMode === "flex" && $titleWrapperFlex,
$titleContainerStyleOverride,
]}
>
<Text
weight="medium"
size="md"
text={titleContent}
style={[$title, $titleStyleOverride]}
/>
</View>
)}
<HeaderAction
tx={rightTx}
text={rightText}
icon={rightIcon}
iconColor={rightIconColor}
onPress={onRightPress}
txOptions={rightTxOptions}
backgroundColor={backgroundColor}
ActionComponent={RightActionComponent}
/>
</View>
</View>
)
}
/**
* @param {HeaderActionProps} props - The props for the `HeaderAction` component.
* @returns {JSX.Element} The rendered `HeaderAction` component.
*/
function HeaderAction(props: HeaderActionProps) {
const { backgroundColor, icon, text, tx, txOptions, onPress, ActionComponent, iconColor } = props
const { themed } = useAppTheme()
const { t } = useTranslation()
const content = tx ? t(tx, txOptions) : text
if (ActionComponent) return ActionComponent
if (content) {
return (
<TouchableOpacity
style={themed([$actionTextContainer, { backgroundColor }])}
onPress={onPress}
disabled={!onPress}
activeOpacity={0.8}
>
<Text weight="medium" size="md" text={content} style={themed($actionText)} />
</TouchableOpacity>
)
}
if (icon) {
return (
<PressableIcon
size={s(24)}
icon={icon}
color={iconColor}
onPress={onPress}
containerStyle={[
themed([$actionIconContainer, { backgroundColor }]),
isRTL ? { transform: [{ rotate: "180deg" }] } : {},
]}
/>
)
}
return <View style={[$actionFillerContainer, { backgroundColor }]} />
}
const $wrapper: ViewStyle = {
height: s(56),
alignItems: "center",
justifyContent: "space-between",
}
const $container: ViewStyle = {
width: "100%",
}
const $title: TextStyle = {
textAlign: "center",
}
const $actionTextContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
flexGrow: 0,
alignItems: "center",
justifyContent: "center",
height: "100%",
paddingHorizontal: spacing.md,
zIndex: 2,
})
const $actionText: ThemedStyle<TextStyle> = ({ colors }) => ({
color: colors.tint,
})
const $actionIconContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
flexGrow: 0,
alignItems: "center",
justifyContent: "center",
height: "100%",
paddingHorizontal: spacing.md,
zIndex: 2,
})
const $actionFillerContainer: ViewStyle = {
width: s(16),
}
const $titleWrapperPointerEvents: ViewStyle = {
pointerEvents: "none",
}
const $titleWrapperCenter: ThemedStyle<ViewStyle> = ({ spacing }) => ({
alignItems: "center",
justifyContent: "center",
height: "100%",
width: "100%",
position: "absolute",
paddingHorizontal: spacing.xxl,
zIndex: 1,
})
const $titleWrapperFlex: ViewStyle = {
justifyContent: "center",
flexGrow: 1,
}

View File

@@ -0,0 +1,198 @@
import { ComponentType } from "react"
import {
StyleProp,
TouchableOpacity,
TouchableOpacityProps,
View,
ViewProps,
ViewStyle,
} from "react-native"
import {
ArrowLeft,
BarChart2,
Bell,
Bug,
Camera,
Check,
ChevronLeft,
ChevronRight,
Copy,
CreditCard,
Eye,
EyeOff,
FileText,
Github,
Globe,
HandMetal,
Heart,
Home,
Info,
Layers,
Lock,
LogOut,
Mail,
MapPin,
Menu,
MessageSquare,
Mic,
Monitor,
Moon,
MoreHorizontal,
Pencil,
Settings,
Shield,
Smartphone,
Sun,
Tablet,
Tag,
User,
Users,
X,
LucideProps,
} from "lucide-react-native"
import { useAppTheme } from "@/theme/context"
import { s } from "@/utils/responsive"
// Map icon names to Lucide components
const iconRegistry: Record<string, ComponentType<LucideProps>> = {
back: ArrowLeft,
barChart: BarChart2,
bell: Bell,
camera: Camera,
caretLeft: ChevronLeft,
caretRight: ChevronRight,
check: Check,
clap: HandMetal,
community: Users,
components: Layers,
copy: Copy,
creditCard: CreditCard,
debug: Bug,
fileText: FileText,
github: Github,
globe: Globe,
heart: Heart,
hidden: EyeOff,
home: Home,
info: Info,
ladybug: Bug,
lock: Lock,
logout: LogOut,
mail: Mail,
menu: Menu,
monitor: Monitor,
moon: Moon,
more: MoreHorizontal,
pencil: Pencil,
pin: MapPin,
podcast: Mic,
settings: Settings,
shield: Shield,
slack: MessageSquare,
smartphone: Smartphone,
sun: Sun,
tablet: Tablet,
tag: Tag,
user: User,
view: Eye,
x: X,
}
export type IconTypes = keyof typeof iconRegistry
type BaseIconProps = {
/**
* The name of the icon
*/
icon: IconTypes
/**
* An optional tint color for the icon
*/
color?: string
/**
* An optional size for the icon. If not provided, defaults to 24.
*/
size?: number
/**
* Style overrides for the icon container
*/
containerStyle?: StyleProp<ViewStyle>
/**
* Stroke width for the icon (default: 2)
*/
strokeWidth?: number
}
type PressableIconProps = Omit<TouchableOpacityProps, "style"> & BaseIconProps
type IconProps = Omit<ViewProps, "style"> & BaseIconProps
/**
* A component to render a registered icon.
* It is wrapped in a <TouchableOpacity />
* @see [Documentation and Examples]{@link https://docs.infinite.red/ignite-cli/boilerplate/app/components/Icon/}
* @param {PressableIconProps} props - The props for the `PressableIcon` component.
* @returns {JSX.Element} The rendered `PressableIcon` component.
*/
export function PressableIcon(props: PressableIconProps) {
const {
icon,
color,
size = s(24),
containerStyle: $containerStyleOverride,
strokeWidth = 2,
...pressableProps
} = props
const { theme } = useAppTheme()
const IconComponent = iconRegistry[icon]
if (!IconComponent) {
if (__DEV__) console.warn(`Icon "${icon}" not found in registry`)
return null
}
return (
<TouchableOpacity {...pressableProps} style={$containerStyleOverride}>
<IconComponent color={color ?? theme.colors.text} size={size} strokeWidth={strokeWidth} />
</TouchableOpacity>
)
}
/**
* A component to render a registered icon.
* It is wrapped in a <View />, use `PressableIcon` if you want to react to input
* @see [Documentation and Examples]{@link https://docs.infinite.red/ignite-cli/boilerplate/app/components/Icon/}
* @param {IconProps} props - The props for the `Icon` component.
* @returns {JSX.Element} The rendered `Icon` component.
*/
export function Icon(props: IconProps) {
const {
icon,
color,
size = s(24),
containerStyle: $containerStyleOverride,
strokeWidth = 2,
...viewProps
} = props
const { theme } = useAppTheme()
const IconComponent = iconRegistry[icon]
if (!IconComponent) {
if (__DEV__) console.warn(`Icon "${icon}" not found in registry`)
return null
}
return (
<View {...viewProps} style={$containerStyleOverride}>
<IconComponent color={color ?? theme.colors.text} size={size} strokeWidth={strokeWidth} />
</View>
)
}
export { iconRegistry }

View File

@@ -0,0 +1,257 @@
import { forwardRef, ReactElement } from "react"
import {
StyleProp,
TextStyle,
TouchableOpacity,
TouchableOpacityProps,
View,
ViewStyle,
} from "react-native"
import { useAppTheme } from "@/theme/context"
import { $styles } from "@/theme/styles"
import type { ThemedStyle } from "@/theme/types"
import { s } from "@/utils/responsive"
import { Icon, IconTypes } from "./Icon"
import { Text, TextProps } from "./Text"
export interface ListItemProps extends TouchableOpacityProps {
/**
* How tall the list item should be.
* Default: 56
*/
height?: number
/**
* Whether to show the top separator.
* Default: false
*/
topSeparator?: boolean
/**
* Whether to show the bottom separator.
* Default: false
*/
bottomSeparator?: boolean
/**
* Text to display if not using `tx` or nested components.
*/
text?: TextProps["text"]
/**
* Text which is looked up via i18n.
*/
tx?: TextProps["tx"]
/**
* Children components.
*/
children?: TextProps["children"]
/**
* Optional options to pass to i18n. Useful for interpolation
* as well as explicitly setting locale or translation fallbacks.
*/
txOptions?: TextProps["txOptions"]
/**
* Optional text style override.
*/
textStyle?: StyleProp<TextStyle>
/**
* Pass any additional props directly to the Text component.
*/
TextProps?: TextProps
/**
* Optional View container style override.
*/
containerStyle?: StyleProp<ViewStyle>
/**
* Optional TouchableOpacity style override.
*/
style?: StyleProp<ViewStyle>
/**
* Icon that should appear on the left.
*/
leftIcon?: IconTypes
/**
* An optional tint color for the left icon
*/
leftIconColor?: string
/**
* Icon that should appear on the right.
*/
rightIcon?: IconTypes
/**
* An optional tint color for the right icon
*/
rightIconColor?: string
/**
* Right action custom ReactElement.
* Overrides `rightIcon`.
*/
RightComponent?: ReactElement
/**
* Left action custom ReactElement.
* Overrides `leftIcon`.
*/
LeftComponent?: ReactElement
}
interface ListItemActionProps {
icon?: IconTypes
iconColor?: string
Component?: ReactElement
size: number
side: "left" | "right"
}
/**
* A styled row component that can be used in FlatList, SectionList, or by itself.
* @see [Documentation and Examples]{@link https://docs.infinite.red/ignite-cli/boilerplate/app/components/ListItem/}
* @param {ListItemProps} props - The props for the `ListItem` component.
* @returns {JSX.Element} The rendered `ListItem` component.
*/
export const ListItem = forwardRef<View, ListItemProps>(function ListItem(
props: ListItemProps,
ref,
) {
const {
bottomSeparator,
children,
height = s(56),
LeftComponent,
leftIcon,
leftIconColor,
RightComponent,
rightIcon,
rightIconColor,
style,
text,
TextProps,
topSeparator,
tx,
txOptions,
textStyle: $textStyleOverride,
containerStyle: $containerStyleOverride,
...TouchableOpacityProps
} = props
const { themed } = useAppTheme()
const isTouchable =
TouchableOpacityProps.onPress !== undefined ||
TouchableOpacityProps.onPressIn !== undefined ||
TouchableOpacityProps.onPressOut !== undefined ||
TouchableOpacityProps.onLongPress !== undefined
const $textStyles = [$textStyle, $textStyleOverride, TextProps?.style]
const $containerStyles = [
topSeparator && $separatorTop,
bottomSeparator && $separatorBottom,
$containerStyleOverride,
]
const $touchableStyles = [$styles.row, $touchableStyle, { minHeight: height }, style]
const Wrapper = isTouchable ? TouchableOpacity : View
return (
<View ref={ref} style={themed($containerStyles)}>
<Wrapper {...TouchableOpacityProps} style={$touchableStyles}>
<ListItemAction
side="left"
size={height}
icon={leftIcon}
iconColor={leftIconColor}
Component={LeftComponent}
/>
<Text {...TextProps} tx={tx} text={text} txOptions={txOptions} style={themed($textStyles)}>
{children}
</Text>
<ListItemAction
side="right"
size={height}
icon={rightIcon}
iconColor={rightIconColor}
Component={RightComponent}
/>
</Wrapper>
</View>
)
})
/**
* @param {ListItemActionProps} props - The props for the `ListItemAction` component.
* @returns {JSX.Element | null} The rendered `ListItemAction` component.
*/
function ListItemAction(props: ListItemActionProps) {
const { icon, Component, iconColor, size, side } = props
const { themed } = useAppTheme()
const $iconContainerStyles = [$iconContainer]
if (Component) {
return (
<View
style={themed([
$iconContainerStyles,
side === "left" && $iconContainerLeft,
side === "right" && $iconContainerRight,
{ height: size },
])}
>
{Component}
</View>
)
}
if (icon !== undefined) {
return (
<Icon
size={s(24)}
icon={icon}
color={iconColor}
containerStyle={themed([
$iconContainerStyles,
side === "left" && $iconContainerLeft,
side === "right" && $iconContainerRight,
{ height: size },
])}
/>
)
}
return null
}
const $separatorTop: ThemedStyle<ViewStyle> = ({ colors }) => ({
borderTopWidth: 1,
borderTopColor: colors.separator,
})
const $separatorBottom: ThemedStyle<ViewStyle> = ({ colors }) => ({
borderBottomWidth: 1,
borderBottomColor: colors.separator,
})
const $textStyle: ThemedStyle<TextStyle> = ({ spacing }) => ({
paddingVertical: spacing.xs,
alignSelf: "center",
flexGrow: 1,
flexShrink: 1,
})
const $touchableStyle: ViewStyle = {
alignItems: "flex-start",
}
const $iconContainer: ViewStyle = {
justifyContent: "center",
alignItems: "center",
flexGrow: 0,
}
const $iconContainerLeft: ThemedStyle<ViewStyle> = ({ spacing }) => ({
marginEnd: spacing.md,
})
const $iconContainerRight: ThemedStyle<ViewStyle> = ({ spacing }) => ({
marginStart: spacing.md,
})

View File

@@ -0,0 +1,386 @@
import { ReactNode } from "react"
import {
Modal as RNModal,
ModalProps as RNModalProps,
Platform,
Pressable,
StyleProp,
TextStyle,
View,
ViewStyle,
} from "react-native"
import { KeyboardAwareScrollView } from "react-native-keyboard-controller"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { useAppTheme } from "@/theme/context"
import type { ThemedStyle } from "@/theme/types"
import { s } from "@/utils/responsive"
import { Button, ButtonProps } from "./Button"
import { Icon } from "./Icon"
import { Text, TextProps } from "./Text"
type Presets = "center" | "bottom" | "fullscreen"
export interface ModalProps extends Omit<RNModalProps, "children"> {
/**
* Whether the modal is visible.
*/
visible: boolean
/**
* Callback when the modal is requested to close.
*/
onClose: () => void
/**
* Modal position preset.
* - `center`: Centered dialog (default)
* - `bottom`: Bottom sheet style, slides up from bottom
* - `fullscreen`: Full screen modal
* @default "center"
*/
preset?: Presets
/**
* Title text which is looked up via i18n.
*/
titleTx?: TextProps["tx"]
/**
* Title text to display if not using `titleTx`.
*/
title?: string
/**
* Optional title options to pass to i18n.
*/
titleTxOptions?: TextProps["txOptions"]
/**
* Style overrides for the title text.
*/
titleStyle?: StyleProp<TextStyle>
/**
* Whether to show the close button in the header.
* @default true
*/
showCloseButton?: boolean
/**
* Whether to close the modal when pressing the overlay.
* @default true
*/
closeOnOverlayPress?: boolean
/**
* The content to display inside the modal.
*/
children?: ReactNode
/**
* Style overrides for the overlay.
*/
overlayStyle?: StyleProp<ViewStyle>
/**
* Style overrides for the content container.
*/
contentStyle?: StyleProp<ViewStyle>
/**
* Props for the confirm button (right button).
* If provided, the footer buttons will be shown.
*/
confirmButtonProps?: ButtonProps
/**
* Props for the cancel button (left button).
* If provided along with confirmButtonProps, both buttons will be shown.
*/
cancelButtonProps?: ButtonProps
/**
* Custom footer component.
* Overrides confirmButtonProps and cancelButtonProps.
*/
FooterComponent?: ReactNode
/**
* Animation type for the modal.
* Defaults based on preset: "fade" for center, "slide" for bottom/fullscreen
*/
animationType?: "none" | "slide" | "fade"
/**
* Maximum width of the modal content (only applies to "center" preset).
* @default 400
*/
maxWidth?: number
}
/**
* A reusable modal component with overlay, title, close button, and footer actions.
* Supports theming, internationalization, and multiple position presets.
* @param {ModalProps} props - The props for the `Modal` component.
* @returns {JSX.Element} The rendered `Modal` component.
* @example
* // Center modal (default)
* <Modal
* visible={isVisible}
* onClose={() => setIsVisible(false)}
* titleTx="common:confirm"
* >
* <Text tx="modal:content" />
* </Modal>
*
* @example
* // Bottom sheet modal
* <Modal
* visible={isVisible}
* onClose={() => setIsVisible(false)}
* preset="bottom"
* titleTx="profile:editProfile"
* >
* <TextField ... />
* </Modal>
*/
export function Modal(props: ModalProps) {
const {
visible,
onClose,
preset = "center",
titleTx,
title,
titleTxOptions,
titleStyle: $titleStyleOverride,
showCloseButton = true,
closeOnOverlayPress = true,
children,
overlayStyle: $overlayStyleOverride,
contentStyle: $contentStyleOverride,
confirmButtonProps,
cancelButtonProps,
FooterComponent,
animationType,
maxWidth = s(400),
...rest
} = props
const { themed, theme } = useAppTheme()
const insets = useSafeAreaInsets()
const hasTitleArea = !!(titleTx || title || showCloseButton)
const hasFooter = !!(FooterComponent || confirmButtonProps)
// Determine animation type based on preset
const resolvedAnimationType = animationType ?? (preset === "center" ? "fade" : "slide")
// Build overlay style based on preset
const $overlayStyle: StyleProp<ViewStyle> = [
themed($overlayBase),
preset === "center" && themed($overlayCenter),
preset === "bottom" && $overlayBottom,
preset === "fullscreen" && $overlayFullscreen,
$overlayStyleOverride,
]
// Build content container style based on preset
const $contentContainerStyle: StyleProp<ViewStyle> = [
themed($contentBase),
preset === "center" && [themed($contentCenter), { maxWidth }],
preset === "bottom" && [themed($contentBottom), { paddingBottom: insets.bottom || s(24) }],
preset === "fullscreen" && [
themed($contentFullscreen),
{ paddingTop: insets.top, paddingBottom: insets.bottom },
],
$contentStyleOverride,
]
const $titleStyle: StyleProp<TextStyle> = [
themed($titleBase),
preset === "bottom" && $titleBottom,
$titleStyleOverride,
]
const renderHeader = () =>
hasTitleArea ? (
<View style={preset === "bottom" ? $headerBottom : $header}>
{(titleTx || title) && (
<Text
preset="subheading"
tx={titleTx}
text={title}
txOptions={titleTxOptions}
style={$titleStyle}
/>
)}
{showCloseButton && (
<Pressable
onPress={onClose}
style={[themed($closeButton), preset === "bottom" && $closeButtonBottom]}
hitSlop={{ top: s(10), bottom: s(10), left: s(10), right: s(10) }}
accessibilityRole="button"
accessibilityLabel="Close"
>
<Icon icon="x" size={20} color={theme.colors.textDim} />
</Pressable>
)}
</View>
) : null
const renderFooter = () =>
hasFooter ? (
<View style={themed($footer)}>
{FooterComponent ? (
FooterComponent
) : (
<>
{cancelButtonProps && (
<Button preset="default" style={themed($footerButton)} {...cancelButtonProps} />
)}
{confirmButtonProps && (
<Button preset="filled" style={themed($footerButton)} {...confirmButtonProps} />
)}
</>
)}
</View>
) : null
// For bottom preset, wrap content in KeyboardAwareScrollView
const renderContent = () => {
if (preset === "bottom") {
return (
<KeyboardAwareScrollView
bottomOffset={Platform.OS === "ios" ? 20 : 0}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
contentContainerStyle={$scrollContent}
>
{renderHeader()}
<View style={themed($body)}>{children}</View>
{renderFooter()}
</KeyboardAwareScrollView>
)
}
return (
<>
{renderHeader()}
<View style={themed($body)}>{children}</View>
{renderFooter()}
</>
)
}
return (
<RNModal
visible={visible}
transparent
animationType={resolvedAnimationType}
onRequestClose={onClose}
statusBarTranslucent
{...rest}
>
<Pressable style={$overlayStyle} onPress={closeOnOverlayPress ? onClose : undefined}>
<Pressable style={$contentContainerStyle} onPress={(e) => e.stopPropagation()}>
{/* Drag Handle for bottom preset */}
{preset === "bottom" && <View style={themed($dragHandle)} />}
{renderContent()}
</Pressable>
</Pressable>
</RNModal>
)
}
// Base styles
const $overlayBase: ThemedStyle<ViewStyle> = () => ({
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
})
const $contentBase: ThemedStyle<ViewStyle> = ({ colors, spacing }) => ({
backgroundColor: colors.background,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
})
// Center preset styles
const $overlayCenter: ThemedStyle<ViewStyle> = () => ({
justifyContent: "center",
alignItems: "center",
padding: s(24),
})
const $contentCenter: ThemedStyle<ViewStyle> = () => ({
borderRadius: s(12),
width: "100%",
overflow: "hidden",
})
// Bottom preset styles
const $overlayBottom: ViewStyle = {
justifyContent: "flex-end",
}
const $contentBottom: ThemedStyle<ViewStyle> = ({ spacing }) => ({
borderTopLeftRadius: s(20),
borderTopRightRadius: s(20),
paddingTop: spacing.sm,
maxHeight: "90%",
})
const $dragHandle: ThemedStyle<ViewStyle> = ({ colors, spacing }) => ({
width: s(36),
height: s(4),
borderRadius: s(2),
backgroundColor: colors.separator,
alignSelf: "center",
marginBottom: spacing.sm,
})
const $titleBottom: TextStyle = {
textAlign: "center",
}
// Fullscreen preset styles
const $overlayFullscreen: ViewStyle = {
backgroundColor: "transparent",
}
const $contentFullscreen: ThemedStyle<ViewStyle> = () => ({
flex: 1,
borderRadius: 0,
})
// Common styles
const $header: ViewStyle = {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
}
const $headerBottom: ViewStyle = {
alignItems: "center",
justifyContent: "center",
}
const $titleBase: ThemedStyle<TextStyle> = () => ({
textAlign: "left",
})
const $closeButton: ThemedStyle<ViewStyle> = ({ spacing }) => ({
padding: spacing.xxs,
marginLeft: spacing.sm,
})
const $closeButtonBottom: ViewStyle = {
position: "absolute",
right: 0,
top: 0,
}
const $body: ThemedStyle<ViewStyle> = ({ spacing }) => ({
paddingVertical: spacing.md,
})
const $footer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
flexDirection: "row",
justifyContent: "flex-end",
gap: spacing.sm,
paddingTop: spacing.sm,
})
const $footerButton: ThemedStyle<ViewStyle> = () => ({
minWidth: s(80),
})
const $scrollContent: ViewStyle = {
flexGrow: 1,
}

View File

@@ -0,0 +1,306 @@
import { ReactNode, useRef, useState } from "react"
import {
KeyboardAvoidingView,
KeyboardAvoidingViewProps,
LayoutChangeEvent,
Platform,
ScrollView,
ScrollViewProps,
StyleProp,
View,
ViewStyle,
} from "react-native"
import { useScrollToTop } from "@react-navigation/native"
import { SystemBars, SystemBarsProps, SystemBarStyle } from "react-native-edge-to-edge"
import { KeyboardAwareScrollView } from "react-native-keyboard-controller"
import { useAppTheme } from "@/theme/context"
import { $styles } from "@/theme/styles"
import { s } from "@/utils/responsive"
import { ExtendedEdge, useSafeAreaInsetsStyle } from "@/utils/useSafeAreaInsetsStyle"
export const DEFAULT_BOTTOM_OFFSET = s(50)
interface BaseScreenProps {
/**
* Children components.
*/
children?: ReactNode
/**
* Style for the outer content container useful for padding & margin.
*/
style?: StyleProp<ViewStyle>
/**
* Style for the inner content container useful for padding & margin.
*/
contentContainerStyle?: StyleProp<ViewStyle>
/**
* Override the default edges for the safe area.
*/
safeAreaEdges?: ExtendedEdge[]
/**
* Background color
*/
backgroundColor?: string
/**
* System bar setting. Defaults to dark.
*/
systemBarStyle?: SystemBarStyle
/**
* By how much should we offset the keyboard? Defaults to 0.
*/
keyboardOffset?: number
/**
* By how much we scroll up when the keyboard is shown. Defaults to 50.
*/
keyboardBottomOffset?: number
/**
* Pass any additional props directly to the SystemBars component.
*/
SystemBarsProps?: SystemBarsProps
/**
* Pass any additional props directly to the KeyboardAvoidingView component.
*/
KeyboardAvoidingViewProps?: KeyboardAvoidingViewProps
}
interface FixedScreenProps extends BaseScreenProps {
preset?: "fixed"
}
interface ScrollScreenProps extends BaseScreenProps {
preset?: "scroll"
/**
* Should keyboard persist on screen tap. Defaults to handled.
* Only applies to scroll preset.
*/
keyboardShouldPersistTaps?: "handled" | "always" | "never"
/**
* Pass any additional props directly to the ScrollView component.
*/
ScrollViewProps?: ScrollViewProps
}
interface AutoScreenProps extends Omit<ScrollScreenProps, "preset"> {
preset?: "auto"
/**
* Threshold to trigger the automatic disabling/enabling of scroll ability.
* Defaults to `{ percent: 0.92 }`.
*/
scrollEnabledToggleThreshold?: { percent?: number; point?: number }
}
export type ScreenProps = ScrollScreenProps | FixedScreenProps | AutoScreenProps
const isIos = Platform.OS === "ios"
type ScreenPreset = "fixed" | "scroll" | "auto"
/**
* @param {ScreenPreset?} preset - The preset to check.
* @returns {boolean} - Whether the preset is non-scrolling.
*/
function isNonScrolling(preset?: ScreenPreset) {
return !preset || preset === "fixed"
}
/**
* Custom hook that handles the automatic enabling/disabling of scroll ability based on the content size and screen size.
* @param {UseAutoPresetProps} props - The props for the `useAutoPreset` hook.
* @returns {{boolean, Function, Function}} - The scroll state, and the `onContentSizeChange` and `onLayout` functions.
*/
function useAutoPreset(props: AutoScreenProps): {
scrollEnabled: boolean
onContentSizeChange: (w: number, h: number) => void
onLayout: (e: LayoutChangeEvent) => void
} {
const { preset, scrollEnabledToggleThreshold } = props
const { percent = 0.92, point = 0 } = scrollEnabledToggleThreshold || {}
const scrollViewHeight = useRef<null | number>(null)
const scrollViewContentHeight = useRef<null | number>(null)
const [scrollEnabled, setScrollEnabled] = useState(true)
function updateScrollState() {
if (scrollViewHeight.current === null || scrollViewContentHeight.current === null) return
// check whether content fits the screen then toggle scroll state according to it
const contentFitsScreen = (function () {
if (point) {
return scrollViewContentHeight.current < scrollViewHeight.current - point
} else {
return scrollViewContentHeight.current < scrollViewHeight.current * percent
}
})()
// content is less than the size of the screen, so we can disable scrolling
if (scrollEnabled && contentFitsScreen) setScrollEnabled(false)
// content is greater than the size of the screen, so let's enable scrolling
if (!scrollEnabled && !contentFitsScreen) setScrollEnabled(true)
}
/**
* @param {number} w - The width of the content.
* @param {number} h - The height of the content.
*/
function onContentSizeChange(w: number, h: number) {
// update scroll-view content height
scrollViewContentHeight.current = h
updateScrollState()
}
/**
* @param {LayoutChangeEvent} e = The layout change event.
*/
function onLayout(e: LayoutChangeEvent) {
const { height } = e.nativeEvent.layout
// update scroll-view height
scrollViewHeight.current = height
updateScrollState()
}
// update scroll state on every render
if (preset === "auto") updateScrollState()
return {
scrollEnabled: preset === "auto" ? scrollEnabled : true,
onContentSizeChange,
onLayout,
}
}
/**
* @param {ScreenProps} props - The props for the `ScreenWithoutScrolling` component.
* @returns {JSX.Element} - The rendered `ScreenWithoutScrolling` component.
*/
function ScreenWithoutScrolling(props: ScreenProps) {
const { style, contentContainerStyle, children, preset } = props
return (
<View style={[$outerStyle, style]}>
<View style={[$innerStyle, preset === "fixed" && $justifyFlexEnd, contentContainerStyle]}>
{children}
</View>
</View>
)
}
/**
* @param {ScreenProps} props - The props for the `ScreenWithScrolling` component.
* @returns {JSX.Element} - The rendered `ScreenWithScrolling` component.
*/
function ScreenWithScrolling(props: ScreenProps) {
const {
children,
keyboardShouldPersistTaps = "handled",
keyboardBottomOffset = DEFAULT_BOTTOM_OFFSET,
contentContainerStyle,
ScrollViewProps,
style,
} = props as ScrollScreenProps
const ref = useRef<ScrollView>(null)
const { scrollEnabled, onContentSizeChange, onLayout } = useAutoPreset(props as AutoScreenProps)
// Add native behavior of pressing the active tab to scroll to the top of the content
// More info at: https://reactnavigation.org/docs/use-scroll-to-top/
useScrollToTop(ref)
return (
<KeyboardAwareScrollView
bottomOffset={keyboardBottomOffset}
{...{ keyboardShouldPersistTaps, scrollEnabled, ref }}
{...ScrollViewProps}
onLayout={(e) => {
onLayout(e)
ScrollViewProps?.onLayout?.(e)
}}
onContentSizeChange={(w: number, h: number) => {
onContentSizeChange(w, h)
ScrollViewProps?.onContentSizeChange?.(w, h)
}}
style={[$outerStyle, ScrollViewProps?.style, style]}
contentContainerStyle={[
$innerStyle,
ScrollViewProps?.contentContainerStyle,
contentContainerStyle,
]}
>
{children}
</KeyboardAwareScrollView>
)
}
/**
* Represents a screen component that provides a consistent layout and behavior for different screen presets.
* The `Screen` component can be used with different presets such as "fixed", "scroll", or "auto".
* It handles safe area insets, status bar settings, keyboard avoiding behavior, and scrollability based on the preset.
* @see [Documentation and Examples]{@link https://docs.infinite.red/ignite-cli/boilerplate/app/components/Screen/}
* @param {ScreenProps} props - The props for the `Screen` component.
* @returns {JSX.Element} The rendered `Screen` component.
*/
export function Screen(props: ScreenProps) {
const {
theme: { colors },
themeContext,
} = useAppTheme()
const {
backgroundColor,
KeyboardAvoidingViewProps,
keyboardOffset = 0,
safeAreaEdges,
SystemBarsProps,
systemBarStyle,
} = props
const $containerInsets = useSafeAreaInsetsStyle(safeAreaEdges)
return (
<View
style={[
$containerStyle,
{ backgroundColor: backgroundColor || colors.background },
$containerInsets,
]}
>
<SystemBars
style={systemBarStyle || (themeContext === "dark" ? "light" : "dark")}
{...SystemBarsProps}
/>
<KeyboardAvoidingView
behavior={isIos ? "padding" : "height"}
keyboardVerticalOffset={keyboardOffset}
{...KeyboardAvoidingViewProps}
style={[$styles.flex1, KeyboardAvoidingViewProps?.style]}
>
{isNonScrolling(props.preset) ? (
<ScreenWithoutScrolling {...props} />
) : (
<ScreenWithScrolling {...props} />
)}
</KeyboardAvoidingView>
</View>
)
}
const $containerStyle: ViewStyle = {
flex: 1,
height: "100%",
width: "100%",
}
const $outerStyle: ViewStyle = {
flex: 1,
height: "100%",
width: "100%",
}
const $justifyFlexEnd: ViewStyle = {
justifyContent: "flex-end",
}
const $innerStyle: ViewStyle = {
justifyContent: "flex-start",
alignItems: "stretch",
}

View File

@@ -0,0 +1,120 @@
import { createContext, useContext, useEffect } from "react"
import { StyleProp, View, ViewStyle } from "react-native"
import Animated, {
SharedValue,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
Easing,
interpolate,
} from "react-native-reanimated"
import { useAppTheme } from "@/theme/context"
import { s } from "@/utils/responsive"
// Shared animation context for synchronized pulse
const PulseContext = createContext<SharedValue<number> | null>(null)
export interface SkeletonProps {
/**
* Width of the skeleton. Can be number or percentage string.
* @default "100%"
*/
width?: number | `${number}%`
/**
* Height of the skeleton.
* @default 16
*/
height?: number
/**
* Border radius. Use "round" for circle.
* @default 4
*/
radius?: number | "round"
/**
* Style overrides
*/
style?: StyleProp<ViewStyle>
}
/**
* A skeleton placeholder with pulse animation.
* When used inside SkeletonContainer, all skeletons animate in sync.
*/
export function Skeleton(props: SkeletonProps) {
const { width = "100%", height = s(16), radius = s(4), style } = props
const { theme } = useAppTheme()
// Use shared animation from context, or create own
const sharedProgress = useContext(PulseContext)
const localProgress = useSharedValue(0)
const progress = sharedProgress || localProgress
// Only start local animation if not using shared context
useEffect(() => {
if (!sharedProgress) {
localProgress.value = withRepeat(
withTiming(1, { duration: 1000, easing: Easing.inOut(Easing.ease) }),
-1,
true,
)
}
}, [sharedProgress, localProgress])
const animatedStyle = useAnimatedStyle(() => ({
opacity: interpolate(progress.value, [0, 1], [0.4, 1]),
}))
const borderRadius = radius === "round" ? (typeof height === "number" ? height / 2 : 999) : radius
return (
<Animated.View
style={[
{ width, height, borderRadius, backgroundColor: theme.colors.border },
animatedStyle,
style,
]}
/>
)
}
export interface SkeletonContainerProps {
/**
* Children (Skeleton components)
*/
children: React.ReactNode
/**
* Style overrides for container
*/
style?: StyleProp<ViewStyle>
}
/**
* Container that synchronizes pulse animation across all child Skeletons.
*
* @example
* <SkeletonContainer>
* <Skeleton width="60%" height={14} />
* <Skeleton width={40} height={40} radius="round" />
* </SkeletonContainer>
*/
export function SkeletonContainer(props: SkeletonContainerProps) {
const { children, style } = props
const progress = useSharedValue(0)
useEffect(() => {
progress.value = withRepeat(
withTiming(1, { duration: 1000, easing: Easing.inOut(Easing.ease) }),
-1,
true,
)
}, [progress])
return (
<PulseContext.Provider value={progress}>
<View style={style}>{children}</View>
</PulseContext.Provider>
)
}

View File

@@ -0,0 +1,23 @@
import { NavigationContainer } from "@react-navigation/native"
import { render } from "@testing-library/react-native"
import { Text } from "./Text"
import { ThemeProvider } from "../theme/context"
/* This is an example component test using react-native-testing-library. For more
* information on how to write your own, see the documentation here:
* https://callstack.github.io/react-native-testing-library/ */
const testText = "Test string"
describe("Text", () => {
it("should render the component", () => {
const { getByText } = render(
<ThemeProvider>
<NavigationContainer>
<Text text={testText} />
</NavigationContainer>
</ThemeProvider>,
)
expect(getByText(testText)).toBeDefined()
})
})

View File

@@ -0,0 +1,118 @@
import { ReactNode, forwardRef, ForwardedRef } from "react"
// eslint-disable-next-line no-restricted-imports
import { StyleProp, Text as RNText, TextProps as RNTextProps, TextStyle } from "react-native"
import { TOptions } from "i18next"
import { useTranslation } from "react-i18next"
import { isRTL, TxKeyPath } from "@/i18n"
import { useAppTheme } from "@/theme/context"
import type { ThemedStyle, ThemedStyleArray } from "@/theme/types"
import { typography } from "@/theme/typography"
import { fs } from "@/utils/responsive"
type Sizes = keyof typeof $sizeStyles
type Weights = keyof typeof typography.primary
type Presets = "default" | "bold" | "heading" | "subheading" | "formLabel" | "formHelper"
export interface TextProps extends RNTextProps {
/**
* Text which is looked up via i18n.
*/
tx?: TxKeyPath
/**
* The text to display if not using `tx` or nested components.
*/
text?: string
/**
* Optional options to pass to i18n. Useful for interpolation
* as well as explicitly setting locale or translation fallbacks.
*/
txOptions?: TOptions
/**
* An optional style override useful for padding & margin.
*/
style?: StyleProp<TextStyle>
/**
* One of the different types of text presets.
*/
preset?: Presets
/**
* Text weight modifier.
*/
weight?: Weights
/**
* Text size modifier.
*/
size?: Sizes
/**
* Children components.
*/
children?: ReactNode
}
/**
* For your text displaying needs.
* This component is a HOC over the built-in React Native one.
* @see [Documentation and Examples]{@link https://docs.infinite.red/ignite-cli/boilerplate/app/components/Text/}
* @param {TextProps} props - The props for the `Text` component.
* @returns {JSX.Element} The rendered `Text` component.
*/
export const Text = forwardRef(function Text(props: TextProps, ref: ForwardedRef<RNText>) {
const { weight, size, tx, txOptions, text, children, style: $styleOverride, ...rest } = props
const { themed } = useAppTheme()
const { t } = useTranslation()
const i18nText = tx && t(tx, txOptions)
const content = i18nText || text || children
const preset: Presets = props.preset ?? "default"
const $styles: StyleProp<TextStyle> = [
$rtlStyle,
themed($presets[preset]),
weight && $fontWeightStyles[weight],
size && $sizeStyles[size],
$styleOverride,
]
return (
<RNText {...rest} style={$styles} ref={ref}>
{content}
</RNText>
)
})
const $sizeStyles = {
xxl: { fontSize: fs(36), lineHeight: fs(44) } satisfies TextStyle,
xl: { fontSize: fs(24), lineHeight: fs(34) } satisfies TextStyle,
lg: { fontSize: fs(20), lineHeight: fs(32) } satisfies TextStyle,
md: { fontSize: fs(18), lineHeight: fs(26) } satisfies TextStyle,
sm: { fontSize: fs(16), lineHeight: fs(24) } satisfies TextStyle,
xs: { fontSize: fs(14), lineHeight: fs(21) } satisfies TextStyle,
xxs: { fontSize: fs(12), lineHeight: fs(18) } satisfies TextStyle,
}
const $fontWeightStyles = Object.entries(typography.primary).reduce((acc, [weight, fontFamily]) => {
return { ...acc, [weight]: { fontFamily } }
}, {}) as Record<Weights, TextStyle>
const $baseStyle: ThemedStyle<TextStyle> = (theme) => ({
...$sizeStyles.sm,
...$fontWeightStyles.normal,
color: theme.colors.text,
})
const $presets: Record<Presets, ThemedStyleArray<TextStyle>> = {
default: [$baseStyle],
bold: [$baseStyle, { ...$fontWeightStyles.bold }],
heading: [
$baseStyle,
{
...$sizeStyles.xxl,
...$fontWeightStyles.bold,
},
],
subheading: [$baseStyle, { ...$sizeStyles.lg, ...$fontWeightStyles.medium }],
formLabel: [$baseStyle, { ...$fontWeightStyles.medium }],
formHelper: [$baseStyle, { ...$sizeStyles.sm, ...$fontWeightStyles.normal }],
}
const $rtlStyle: TextStyle = isRTL ? { writingDirection: "rtl" } : {}

View File

@@ -0,0 +1,292 @@
import { ComponentType, forwardRef, Ref, useImperativeHandle, useRef } from "react"
import {
ImageStyle,
StyleProp,
// eslint-disable-next-line no-restricted-imports
TextInput,
TextInputProps,
TextStyle,
TouchableOpacity,
View,
ViewStyle,
} from "react-native"
import { useTranslation } from "react-i18next"
import { isRTL } from "@/i18n"
import { useAppTheme } from "@/theme/context"
import { $styles } from "@/theme/styles"
import type { ThemedStyle, ThemedStyleArray } from "@/theme/types"
import { s, fs } from "@/utils/responsive"
import { Text, TextProps } from "./Text"
export interface TextFieldAccessoryProps {
style: StyleProp<ViewStyle | TextStyle | ImageStyle>
status: TextFieldProps["status"]
multiline: boolean
editable: boolean
}
export interface TextFieldProps extends Omit<TextInputProps, "ref"> {
/**
* A style modifier for different input states.
*/
status?: "error" | "disabled"
/**
* The label text to display if not using `labelTx`.
*/
label?: TextProps["text"]
/**
* Label text which is looked up via i18n.
*/
labelTx?: TextProps["tx"]
/**
* Optional label options to pass to i18n. Useful for interpolation
* as well as explicitly setting locale or translation fallbacks.
*/
labelTxOptions?: TextProps["txOptions"]
/**
* Pass any additional props directly to the label Text component.
*/
LabelTextProps?: TextProps
/**
* The helper text to display if not using `helperTx`.
*/
helper?: TextProps["text"]
/**
* Helper text which is looked up via i18n.
*/
helperTx?: TextProps["tx"]
/**
* Optional helper options to pass to i18n. Useful for interpolation
* as well as explicitly setting locale or translation fallbacks.
*/
helperTxOptions?: TextProps["txOptions"]
/**
* Pass any additional props directly to the helper Text component.
*/
HelperTextProps?: TextProps
/**
* The placeholder text to display if not using `placeholderTx`.
*/
placeholder?: TextProps["text"]
/**
* Placeholder text which is looked up via i18n.
*/
placeholderTx?: TextProps["tx"]
/**
* Optional placeholder options to pass to i18n. Useful for interpolation
* as well as explicitly setting locale or translation fallbacks.
*/
placeholderTxOptions?: TextProps["txOptions"]
/**
* Optional input style override.
*/
style?: StyleProp<TextStyle>
/**
* Style overrides for the container
*/
containerStyle?: StyleProp<ViewStyle>
/**
* Style overrides for the input wrapper
*/
inputWrapperStyle?: StyleProp<ViewStyle>
/**
* An optional component to render on the right side of the input.
* Example: `RightAccessory={(props) => <Icon icon="ladybug" containerStyle={props.style} color={props.editable ? colors.textDim : colors.text} />}`
* Note: It is a good idea to memoize this.
*/
RightAccessory?: ComponentType<TextFieldAccessoryProps>
/**
* An optional component to render on the left side of the input.
* Example: `LeftAccessory={(props) => <Icon icon="ladybug" containerStyle={props.style} color={props.editable ? colors.textDim : colors.text} />}`
* Note: It is a good idea to memoize this.
*/
LeftAccessory?: ComponentType<TextFieldAccessoryProps>
}
/**
* A component that allows for the entering and editing of text.
* @see [Documentation and Examples]{@link https://docs.infinite.red/ignite-cli/boilerplate/app/components/TextField/}
* @param {TextFieldProps} props - The props for the `TextField` component.
* @returns {JSX.Element} The rendered `TextField` component.
*/
export const TextField = forwardRef(function TextField(props: TextFieldProps, ref: Ref<TextInput>) {
const {
labelTx,
label,
labelTxOptions,
placeholderTx,
placeholder,
placeholderTxOptions,
helper,
helperTx,
helperTxOptions,
status,
RightAccessory,
LeftAccessory,
HelperTextProps,
LabelTextProps,
style: $inputStyleOverride,
containerStyle: $containerStyleOverride,
inputWrapperStyle: $inputWrapperStyleOverride,
...TextInputProps
} = props
const input = useRef<TextInput>(null)
const { t } = useTranslation()
const {
themed,
theme: { colors },
} = useAppTheme()
const disabled = TextInputProps.editable === false || status === "disabled"
const placeholderContent = placeholderTx ? t(placeholderTx, placeholderTxOptions) : placeholder
const $containerStyles = [$containerStyleOverride]
const $labelStyles = [$labelStyle, LabelTextProps?.style]
const $inputWrapperStyles = [
$styles.row,
$inputWrapperStyle,
status === "error" && { borderColor: colors.error },
TextInputProps.multiline && { minHeight: s(112) },
LeftAccessory && { paddingStart: 0 },
RightAccessory && { paddingEnd: 0 },
$inputWrapperStyleOverride,
]
const $inputStyles: ThemedStyleArray<TextStyle> = [
$inputStyle,
disabled && { color: colors.textDim },
isRTL && { textAlign: "right" as TextStyle["textAlign"] },
TextInputProps.multiline && { height: "auto" },
$inputStyleOverride,
]
const $helperStyles = [
$helperStyle,
status === "error" && { color: colors.error },
HelperTextProps?.style,
]
/**
*
*/
function focusInput() {
if (disabled) return
input.current?.focus()
}
useImperativeHandle(ref, () => input.current as TextInput)
return (
<TouchableOpacity
activeOpacity={1}
style={$containerStyles}
onPress={focusInput}
accessibilityState={{ disabled }}
>
{!!(label || labelTx) && (
<Text
preset="formLabel"
text={label}
tx={labelTx}
txOptions={labelTxOptions}
{...LabelTextProps}
style={themed($labelStyles)}
/>
)}
<View style={themed($inputWrapperStyles)}>
{!!LeftAccessory && (
<LeftAccessory
style={themed($leftAccessoryStyle)}
status={status}
editable={!disabled}
multiline={TextInputProps.multiline ?? false}
/>
)}
<TextInput
ref={input}
underlineColorAndroid={colors.transparent}
textAlignVertical="top"
placeholder={placeholderContent}
placeholderTextColor={colors.textDim}
{...TextInputProps}
editable={!disabled}
style={themed($inputStyles)}
/>
{!!RightAccessory && (
<RightAccessory
style={themed($rightAccessoryStyle)}
status={status}
editable={!disabled}
multiline={TextInputProps.multiline ?? false}
/>
)}
</View>
{!!(helper || helperTx) && (
<Text
preset="formHelper"
text={helper}
tx={helperTx}
txOptions={helperTxOptions}
{...HelperTextProps}
style={themed($helperStyles)}
/>
)}
</TouchableOpacity>
)
})
const $labelStyle: ThemedStyle<TextStyle> = ({ spacing }) => ({
marginBottom: spacing.xs,
})
const $inputWrapperStyle: ThemedStyle<ViewStyle> = ({ colors }) => ({
alignItems: "flex-start",
borderWidth: 1,
borderRadius: s(4),
backgroundColor: colors.palette.neutral200,
borderColor: colors.palette.neutral400,
overflow: "hidden",
})
const $inputStyle: ThemedStyle<TextStyle> = ({ colors, typography, spacing }) => ({
flex: 1,
alignSelf: "stretch",
fontFamily: typography.primary.normal,
color: colors.text,
fontSize: fs(16),
height: s(24),
// https://github.com/facebook/react-native/issues/21720#issuecomment-532642093
paddingVertical: 0,
paddingHorizontal: 0,
marginVertical: spacing.xs,
marginHorizontal: spacing.sm,
})
const $helperStyle: ThemedStyle<TextStyle> = ({ spacing }) => ({
marginTop: spacing.xs,
})
const $rightAccessoryStyle: ThemedStyle<ViewStyle> = ({ spacing }) => ({
marginEnd: spacing.xs,
height: s(40),
justifyContent: "center",
alignItems: "center",
})
const $leftAccessoryStyle: ThemedStyle<ViewStyle> = ({ spacing }) => ({
marginStart: spacing.xs,
height: s(40),
justifyContent: "center",
alignItems: "center",
})

View File

@@ -0,0 +1,106 @@
import { useEffect, useRef, useCallback } from "react"
import { Animated, StyleProp, View, ViewStyle } from "react-native"
import { useAppTheme } from "@/theme/context"
import { $styles } from "@/theme/styles"
import { s } from "@/utils/responsive"
import { Icon, IconTypes } from "../Icon"
import { $inputOuterBase, BaseToggleInputProps, ToggleProps, Toggle } from "./Toggle"
export interface CheckboxToggleProps extends Omit<ToggleProps<CheckboxInputProps>, "ToggleInput"> {
/**
* Checkbox-only prop that changes the icon used for the "on" state.
*/
icon?: IconTypes
}
interface CheckboxInputProps extends BaseToggleInputProps<CheckboxToggleProps> {
icon?: CheckboxToggleProps["icon"]
}
/**
* @param {CheckboxToggleProps} props - The props for the `Checkbox` component.
* @see [Documentation and Examples]{@link https://docs.infinite.red/ignite-cli/boilerplate/app/components/Checkbox}
* @returns {JSX.Element} The rendered `Checkbox` component.
*/
export function Checkbox(props: CheckboxToggleProps) {
const { icon, ...rest } = props
const checkboxInput = useCallback(
(toggleProps: CheckboxInputProps) => <CheckboxInput {...toggleProps} icon={icon} />,
[icon],
)
return <Toggle accessibilityRole="checkbox" {...rest} ToggleInput={checkboxInput} />
}
function CheckboxInput(props: CheckboxInputProps) {
const {
on,
status,
disabled,
icon = "check",
outerStyle: $outerStyleOverride,
innerStyle: $innerStyleOverride,
} = props
const {
theme: { colors },
} = useAppTheme()
const opacity = useRef(new Animated.Value(0))
useEffect(() => {
Animated.timing(opacity.current, {
toValue: on ? 1 : 0,
duration: 300,
useNativeDriver: true,
}).start()
}, [on])
const offBackgroundColor = [
disabled && colors.palette.neutral400,
status === "error" && colors.errorBackground,
colors.palette.neutral200,
].filter(Boolean)[0]
const outerBorderColor = [
disabled && colors.palette.neutral400,
status === "error" && colors.error,
!on && colors.palette.neutral800,
colors.palette.secondary500,
].filter(Boolean)[0]
const onBackgroundColor = [
disabled && colors.transparent,
status === "error" && colors.errorBackground,
colors.palette.secondary500,
].filter(Boolean)[0]
const iconTintColor = [
disabled && colors.palette.neutral600,
status === "error" && colors.error,
colors.palette.accent100,
].filter(Boolean)[0] as string | undefined
return (
<View
style={[
$inputOuter,
{ backgroundColor: offBackgroundColor, borderColor: outerBorderColor },
$outerStyleOverride,
]}
>
<Animated.View
style={[
$styles.toggleInner,
{ backgroundColor: onBackgroundColor },
$innerStyleOverride,
{ opacity: opacity.current },
]}
>
<Icon icon={icon} size={s(20)} color={iconTintColor} />
</Animated.View>
</View>
)
}
const $inputOuter: StyleProp<ViewStyle> = [$inputOuterBase, { borderRadius: s(4) }]

View File

@@ -0,0 +1,107 @@
import { useEffect, useRef } from "react"
import { StyleProp, View, ViewStyle, Animated } from "react-native"
import { useAppTheme } from "@/theme/context"
import { $styles } from "@/theme/styles"
import { s } from "@/utils/responsive"
import { $inputOuterBase, BaseToggleInputProps, ToggleProps, Toggle } from "./Toggle"
export interface RadioToggleProps extends Omit<ToggleProps<RadioInputProps>, "ToggleInput"> {
/**
* Optional style prop that affects the dot View.
*/
inputDetailStyle?: ViewStyle
}
interface RadioInputProps extends BaseToggleInputProps<RadioToggleProps> {}
/**
* @param {RadioToggleProps} props - The props for the `Radio` component.
* @see [Documentation and Examples]{@link https://docs.infinite.red/ignite-cli/boilerplate/app/components/Radio}
* @returns {JSX.Element} The rendered `Radio` component.
*/
export function Radio(props: RadioToggleProps) {
return <Toggle accessibilityRole="radio" {...props} ToggleInput={RadioInput} />
}
function RadioInput(props: RadioInputProps) {
const {
on,
status,
disabled,
outerStyle: $outerStyleOverride,
innerStyle: $innerStyleOverride,
detailStyle: $detailStyleOverride,
} = props
const {
theme: { colors },
} = useAppTheme()
const opacity = useRef(new Animated.Value(0))
useEffect(() => {
Animated.timing(opacity.current, {
toValue: on ? 1 : 0,
duration: 300,
useNativeDriver: true,
}).start()
}, [on])
const offBackgroundColor = [
disabled && colors.palette.neutral400,
status === "error" && colors.errorBackground,
colors.palette.neutral200,
].filter(Boolean)[0]
const outerBorderColor = [
disabled && colors.palette.neutral400,
status === "error" && colors.error,
!on && colors.palette.neutral800,
colors.palette.secondary500,
].filter(Boolean)[0]
const onBackgroundColor = [
disabled && colors.transparent,
status === "error" && colors.errorBackground,
colors.palette.neutral100,
].filter(Boolean)[0]
const dotBackgroundColor = [
disabled && colors.palette.neutral600,
status === "error" && colors.error,
colors.palette.secondary500,
].filter(Boolean)[0]
return (
<View
style={[
$inputOuter,
{ backgroundColor: offBackgroundColor, borderColor: outerBorderColor },
$outerStyleOverride,
]}
>
<Animated.View
style={[
$styles.toggleInner,
{ backgroundColor: onBackgroundColor },
$innerStyleOverride,
{ opacity: opacity.current },
]}
>
<View
style={[$radioDetail, { backgroundColor: dotBackgroundColor }, $detailStyleOverride]}
/>
</Animated.View>
</View>
)
}
const $radioDetail: ViewStyle = {
width: s(12),
height: s(12),
borderRadius: s(6),
}
const $inputOuter: StyleProp<ViewStyle> = [$inputOuterBase, { borderRadius: s(12) }]

View File

@@ -0,0 +1,256 @@
import { useEffect, useMemo, useRef, useCallback } from "react"
import { Animated, Platform, StyleProp, View, ViewStyle } from "react-native"
import { Icon } from "@/components/Icon"
import { isRTL } from "@/i18n"
import { useAppTheme } from "@/theme/context"
import { $styles } from "@/theme/styles"
import type { ThemedStyle } from "@/theme/types"
import { s } from "@/utils/responsive"
import { $inputOuterBase, BaseToggleInputProps, Toggle, ToggleProps } from "./Toggle"
export interface SwitchToggleProps extends Omit<ToggleProps<SwitchInputProps>, "ToggleInput"> {
/**
* Switch-only prop that adds a text/icon label for on/off states.
*/
accessibilityMode?: "text" | "icon"
/**
* Optional style prop that affects the knob View.
* Note: `width` and `height` rules should be points (numbers), not percentages.
*/
inputDetailStyle?: Omit<ViewStyle, "width" | "height"> & { width?: number; height?: number }
}
interface SwitchInputProps extends BaseToggleInputProps<SwitchToggleProps> {
accessibilityMode?: SwitchToggleProps["accessibilityMode"]
}
/**
* @param {SwitchToggleProps} props - The props for the `Switch` component.
* @see [Documentation and Examples]{@link https://docs.infinite.red/ignite-cli/boilerplate/app/components/Switch}
* @returns {JSX.Element} The rendered `Switch` component.
*/
export function Switch(props: SwitchToggleProps) {
const { accessibilityMode, ...rest } = props
const switchInput = useCallback(
(toggleProps: SwitchInputProps) => (
<SwitchInput {...toggleProps} accessibilityMode={accessibilityMode} />
),
[accessibilityMode],
)
return <Toggle accessibilityRole="switch" {...rest} ToggleInput={switchInput} />
}
function SwitchInput(props: SwitchInputProps) {
const {
on,
status,
disabled,
outerStyle: $outerStyleOverride,
innerStyle: $innerStyleOverride,
detailStyle: $detailStyleOverride,
} = props
const {
theme: { colors },
themed,
} = useAppTheme()
const animate = useRef(new Animated.Value(on ? 1 : 0)) // Initial value is set based on isActive
const opacity = useRef(new Animated.Value(0))
useEffect(() => {
Animated.timing(animate.current, {
toValue: on ? 1 : 0,
duration: 300,
useNativeDriver: true, // Enable native driver for smoother animations
}).start()
}, [on])
useEffect(() => {
Animated.timing(opacity.current, {
toValue: on ? 1 : 0,
duration: 300,
useNativeDriver: true,
}).start()
}, [on])
const knobSizeFallback = 2
const knobWidth = [$detailStyleOverride?.width, $switchDetail?.width, knobSizeFallback].find(
(v) => typeof v === "number",
)
const knobHeight = [$detailStyleOverride?.height, $switchDetail?.height, knobSizeFallback].find(
(v) => typeof v === "number",
)
const offBackgroundColor = [
disabled && colors.palette.neutral400,
status === "error" && colors.errorBackground,
colors.palette.neutral300,
].filter(Boolean)[0]
const onBackgroundColor = [
disabled && colors.transparent,
status === "error" && colors.errorBackground,
colors.palette.secondary500,
].filter(Boolean)[0]
const knobBackgroundColor = (function () {
if (on) {
return [
$detailStyleOverride?.backgroundColor,
status === "error" && colors.error,
disabled && colors.palette.neutral600,
colors.palette.neutral100,
].filter(Boolean)[0]
} else {
return [
$innerStyleOverride?.backgroundColor,
disabled && colors.palette.neutral600,
status === "error" && colors.error,
colors.palette.neutral200,
].filter(Boolean)[0]
}
})()
const rtlAdjustment = isRTL ? -1 : 1
const $themedSwitchInner = useMemo(() => themed([$styles.toggleInner, $switchInner]), [themed])
const offsetLeft = ($innerStyleOverride?.paddingStart ||
$innerStyleOverride?.paddingLeft ||
$themedSwitchInner?.paddingStart ||
$themedSwitchInner?.paddingLeft ||
0) as number
const offsetRight = ($innerStyleOverride?.paddingEnd ||
$innerStyleOverride?.paddingRight ||
$themedSwitchInner?.paddingEnd ||
$themedSwitchInner?.paddingRight ||
0) as number
const outputRange =
Platform.OS === "web"
? isRTL
? [+(knobWidth || 0) + offsetRight, offsetLeft]
: [offsetLeft, +(knobWidth || 0) + offsetRight]
: [rtlAdjustment * offsetLeft, rtlAdjustment * (+(knobWidth || 0) + offsetRight)]
const $animatedSwitchKnob = animate.current.interpolate({
inputRange: [0, 1],
outputRange,
})
return (
<View style={[$inputOuter, { backgroundColor: offBackgroundColor }, $outerStyleOverride]}>
<Animated.View
style={[
$themedSwitchInner,
{ backgroundColor: onBackgroundColor },
$innerStyleOverride,
{ opacity: opacity.current },
]}
/>
<SwitchAccessibilityLabel {...props} role="on" />
<SwitchAccessibilityLabel {...props} role="off" />
<Animated.View
style={[
$switchDetail,
$detailStyleOverride,
{ transform: [{ translateX: $animatedSwitchKnob }] },
{ width: knobWidth, height: knobHeight },
{ backgroundColor: knobBackgroundColor },
]}
/>
</View>
)
}
/**
* @param {ToggleInputProps & { role: "on" | "off" }} props - The props for the `SwitchAccessibilityLabel` component.
* @returns {JSX.Element} The rendered `SwitchAccessibilityLabel` component.
*/
function SwitchAccessibilityLabel(props: SwitchInputProps & { role: "on" | "off" }) {
const { on, disabled, status, accessibilityMode, role, innerStyle, detailStyle } = props
const {
theme: { colors },
} = useAppTheme()
if (!accessibilityMode) return null
const shouldLabelBeVisible = (on && role === "on") || (!on && role === "off")
const $switchAccessibilityStyle: StyleProp<ViewStyle> = [
$switchAccessibility,
role === "off" && { end: "5%" },
role === "on" && { left: "5%" },
]
const color = (function () {
if (disabled) return colors.palette.neutral600
if (status === "error") return colors.error
if (!on) return innerStyle?.backgroundColor || colors.palette.secondary500
return detailStyle?.backgroundColor || colors.palette.neutral100
})() as string
return (
<View style={$switchAccessibilityStyle}>
{accessibilityMode === "text" && shouldLabelBeVisible && (
<View
style={[
role === "on" && $switchAccessibilityLine,
role === "on" && { backgroundColor: color },
role === "off" && $switchAccessibilityCircle,
role === "off" && { borderColor: color },
]}
/>
)}
{accessibilityMode === "icon" && shouldLabelBeVisible && (
<Icon icon={role === "off" ? "hidden" : "view"} size={14} color={color} />
)}
</View>
)
}
const $inputOuter: StyleProp<ViewStyle> = [
$inputOuterBase,
{ height: s(32), width: s(56), borderRadius: s(16), borderWidth: 0 },
]
const $switchInner: ThemedStyle<ViewStyle> = ({ colors }) => ({
borderColor: colors.transparent,
position: "absolute",
paddingStart: 4,
paddingEnd: 4,
})
const $switchDetail: SwitchToggleProps["inputDetailStyle"] = {
borderRadius: s(12),
position: "absolute",
width: s(24),
height: s(24),
}
const $switchAccessibility: ViewStyle = {
width: "40%",
justifyContent: "center",
alignItems: "center",
}
const $switchAccessibilityLine: ViewStyle = {
width: 2,
height: s(12),
}
const $switchAccessibilityCircle: ViewStyle = {
borderWidth: 2,
width: s(12),
height: s(12),
borderRadius: s(6),
}

View File

@@ -0,0 +1,286 @@
import { ComponentType, FC, useMemo } from "react"
import {
GestureResponderEvent,
ImageStyle,
StyleProp,
SwitchProps,
TextInputProps,
TextStyle,
TouchableOpacity,
TouchableOpacityProps,
View,
ViewProps,
ViewStyle,
} from "react-native"
import { useAppTheme } from "@/theme/context"
import { $styles } from "@/theme/styles"
import type { ThemedStyle } from "@/theme/types"
import { s } from "@/utils/responsive"
import { Text, TextProps } from "../Text"
export interface ToggleProps<T> extends Omit<TouchableOpacityProps, "style"> {
/**
* A style modifier for different input states.
*/
status?: "error" | "disabled"
/**
* If false, input is not editable. The default value is true.
*/
editable?: TextInputProps["editable"]
/**
* The value of the field. If true the component will be turned on.
*/
value?: boolean
/**
* Invoked with the new value when the value changes.
*/
onValueChange?: SwitchProps["onValueChange"]
/**
* Style overrides for the container
*/
containerStyle?: StyleProp<ViewStyle>
/**
* Style overrides for the input wrapper
*/
inputWrapperStyle?: StyleProp<ViewStyle>
/**
* Optional input wrapper style override.
* This gives the inputs their size, shape, "off" background-color, and outer border.
*/
inputOuterStyle?: ViewStyle
/**
* Optional input style override.
* This gives the inputs their inner characteristics and "on" background-color.
*/
inputInnerStyle?: ViewStyle
/**
* Optional detail style override.
* See Checkbox, Radio, and Switch for more details
*/
inputDetailStyle?: ViewStyle
/**
* The position of the label relative to the action component.
* Default: right
*/
labelPosition?: "left" | "right"
/**
* The label text to display if not using `labelTx`.
*/
label?: TextProps["text"]
/**
* Label text which is looked up via i18n.
*/
labelTx?: TextProps["tx"]
/**
* Optional label options to pass to i18n. Useful for interpolation
* as well as explicitly setting locale or translation fallbacks.
*/
labelTxOptions?: TextProps["txOptions"]
/**
* Style overrides for label text.
*/
labelStyle?: StyleProp<TextStyle>
/**
* Pass any additional props directly to the label Text component.
*/
LabelTextProps?: TextProps
/**
* The helper text to display if not using `helperTx`.
*/
helper?: TextProps["text"]
/**
* Helper text which is looked up via i18n.
*/
helperTx?: TextProps["tx"]
/**
* Optional helper options to pass to i18n. Useful for interpolation
* as well as explicitly setting locale or translation fallbacks.
*/
helperTxOptions?: TextProps["txOptions"]
/**
* Pass any additional props directly to the helper Text component.
*/
HelperTextProps?: TextProps
/**
* The input control for the type of toggle component
*/
ToggleInput: FC<BaseToggleInputProps<T>>
}
export interface BaseToggleInputProps<T> {
on: boolean
status: ToggleProps<T>["status"]
disabled: boolean
outerStyle: ViewStyle
innerStyle: ViewStyle
detailStyle: ViewStyle | ImageStyle
}
/**
* Renders a boolean input.
* This is a controlled component that requires an onValueChange callback that updates the value prop in order for the component to reflect user actions. If the value prop is not updated, the component will continue to render the supplied value prop instead of the expected result of any user actions.
* @param {ToggleProps} props - The props for the `Toggle` component.
* @returns {JSX.Element} The rendered `Toggle` component.
*/
export function Toggle<T>(props: ToggleProps<T>) {
const {
editable = true,
status,
value,
onPress,
onValueChange,
labelPosition = "right",
helper,
helperTx,
helperTxOptions,
HelperTextProps,
containerStyle: $containerStyleOverride,
inputWrapperStyle: $inputWrapperStyleOverride,
ToggleInput,
accessibilityRole,
...WrapperProps
} = props
const {
theme: { colors },
themed,
} = useAppTheme()
const disabled = editable === false || status === "disabled" || props.disabled
const Wrapper = useMemo(
() => (disabled ? View : TouchableOpacity) as ComponentType<TouchableOpacityProps | ViewProps>,
[disabled],
)
const $containerStyles = [$containerStyleOverride]
const $inputWrapperStyles = [$styles.row, $inputWrapper, $inputWrapperStyleOverride]
const $helperStyles = themed([
$helper,
status === "error" && { color: colors.error },
HelperTextProps?.style,
])
/**
* @param {GestureResponderEvent} e - The event object.
*/
function handlePress(e: GestureResponderEvent) {
if (disabled) return
onValueChange?.(!value)
onPress?.(e)
}
return (
<Wrapper
activeOpacity={1}
accessibilityRole={accessibilityRole}
accessibilityState={{ checked: value, disabled }}
{...WrapperProps}
style={$containerStyles}
onPress={handlePress}
>
<View style={$inputWrapperStyles}>
{labelPosition === "left" && <FieldLabel<T> {...props} labelPosition={labelPosition} />}
<ToggleInput
on={!!value}
disabled={!!disabled}
status={status}
outerStyle={props.inputOuterStyle ?? {}}
innerStyle={props.inputInnerStyle ?? {}}
detailStyle={props.inputDetailStyle ?? {}}
/>
{labelPosition === "right" && <FieldLabel<T> {...props} labelPosition={labelPosition} />}
</View>
{!!(helper || helperTx) && (
<Text
preset="formHelper"
text={helper}
tx={helperTx}
txOptions={helperTxOptions}
{...HelperTextProps}
style={$helperStyles}
/>
)}
</Wrapper>
)
}
/**
* @param {ToggleProps} props - The props for the `FieldLabel` component.
* @returns {JSX.Element} The rendered `FieldLabel` component.
*/
function FieldLabel<T>(props: ToggleProps<T>) {
const {
status,
label,
labelTx,
labelTxOptions,
LabelTextProps,
labelPosition,
labelStyle: $labelStyleOverride,
} = props
const {
theme: { colors },
themed,
} = useAppTheme()
if (!label && !labelTx && !LabelTextProps?.children) return null
const $labelStyle = themed([
$label,
status === "error" && { color: colors.error },
labelPosition === "right" && $labelRight,
labelPosition === "left" && $labelLeft,
$labelStyleOverride,
LabelTextProps?.style,
])
return (
<Text
preset="formLabel"
text={label}
tx={labelTx}
txOptions={labelTxOptions}
{...LabelTextProps}
style={$labelStyle}
/>
)
}
const $inputWrapper: ViewStyle = {
alignItems: "center",
}
export const $inputOuterBase: ViewStyle = {
height: s(24),
width: s(24),
borderWidth: 2,
alignItems: "center",
overflow: "hidden",
flexGrow: 0,
flexShrink: 0,
justifyContent: "space-between",
flexDirection: "row",
}
const $helper: ThemedStyle<TextStyle> = ({ spacing }) => ({
marginTop: spacing.xs,
})
const $label: TextStyle = {
flex: 1,
}
const $labelRight: ThemedStyle<TextStyle> = ({ spacing }) => ({
marginStart: spacing.md,
})
const $labelLeft: ThemedStyle<TextStyle> = ({ spacing }) => ({
marginEnd: spacing.md,
})

View File

@@ -0,0 +1,26 @@
export interface ConfigBaseProps {
persistNavigation: "always" | "dev" | "prod" | "never"
catchErrors: "always" | "dev" | "prod" | "never"
exitRoutes: string[]
}
export type PersistNavigationConfig = ConfigBaseProps["persistNavigation"]
const BaseConfig: ConfigBaseProps = {
// This feature is particularly useful in development mode, but
// can be used in production as well if you prefer.
persistNavigation: "dev",
/**
* Only enable if we're catching errors in the right environment
*/
catchErrors: "always",
/**
* This is a list of all the route names that will exit the app if the back button
* is pressed while in that screen. Only affects Android.
*/
exitRoutes: ["Welcome"],
}
export default BaseConfig

View File

@@ -0,0 +1,16 @@
/**
* These are configuration settings for the dev environment.
*
* Do not include API secrets in this file or anywhere in your JS.
*
* https://reactnative.dev/docs/security#storing-sensitive-info
*/
export default {
API_URL: "https://api.rss2json.com/v1/",
AUTH_API_URL: "https://auth.upay01.com",
// Google Sign-In Client IDs from Google Cloud Console
GOOGLE_WEB_CLIENT_ID: "500211604129-4c5ij6e87jhlitoaclf8gfkco8ca6t9k.apps.googleusercontent.com",
GOOGLE_IOS_CLIENT_ID: "500211604129-uiln0fhiaj05jjg256oahkdcucpi6cqb.apps.googleusercontent.com",
GOOGLE_ANDROID_CLIENT_ID:
"500211604129-f5rsg6e1bi7300i0goii8ckl9ld5jk3r.apps.googleusercontent.com",
}

View File

@@ -0,0 +1,16 @@
/**
* These are configuration settings for the production environment.
*
* Do not include API secrets in this file or anywhere in your JS.
*
* https://reactnative.dev/docs/security#storing-sensitive-info
*/
export default {
API_URL: "https://api.rss2json.com/v1/",
AUTH_API_URL: "https://auth.upay01.com",
// Google Sign-In Client IDs from Google Cloud Console
GOOGLE_WEB_CLIENT_ID: "500211604129-4c5ij6e87jhlitoaclf8gfkco8ca6t9k.apps.googleusercontent.com",
GOOGLE_IOS_CLIENT_ID: "500211604129-uiln0fhiaj05jjg256oahkdcucpi6cqb.apps.googleusercontent.com",
GOOGLE_ANDROID_CLIENT_ID:
"500211604129-f5rsg6e1bi7300i0goii8ckl9ld5jk3r.apps.googleusercontent.com",
}

View File

@@ -0,0 +1,28 @@
/**
* This file imports configuration objects from either the config.dev.js file
* or the config.prod.js file depending on whether we are in __DEV__ or not.
*
* Note that we do not gitignore these files. Unlike on web servers, just because
* these are not checked into your repo doesn't mean that they are secure.
* In fact, you're shipping a JavaScript bundle with every
* config variable in plain text. Anyone who downloads your app can easily
* extract them.
*
* If you doubt this, just bundle your app, and then go look at the bundle and
* search it for one of your config variable values. You'll find it there.
*
* Read more here: https://reactnative.dev/docs/security#storing-sensitive-info
*/
import BaseConfig from "./config.base"
import DevConfig from "./config.dev"
import ProdConfig from "./config.prod"
let ExtraConfig = ProdConfig
if (__DEV__) {
ExtraConfig = DevConfig
}
const Config = { ...BaseConfig, ...ExtraConfig }
export default Config

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,134 @@
import {
createContext,
FC,
PropsWithChildren,
useCallback,
useContext,
useMemo,
useState,
} from "react"
import { translate } from "@/i18n/translate"
import { api } from "@/services/api"
import type { EpisodeItem } from "@/services/api/types"
import { formatDate } from "@/utils/formatDate"
export type EpisodeContextType = {
totalEpisodes: number
totalFavorites: number
episodesForList: EpisodeItem[]
fetchEpisodes: () => Promise<void>
favoritesOnly: boolean
toggleFavoritesOnly: () => void
hasFavorite: (episode: EpisodeItem) => boolean
toggleFavorite: (episode: EpisodeItem) => void
}
export const EpisodeContext = createContext<EpisodeContextType | null>(null)
export interface EpisodeProviderProps {}
export const EpisodeProvider: FC<PropsWithChildren<EpisodeProviderProps>> = ({ children }) => {
const [episodes, setEpisodes] = useState<EpisodeItem[]>([])
const [favorites, setFavorites] = useState<string[]>([])
const [favoritesOnly, setFavoritesOnly] = useState<boolean>(false)
const fetchEpisodes = useCallback(async () => {
const response = await api.getEpisodes()
if (response.kind === "ok") {
setEpisodes(response.episodes)
} else {
console.error(`Error fetching episodes: ${JSON.stringify(response)}`)
}
}, [])
const toggleFavoritesOnly = useCallback(() => {
setFavoritesOnly((prev) => !prev)
}, [])
const toggleFavorite = useCallback(
(episode: EpisodeItem) => {
if (favorites.some((fav) => fav === episode.guid)) {
setFavorites((prev) => prev.filter((fav) => fav !== episode.guid))
} else {
setFavorites((prev) => [...prev, episode.guid])
}
},
[favorites],
)
const hasFavorite = useCallback(
(episode: EpisodeItem) => favorites.some((fav) => fav === episode.guid),
[favorites],
)
const episodesForList = useMemo(() => {
return favoritesOnly ? episodes.filter((episode) => favorites.includes(episode.guid)) : episodes
}, [episodes, favorites, favoritesOnly])
const value = {
totalEpisodes: episodes.length,
totalFavorites: favorites.length,
episodesForList,
fetchEpisodes,
favoritesOnly,
toggleFavoritesOnly,
hasFavorite,
toggleFavorite,
}
return <EpisodeContext.Provider value={value}>{children}</EpisodeContext.Provider>
}
export const useEpisodes = () => {
const context = useContext(EpisodeContext)
if (!context) throw new Error("useEpisodes must be used within an EpisodeProvider")
return context
}
// A helper hook to extract and format episode details
export const useEpisode = (episode: EpisodeItem) => {
const { hasFavorite } = useEpisodes()
const isFavorite = hasFavorite(episode)
let datePublished
try {
const formatted = formatDate(episode.pubDate)
datePublished = {
textLabel: formatted,
accessibilityLabel: translate("demoPodcastListScreen:accessibility.publishLabel", {
date: formatted,
}),
}
} catch {
datePublished = { textLabel: "", accessibilityLabel: "" }
}
const seconds = Number(episode.enclosure?.duration ?? 0)
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = Math.floor((seconds % 3600) % 60)
const duration = {
textLabel: `${h > 0 ? `${h}:` : ""}${m > 0 ? `${m}:` : ""}${s}`,
accessibilityLabel: translate("demoPodcastListScreen:accessibility.durationLabel", {
hours: h,
minutes: m,
seconds: s,
}),
}
const trimmedTitle = episode.title?.trim()
const titleMatches = trimmedTitle?.match(/^(RNR.*\d)(?: - )(.*$)/)
const parsedTitleAndSubtitle =
titleMatches && titleMatches.length === 3
? { title: titleMatches[1], subtitle: titleMatches[2] }
: { title: trimmedTitle, subtitle: "" }
return {
isFavorite,
datePublished,
duration,
parsedTitleAndSubtitle,
}
}

View File

@@ -0,0 +1,228 @@
import {
createContext,
FC,
PropsWithChildren,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react"
// eslint-disable-next-line no-restricted-imports
import { Animated, Modal, TextStyle, View, ViewStyle } from "react-native"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { Text } from "@/components/Text"
import { useAppTheme } from "@/theme/context"
import type { ThemedStyle } from "@/theme/types"
import { s } from "@/utils/responsive"
export type ToastType = "success" | "error" | "warning" | "info"
export interface ToastConfig {
message: string
type?: ToastType
duration?: number
}
export interface ToastContextType {
show: (config: ToastConfig) => void
success: (message: string, duration?: number) => void
error: (message: string, duration?: number) => void
warning: (message: string, duration?: number) => void
info: (message: string, duration?: number) => void
hide: () => void
}
// Default durations by type (in ms)
const DEFAULT_DURATIONS: Record<ToastType, number> = {
success: 3000,
error: 5000, // Errors show longer
warning: 4000,
info: 3000,
}
const ToastContext = createContext<ToastContextType | null>(null)
export const ToastProvider: FC<PropsWithChildren> = ({ children }) => {
const [visible, setVisible] = useState(false)
const [config, setConfig] = useState<ToastConfig>({ message: "" })
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const fadeAnim = useRef(new Animated.Value(0)).current
const insets = useSafeAreaInsets()
const {
themed,
theme: { colors },
} = useAppTheme()
// Clear timeout on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [])
const hide = useCallback(() => {
// Clear any pending timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
// Fade out animation
Animated.timing(fadeAnim, {
toValue: 0,
duration: 200,
useNativeDriver: true,
}).start(() => {
setVisible(false)
})
}, [fadeAnim])
const show = useCallback(
(newConfig: ToastConfig) => {
// Clear any existing timeout to prevent premature hide
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
setConfig(newConfig)
setVisible(true)
// Fade in animation
fadeAnim.setValue(0)
Animated.timing(fadeAnim, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}).start()
// Auto hide after duration (use type-specific default if not provided)
const type = newConfig.type ?? "info"
const duration = newConfig.duration ?? DEFAULT_DURATIONS[type]
timeoutRef.current = setTimeout(() => {
hide()
}, duration)
},
[hide, fadeAnim],
)
const success = useCallback(
(message: string, duration?: number) => {
show({ message, type: "success", duration })
},
[show],
)
const error = useCallback(
(message: string, duration?: number) => {
show({ message, type: "error", duration })
},
[show],
)
const warning = useCallback(
(message: string, duration?: number) => {
show({ message, type: "warning", duration })
},
[show],
)
const info = useCallback(
(message: string, duration?: number) => {
show({ message, type: "info", duration })
},
[show],
)
const value = useMemo(
() => ({ show, success, error, warning, info, hide }),
[show, success, error, warning, info, hide],
)
const getBackgroundColor = (type: ToastType = "info") => {
switch (type) {
case "success":
return colors.palette.primary500
case "error":
return colors.error
case "warning":
return colors.palette.accent500
case "info":
default:
return colors.palette.secondary400
}
}
const getTextColor = (type: ToastType = "info") => {
switch (type) {
case "warning":
return colors.palette.neutral800
default:
return colors.palette.neutral100
}
}
return (
<ToastContext.Provider value={value}>
{children}
<Modal
visible={visible}
transparent
animationType="fade"
presentationStyle="overFullScreen"
statusBarTranslucent
onRequestClose={hide}
>
<Animated.View
pointerEvents="none"
style={[$overlay, { paddingTop: insets.top + 20 }, { opacity: fadeAnim }]}
>
<View
style={[themed($toastContainer), { backgroundColor: getBackgroundColor(config.type) }]}
>
<Text
text={config.message}
size="sm"
style={[themed($toastText), { color: getTextColor(config.type) }]}
/>
</View>
</Animated.View>
</Modal>
</ToastContext.Provider>
)
}
export const useToast = (): ToastContextType => {
const context = useContext(ToastContext)
if (!context) {
throw new Error("useToast must be used within a ToastProvider")
}
return context
}
const $overlay: ViewStyle = {
flex: 1,
alignItems: "center",
}
const $toastContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
minWidth: s(120),
maxWidth: "80%",
paddingVertical: spacing.md,
paddingHorizontal: spacing.lg,
borderRadius: s(8),
shadowColor: "#000",
shadowOffset: { width: 0, height: s(2) },
shadowOpacity: 0.25,
shadowRadius: s(4),
elevation: 5,
})
const $toastText: ThemedStyle<TextStyle> = () => ({
textAlign: "center",
})

View File

@@ -0,0 +1,9 @@
/**
* This file is loaded in React Native and exports the RN version
* of Reactotron's client.
*
* Web is loaded from ReactotronClient.web.ts.
*/
import Reactotron from "reactotron-react-native"
export { Reactotron }

View File

@@ -0,0 +1,12 @@
/**
* This file is loaded in web and exports the React.js version
* of Reactotron's client.
*
* React Native is loaded from ReactotronClient.ts.
*
* If your project does not need web support, you can delete this file and
* remove reactotron-react-js from your package.json dependencies.
*/
import Reactotron from "reactotron-react-js"
export { Reactotron }

View File

@@ -0,0 +1,139 @@
/**
* This file does the setup for integration with Reactotron, which is a
* free desktop app for inspecting and debugging your React Native app.
* @see https://github.com/infinitered/reactotron
*/
import { Platform, NativeModules } from "react-native"
import { ArgType } from "reactotron-core-client"
import { ReactotronReactNative } from "reactotron-react-native"
import mmkvPlugin from "reactotron-react-native-mmkv"
import { goBack, resetRoot, navigate } from "@/navigators/navigationUtilities"
import { storage } from "@/utils/storage"
import { Reactotron } from "./ReactotronClient"
const reactotron = Reactotron.configure({
name: require("../../package.json").name,
onConnect: () => {
/** since this file gets hot reloaded, let's clear the past logs every time we connect */
Reactotron.clear()
},
})
reactotron.use(mmkvPlugin<ReactotronReactNative>({ storage }))
if (Platform.OS !== "web") {
reactotron.useReactNative({
networking: {
ignoreUrls: /symbolicate/,
},
})
}
/**
* Reactotron allows you to define custom commands that you can run
* from Reactotron itself, and they will run in your app.
*
* Define them in the section below with `onCustomCommand`. Use your
* creativity -- this is great for development to quickly and easily
* get your app into the state you want.
*
* NOTE: If you edit this file while running the app, you will need to do a full refresh
* or else your custom commands won't be registered correctly.
*/
reactotron.onCustomCommand({
title: "Show Dev Menu",
description: "Opens the React Native dev menu",
command: "showDevMenu",
handler: () => {
Reactotron.log("Showing React Native dev menu")
NativeModules.DevMenu.show()
},
})
reactotron.onCustomCommand({
title: "Reset Navigation State",
description: "Resets the navigation state",
command: "resetNavigation",
handler: () => {
Reactotron.log("resetting navigation state")
resetRoot({ index: 0, routes: [] })
},
})
reactotron.onCustomCommand<[{ name: "route"; type: ArgType.String }]>({
command: "navigateTo",
handler: (args) => {
const { route } = args ?? {}
if (route) {
Reactotron.log(`Navigating to: ${route}`)
// @ts-ignore
navigate(route as any) // this should be tied to the navigator, but since this is for debugging, we can navigate to illegal routes
} else {
Reactotron.log("Could not navigate. No route provided.")
}
},
title: "Navigate To Screen",
description: "Navigates to a screen by name.",
args: [{ name: "route", type: ArgType.String }],
})
reactotron.onCustomCommand({
title: "Go Back",
description: "Goes back",
command: "goBack",
handler: () => {
Reactotron.log("Going back")
goBack()
},
})
/**
* We're going to add `console.tron` to the Reactotron object.
* Now, anywhere in our app in development, we can use Reactotron like so:
*
* ```
* if (__DEV__) {
* console.tron.display({
* name: 'JOKE',
* preview: 'What's the best thing about Switzerland?',
* value: 'I don't know, but the flag is a big plus!',
* important: true
* })
* }
* ```
*
* Use this power responsibly! :)
*/
console.tron = reactotron
/**
* We tell typescript about our dark magic
*
* You can also import Reactotron yourself from ./reactotronClient
* and use it directly, like Reactotron.log('hello world')
*/
declare global {
interface Console {
/**
* Reactotron client for logging, displaying, measuring performance, and more.
* @see https://github.com/infinitered/reactotron
* @example
* if (__DEV__) {
* console.tron.display({
* name: 'JOKE',
* preview: 'What's the best thing about Switzerland?',
* value: 'I don't know, but the flag is a big plus!',
* important: true
* })
* }
*/
tron: typeof reactotron
}
}
/**
* Now that we've setup all our Reactotron configuration, let's connect!
*/
reactotron.connect()

297
RN_TEMPLATE/app/i18n/ar.ts Normal file
View File

@@ -0,0 +1,297 @@
import demoAr from "./demo-ar"
import { Translations } from "./en"
const ar: Translations = {
common: {
ok: "نعم",
cancel: "حذف",
save: "حفظ",
back: "خلف",
logOut: "تسجيل خروج",
copied: "تم النسخ",
},
welcomeScreen: {
postscript:
"ربما لا يكون هذا هو الشكل الذي يبدو عليه تطبيقك مالم يمنحك المصمم هذه الشاشات وشحنها في هذه الحالة",
readyForLaunch: "تطبيقك تقريبا جاهز للتشغيل",
exciting: "اوه هذا مثير",
letsGo: "لنذهب",
},
errorScreen: {
title: "هناك خطأ ما",
friendlySubtitle:
"هذه هي الشاشة التي سيشاهدها المستخدمون في عملية الانتاج عند حدوث خطأ. سترغب في تخصيص هذه الرسالة ( الموجودة في 'ts.en/i18n/app') وربما التخطيط ايضاً ('app/screens/ErrorScreen'). إذا كنت تريد إزالة هذا بالكامل، تحقق من 'app/app.tsp' من اجل عنصر <ErrorBoundary>.",
reset: "اعادة تعيين التطبيق",
traceTitle: "خطأ من مجموعة %{name}",
},
emptyStateComponent: {
generic: {
heading: "فارغة جداً....حزين",
content: "لا توجد بيانات حتى الآن. حاول النقر فوق الزر لتحديث التطبيق او اعادة تحميله.",
button: "لنحاول هذا مرّة أخرى",
},
},
errors: {
invalidEmail: "عنوان البريد الالكتروني غير صالح",
},
authErrors: {
timeout: "Request timed out. Please try again.",
cannotConnect: "Cannot connect to server. Please check your network.",
serverError: "Server error. Please try again later.",
badData: "Invalid response from server.",
unknownError: "An unknown error occurred.",
E001: "Username already exists.",
E002: "Email is already registered.",
E003: "Invalid referral code.",
E004: "No pending registration request.",
E005: "Invalid verification code.",
E006: "Referral code already bound.",
E007: "Cannot use your own referral code.",
E011: "Invalid email or password.",
E012: "Account temporarily locked. Please try again later.",
E013: "No pending login request.",
E014: "User does not exist or has been disabled.",
E015: "Invalid verification code.",
E016: "Invalid Telegram authentication.",
E017: "Invalid Telegram data format.",
E018: "Google authentication failed.",
E019: "Please provide Google token.",
E021: "Current password is incorrect.",
E022: "New password cannot be the same as current password.",
E023: "Email is not registered.",
E024: "Account has been disabled.",
E025: "No pending password reset request.",
E026: "Password reset code has expired.",
E027: "Invalid password reset code.",
E028: "User does not exist.",
E029: "No fields provided to update.",
E041: "Sending verification code too frequently. Please wait.",
E042: "Email verification code has expired.",
E043: "Invalid email verification code.",
E044: "Email is already verified.",
E045: "Email is already used by another user.",
E046: "No verified email address.",
E047: "Please send verification code to current email first.",
E048: "Please verify current email first.",
E049: "Please send verification code to new email first.",
E050: "Invalid action parameter.",
},
loginScreen: {
logIn: "تسجيل الدخول",
enterDetails:
".ادخل التفاصيل الخاصة بك ادناه لفتح معلومات سرية للغاية. لن تخمن ابداً ما الذي ننتظره. او ربما ستفعل انها انها ليست علم الصواريخ",
emailFieldLabel: "البريد الالكتروني",
passwordFieldLabel: "كلمة السر",
emailFieldPlaceholder: "ادخل بريدك الالكتروني",
passwordFieldPlaceholder: "كلمة السر هنا فائقة السر",
tapToLogIn: "انقر لتسجيل الدخول!",
hint: "(: تلميح: يمكنك استخدام اي عنوان بريد الكتروني وكلمة السر المفضلة لديك",
},
navigator: {
componentsTab: "عناصر",
debugTab: "تصحيح",
communityTab: "واصل اجتماعي",
podcastListTab: "البودكاست",
profileTab: "الملف الشخصي",
},
profileScreen: {
title: "الملف الشخصي",
guest: "ضيف",
uid: "UID",
username: "اسم المستخدم",
referralCode: "رمز الإحالة",
settings: "الإعدادات",
darkMode: "الوضع الداكن",
notifications: "الإشعارات",
security: "الأمان",
account: "الحساب",
accountStatus: "حالة الحساب",
active: "نشط",
inactive: "غير نشط",
regular: "عادي",
emailVerified: "البريد الإلكتروني موثق",
verified: "موثق",
unverified: "غير موثق",
loginMethods: "طرق تسجيل الدخول",
version: "الإصدار",
changePassword: "تغيير كلمة المرور",
changeEmail: "تغيير البريد الإلكتروني",
editNickname: "تعديل الاسم المستعار",
editProfile: "تعديل الملف الشخصي",
nicknamePlaceholder: "أدخل اسمك المستعار",
nickname: "الاسم المستعار",
tapToChangeAvatar: "اضغط لتغيير الصورة الرمزية",
about: "حول",
},
changePasswordScreen: {
title: "تغيير كلمة المرور",
description: "أدخل كلمة المرور الحالية واختر كلمة مرور جديدة.",
oldPassword: "كلمة المرور الحالية",
newPassword: "كلمة المرور الجديدة",
confirmPassword: "تأكيد كلمة المرور الجديدة",
logoutOtherDevices: "تسجيل الخروج من الأجهزة الأخرى",
submit: "تغيير كلمة المرور",
success: "نجاح",
successMessage: "تم تغيير كلمة المرور بنجاح.",
oldPasswordRequired: "كلمة المرور الحالية مطلوبة.",
newPasswordRequired: "كلمة المرور الجديدة مطلوبة.",
passwordTooShort: "يجب أن تتكون كلمة المرور من 6 أحرف على الأقل.",
passwordMismatch: "كلمتا المرور غير متطابقتين.",
samePassword: "لا يمكن أن تكون كلمة المرور الجديدة مثل الحالية.",
},
changeEmailScreen: {
title: "تغيير البريد الإلكتروني",
step1Label: "تحقق",
step2Label: "ربط",
step1Title: "الخطوة 1: تحقق من البريد الإلكتروني الحالي",
step1Description: "سنرسل رمز التحقق إلى بريدك الإلكتروني الحالي.",
step2Title: "الخطوة 2: ربط البريد الإلكتروني الجديد",
step2Description: "أدخل عنوان بريدك الإلكتروني الجديد وتحقق منه.",
currentEmail: "البريد الإلكتروني الحالي",
newEmail: "عنوان البريد الإلكتروني الجديد",
verificationCode: "رمز التحقق",
sendCode: "إرسال رمز التحقق",
sendCodeToNewEmail: "إرسال الرمز إلى البريد الجديد",
verify: "تحقق",
confirmNewEmail: "تأكيد البريد الإلكتروني الجديد",
resendCode: "إعادة إرسال الرمز",
success: "نجاح",
successMessage: "تم تغيير بريدك الإلكتروني بنجاح.",
codeRequired: "رمز التحقق مطلوب.",
codeInvalid: "يجب أن يتكون رمز التحقق من 6 أرقام.",
newEmailRequired: "عنوان البريد الإلكتروني الجديد مطلوب.",
emailInvalid: "يرجى إدخال عنوان بريد إلكتروني صالح.",
sameEmail: "لا يمكن أن يكون البريد الجديد مثل البريد الحالي.",
},
settingsScreen: {
title: "الإعدادات",
appearance: "المظهر",
theme: "المظهر",
darkMode: "الوضع الداكن",
language: "اللغة",
currentLanguage: "اللغة",
},
languageScreen: {
title: "اللغة",
selectHint: "اختر لغتك المفضلة",
},
themeScreen: {
title: "المظهر",
selectHint: "اختر المظهر المفضل لديك",
system: "النظام",
light: "فاتح",
dark: "داكن",
},
securityScreen: {
title: "الأمان",
description: "إدارة إعدادات أمان حسابك.",
changePassword: "تغيير كلمة المرور",
changeEmail: "تغيير البريد الإلكتروني",
// Session management
activeSessions: "Active Sessions",
activeSessionsDescription: "Devices currently logged into your account",
currentDevice: "Current Device",
lastActive: "Last active",
loginMethod: "Login via",
logoutDevice: "Logout",
logoutAllOther: "Logout All Other Devices",
logoutAllOtherDescription: "This will log out all devices except the current one.",
noOtherSessions: "No other active sessions",
sessionRevoked: "Device has been logged out",
sessionsRevoked: "{{count}} devices have been logged out",
confirmLogout: "Confirm Logout",
confirmLogoutMessage: "Are you sure you want to log out this device?",
confirmLogoutAllMessage: "Are you sure you want to log out all other devices?",
deviceTypes: {
desktop: "Desktop",
mobile: "Mobile",
tablet: "Tablet",
unknown: "Unknown Device",
},
},
aboutScreen: {
title: "حول",
appInfo: "معلومات التطبيق",
appName: "اسم التطبيق",
version: "الإصدار",
buildVersion: "إصدار البناء",
appId: "معرف التطبيق",
appVersion: "إصدار التطبيق",
legal: "قانوني",
privacyPolicy: "سياسة الخصوصية",
termsOfService: "شروط الخدمة",
},
communityScreen: {
title: "تواصل مع المجتمع",
tagLine:
"قم بالتوصيل لمنتدى Infinite Red الذي يضم تفاعل المهندسين المحلّيين ورفع مستوى تطوير تطبيقك معنا",
joinUsOnSlackTitle: "انضم الينا على Slack",
joinUsOnSlack:
"هل ترغب في وجود مكان للتواصل مع مهندسي React Native حول العالم؟ الانضمام الى المحادثة في سلاك المجتمع الاحمر اللانهائي! مجتمعناالمتنامي هو مساحةآمنة لطرح الاسئلة والتعلم من الآخرين وتنمية شبكتك.",
joinSlackLink: "انضم الي مجتمع Slack",
makeIgniteEvenBetterTitle: "اجعل Ignite افضل",
makeIgniteEvenBetter:
"هل لديك فكرة لجعل Ignite افضل؟ نحن سعداء لسماع ذلك! نحن نبحث دائماً عن الآخرين الذين يرغبون في مساعدتنا في بناء افضل الادوات المحلية التفاعلية المتوفرة هناك. انضم الينا عبر GitHub للانضمام الينا في بناء مستقبل Ignite",
contributeToIgniteLink: "ساهم في Ignite",
theLatestInReactNativeTitle: "الاحدث في React Native",
theLatestInReactNative: "نخن هنا لنبقيك محدثاً على جميع React Native التي تعرضها",
reactNativeRadioLink: "راديو React Native",
reactNativeNewsletterLink: "نشرة اخبار React Native",
reactNativeLiveLink: "مباشر React Native",
chainReactConferenceLink: "مؤتمر Chain React",
hireUsTitle: "قم بتوظيف Infinite Red لمشروعك القادم",
hireUs:
"سواء كان الامر يتعلّق بتشغيل مشروع كامل او اعداد الفرق بسرعة من خلال التدريب العلمي لدينا، يمكن ان يساعد Infinite Red اللامتناهي في اي مشروع محلي يتفاعل معه.",
hireUsLink: "ارسل لنا رسالة",
},
showroomScreen: {
jumpStart: "مكونات او عناصر لبدء مشروعك",
lorem2Sentences:
"عامل الناس بأخلاقك لا بأخلاقهم. عامل الناس بأخلاقك لا بأخلاقهم. عامل الناس بأخلاقك لا بأخلاقهم",
demoHeaderTxExample: "ياي",
demoViaTxProp: "عبر `tx` Prop",
demoViaSpecifiedTxProp: "Prop `{{prop}}Tx` عبر",
},
demoDebugScreen: {
howTo: "كيف",
title: "التصحيح",
tagLine: "مبروك، لديك نموذج اصلي متقدم للغاية للتفاعل هنا. الاستفادة من هذه النمذجة",
reactotron: "Reactotron ارسل إلى",
reportBugs: "الابلاغ عن اخطاء",
demoList: "قائمة تجريبية",
demoPodcastList: "قائمة البودكاست التجريبي",
androidReactotronHint:
"اذا لم ينجح ذللك، فتأكد من تشغيل تطبيق الحاسوب الخاص Reactotron، وقم بتشغيل عكس adb tcp:9090 \ntcp:9090 من جهازك الطرفي ، واعد تحميل التطبيق",
iosReactotronHint:
"اذا لم ينجح ذلك، فتأكد من تشغيل تطبيق الحاسوب الخاص ب Reactotron وأعد تحميل التطبيق",
macosReactotronHint: "اذا لم ينجح ذلك، فتأكد من تشغيل الحاسوب ب Reactotron وأعد تحميل التطبيق",
webReactotronHint: "اذا لم ينجح ذلك، فتأكد من تشغيل الحاسوب ب Reactotron وأعد تحميل التطبيق",
windowsReactotronHint:
"اذا لم ينجح ذلك، فتأكد من تشغيل الحاسوب ب Reactotron وأعد تحميل التطبيق",
},
demoPodcastListScreen: {
title: "حلقات إذاعية React Native",
onlyFavorites: "المفضلة فقط",
favoriteButton: "المفضل",
unfavoriteButton: "غير مفضل",
accessibility: {
cardHint: "انقر مرّتين للاستماع على الحلقة. انقر مرّتين وانتظر لتفعيل {{action}} هذه الحلقة.",
switch: "قم بالتبديل لاظهار المفضّلة فقط.",
favoriteAction: "تبديل المفضلة",
favoriteIcon: "الحلقة الغير مفضّلة",
unfavoriteIcon: "الحلقة المفضّلة",
publishLabel: "نشرت {{date}}",
durationLabel: "المدّة: {{hours}} ساعات {{minutes}} دقائق {{seconds}} ثواني",
},
noFavoritesEmptyState: {
heading: "هذا يبدو فارغاً بعض الشيء.",
content:
"لم تتم اضافة اي مفضلات حتى الان. اضغط على القلب في إحدى الحلقات لإضافته الى المفضلة.",
},
},
...demoAr,
}
export default ar

View File

@@ -0,0 +1,462 @@
import { DemoTranslations } from "./demo-en"
export const demoAr: DemoTranslations = {
demoIcon: {
description:
"مكون لعرض أيقونة مسجلة.يتم تغليفه في <TouchableOpacity> يتم توفير 'OnPress'، وإلا يتم توفير <View",
useCase: {
icons: {
name: "Icons",
description: "قائمة الرموز المسجلة داخل المكون.",
},
size: {
name: "Size",
description: "هناك حجم الدعامة.",
},
color: {
name: "لون",
description: "هناك لون الدعامة.",
},
styling: {
name: "التصميم",
description: "يمكن تصميم المكون بسهولة.",
},
},
},
demoTextField: {
description: "TextField يسمح المكون بإدخال النص وتحريره.",
useCase: {
statuses: {
name: "الحالات",
description:
"هناك حالة مماثلة ل 'preset' في المكونات الأخرى، ولكنها تؤثر على وظيفة المكون ايضاً.",
noStatus: {
label: "لا يوجد حالات",
helper: "هذه هي الحالة الافتراضية",
placeholder: "النص يذهب هنا",
},
error: {
label: "حالة الخطأ",
helper: "الحالة التي يجب استخدامها عند وجود خطأ",
placeholder: "النص يذهب هنا",
},
disabled: {
label: "حالة الإعاقة",
helper: "يعطل إمكانية التحرير ويكتم النص",
placeholder: "النص يذهب هنا",
},
},
passingContent: {
name: "محتوى عابر",
description: "هناك عدة طرق مختلفة لتمرير المحتوى",
viaLabel: {
labelTx: "عبر 'label' الدعامة",
helper: "عبر 'helper' الدعامة",
placeholder: "عبر 'placeholder' الدعامة",
},
rightAccessory: {
label: "RightAccessory",
helper: "هذه الدعامة تأخذ دالة تقوم بإرجاع عنصر React",
},
leftAccessory: {
label: "LeftAccessory",
helper: "هذه الدعامة تأخذ دالة تقوم بإرجاع عنصر React",
},
supportsMultiline: {
label: "يدعم Multiline",
helper: "يتيح إدخالا اطول للنص متعدد الأسطر.",
},
},
styling: {
name: "التصميم",
description: "يمكن تصميم المكون بسهولة",
styleInput: {
label: "أسلوب الإدخال",
helper: "عبر دعامة 'Style'",
},
styleInputWrapper: {
label: "غلاف ادخال النمط",
helper: "عبر دعامة 'InputWrapperStyle'",
},
styleContainer: {
label: "حاوية النمط",
helper: "عبر دعامة 'containerstyle'",
},
styleLabel: {
label: "تسمية النمط والمساعد",
helper: "عبر أسلوب الدعامة 'LabelTextProps' & 'HelperTextProps'",
},
styleAccessories: {
label: "اكسسورات الاناقة",
helper: "عبر أسلوب الدعامة 'RightAccessory' & 'LeftAccessory'",
},
},
},
},
demoToggle: {
description:
"يقوم بعرض ادخال منطقي.هذا مكون خاضع للتحكم ويتطلب استدعاء OnValueChanger الذي يقوم بتحديث خاصية القيمة حتى يعكس المكون إجراءات المستخدم. إذا لم يتم تحديث خاصية القيمة، فسيستمر المكون في عرض خاصية القيمة المقدمة بدلا من النتيجة المتوقعة لأي إجراءات مستخدم.",
useCase: {
variants: {
name: "المتغيرات",
description:
"تدعم المكونات عددا قليلا من المتغيرات المختلفة. اذا كانت هناك حاجة إلى تخصيص كبير لمتغير معين، فيمكن إعادة صياغته بسهولة. الافتراضي هو 'checkbox'",
checkbox: {
label: "'checkbox' متغير",
helper: "يمكن استخدامه كمدخل تشغيل \\ إيقاف واحد",
},
radio: {
label: "'radio' متغير",
helper: "استخدام هذا عندما يكون لديك خيارات متعددة",
},
switch: {
label: "'switch' متغير",
helper: "مدخل تشغيل/إيقاف أكثر بروزا. يتمتع بدعم إمكانية الوصول بشكل أفضل.",
},
},
statuses: {
name: "الحالات",
description:
"هناك دعامة حالة مشابهة ل 'preset' في المكونات الأخرى، لكنها تؤثر على وظائف المكونات ايضاً",
noStatus: "لا توجد حالات- هذا هو الوضع الافتراضي",
errorStatus: "حالة الخطأ - استخدمها عندما يكون هناك خطأ",
disabledStatus: "حالة معطلة- تعطيل إمكانية التحرير وكتم صوت الإدخال",
},
passingContent: {
name: "محتوى عابر",
description: "هناك عدة طرق مختلفة لتمرير المحتوى",
useCase: {
checkBox: {
label: "عبر دعامة 'labelTx'",
helper: "عبر دعامة 'helpertx'",
},
checkBoxMultiLine: {
helper: "يدعم خطوط متعددة-Nulla provident consectetur labore sunt ea labore ",
},
radioChangeSides: {
helper: "يمكنك تغيير الجانبين - Laborum labore adipisicing in eu ipsum deserunt.",
},
customCheckBox: {
label: "مرر أيقونة مربع الاختيار المخصص",
},
switch: {
label: "يمكن قراءة المفاتيح كنص",
helper:
"بشكل افتراضي، لا يستخدم هذا الخيار \"text' نظرا لأنه اعتمادا على الخط، قد تبدو الأحرف التي يتم تشغيلها/ايقافها غريبة. قم بالتخصيص حسب الحاجة",
},
switchAid: {
label: "او بمساعدة أيقونة",
},
},
},
styling: {
name: "التصميم",
description: "يمكن تصميم المكون بسهولة",
outerWrapper: "١- تصميم الغلاف الخارجي للإدخال",
innerWrapper: "٢- تصميم الغلاف الداخلي للإدخال",
inputDetail: "٣- تصميم تفاصيل الإدخال",
labelTx: "يمكنك ايضاً تصميم الملصق labelTx",
styleContainer: "او، قم بتصميم الحاوية بأكملها",
},
},
},
demoButton: {
description:
"مكون يسمح للمستخدمين بإتخاذ الإجراءات والاختيارات. يلف مكون النص بمكون قابل للضغط",
useCase: {
presets: {
name: "الإعدادات المسبقة",
description: "هناك عدد قليل من الإعدادات المسبقة التي تم تكوينها مسبقاً",
},
passingContent: {
name: "محتوى عابر",
description: "هناك عدة طرق مختلفة لتمرير المحتوى",
viaTextProps: "عبر الدعامة 'text'- Billum In",
children: "أولاد- Irure Reprehenderit",
rightAccessory: "RightAccessory - Duis Quis",
leftAccessory: "LeftAccessory - Duis Proident",
nestedChildren: "الأطفال المتداخلون-\tprovident genial",
nestedChildren2: "Ullamco cupidatat officia exercitation velit non ullamco nisi..",
nestedChildren3: "Occaecat aliqua irure proident veniam.",
multiLine:
"Multiline - consequat veniam veniam reprehenderit. Fugiat id nisi quis duis sunt proident mollit dolor mollit adipisicing proident deserunt.",
},
styling: {
name: "التصميم",
description: "يمكن تصميم المكون بسهولة",
styleContainer: "حاوية الأسلوب- الإثارة",
styleText: "نص النمط- ِEa Anim",
styleAccessories: "اكسسوارات الاناقة - enim ea id fugiat anim ad.",
pressedState: "نمط الحالة المضغوطة - fugiat anim",
},
disabling: {
name: "تعطيل",
description: "يمكن تعطيل المكون، وتصميمه بناء على ذلك. سيتم تعطيل سلوك الضغط",
standard: "إبطال - معيار",
filled: "إبطال - مملوء",
reversed: "إبطال- معكوس",
accessory: "نمط الملحق المعطل",
textStyle: "نمط النص المعطل",
},
},
},
demoListItem: {
description: "مكون صف مصمم يمكن استخدامه في FlatList او SectionList او بمفرده",
useCase: {
height: {
name: "علو",
description: "يمكن ان يكون الصف بارتفاعات مختلفة",
defaultHeight: "الارتفاع الافتراضي (56px)",
customHeight: "ارتفاع مخصص عبر دعامة 'height'",
textHeight:
"الارتفاع يتم تحديده من خلال محتوى النص - Reprehenderit incididunt deserunt do do ea labore.",
longText: "تحديد النص إلى سطر واحد - Reprehenderit incididunt deserunt do do ea labore.",
},
separators: {
name: "الفواصل",
description: "الفاصل/ المقسم مهيّأ مسبقاً وهو اختياري",
topSeparator: "فقط فاصل علوي",
topAndBottomSeparator: "الفواصل العلوية والسفلية",
bottomSeparator: "فقط فاصل سفلي",
},
icons: {
name: "الأيقونات",
description: "يمكنك تخصيص الرموز على اليسار أو اليمين",
leftIcon: "أيقونة اليسار",
rightIcon: "أيقونة اليمين",
leftRightIcons: "أيقونة اليمين واليسار",
},
customLeftRight: {
name: "مكونات مخصصة لليسار /اليمين",
description: "اذا كنت بحاجة إلى مخصص لليسار/اليمين فيمكنك تمريره",
customLeft: "مكون يسار مخصص",
customRight: "مكون يمين مخصص",
},
passingContent: {
name: "محتوى عابر",
description: "هناك عدة طرق مختلفة لتمرير المحتوى",
text: "عبر دعامة 'text' - reprehenderit sint",
children: "أولاد- mostrud mollit",
nestedChildren1: "الأولاد المتداخلون - proident veniam.",
nestedChildren2: "Ullamco cupidatat officia exercitation velit non ullamco nisi..",
},
listIntegration: {
name: "دمج مع/ FlatList",
description: "يمكن دمج المكون بسهولة مع واجهة القائمة المفضلة لديك",
},
styling: {
name: "التصميم",
description: "يمكن تصميم المكون بسهولة.",
styledText: "نص مصمم",
styledContainer: "حاوية مصممة (فواصل)",
tintedIcons: "أيقونات ملونة",
},
},
},
demoCard: {
description:
"البطاقات مفيدة لعرض المعلومات ذات الصلة بطريقة محددة. اذا كان ListItem يعرض المحتوى أفقياً، فيمكن استخدام البطاقة لعرض المحتوى رأسياً.",
useCase: {
presets: {
name: "الإعدادات المسبقة",
description: "هناك عدد قليل من الإعدادات المسبقة التي تم تكوينها مسبقاً",
default: {
heading: "الأعداد المسبق الافتراضي ( تقصير)",
content: "Incididunt magna ut aliquip consectetur mollit dolor.",
footer: "Consectetur nulla non aliquip velit.",
},
reversed: {
heading: "الأعداد المسبق المعكوس",
content: "Reprehenderit occaecat proident amet id laboris.",
footer: "Consectetur tempor ea non labore anim .",
},
},
verticalAlignment: {
name: "انحياز عمودي",
description:
"اعتمادا على ما هو مطلوب، تأتي البطاقة مهيأة مسبقاً باستراتيجيات محاذاة مختلفة",
top: {
heading: "قمة (تقصير)",
content: "يتم محاذاة كل محتوى تلقائياً إلى الأعلى",
footer: "حتى التذييل",
},
center: {
heading: "مركز",
content: "يتم تركيز المحتوى بالنسبة لارتفاع البطاقة",
footer: "أنا ايضاً!",
},
spaceBetween: {
heading: "مسافة بين الكلمات",
content: "يتم توزيع جميع المحتويات بالتساوي",
footer: "أنا حيث أريد ان أكون",
},
reversed: {
heading: "Force Footer Bottom",
content: "يؤدي هذا إلى دفع التذييل إلى المكان الذي ينتمي اليه.",
footer: "أنا وحد جداًهنا",
},
},
passingContent: {
name: "محتوى عابر",
description: "هناك عدة طرق مختلفة لتمرير المحتوى.",
heading: "عبر دعم 'heading'",
content: "عبر دعم 'content'",
footer: "أنا وحيد هنا.",
},
customComponent: {
name: "مكونات مخصصة",
description:
"يمكن استبدال اي من المكونات المعدة مسبقاً بمكوناتك الخاصة. يمكنك ايضاً اضافة مكونات إضافية.",
rightComponent: "RightComponent",
leftComponent: "LeftComponent",
},
style: {
name: "التصميم",
description: "يمكن تصميم المكون بسهولة.",
heading: "صمم العنوان",
content: "صمم المحتوى",
footer: "صمم التذييل",
},
},
},
demoAutoImage: {
description: "مكون صورة يحدد حجم الصورة البعيدة او صورة data-uri",
useCase: {
remoteUri: {
name: "عن بعد URI",
},
base64Uri: {
name: "Base64 URI",
},
scaledToFitDimensions: {
name: "تم قياسها لتناسب الأبعاد",
description:
" توفيرعرض 'maxWidth' و\\او 'maxHeight' ، سيتم عرض الصورة بنسبة عرض الى ارتفاع. كيف يختلف هذا عن 'resizeMode': 'contain'? اولاً،يمكنك تحديد حجم جانب واحد فقط. (ليس كلاهما). ثانياً، سيتم تغيير الصورة لتناسب الأبعاد المطلوبة بدلاً من مجرد احتوائها داخل حاوية الصورة الخاصة بها.",
heightAuto: " عرض : ٦٠ / طول: auto",
widthAuto: "عرض: auto / طول: ٣٢",
bothManual: "عرض :٦٠ / طول : ٦٠",
},
},
},
demoText: {
description:
"لتلبية احتياجاتك في عرض النصوص. هذا المكون عبارة عن HOC فوق المكون المدمج Native React.",
useCase: {
presets: {
name: "الإعدادات المسبقة",
description: "هناك عدد قليل من الإعدادات المسبقة التي تم تكوينها مسبقاً.",
default:
"الأعداد المسبق الافتراضي - Cillum eu laboris in labore. Excepteur mollit tempor reprehenderit fugiat elit et eu consequat laborum.",
bold: "bold preset - Tempor et ullamco cupidatat in officia. Nulla ea duis elit id sunt ipsum cillum duis deserunt nostrud ut nostrud id.",
subheading: "subheading preset - In Cupidatat Cillum.",
heading: "heading preset - Voluptate Adipis.",
},
sizes: {
name: "قياسات",
description: "هناك حجم الدعامة",
xs: "xs - Ea ipsum est ea ex sunt.",
sm: "sm - Lorem sunt adipisicin.",
md: "md - Consequat id do lorem.",
lg: "lg - Nostrud ipsum ea.",
xl: "xl - Eiusmod ex excepteur.",
xxl: "xxl - Cillum eu laboris.",
},
weights: {
name: "أوزان",
description: "هناك وزن الدعامة",
light:
"light - Nulla magna incididunt excepteur est occaecat duis culpa dolore cupidatat enim et.",
normal:
"normal - Magna incididunt dolor ut veniam veniam laboris aliqua velit ea incididunt.",
medium: "medium - Non duis laborum quis laboris occaecat culpa cillum.",
semibold: "semiBold - Exercitation magna nostrud pariatur laborum occaecat aliqua.",
bold: "bold - Eiusmod ullamco magna exercitation est excepteur.",
},
passingContent: {
name: "محتوى عابر",
description: "هناك عدة طرق مختلفة لتمرير المحتوى.",
viaText:
"via `text` prop - Billum in aute fugiat proident nisi pariatur est. Cupidatat anim cillum eiusmod ad. Officia eu magna aliquip labore dolore consequat.",
viaTx: "عبر دعامة 'tx'",
children: "childrenreprehenderit eu qui amet veniam consectetur.",
nestedChildren: "الأطفال المتداخلون",
nestedChildren2: "Occaecat aliqua irure proident veniam.",
nestedChildren3: "Ullamco cupidatat officia exercitation velit non ullamco nisi..",
nestedChildren4: "Occaecat aliqua irure proident veniam.",
},
styling: {
name: "التصميم",
description: "يمكن تصميم المكون بسهولة.",
text: "Consequat ullamco veniam velit mollit proident excepteur aliquip id culpa ipsum velit sint nostrud.",
text2:
"Eiusmod occaecat laboris eu ex veniam ipsum adipisicing consectetur. Magna ullamco adipisicing tempor adipisicing.",
text3:
"Eiusmod occaecat laboris eu ex veniam ipsum adipisicing consectetur. Magna ullamco adipisicing tempor adipisicing.",
},
},
},
demoHeader: {
description: "المكون الذي يظهر على العديد من الشاشات، سيحمل ازرار التنقل وعنوان الشاشة.",
useCase: {
actionIcons: {
name: "أيقونة الإجرائات ",
description: "يمكنك بسهولة تمرير الرموزالى مكونات الاجراء اليسرى او اليمنى.",
leftIconTitle: "الرمز الأيسر",
rightIconTitle: "الرمز الأيمن ",
bothIconsTitle: "كلا الرمزين",
},
actionText: {
name: "نص العمل",
description: "يمكنك بسهولة تمرير النص الى مكونات الاجراء اليسرى او اليمنى.",
leftTxTitle: "عبر 'leftTx' ",
rightTextTitle: "عبر `rightText`",
},
customActionComponents: {
name: "مكونات الاجراء المخصص",
description:
"اذا لم تكن خيارات الرمز او النسكافية، فيمكنك تمرير مكون الاجراء المخصص الخاص بك.",
customLeftActionTitle: "عمل يسار مخصص ",
},
titleModes: {
name: "اوضاع العنوان",
description:
"يمكن اجبار العنوان على البقاء غي المنتصف ولكن قد يتم قطعه اذا كان طويلاً للغاية. يمكنك بشكل اختياري تعديله وفقاً لأزرار الإجراء.",
centeredTitle: "عنوان مركزي",
flexTitle: "عنوان مرن",
},
styling: {
name: "التصميم",
description: "يمكن تصميم المكون بسهولة",
styledTitle: "عنوان مصمم",
styledWrapperTitle: "غلاف مصمم",
tintedIconsTitle: "أيقونات ملونة",
},
},
},
demoEmptyState: {
description:
"مكون يتم استخدامه عندما لا يكون هناك بيانات لعرضها. ويمكن استخدامه لتوجيه المستخدم الى ما يجب فعله بعد ذلك.",
useCase: {
presets: {
name: "الإعدادات المسبقة",
description:
"يمكن إنشاء نص/صورة مختلفة مجموعات. واحد محدد مسبقاً يسمى 'generic'. لاحظ انه لا يوجد اي خيار افتراضي في حال رغبتك في الحصول على كامل EmptyState مخصصة.",
},
passingContent: {
name: "محتوى عابر",
description: "هناك عدة طرق مختلفة لتمرير المحتوى.",
customizeImageHeading: "تخصيص الصورة",
customizeImageContent: "يمكنك تمرير اي مصدر للصورة",
viaHeadingProp: "عبر دعامة 'heading'",
viaContentProp: "عبر دعامة 'content'",
viaButtonProp: "عبر دعامة 'button'",
},
styling: {
name: "التصميم",
description: "يمكن تصميم المكون بسهولة.",
},
},
},
}
export default demoAr

View File

@@ -0,0 +1,460 @@
export const demoEn = {
demoIcon: {
description:
"A component to render a registered icon. It is wrapped in a <TouchableOpacity /> if `onPress` is provided, otherwise a <View />.",
useCase: {
icons: {
name: "Icons",
description: "List of icons registered inside the component.",
},
size: {
name: "Size",
description: "There's a size prop.",
},
color: {
name: "Color",
description: "There's a color prop.",
},
styling: {
name: "Styling",
description: "The component can be styled easily.",
},
},
},
demoTextField: {
description: "TextField component allows for the entering and editing of text.",
useCase: {
statuses: {
name: "Statuses",
description:
"There is a status prop - similar to `preset` in other components, but affects component functionality as well.",
noStatus: {
label: "No Status",
helper: "This is the default status",
placeholder: "Text goes here",
},
error: {
label: "Error Status",
helper: "Status to use when there is an error",
placeholder: "Text goes here",
},
disabled: {
label: "Disabled Status",
helper: "Disables the editability and mutes text",
placeholder: "Text goes here",
},
},
passingContent: {
name: "Passing Content",
description: "There are a few different ways to pass content.",
viaLabel: {
labelTx: "Via `label` prop",
helper: "Via `helper` prop",
placeholder: "Via `placeholder` prop",
},
rightAccessory: {
label: "RightAccessory",
helper: "This prop takes a function that returns a React element.",
},
leftAccessory: {
label: "LeftAccessory",
helper: "This prop takes a function that returns a React element.",
},
supportsMultiline: {
label: "Supports Multiline",
helper: "Enables a taller input for multiline text.",
},
},
styling: {
name: "Styling",
description: "The component can be styled easily.",
styleInput: {
label: "Style Input",
helper: "Via `style` prop",
},
styleInputWrapper: {
label: "Style Input Wrapper",
helper: "Via `inputWrapperStyle` prop",
},
styleContainer: {
label: "Style Container",
helper: "Via `containerStyle` prop",
},
styleLabel: {
label: "Style Label & Helper",
helper: "Via `LabelTextProps` & `HelperTextProps` style prop",
},
styleAccessories: {
label: "Style Accessories",
helper: "Via `RightAccessory` & `LeftAccessory` style prop",
},
},
},
},
demoToggle: {
description:
"Renders a boolean input. This is a controlled component that requires an onValueChange callback that updates the value prop in order for the component to reflect user actions. If the value prop is not updated, the component will continue to render the supplied value prop instead of the expected result of any user actions.",
useCase: {
variants: {
name: "Variants",
description:
"The component supports a few different variants. If heavy customization of a specific variant is needed, it can be easily refactored. The default is `checkbox`.",
checkbox: {
label: "`checkbox` variant",
helper: "This can be used for a single on/off input.",
},
radio: {
label: "`radio` variant",
helper: "Use this when you have multiple options.",
},
switch: {
label: "`switch` variant",
helper: "A more prominent on/off input. Has better accessibility support.",
},
},
statuses: {
name: "Statuses",
description:
"There is a status prop - similar to `preset` in other components, but affects component functionality as well.",
noStatus: "No status - this is the default",
errorStatus: "Error status - use when there is an error",
disabledStatus: "Disabled status - disables the editability and mutes input",
},
passingContent: {
name: "Passing Content",
description: "There are a few different ways to pass content.",
useCase: {
checkBox: {
label: "Via `labelTx` prop",
helper: "Via `helperTx` prop.",
},
checkBoxMultiLine: {
helper: "Supports multiline - Nulla proident consectetur labore sunt ea labore. ",
},
radioChangeSides: {
helper: "You can change sides - Laborum labore adipisicing in eu ipsum deserunt.",
},
customCheckBox: {
label: "Pass in a custom checkbox icon.",
},
switch: {
label: "Switches can be read as text",
helper:
"By default, this option doesn't use `Text` since depending on the font, the on/off characters might look weird. Customize as needed.",
},
switchAid: {
label: "Or aided with an icon",
},
},
},
styling: {
name: "Styling",
description: "The component can be styled easily.",
outerWrapper: "1 - style the input outer wrapper",
innerWrapper: "2 - style the input inner wrapper",
inputDetail: "3 - style the input detail",
labelTx: "You can also style the labelTx",
styleContainer: "Or, style the entire container",
},
},
},
demoButton: {
description:
"A component that allows users to take actions and make choices. Wraps the Text component with a Pressable component.",
useCase: {
presets: {
name: "Presets",
description: "There are a few presets that are preconfigured.",
},
passingContent: {
name: "Passing Content",
description: "There are a few different ways to pass content.",
viaTextProps: "Via `text` Prop - Billum In",
children: "Children - Irure Reprehenderit",
rightAccessory: "RightAccessory - Duis Quis",
leftAccessory: "LeftAccessory - Duis Proident",
nestedChildren: "Nested children - proident veniam.",
nestedChildren2: "Ullamco cupidatat officia exercitation velit non ullamco nisi..",
nestedChildren3: "Occaecat aliqua irure proident veniam.",
multiLine:
"Multiline - consequat veniam veniam reprehenderit. Fugiat id nisi quis duis sunt proident mollit dolor mollit adipisicing proident deserunt.",
},
styling: {
name: "Styling",
description: "The component can be styled easily.",
styleContainer: "Style Container - Exercitation",
styleText: "Style Text - Ea Anim",
styleAccessories: "Style Accessories - enim ea id fugiat anim ad.",
pressedState: "Style Pressed State - fugiat anim",
},
disabling: {
name: "Disabling",
description:
"The component can be disabled, and styled based on that. Press behavior will be disabled.",
standard: "Disabled - standard",
filled: "Disabled - filled",
reversed: "Disabled - reversed",
accessory: "Disabled accessory style",
textStyle: "Disabled text style",
},
},
},
demoListItem: {
description: "A styled row component that can be used in FlatList, SectionList, or by itself.",
useCase: {
height: {
name: "Height",
description: "The row can be different heights.",
defaultHeight: "Default height (56px)",
customHeight: "Custom height via `height` prop",
textHeight:
"Height determined by text content - Reprehenderit incididunt deserunt do do ea labore.",
longText:
"Limit long text to one line - Reprehenderit incididunt deserunt do do ea labore.",
},
separators: {
name: "Separators",
description: "The separator / divider is preconfigured and optional.",
topSeparator: "Only top separator",
topAndBottomSeparator: "Top and bottom separators",
bottomSeparator: "Only bottom separator",
},
icons: {
name: "Icons",
description: "You can customize the icons on the left or right.",
leftIcon: "Left icon",
rightIcon: "Right Icon",
leftRightIcons: "Left & Right Icons",
},
customLeftRight: {
name: "Custom Left/Right Components",
description: "If you need a custom left/right component, you can pass it in.",
customLeft: "Custom left component",
customRight: "Custom right component",
},
passingContent: {
name: "Passing Content",
description: "There are a few different ways to pass content.",
text: "Via `text` prop - reprehenderit sint",
children: "Children - mostrud mollit",
nestedChildren1: "Nested children - proident veniam.",
nestedChildren2: "Ullamco cupidatat officia exercitation velit non ullamco nisi..",
},
listIntegration: {
name: "Integrating w/ FlatList",
description: "The component can be easily integrated with your favorite list interface.",
},
styling: {
name: "Styling",
description: "The component can be styled easily.",
styledText: "Styled Text",
styledContainer: "Styled Container (separators)",
tintedIcons: "Tinted Icons",
},
},
},
demoCard: {
description:
"Cards are useful for displaying related information in a contained way. If a ListItem displays content horizontally, a Card can be used to display content vertically.",
useCase: {
presets: {
name: "Presets",
description: "There are a few presets that are preconfigured.",
default: {
heading: "Default Preset (default)",
content: "Incididunt magna ut aliquip consectetur mollit dolor.",
footer: "Consectetur nulla non aliquip velit.",
},
reversed: {
heading: "Reversed Preset",
content: "Reprehenderit occaecat proident amet id laboris.",
footer: "Consectetur tempor ea non labore anim .",
},
},
verticalAlignment: {
name: "Vertical Alignment",
description:
"Depending on what's required, the card comes preconfigured with different alignment strategies.",
top: {
heading: "Top (default)",
content: "All content is automatically aligned to the top.",
footer: "Even the footer",
},
center: {
heading: "Center",
content: "Content is centered relative to the card's height.",
footer: "Me too!",
},
spaceBetween: {
heading: "Space Between",
content: "All content is spaced out evenly.",
footer: "I am where I want to be.",
},
reversed: {
heading: "Force Footer Bottom",
content: "This pushes the footer where it belongs.",
footer: "I'm so lonely down here.",
},
},
passingContent: {
name: "Passing Content",
description: "There are a few different ways to pass content.",
heading: "Via `heading` Prop",
content: "Via `content` Prop",
footer: "I'm so lonely down here.",
},
customComponent: {
name: "Custom Components",
description:
"Any of the preconfigured components can be replaced with your own. You can also add additional ones.",
rightComponent: "RightComponent",
leftComponent: "LeftComponent",
},
style: {
name: "Styling",
description: "The component can be styled easily.",
heading: "Style the Heading",
content: "Style the Content",
footer: "Style the Footer",
},
},
},
demoAutoImage: {
description: "An Image component that automatically sizes a remote or data-uri image.",
useCase: {
remoteUri: { name: "Remote URI" },
base64Uri: { name: "Base64 URI" },
scaledToFitDimensions: {
name: "Scaled to Fit Dimensions",
description:
"Providing a `maxWidth` and/or `maxHeight` props, the image will automatically scale while retaining it's aspect ratio. How is this different from `resizeMode: 'contain'`? Firstly, you can specify only one side's size (not both). Secondly, the image will scale to fit the desired dimensions instead of just being contained within its image-container.",
heightAuto: "width: 60 / height: auto",
widthAuto: "width: auto / height: 32",
bothManual: "width: 60 / height: 60",
},
},
},
demoText: {
description:
"For your text displaying needs. This component is a HOC over the built-in React Native one.",
useCase: {
presets: {
name: "Presets",
description: "There are a few presets that are preconfigured.",
default:
"default preset - Cillum eu laboris in labore. Excepteur mollit tempor reprehenderit fugiat elit et eu consequat laborum.",
bold: "bold preset - Tempor et ullamco cupidatat in officia. Nulla ea duis elit id sunt ipsum cillum duis deserunt nostrud ut nostrud id.",
subheading: "subheading preset - In Cupidatat Cillum.",
heading: "heading preset - Voluptate Adipis.",
},
sizes: {
name: "Sizes",
description: "There's a size prop.",
xs: "xs - Ea ipsum est ea ex sunt.",
sm: "sm - Lorem sunt adipisicin.",
md: "md - Consequat id do lorem.",
lg: "lg - Nostrud ipsum ea.",
xl: "xl - Eiusmod ex excepteur.",
xxl: "xxl - Cillum eu laboris.",
},
weights: {
name: "Weights",
description: "There's a weight prop.",
light:
"light - Nulla magna incididunt excepteur est occaecat duis culpa dolore cupidatat enim et.",
normal:
"normal - Magna incididunt dolor ut veniam veniam laboris aliqua velit ea incididunt.",
medium: "medium - Non duis laborum quis laboris occaecat culpa cillum.",
semibold: "semiBold - Exercitation magna nostrud pariatur laborum occaecat aliqua.",
bold: "bold - Eiusmod ullamco magna exercitation est excepteur.",
},
passingContent: {
name: "Passing Content",
description: "There are a few different ways to pass content.",
viaText:
"via `text` prop - Billum in aute fugiat proident nisi pariatur est. Cupidatat anim cillum eiusmod ad. Officia eu magna aliquip labore dolore consequat.",
viaTx: "via `tx` prop -",
children: "children - Aliqua velit irure reprehenderit eu qui amet veniam consectetur.",
nestedChildren: "Nested children -",
nestedChildren2: "Occaecat aliqua irure proident veniam.",
nestedChildren3: "Ullamco cupidatat officia exercitation velit non ullamco nisi..",
nestedChildren4: "Occaecat aliqua irure proident veniam.",
},
styling: {
name: "Styling",
description: "The component can be styled easily.",
text: "Consequat ullamco veniam velit mollit proident excepteur aliquip id culpa ipsum velit sint nostrud.",
text2:
"Eiusmod occaecat laboris eu ex veniam ipsum adipisicing consectetur. Magna ullamco adipisicing tempor adipisicing.",
text3:
"Eiusmod occaecat laboris eu ex veniam ipsum adipisicing consectetur. Magna ullamco adipisicing tempor adipisicing.",
},
},
},
demoHeader: {
description:
"Component that appears on many screens. Will hold navigation buttons and screen title.",
useCase: {
actionIcons: {
name: "Action Icons",
description: "You can easily pass in icons to the left or right action components.",
leftIconTitle: "Left Icon",
rightIconTitle: "Right Icon",
bothIconsTitle: "Both Icons",
},
actionText: {
name: "Action Text",
description: "You can easily pass in text to the left or right action components.",
leftTxTitle: "Via `leftTx`",
rightTextTitle: "Via `rightText`",
},
customActionComponents: {
name: "Custom Action Components",
description:
"If the icon or text options are not enough, you can pass in your own custom action component.",
customLeftActionTitle: "Custom Left Action",
},
titleModes: {
name: "Title Modes",
description:
"Title can be forced to stay in center (default) but may be cut off if it's too long. You can optionally make it adjust to the action buttons.",
centeredTitle: "Centered Title",
flexTitle: "Flex Title",
},
styling: {
name: "Styling",
description: "The component can be styled easily.",
styledTitle: "Styled Title",
styledWrapperTitle: "Styled Wrapper",
tintedIconsTitle: "Tinted Icons",
},
},
},
demoEmptyState: {
description:
"A component to use when there is no data to display. It can be utilized to direct the user what to do next",
useCase: {
presets: {
name: "Presets",
description:
"You can create different text/image sets. One is predefined called `generic`. Note, there's no default in case you want to have a completely custom EmptyState.",
},
passingContent: {
name: "Passing Content",
description: "There are a few different ways to pass content.",
customizeImageHeading: "Customize Image",
customizeImageContent: "You can pass in any image source.",
viaHeadingProp: "Via `heading` Prop",
viaContentProp: "Via `content` prop.",
viaButtonProp: "Via `button` Prop",
},
styling: {
name: "Styling",
description: "The component can be styled easily.",
},
},
},
}
export default demoEn
export type DemoTranslations = typeof demoEn

View File

@@ -0,0 +1,467 @@
import { DemoTranslations } from "./demo-en"
export const demoEs: DemoTranslations = {
demoIcon: {
description:
"Un componente para dibujar un ícono pre-definido. Si se proporciona el atributo `onPress`, se rodea por un componente <TouchableOpacity />. De lo contrario, se rodea por un componente <View />.",
useCase: {
icons: {
name: "Íconos",
description: "Lista de los íconos pre-definidos para el componente.",
},
size: {
name: "Tamaño",
description: "Hay un atributo para el tamaño.",
},
color: {
name: "Color",
description: "Hay un atributo para el color.",
},
styling: {
name: "Estilo",
description: "El componente puede ser configurado fácilmente.",
},
},
},
demoTextField: {
description: "El componente <TextField /> permite el ingreso y edición de texto.",
useCase: {
statuses: {
name: "Estados",
description:
"Hay un atributo para el estado - similar a `preset` en otros componentes, pero que además impacta en la funcionalidad del componente.",
noStatus: {
label: "Sin estado",
helper: "Este es el estado por defecto",
placeholder: "El texto va acá",
},
error: {
label: "Estado de error",
helper: "Estado para usar en caso de error",
placeholder: "El texto va acá",
},
disabled: {
label: "Estado desactivado",
helper: "Desactiva la edición y atenúa el texto",
placeholder: "El texto va acá",
},
},
passingContent: {
name: "Entregando contenido",
description: "Hay varias formas de entregar contenido.",
viaLabel: {
labelTx: "A través del atributo `label`",
helper: "A través del atributo `helper`",
placeholder: "A través del atributo `placeholder`",
},
rightAccessory: {
label: "Complemento derecho",
helper: "Este atributo requiere una función que retorne un elemento React.",
},
leftAccessory: {
label: "Complemento izquierdo",
helper: "Este atributo requiere una función que retorne un elemento React.",
},
supportsMultiline: {
label: "Soporta múltilíneas",
helper: "Permite un input de texto más largo para texto multilinea.",
},
},
styling: {
name: "Estilo",
description: "El componente puede ser configurado fácilmente.",
styleInput: {
label: "Estilo del input",
helper: "A través de el atributo `style`",
},
styleInputWrapper: {
label: "Estilo del contenedor del input",
helper: "A través de el atributo `inputWrapperStyle`",
},
styleContainer: {
label: "Estilo del contenedor",
helper: "A través de el atributo `containerStyle`",
},
styleLabel: {
label: "Estilo de la etiqueta y texto de ayuda",
helper: "A través de las props de estilo `LabelTextProps` y `HelperTextProps`",
},
styleAccessories: {
label: "Estilo de los accesorios",
helper: "A través de las props de estilo `RightAccessory` y `LeftAccessory`",
},
},
},
},
demoToggle: {
description:
"Dibuja un switch de tipo booleano. Este componente requiere un callback `onValueChange` que actualice el atributo `value` para que este refleje las acciones del usuario. Si el atributo `value` no se actualiza, el componente seguirá mostrando el valor proporcionado por defecto en lugar de lo esperado por las acciones del usuario.",
useCase: {
variants: {
name: "Variantes",
description:
"El componente soporta diferentes variantes. Si se necesita una personalización más avanzada o variante específica, puede ser fácilmente refactorizada. El valor por defecto es `checkbox`.",
checkbox: {
label: "Variante `checkbox`",
helper: "Puede ser utilizada para un único valor del tipo on/off.",
},
radio: {
label: "Variante `radio`",
helper: "Usa esto cuando tengas múltiples opciones.",
},
switch: {
label: "Variante `switch`",
helper:
"Una entrada del tipo on/off que sobresale más. Tiene mejor soporte de accesibilidad.",
},
},
statuses: {
name: "Estados",
description:
"Hay un atributo de estado - similar a `preset` en otros componentes, pero que además impacta en la funcionalidad del componente.",
noStatus: "Sin estado - este es el valor por defecto",
errorStatus: "Estado de error - para usar cuando haya un error",
disabledStatus: "Estado desactivado - desactiva la edición y silencia el input",
},
passingContent: {
name: "Entregando contenido",
description: "Hay varias formas de entregar contenido.",
useCase: {
checkBox: {
label: "A través del atributo `labelTx`",
helper: "A través del atributo `helperTx`.",
},
checkBoxMultiLine: {
helper: "Soporta multi líneas - Nulla proident consectetur labore sunt ea labore.",
},
radioChangeSides: {
helper: "Puedes cambiarle el lado - Laborum labore adipisicing in eu ipsum deserunt.",
},
customCheckBox: {
label: "Pasa un ícono para un checkbox personalizado.",
},
switch: {
label: "Los interruptores pueden leerse como texto",
helper:
"Por defecto, esta opción no usa `Text` ya que, dependiendo de la fuente, los caracteres on/off podrían no dibujarse bien. Personalízalo según tus necesidades.",
},
switchAid: {
label: "O con la ayuda de un ícono",
},
},
},
styling: {
name: "Estilo",
description: "El componente puede ser configurado fácilmente.",
outerWrapper: "1 - configura el contenedor externo del input",
innerWrapper: "2 - configura el contenedor interno del input",
inputDetail: "3 - configura el detalle del input",
labelTx: "También puedes configurar el atributo labelTx",
styleContainer: "O, configura todo el contenedor",
},
},
},
demoButton: {
description:
"Un componente que permite a los usuarios realizar acciones y hacer elecciones. Rodea un componente Text con otro componente Pressable.",
useCase: {
presets: {
name: "Preajustes",
description: "Hay algunos preajustes por defecto.",
},
passingContent: {
name: "Entregando contenido",
description: "Hay varias formas de entregar contenido.",
viaTextProps: "A través del atributo `text` - Billum In",
children: "Contenido anidado (children) - Irure Reprehenderit",
rightAccessory: "Componente derecho - Duis Quis",
leftAccessory: "Componente izquierdo - Duis Proident",
nestedChildren: "Contenido anidado - proident veniam.",
nestedChildren2: "Ullamco cupidatat officia exercitation velit non ullamco nisi..",
nestedChildren3: "Occaecat aliqua irure proident veniam.",
multiLine:
"Multilínea - consequat veniam veniam reprehenderit. Fugiat id nisi quis duis sunt proident mollit dolor mollit adipisicing proident deserunt.",
},
styling: {
name: "Estilo",
description: "El componente puede ser configurando fácilmente.",
styleContainer: "Estilo del contenedor - Exercitation",
styleText: "Estilo del texto - Ea Anim",
styleAccessories: "Estilo de los componentes - enim ea id fugiat anim ad.",
pressedState: "Estilo para el estado presionado - fugiat anim",
},
disabling: {
name: "Desactivado",
description:
"El componente puede ser desactivado y como consecuencia, estilizado. El comportamiento para hacer clic será desactivado.",
standard: "Desactivado - estándar",
filled: "Desactivado - relleno",
reversed: "Desactivado - invertido",
accessory: "Estilo del componente desactivado",
textStyle: "Estilo del texto desactivado",
},
},
},
demoListItem: {
description:
"Un componente estilizado que representa una fila para ser utilizada dentro de un FlatList, SectionList o por sí solo.",
useCase: {
height: {
name: "Altura",
description: "La fila puede tener diferentes alturas.",
defaultHeight: "Altura por defecto (56px)",
customHeight: "Altura personalizada a través del atributo `height`",
textHeight:
"Altura determinada por el contenido del texto - Reprehenderit incididunt deserunt do do ea labore.",
longText:
"Limitar texto largo a solo una línea - Reprehenderit incididunt deserunt do do ea labore.",
},
separators: {
name: "Separadores",
description: "El separador/divisor está preconfigurado y es opcional.",
topSeparator: "Separador solo en la parte superior",
topAndBottomSeparator: "Separadores en la parte superior e inferior",
bottomSeparator: "Separador solo en la parte inferior",
},
icons: {
name: "Íconos",
description: "Puedes personalizar los íconos a la izquierda o a la derecha.",
leftIcon: "Ícono izquierdo",
rightIcon: "Ícono derecho",
leftRightIcons: "Íconos izquierdo y derecho",
},
customLeftRight: {
name: "Componentes personalizados en la izquierda o derecha",
description:
"Puede pasar un componente personalizado en la izquierda o derecha, si así lo necesitas.",
customLeft: "Componente personalizado a la izquierda",
customRight: "Componente personalizado a la derecha",
},
passingContent: {
name: "Entregando contenido",
description: "Hay varias formas de entregar contenido.",
text: "A través del atributo `text` - reprehenderit sint",
children: "Contenido anidado (children) - mostrud mollit",
nestedChildren1: "Contenido anidado 1 - proident veniam.",
nestedChildren2: "Ullamco cupidatat officia exercitation velit non ullamco nisi..",
},
listIntegration: {
name: "Integración con FlatList",
description:
"El componente puede ser fácilmente integrado con tu interfaz de lista preferida.",
},
styling: {
name: "Estilo",
description: "El componente puede ser configurando fácilmente.",
styledText: "Texto estilizado",
styledContainer: "Contenedor estilizado (separadores)",
tintedIcons: "Íconos coloreados",
},
},
},
demoCard: {
description:
"Las tarjetas son útiles para mostrar información relacionada de forma englobada. Si un ListItem muestra el contenido horizontalmente, una tarjeta puede ser también utilizada para mostrar el contenido de manera vertical.",
useCase: {
presets: {
name: "Preajustes",
description: "Hay algunos ajustes preconfigurados.",
default: {
heading: "Preajuste por defecto (default)",
content: "Incididunt magna ut aliquip consectetur mollit dolor.",
footer: "Consectetur nulla non aliquip velit.",
},
reversed: {
heading: "Preajuste inverso",
content: "Reprehenderit occaecat proident amet id laboris.",
footer: "Consectetur tempor ea non labore anim.",
},
},
verticalAlignment: {
name: "Alineamiento vertical",
description:
"Dependiendo del requerimiento, la tarjeta está preconfigurada con diferentes estrategias de alineación.",
top: {
heading: "Arriba (por defecto)",
content: "Todo el contenido está automáticamente alineado en la parte superior.",
footer: "Incluso en el pie de página",
},
center: {
heading: "Centro",
content: "El contenido está centrado en relación con la altura de la tarjeta.",
footer: "¡Yo también!",
},
spaceBetween: {
heading: "Espacio entre",
content: "Todo el contenido está espaciado uniformemente.",
footer: "Estoy donde quiero estar.",
},
reversed: {
heading: "Forzar el pie de página hacia abajo",
content: "Esto empuja el pie de página hacia donde pertenece.",
footer: "Estoy tan solo aquí abajo.",
},
},
passingContent: {
name: "Entregando contenido",
description: "Hay varias formas de entregar contenido.",
heading: "A través del atributo `heading`",
content: "A través del atributo `content`",
footer: "Estoy tan solo aquí abajo.",
},
customComponent: {
name: "Componentes personalizados",
description:
"Cualquier componente preconfigurado puede ser reemplazado por uno específico. Puedes agregar otros si así lo requieres.",
rightComponent: "Componente derecho",
leftComponent: "Componente izquierdo",
},
style: {
name: "Estilo",
description: "El componente puede ser configurado fácilmente.",
heading: "Estilizar el encabezado",
content: "Estilizar el contenido",
footer: "Estilizar el pie de página",
},
},
},
demoAutoImage: {
description:
"Un componente que se ajusta automáticamente el tamaño de una imagen remota o utilizando el atributo data-uri.",
useCase: {
remoteUri: { name: "URI remota" },
base64Uri: { name: "URI Base64" },
scaledToFitDimensions: {
name: "Escalado que se ajusta a las dimensiones",
description:
"Al proporcionar los atributos `maxWidth` y/o `maxHeight`, la imagen se redimensionará automáticamente manteniendo el ratio. ¿En qué se diferencia de `resizeMode: 'contain'`? Para empezar, puedes especificar el tamaño de un solo lado (no ambos). Segundo, la imagen se ajustará a las dimensiones deseadas en lugar de simplemente estar contenida en su contenedor.",
heightAuto: "ancho: 60 / altura: auto",
widthAuto: "ancho: auto / altura: 32",
bothManual: "ancho: 60 / altura: 60",
},
},
},
demoText: {
description:
"Para todo tipo de requerimiento relacionado a mostrar texto. Este componente es un 'wrapper' (HOC) del componente Text de React Native.",
useCase: {
presets: {
name: "Preajustes",
description: "Hay algunos ajustes preconfigurados.",
default:
"ajuste por defecto - Cillum eu laboris in labore. Excepteur mollit tempor reprehenderit fugiat elit et eu consequat laborum.",
bold: "preajuste negrita - Tempor et ullamco cupidatat in officia. Nulla ea duis elit id sunt ipsum cillum duis deserunt nostrud ut nostrud id.",
subheading: "preajuste subtítulo - In Cupidatat Cillum.",
heading: "preajuste título - Voluptate Adipis.",
},
sizes: {
name: "Tamaños",
description: "Hay un atributo de tamaño.",
xs: "xs - Ea ipsum est ea ex sunt.",
sm: "sm - Lorem sunt adipisicin.",
md: "md - Consequat id do lorem.",
lg: "lg - Nostrud ipsum ea.",
xl: "xl - Eiusmod ex excepteur.",
xxl: "xxl - Cillum eu laboris.",
},
weights: {
name: "Grueso",
description: "Hay un atributo de grueso.",
light:
"ligero - Nulla magna incididunt excepteur est occaecat duis culpa dolore cupidatat enim et.",
normal:
"normal - Magna incididunt dolor ut veniam veniam laboris aliqua velit ea incididunt.",
medium: "medio - Non duis laborum quis laboris occaecat culpa cillum.",
semibold: "seminegrita - Exercitation magna nostrud pariatur laborum occaecat aliqua.",
bold: "negrita - Eiusmod ullamco magna exercitation est excepteur.",
},
passingContent: {
name: "Entregando contenido",
description: "Hay varias formas de entregar contenido.",
viaText:
"a través del atributo `text` - Billum in aute fugiat proident nisi pariatur est. Cupidatat anim cillum eiusmod ad. Officia eu magna aliquip labore dolore consequat.",
viaTx: "a través del atributo `tx` -",
children:
"Contenido anidado (children) - Aliqua velit irure reprehenderit eu qui amet veniam consectetur.",
nestedChildren: "Contenidos anidados -",
nestedChildren2: "Occaecat aliqua irure proident veniam.",
nestedChildren3: "Ullamco cupidatat officia exercitation velit non ullamco nisi..",
nestedChildren4: "Occaecat aliqua irure proident veniam.",
},
styling: {
name: "Estilo",
description: "El componente puede ser configurando fácilmente.",
text: "Consequat ullamco veniam velit mollit proident excepteur aliquip id culpa ipsum velit sint nostrud.",
text2:
"Eiusmod occaecat laboris eu ex veniam ipsum adipisicing consectetur. Magna ullamco adipisicing tempor adipisicing.",
text3:
"Eiusmod occaecat laboris eu ex veniam ipsum adipisicing consectetur. Magna ullamco adipisicing tempor adipisicing.",
},
},
},
demoHeader: {
description:
"Componente desplegado en varias pantallas. Va a contener botones de navegación y el título de la pantalla.",
useCase: {
actionIcons: {
name: "Íconos de acción",
description: "Puedes pasar fácilmente íconos a los componentes de la izquierda o derecha.",
leftIconTitle: "Ícono izquierdo",
rightIconTitle: "Ícono derecho",
bothIconsTitle: "Ambos íconos",
},
actionText: {
name: "Texto de acción",
description: "Puedes pasar fácilmente texto a los componentes de la izquierda o derecha.",
leftTxTitle: "A través de `leftTx`",
rightTextTitle: "A través de `rightText`",
},
customActionComponents: {
name: "Componentes personalizados de acción",
description:
"Si las opciones de ícono o texto no son suficientes, puedes pasar tu propio componente personalizado de acción.",
customLeftActionTitle: "Acción izquierda personalizada",
},
titleModes: {
name: "Alineamiento para el título",
description:
"El título puede ser forzado a permanecer centrado (por defecto), pero podría cortarse si es demasiado largo. También puedes hacer que se ajuste a los botones a la izquierda o derecha.",
centeredTitle: "Título centrado",
flexTitle: "Título flexible",
},
styling: {
name: "Estilo",
description: "El componente puede ser configurado fácilmente.",
styledTitle: "Título estilizado",
styledWrapperTitle: "Contenedor estilizado",
tintedIconsTitle: "Íconos coloreados",
},
},
},
demoEmptyState: {
description:
"Un componente para cuando no hay información que mostrar. Puede usarse también para guiar al usuario sobre qué hacer a continuación.",
useCase: {
presets: {
name: "Preajustes",
description:
"Puedes crear distintos conjuntos de texto/imagen. Por ejemplo, con un ajuste predefinido `generic`. Si quieres tener un EmptyState completamente personalizado, ten en cuenta que no hay un valor por defecto.",
},
passingContent: {
name: "Entregando contenido",
description: "Hay varias formas de entregar contenido.",
customizeImageHeading: "Personalizar la imagen",
customizeImageContent: "Puedes pasar cualquier una imagen de distintas fuentes.",
viaHeadingProp: "A través del atributo `heading`",
viaContentProp: "A través del atributo `content`.",
viaButtonProp: "A través del atributo `button`",
},
styling: {
name: "Estilo",
description: "El componente puede ser configurado fácilmente.",
},
},
},
}
export default demoEs

View File

@@ -0,0 +1,469 @@
import { DemoTranslations } from "./demo-en"
export const demoFr: DemoTranslations = {
demoIcon: {
description:
"Un composant pour faire le rendu dune icône enregistrée. Il est enveloppé dans un <TouchableOpacity /> si `onPress` est fourni, sinon dans une <View />.",
useCase: {
icons: {
name: "Icônes",
description: "Liste des icônes enregistrées dans le composant.",
},
size: {
name: "Taille",
description: "Il y a une prop de taille.",
},
color: {
name: "Couleur",
description: "Il y a une prop de couleur.",
},
styling: {
name: "Style",
description: "Le composant peut être facilement stylisé.",
},
},
},
demoTextField: {
description: "Le composant <TextField /> permet la saisie et l'édition de texte.",
useCase: {
statuses: {
name: "Statuts",
description:
"Il y a une prop de statut - similaire à `preset` dans d'autres composants, mais affecte également la fonctionnalité du composant.",
noStatus: {
label: "Pas de statut",
helper: "C'est le statut par défaut",
placeholder: "Le texte passe par là",
},
error: {
label: "Statut d'erreur",
helper: "Statut à utiliser en cas derreur",
placeholder: "Le texte passe par ici",
},
disabled: {
label: "Statut désactivé",
helper: "Désactive lédition et atténue le texte",
placeholder: "Le texte repasse par là",
},
},
passingContent: {
name: "Transfert de contenu",
description: "Il y a plusieurs façons de transmettre du contenu.",
viaLabel: {
labelTx: "Via la prop `label`",
helper: "Via la prop `helper`",
placeholder: "Via la prop `placeholder`",
},
rightAccessory: {
label: "Accessoire droit",
helper: "Cette prop demande une fonction qui retourne un élément React.",
},
leftAccessory: {
label: "Accessoire gauche",
helper: "Cette prop demande une fonction qui retourne un élément React.",
},
supportsMultiline: {
label: "Supporte le multiligne",
helper: "Permet une saisie plus longue pour le texte multiligne.",
},
},
styling: {
name: "Style",
description: "Le composant peut être facilement stylisé.",
styleInput: {
label: "Style de saisie",
helper: "Via la prop `style`",
},
styleInputWrapper: {
label: "Style du wrapper de saisie",
helper: "Via la prop `inputWrapperStyle`",
},
styleContainer: {
label: "Style du conteneur",
helper: "Via la prop `containerStyle`",
},
styleLabel: {
label: "Style du label et de laide",
helper: "Via les props de style `LabelTextProps` et `HelperTextProps`",
},
styleAccessories: {
label: "Style des accessoires",
helper: "Via les props de style `RightAccessory` et `LeftAccessory`",
},
},
},
},
demoToggle: {
description:
"Fait le rendu dun booléen. Ce composant contrôlé nécessite un callback `onValueChange` qui met à jour la prop `value` pour que le composant reflète les actions de l'utilisateur. Si la prop `value` n'est pas mise à jour, le composant continuera à rendre la prop `value` fournie au lieu du résultat attendu des actions de l'utilisateur.",
useCase: {
variants: {
name: "Variantes",
description:
"Le composant supporte différentes variantes. Si une personnalisation poussée d'une variante spécifique est nécessaire, elle peut être facilement refactorisée. La valeur par défaut est `checkbox`.",
checkbox: {
label: "Variante `checkbox`",
helper: "Peut être utilisée pour une seule valeure on/off.",
},
radio: {
label: "Variante `radio`",
helper: "Utilisez ceci quand vous avez plusieurs options.",
},
switch: {
label: "Variante `switch`",
helper:
"Une entrée on/off plus proéminente. Possède un meilleur support daccessibilité.",
},
},
statuses: {
name: "Statuts",
description:
"Il y a une prop de statut - similaire à `preset` dans d'autres composants, mais affecte également la fonctionnalité du composant.",
noStatus: "Pas de statut - c'est le défaut",
errorStatus: "Statut derreur - à utiliser quand il y a une erreur",
disabledStatus: "Statut désactivé - désactive lédition et atténue le style",
},
passingContent: {
name: "Transfert de contenu",
description: "Il y a plusieurs façons de transmettre du contenu.",
useCase: {
checkBox: {
label: "Via la prop `labelTx`",
helper: "Via la prop `helperTx`.",
},
checkBoxMultiLine: {
helper: "Supporte le multiligne - Nulla proident consectetur labore sunt ea labore. ",
},
radioChangeSides: {
helper:
"Vous pouvez changer de côté - Laborum labore adipisicing in eu ipsum deserunt.",
},
customCheckBox: {
label: "Passez une icône de case à cocher personnalisée.",
},
switch: {
label: "Les interrupteurs peuvent être lus comme du texte",
helper:
"Par défaut, cette option nutilise pas `Text` car selon la police, les caractères on/off pourraient paraître étranges. Personnalisez selon vos besoins.",
},
switchAid: {
label: "Ou aidé dune icône",
},
},
},
styling: {
name: "Style",
description: "Le composant peut être facilement stylisé.",
outerWrapper: "1 - styliser le wrapper extérieur de lentrée",
innerWrapper: "2 - styliser le wrapper intérieur de lentrée",
inputDetail: "3 - styliser le détail de lentrée",
labelTx: "Vous pouvez aussi styliser le labelTx",
styleContainer: "Ou, styliser le conteneur entier",
},
},
},
demoButton: {
description:
"Un composant qui permet aux utilisateurs deffectuer des actions et de faire des choix. Enveloppe le composant Text avec un composant Pressable.",
useCase: {
presets: {
name: "Préréglages",
description: "Il y a quelques préréglages préconfigurés.",
},
passingContent: {
name: "Transfert de contenu",
description: "Il y a plusieurs façons de transmettre du contenu.",
viaTextProps: "Via la prop `text` - Billum In",
children: "Enfants - Irure Reprehenderit",
rightAccessory: "Accessoire droit - Duis Quis",
leftAccessory: "Accessoire gauche - Duis Proident",
nestedChildren: "Enfants imbriqués - proident veniam.",
nestedChildren2: "Ullamco cupidatat officia exercitation velit non ullamco nisi..",
nestedChildren3: "Occaecat aliqua irure proident veniam.",
multiLine:
"Multiligne - consequat veniam veniam reprehenderit. Fugiat id nisi quis duis sunt proident mollit dolor mollit adipisicing proident deserunt.",
},
styling: {
name: "Style",
description: "Le composant peut être facilement stylisé.",
styleContainer: "Style du conteneur - Exercitation",
styleText: "Style du texte - Ea Anim",
styleAccessories: "Style des accessoires - enim ea id fugiat anim ad.",
pressedState: "Style de létat pressé - fugiat anim",
},
disabling: {
name: "Désactivation",
description:
"Le composant peut être désactivé et stylisé en conséquence. Le comportement de pression sera désactivé.",
standard: "Désactivé - standard",
filled: "Désactivé - rempli",
reversed: "Désactivé - inversé",
accessory: "Style daccessoire désactivé",
textStyle: "Style de texte désactivé",
},
},
},
demoListItem: {
description:
"Un composant de ligne stylisé qui peut être utilisé dans FlatList, SectionList, ou seul.",
useCase: {
height: {
name: "Hauteur",
description: "La ligne peut avoir différentes hauteurs.",
defaultHeight: "Hauteur par défaut (56px)",
customHeight: "Hauteur personnalisée via la prop `height`",
textHeight:
"Hauteur déterminée par le contenu du texte - Reprehenderit incididunt deserunt do do ea labore.",
longText:
"Limiter le texte long à une ligne - Reprehenderit incididunt deserunt do do ea labore.",
},
separators: {
name: "Séparateurs",
description: "Le séparateur / diviseur est préconfiguré et optionnel.",
topSeparator: "Séparateur uniquement en haut",
topAndBottomSeparator: "Séparateurs en haut et en bas",
bottomSeparator: "Séparateur uniquement en bas",
},
icons: {
name: "Icônes",
description: "Vous pouvez personnaliser les icônes à gauche ou à droite.",
leftIcon: "Icône gauche",
rightIcon: "Icône droite",
leftRightIcons: "Icônes gauche et droite",
},
customLeftRight: {
name: "Composants personnalisés gauche/droite",
description:
"Si vous avez besoin dun composant personnalisé à gauche/droite, vous pouvez le passer.",
customLeft: "Composant personnalisé à gauche",
customRight: "Composant personnalisé à droite",
},
passingContent: {
name: "Transfert de contenu",
description: "Il y a plusieurs façons de transmettre du contenu.",
text: "Via la prop `text` - reprehenderit sint",
children: "Enfants - mostrud mollit",
nestedChildren1: "Enfants imbriqués - proident veniam.",
nestedChildren2: "Ullamco cupidatat officia exercitation velit non ullamco nisi..",
},
listIntegration: {
name: "Intégration avec FlatList",
description:
"Le composant peut être facilement intégré avec votre interface de liste préférée.",
},
styling: {
name: "Style",
description: "Le composant peut être facilement stylisé.",
styledText: "Texte stylisé",
styledContainer: "Conteneur stylisé (séparateurs)",
tintedIcons: "Icônes teintées",
},
},
},
demoCard: {
description:
"Les cartes sont utiles pour afficher des informations connexes de manière contenue. Si un ListItem affiche le contenu horizontalement, une Card peut être utilisée pour afficher le contenu verticalement.",
useCase: {
presets: {
name: "Préréglages",
description: "Il y a quelques préréglages préconfigurés.",
default: {
heading: "Préréglage par défaut (default)",
content: "Incididunt magna ut aliquip consectetur mollit dolor.",
footer: "Consectetur nulla non aliquip velit.",
},
reversed: {
heading: "Préréglage inversé",
content: "Reprehenderit occaecat proident amet id laboris.",
footer: "Consectetur tempor ea non labore anim .",
},
},
verticalAlignment: {
name: "Alignement vertical",
description:
"Selon les besoins, la carte est préconfigurée avec différentes stratégies dalignement.",
top: {
heading: "Haut (par défaut)",
content: "Tout le contenu est automatiquement aligné en haut.",
footer: "Même le pied de page",
},
center: {
heading: "Centre",
content: "Le contenu est centré par rapport à la hauteur de la carte.",
footer: "Moi aussi !",
},
spaceBetween: {
heading: "Espace entre",
content: "Tout le contenu est espacé uniformément.",
footer: "Je suis là où je veux être.",
},
reversed: {
heading: "Forcer le pied de page en bas",
content: "Cela pousse le pied de page là où il appartient.",
footer: "Je suis si seul ici en bas.",
},
},
passingContent: {
name: "Transfert de contenu",
description: "Il y a plusieurs façons de transmettre du contenu.",
heading: "Via la prop `heading`",
content: "Via la prop `content`",
footer: "Je suis si seul ici en bas.",
},
customComponent: {
name: "Composants personnalisés",
description:
"Nimporte quels composants préconfigurés peuvent être remplacé par le vôtre. Vous pouvez également en ajouter dautres.",
rightComponent: "Composant droit",
leftComponent: "Composant gauche",
},
style: {
name: "Style",
description: "Le composant peut être facilement stylisé.",
heading: "Styliser len-tête",
content: "Styliser le contenu",
footer: "Styliser le pied de page",
},
},
},
demoAutoImage: {
description:
"Un composant Image qui dimensionne automatiquement une image distante ou data-uri.",
useCase: {
remoteUri: { name: "URI distante" },
base64Uri: { name: "URI Base64" },
scaledToFitDimensions: {
name: "Mis à léchelle pour sadapter aux dimensions",
description:
"En fournissant les props `maxWidth` et/ou `maxHeight`, limage se redimensionnera automatiquement à léchelle tout en conservant son rapport daspect. En quoi est-ce différent de `resizeMode: 'contain'` ? Premièrement, vous pouvez spécifier la taille d'un seul côté (pas les deux). Deuxièmement, l'image s'adaptera aux dimensions souhaitées au lieu d'être simplement contenue dans son conteneur d'image.",
heightAuto: "largeur: 60 / hauteur: auto",
widthAuto: "largeur: auto / hauteur: 32",
bothManual: "largeur: 60 / hauteur: 60",
},
},
},
demoText: {
description:
"Pour vos besoins d'affichage de texte. Ce composant est un HOC sur celui intégré à React Native.",
useCase: {
presets: {
name: "Préréglages",
description: "Il y a quelques réglages préconfigurés.",
default:
"préréglage par défaut - Cillum eu laboris in labore. Excepteur mollit tempor reprehenderit fugiat elit et eu consequat laborum.",
bold: "préréglage gras - Tempor et ullamco cupidatat in officia. Nulla ea duis elit id sunt ipsum cillum duis deserunt nostrud ut nostrud id.",
subheading: "préréglage sous-titre - In Cupidatat Cillum.",
heading: "préréglage titre - Voluptate Adipis.",
},
sizes: {
name: "Tailles",
description: "Il y a une prop de taille.",
xs: "xs - Ea ipsum est ea ex sunt.",
sm: "sm - Lorem sunt adipisicin.",
md: "md - Consequat id do lorem.",
lg: "lg - Nostrud ipsum ea.",
xl: "xl - Eiusmod ex excepteur.",
xxl: "xxl - Cillum eu laboris.",
},
weights: {
name: "Graisse",
description: "Il y a une prop de graisse.",
light:
"léger - Nulla magna incididunt excepteur est occaecat duis culpa dolore cupidatat enim et.",
normal:
"normal - Magna incididunt dolor ut veniam veniam laboris aliqua velit ea incididunt.",
medium: "moyen - Non duis laborum quis laboris occaecat culpa cillum.",
semibold: "demi-gras - Exercitation magna nostrud pariatur laborum occaecat aliqua.",
bold: "gras - Eiusmod ullamco magna exercitation est excepteur.",
},
passingContent: {
name: "Transfert de contenu",
description: "Il y a plusieurs façons de transférer du contenu.",
viaText:
"via la prop `text` - Billum in aute fugiat proident nisi pariatur est. Cupidatat anim cillum eiusmod ad. Officia eu magna aliquip labore dolore consequat.",
viaTx: "via la prop `tx` -",
children: "enfants - Aliqua velit irure reprehenderit eu qui amet veniam consectetur.",
nestedChildren: "Enfants imbriqués -",
nestedChildren2: "Occaecat aliqua irure proident veniam.",
nestedChildren3: "Ullamco cupidatat officia exercitation velit non ullamco nisi..",
nestedChildren4: "Occaecat aliqua irure proident veniam.",
},
styling: {
name: "Style",
description: "Le composant peut être facilement stylisé.",
text: "Consequat ullamco veniam velit mollit proident excepteur aliquip id culpa ipsum velit sint nostrud.",
text2:
"Eiusmod occaecat laboris eu ex veniam ipsum adipisicing consectetur. Magna ullamco adipisicing tempor adipisicing.",
text3:
"Eiusmod occaecat laboris eu ex veniam ipsum adipisicing consectetur. Magna ullamco adipisicing tempor adipisicing.",
},
},
},
demoHeader: {
description:
"Composant qui apparaît sur de nombreux écrans. Contiendra les boutons de navigation et le titre de lécran.",
useCase: {
actionIcons: {
name: "Icônes daction",
description:
"Vous pouvez facilement passer des icônes aux composants daction gauche ou droit.",
leftIconTitle: "Icône gauche",
rightIconTitle: "Icône droite",
bothIconsTitle: "Les deux icônes",
},
actionText: {
name: "Texte daction",
description:
"Vous pouvez facilement passer du texte aux composants daction gauche ou droit.",
leftTxTitle: "Via `leftTx`",
rightTextTitle: "Via `rightText`",
},
customActionComponents: {
name: "Composants daction personnalisés",
description:
"Si les options dicône ou de texte ne suffisent pas, vous pouvez passer votre propre composant daction personnalisé.",
customLeftActionTitle: "Action gauche personnalisée",
},
titleModes: {
name: "Modes de titre",
description:
"Le titre peut être forcé à rester au centre (par défaut) mais peut être coupé sil est trop long. Vous pouvez éventuellement le faire sajuster aux boutons daction.",
centeredTitle: "Titre centré",
flexTitle: "Titre flexible",
},
styling: {
name: "Style",
description: "Le composant peut être facilement stylisé.",
styledTitle: "Titre stylisé",
styledWrapperTitle: "Wrapper stylisé",
tintedIconsTitle: "Icônes teintées",
},
},
},
demoEmptyState: {
description:
"Un composant à utiliser lorsquil ny a pas de données à afficher. Il peut être utilisé pour diriger lutilisateur sur ce quil faut faire ensuite.",
useCase: {
presets: {
name: "Préréglages",
description:
"Vous pouvez créer différents ensembles de texte/image. Un est prédéfini appelé `generic`. Notez quil ny a pas de valeur par défaut au cas où vous voudriez avoir un EmptyState complètement personnalisé.",
},
passingContent: {
name: "Transfert de contenu",
description: "Il y a plusieurs façons de transférer du contenu.",
customizeImageHeading: "Personnaliser limage",
customizeImageContent: "Vous pouvez passer nimporte quelle source d'image.",
viaHeadingProp: "Via la prop `heading`",
viaContentProp: "Via la prop `content`.",
viaButtonProp: "Via la prop `button`",
},
styling: {
name: "Style",
description: "Le composant peut être facilement stylisé.",
},
},
},
}
export default demoFr

View File

@@ -0,0 +1,466 @@
import { DemoTranslations } from "./demo-en"
export const demoHi: DemoTranslations = {
demoIcon: {
description:
"एक पंजीकृत आइकन को रेंडर करने के लिए एक कंपोनेंट। यदि `onPress` प्रदान किया जाता है तो यह <TouchableOpacity /> में लपेटा जाता है, अन्यथा <View /> में।",
useCase: {
icons: {
name: "आइकन",
description: "कंपोनेंट के अंदर पंजीकृत आइकनों की सूची।",
},
size: {
name: "आकार",
description: "एक आकार प्रॉप है।",
},
color: {
name: "रंग",
description: "एक रंग प्रॉप है।",
},
styling: {
name: "स्टाइलिंग",
description: "कंपोनेंट को आसानी से स्टाइल किया जा सकता है।",
},
},
},
demoTextField: {
description: "टेक्स्टफील्ड कंपोनेंट टेक्स्ट दर्ज करने और संपादित करने की अनुमति देता है।",
useCase: {
statuses: {
name: "स्थितियाँ",
description:
"एक स्थिति प्रॉप है - अन्य कंपोनेंट्स में `preset` के समान, लेकिन कंपोनेंट की कार्यक्षमता को भी प्रभावित करता है।",
noStatus: {
label: "कोई स्थिति नहीं",
helper: "यह डिफ़ॉल्ट स्थिति है",
placeholder: "टेक्स्ट यहाँ जाता है",
},
error: {
label: "त्रुटि स्थिति",
helper: "त्रुटि होने पर उपयोग करने के लिए स्थिति",
placeholder: "टेक्स्ट यहाँ जाता है",
},
disabled: {
label: "अक्षम स्थिति",
helper: "संपादन को अक्षम करता है और टेक्स्ट को मंद करता है",
placeholder: "टेक्स्ट यहाँ जाता है",
},
},
passingContent: {
name: "सामग्री पास करना",
description: "सामग्री पास करने के कई तरीके हैं।",
viaLabel: {
labelTx: "`label` प्रॉप के माध्यम से",
helper: "`helper` प्रॉप के माध्यम से",
placeholder: "`placeholder` प्रॉप के माध्यम से",
},
rightAccessory: {
label: "दायाँ सहायक",
helper: "यह प्रॉप एक फ़ंक्शन लेता है जो एक React तत्व लौटाता है।",
},
leftAccessory: {
label: "बायाँ सहायक",
helper: "यह प्रॉप एक फ़ंक्शन लेता है जो एक React तत्व लौटाता है।",
},
supportsMultiline: {
label: "मल्टीलाइन का समर्थन करता है",
helper: "मल्टीलाइन टेक्स्ट के लिए एक लंबा इनपुट सक्षम करता है।",
},
},
styling: {
name: "स्टाइलिंग",
description: "कंपोनेंट को आसानी से स्टाइल किया जा सकता है।",
styleInput: {
label: "इनपुट स्टाइल",
helper: "`style` प्रॉप के माध्यम से",
},
styleInputWrapper: {
label: "इनपुट रैपर स्टाइल",
helper: "`inputWrapperStyle` प्रॉप के माध्यम से",
},
styleContainer: {
label: "कंटेनर स्टाइल",
helper: "`containerStyle` प्रॉप के माध्यम से",
},
styleLabel: {
label: "लेबल और हेल्पर स्टाइल",
helper: "`LabelTextProps` और `HelperTextProps` स्टाइल प्रॉप के माध्यम से",
},
styleAccessories: {
label: "सहायक स्टाइल",
helper: "`RightAccessory` और `LeftAccessory` स्टाइल प्रॉप के माध्यम से",
},
},
},
},
demoToggle: {
description:
"एक बूलियन इनपुट रेंडर करता है। यह एक नियंत्रित कंपोनेंट है जिसे उपयोगकर्ता क्रियाओं को दर्शाने के लिए value प्रॉप को अपडेट करने वाले onValueChange कॉलबैक की आवश्यकता होती है। यदि value प्रॉप अपडेट नहीं की जाती है, तो कंपोनेंट उपयोगकर्ता क्रियाओं के अपेक्षित परिणाम के बजाय आपूर्ति की गई value प्रॉप को रेंडर करना जारी रखेगा।",
useCase: {
variants: {
name: "विविधताएँ",
description:
"कंपोनेंट कुछ अलग-अलग विविधताओं का समर्थन करता है। यदि किसी विशिष्ट विविधता के भारी अनुकूलन की आवश्यकता है, तो इसे आसानी से पुनर्गठित किया जा सकता है। डिफ़ॉल्ट `checkbox` है।",
checkbox: {
label: "`checkbox` विविधता",
helper: "इसका उपयोग एकल चालू/बंद इनपुट के लिए किया जा सकता है।",
},
radio: {
label: "`radio` विविधता",
helper: "जब आपके पास कई विकल्प हों तो इसका उपयोग करें।",
},
switch: {
label: "`switch` विविधता",
helper: "एक अधिक प्रमुख चालू/बंद इनपुट। बेहतर पहुँच समर्थन है।",
},
},
statuses: {
name: "स्थितियाँ",
description:
"एक स्थिति प्रॉप है - अन्य कंपोनेंट्स में `preset` के समान, लेकिन कंपोनेंट की कार्यक्षमता को भी प्रभावित करता है।",
noStatus: "कोई स्थिति नहीं - यह डिफ़ॉल्ट है",
errorStatus: "त्रुटि स्थिति - जब कोई त्रुटि हो तो उपयोग करें",
disabledStatus: "अक्षम स्थिति - संपादन को अक्षम करता है और इनपुट को मंद करता है",
},
passingContent: {
name: "सामग्री पास करना",
description: "सामग्री पास करने के कई तरीके हैं।",
useCase: {
checkBox: {
label: "`labelTx` प्रॉप के माध्यम से",
helper: "`helperTx` प्रॉप के माध्यम से।",
},
checkBoxMultiLine: {
helper:
"मल्टीलाइन का समर्थन करता है - Nulla proident consectetur labore sunt ea labore. ",
},
radioChangeSides: {
helper: "आप पक्ष बदल सकते हैं - Laborum labore adipisicing in eu ipsum deserunt.",
},
customCheckBox: {
label: "एक कस्टम चेकबॉक्स आइकन पास करें।",
},
switch: {
label: "स्विच को टेक्स्ट के रूप में पढ़ा जा सकता है",
helper:
"डिफ़ॉल्ट रूप से, यह विकल्प `Text` का उपयोग नहीं करता है क्योंकि फ़ॉन्ट के आधार पर, चालू/बंद अक्षर अजीब दिख सकते हैं। आवश्यकतानुसार अनुकूलित करें।",
},
switchAid: {
label: "या एक आइकन की मदद से",
},
},
},
styling: {
name: "स्टाइलिंग",
description: "कंपोनेंट को आसानी से स्टाइल किया जा सकता है।",
outerWrapper: "1 - इनपुट के बाहरी रैपर को स्टाइल करें",
innerWrapper: "2 - इनपुट के आंतरिक रैपर को स्टाइल करें",
inputDetail: "3 - इनपुट विवरण को स्टाइल करें",
labelTx: "आप labelTx को भी स्टाइल कर सकते हैं",
styleContainer: "या, पूरे कंटेनर को स्टाइल करें",
},
},
},
demoButton: {
description:
"एक कंपोनेंट जो उपयोगकर्ताओं को कार्रवाई करने और विकल्प चुनने की अनुमति देता है। Text कंपोनेंट को Pressable कंपोनेंट के साथ लपेटता है।",
useCase: {
presets: {
name: "प्रीसेट",
description: "कुछ पूर्व-कॉन्फ़िगर किए गए प्रीसेट हैं।",
},
passingContent: {
name: "सामग्री पास करना",
description: "सामग्री पास करने के कई तरीके हैं।",
viaTextProps: "`text` प्रॉप के माध्यम से - Billum In",
children: "चिल्ड्रन - Irure Reprehenderit",
rightAccessory: "दायां एक्सेसरी - Duis Quis",
leftAccessory: "बायां एक्सेसरी - Duis Proident",
nestedChildren: "नेस्टेड चिल्ड्रन - proident veniam.",
nestedChildren2: "Ullamco cupidatat officia exercitation velit non ullamco nisi..",
nestedChildren3: "Occaecat aliqua irure proident veniam.",
multiLine:
"मल्टीलाइन - consequat veniam veniam reprehenderit. Fugiat id nisi quis duis sunt proident mollit dolor mollit adipisicing proident deserunt.",
},
styling: {
name: "स्टाइलिंग",
description: "कंपोनेंट को आसानी से स्टाइल किया जा सकता है।",
styleContainer: "कंटेनर स्टाइल - Exercitation",
styleText: "टेक्स्ट स्टाइल - Ea Anim",
styleAccessories: "एक्सेसरीज़ स्टाइल - enim ea id fugiat anim ad.",
pressedState: "दबाए गए स्थिति का स्टाइल - fugiat anim",
},
disabling: {
name: "अक्षम करना",
description:
"कंपोनेंट को अक्षम किया जा सकता है और उसके आधार पर स्टाइल किया जा सकता है। दबाने का व्यवहार अक्षम हो जाएगा।",
standard: "अक्षम - मानक",
filled: "अक्षम - भरा हुआ",
reversed: "अक्षम - उलटा",
accessory: "अक्षम एक्सेसरी स्टाइल",
textStyle: "अक्षम टेक्स्ट स्टाइल",
},
},
},
demoListItem: {
description:
"एक स्टाइल किया गया पंक्ति कंपोनेंट जो FlatList, SectionList, या अकेले उपयोग किया जा सकता है।",
useCase: {
height: {
name: "ऊँचाई",
description: "पंक्ति की विभिन्न ऊँचाइयाँ हो सकती हैं।",
defaultHeight: "डिफ़ॉल्ट ऊँचाई (56px)",
customHeight: "`height` प्रॉप के माध्यम से कस्टम ऊँचाई",
textHeight:
"टेक्स्ट सामग्री द्वारा निर्धारित ऊँचाई - Reprehenderit incididunt deserunt do do ea labore.",
longText:
"लंबे टेक्स्ट को एक पंक्ति तक सीमित करें - Reprehenderit incididunt deserunt do do ea labore.",
},
separators: {
name: "विभाजक",
description: "विभाजक / डिवाइडर पूर्व-कॉन्फ़िगर किया गया है और वैकल्पिक है।",
topSeparator: "केवल ऊपरी विभाजक",
topAndBottomSeparator: "ऊपरी और निचले विभाजक",
bottomSeparator: "केवल निचला विभाजक",
},
icons: {
name: "आइकन",
description: "आप बाएँ या दाएँ आइकन को कस्टमाइज़ कर सकते हैं।",
leftIcon: "बायाँ आइकन",
rightIcon: "दायाँ आइकन",
leftRightIcons: "बाएँ और दाएँ आइकन",
},
customLeftRight: {
name: "कस्टम बायाँ/दायाँ कंपोनेंट",
description:
"यदि आपको कस्टम बायाँ/दायाँ कंपोनेंट की आवश्यकता है, तो आप इसे पास कर सकते हैं।",
customLeft: "कस्टम बायाँ कंपोनेंट",
customRight: "कस्टम दायाँ कंपोनेंट",
},
passingContent: {
name: "सामग्री पास करना",
description: "सामग्री पास करने के कई तरीके हैं।",
text: "`text` प्रॉप के माध्यम से - reprehenderit sint",
children: "चिल्ड्रन - mostrud mollit",
nestedChildren1: "नेस्टेड चिल्ड्रन - proident veniam.",
nestedChildren2: "Ullamco cupidatat officia exercitation velit non ullamco nisi..",
},
listIntegration: {
name: "FlatList के साथ एकीकरण",
description:
"कंपोनेंट को आसानी से आपके पसंदीदा सूची इंटरफेस के साथ एकीकृत किया जा सकता है।",
},
styling: {
name: "स्टाइलिंग",
description: "कंपोनेंट को आसानी से स्टाइल किया जा सकता है।",
styledText: "स्टाइल किया गया टेक्स्ट",
styledContainer: "स्टाइल किया गया कंटेनर (विभाजक)",
tintedIcons: "रंगीन आइकन",
},
},
},
demoCard: {
description:
"कार्ड संबंधित जानकारी को एक संयमित तरीके से प्रदर्शित करने के लिए उपयोगी हैं। यदि एक ListItem सामग्री को क्षैतिज रूप से प्रदर्शित करता है, तो एक कार्ड का उपयोग सामग्री को लंबवत रूप से प्रदर्शित करने के लिए किया जा सकता है।",
useCase: {
presets: {
name: "प्रीसेट",
description: "कुछ पूर्व-कॉन्फ़िगर किए गए प्रीसेट हैं।",
default: {
heading: "डिफ़ॉल्ट प्रीसेट (डिफ़ॉल्ट)",
content: "Incididunt magna ut aliquip consectetur mollit dolor.",
footer: "Consectetur nulla non aliquip velit.",
},
reversed: {
heading: "रिवर्स्ड प्रीसेट",
content: "Reprehenderit occaecat proident amet id laboris.",
footer: "Consectetur tempor ea non labore anim .",
},
},
verticalAlignment: {
name: "ऊर्ध्वाधर संरेखण",
description:
"आवश्यकता के अनुसार, कार्ड विभिन्न संरेखण रणनीतियों के साथ पूर्व-कॉन्फ़िगर किया गया है।",
top: {
heading: "शीर्ष (डिफ़ॉल्ट)",
content: "सभी सामग्री स्वचालित रूप से शीर्ष पर संरेखित होती है।",
footer: "यहां तक कि फुटर भी",
},
center: {
heading: "मध्य",
content: "सामग्री कार्ड की ऊंचाई के सापेक्ष केंद्रित होती है।",
footer: "मैं भी!",
},
spaceBetween: {
heading: "स्पेस बिटवीन",
content: "सभी सामग्री समान रूप से फैली हुई है।",
footer: "मैं वहां हूं जहां मैं होना चाहता हूं।",
},
reversed: {
heading: "फुटर को नीचे रखें",
content: "यह फुटर को उसके सही स्थान पर धकेलता है।",
footer: "मैं यहां नीचे बहुत अकेला हूं।",
},
},
passingContent: {
name: "सामग्री पास करना",
description: "सामग्री पास करने के कई तरीके हैं।",
heading: "`heading` प्रॉप के माध्यम से",
content: "`content` प्रॉप के माध्यम से",
footer: "मैं यहां नीचे बहुत अकेला हूं।",
},
customComponent: {
name: "कस्टम कंपोनेंट्स",
description:
"किसी भी पूर्व-कॉन्फ़िगर किए गए कंपोनेंट को आपके अपने कंपोनेंट से बदला जा सकता है। आप अतिरिक्त कंपोनेंट भी जोड़ सकते हैं।",
rightComponent: "दायाँ कंपोनेंट",
leftComponent: "बायाँ कंपोनेंट",
},
style: {
name: "स्टाइलिंग",
description: "कंपोनेंट को आसानी से स्टाइल किया जा सकता है।",
heading: "शीर्षक को स्टाइल करें",
content: "सामग्री को स्टाइल करें",
footer: "फुटर को स्टाइल करें",
},
},
},
demoAutoImage: {
description:
"एक छवि कंपोनेंट जो स्वचालित रूप से रिमोट या डेटा-यूआरआई छवि का आकार निर्धारित करता है।",
useCase: {
remoteUri: { name: "रिमोट यूआरआई" },
base64Uri: { name: "बेस64 यूआरआई" },
scaledToFitDimensions: {
name: "आयामों के अनुरूप स्केल किया गया",
description:
"`maxWidth` और/या `maxHeight` प्रॉप्स प्रदान करने पर, छवि स्वचालित रूप से अपने आस्पेक्ट अनुपात को बनाए रखते हुए स्केल होगी। यह `resizeMode: 'contain'` से कैसे अलग है? पहला, आप केवल एक तरफ का आकार निर्दिष्ट कर सकते हैं (दोनों नहीं)। दूसरा, छवि वांछित आयामों के अनुरूप स्केल होगी, न कि केवल अपने छवि-कंटेनर के भीतर समाहित होगी।",
heightAuto: "चौड़ाई: 60 / ऊंचाई: स्वचालित",
widthAuto: "चौड़ाई: स्वचालित / ऊंचाई: 32",
bothManual: "चौड़ाई: 60 / ऊंचाई: 60",
},
},
},
demoText: {
description:
"आपकी टेक्स्ट प्रदर्शन आवश्यकताओं के लिए। यह कंपोनेंट अंतर्निहित React Native कंपोनेंट पर एक HOC है।",
useCase: {
presets: {
name: "प्रीसेट",
description: "कुछ पूर्व-कॉन्फ़िगर किए गए प्रीसेट हैं।",
default:
"डिफ़ॉल्ट प्रीसेट - Cillum eu laboris in labore. Excepteur mollit tempor reprehenderit fugiat elit et eu consequat laborum.",
bold: "बोल्ड प्रीसेट - Tempor et ullamco cupidatat in officia. Nulla ea duis elit id sunt ipsum cillum duis deserunt nostrud ut nostrud id.",
subheading: "सबहेडिंग प्रीसेट - In Cupidatat Cillum.",
heading: "हेडिंग प्रीसेट - Voluptate Adipis.",
},
sizes: {
name: "आकार",
description: "एक आकार प्रॉप है।",
xs: "xs - Ea ipsum est ea ex sunt.",
sm: "sm - Lorem sunt adipisicin.",
md: "md - Consequat id do lorem.",
lg: "lg - Nostrud ipsum ea.",
xl: "xl - Eiusmod ex excepteur.",
xxl: "xxl - Cillum eu laboris.",
},
weights: {
name: "वजन",
description: "एक वजन प्रॉप है।",
light:
"लाइट - Nulla magna incididunt excepteur est occaecat duis culpa dolore cupidatat enim et.",
normal:
"सामान्य - Magna incididunt dolor ut veniam veniam laboris aliqua velit ea incididunt.",
medium: "मध्यम - Non duis laborum quis laboris occaecat culpa cillum.",
semibold: "सेमीबोल्ड - Exercitation magna nostrud pariatur laborum occaecat aliqua.",
bold: "बोल्ड - Eiusmod ullamco magna exercitation est excepteur.",
},
passingContent: {
name: "सामग्री पास करना",
description: "सामग्री पास करने के कई तरीके हैं।",
viaText:
"`text` प्रॉप के माध्यम से - Billum in aute fugiat proident nisi pariatur est. Cupidatat anim cillum eiusmod ad. Officia eu magna aliquip labore dolore consequat.",
viaTx: "`tx` प्रॉप के माध्यम से -",
children: "चिल्ड्रन - Aliqua velit irure reprehenderit eu qui amet veniam consectetur.",
nestedChildren: "नेस्टेड चिल्ड्रन -",
nestedChildren2: "Occaecat aliqua irure proident veniam.",
nestedChildren3: "Ullamco cupidatat officia exercitation velit non ullamco nisi..",
nestedChildren4: "Occaecat aliqua irure proident veniam.",
},
styling: {
name: "स्टाइलिंग",
description: "कंपोनेंट को आसानी से स्टाइल किया जा सकता है।",
text: "Consequat ullamco veniam velit mollit proident excepteur aliquip id culpa ipsum velit sint nostrud.",
text2:
"Eiusmod occaecat laboris eu ex veniam ipsum adipisicing consectetur. Magna ullamco adipisicing tempor adipisicing.",
text3:
"Eiusmod occaecat laboris eu ex veniam ipsum adipisicing consectetur. Magna ullamco adipisicing tempor adipisicing.",
},
},
},
demoHeader: {
description:
"कई स्क्रीन पर दिखाई देने वाला कंपोनेंट। यह नेविगेशन बटन और स्क्रीन शीर्षक धारण करेगा।",
useCase: {
actionIcons: {
name: "एक्शन आइकन",
description: "आप आसानी से बाएँ या दाएँ एक्शन कंपोनेंट्स में आइकन पास कर सकते हैं।",
leftIconTitle: "बायाँ आइकन",
rightIconTitle: "दायाँ आइकन",
bothIconsTitle: "दोनों आइकन",
},
actionText: {
name: "एक्शन टेक्स्ट",
description: "आप आसानी से बाएँ या दाएँ एक्शन कंपोनेंट्स में टेक्स्ट पास कर सकते हैं।",
leftTxTitle: "`leftTx` के माध्यम से",
rightTextTitle: "`rightText` के माध्यम से",
},
customActionComponents: {
name: "कस्टम एक्शन कंपोनेंट्स",
description:
"यदि आइकन या टेक्स्ट विकल्प पर्याप्त नहीं हैं, तो आप अपना खुद का कस्टम एक्शन कंपोनेंट पास कर सकते हैं।",
customLeftActionTitle: "कस्टम बायाँ एक्शन",
},
titleModes: {
name: "शीर्षक मोड",
description:
"शीर्षक को मध्य में रहने के लिए मजबूर किया जा सकता है (डिफ़ॉल्ट) लेकिन यदि यह बहुत लंबा है तो काटा जा सकता है। वैकल्पिक रूप से आप इसे एक्शन बटनों के अनुसार समायोजित कर सकते हैं।",
centeredTitle: "केंद्रित शीर्षक",
flexTitle: "फ्लेक्स शीर्षक",
},
styling: {
name: "स्टाइलिंग",
description: "कंपोनेंट को आसानी से स्टाइल किया जा सकता है।",
styledTitle: "स्टाइल किया गया शीर्षक",
styledWrapperTitle: "स्टाइल किया गया रैपर",
tintedIconsTitle: "रंगीन आइकन",
},
},
},
demoEmptyState: {
description:
"जब प्रदर्शित करने के लिए कोई डेटा नहीं है तो उपयोग करने के लिए एक कंपोनेंट। इसका उपयोग उपयोगकर्ता को अगला क्या करना है, यह निर्देशित करने के लिए किया जा सकता है।",
useCase: {
presets: {
name: "प्रीसेट",
description:
"आप विभिन्न टेक्स्ट/छवि सेट बना सकते हैं। एक पूर्व-परिभाषित है जिसे `generic` कहा जाता है। ध्यान दें, कोई डिफ़ॉल्ट नहीं है यदि आप पूरी तरह से कस्टम EmptyState चाहते हैं।",
},
passingContent: {
name: "सामग्री पास करना",
description: "सामग्री पास करने के कई तरीके हैं।",
customizeImageHeading: "छवि को अनुकूलित करें",
customizeImageContent: "आप कोई भी छवि स्रोत पास कर सकते हैं।",
viaHeadingProp: "`heading` प्रॉप के माध्यम से",
viaContentProp: "`content` प्रॉप के माध्यम से।",
viaButtonProp: "`button` प्रॉप के माध्यम से",
},
styling: {
name: "स्टाइलिंग",
description: "कंपोनेंट को आसानी से स्टाइल किया जा सकता है।",
},
},
},
}
export default demoHi

View File

@@ -0,0 +1,462 @@
import { DemoTranslations } from "./demo-en"
export const demoJa: DemoTranslations = {
demoIcon: {
description:
"あらかじめ登録されたアイコンを描画するコンポーネントです。 `onPress` が提供されている場合は <TouchableOpacity /> にラップされますが、それ以外の場合は <View /> にラップされます。",
useCase: {
icons: {
name: "アイコン",
description: "登録されたアイコンのリストです。",
},
size: {
name: "サイズ",
description: "sizeのpropsです。",
},
color: {
name: "カラー",
description: "colorのpropsです。",
},
styling: {
name: "スタイリング",
description: "このコンポーネントはスタイリングの変更ができます。",
},
},
},
demoTextField: {
description: "このコンポーネントはテキストの入力と編集ができます。",
useCase: {
statuses: {
name: "ステータス",
description:
"status - これは他コンポーネントの`preset`の似ていますが、これはコンポーネントの機能も変えるpropsです。",
noStatus: {
label: "ステータスなし",
helper: "デフォルトのステータスです",
placeholder: "テキストが入力されます",
},
error: {
label: "エラーステータス",
helper: "エラーが発生した場合に使用されるステータスです",
placeholder: "ここにテキストが入力されます",
},
disabled: {
label: "無効(disabled)ステータス",
helper: "編集不可となるステータスです",
placeholder: "ここにテキストが入力されます",
},
},
passingContent: {
name: "コンテントを渡す",
description: "コンテントを渡す方法はいくつかあります。",
viaLabel: {
labelTx: "`label` から",
helper: "`helper` から",
placeholder: "`placeholder` から",
},
rightAccessory: {
label: "右側にアクセサリー",
helper: "このpropsはReact要素を返す関数をうけとります。",
},
leftAccessory: {
label: "左側にアクセサリー",
helper: "このpropsはReact要素を返す関数をうけとります。",
},
supportsMultiline: {
label: "複数行サポート",
helper: "複数行の入力が出来るようになります。",
},
},
styling: {
name: "スタイリング",
description: "このコンポーネントはスタイリングの変更ができます。",
styleInput: {
label: "インプットのスタイル",
helper: "`style`から",
},
styleInputWrapper: {
label: "インプットラッパーのスタイル",
helper: "`inputWrapperStyle`から",
},
styleContainer: {
label: "スタイルコンテナのスタイル",
helper: "`containerStyle`から",
},
styleLabel: {
label: "ラベルとヘルパーのスタイル",
helper: "`LabelTextProps` & `HelperTextProps`から",
},
styleAccessories: {
label: "アクセサリーのスタイル",
helper: "`RightAccessory` & `LeftAccessory`から",
},
},
},
},
demoToggle: {
description:
"ブーリアンの入力を表示するコンポーネントです。コンポーネントはvalueの値を使用して描画するので、onValueChangeコールバックを使って値を変更し、valueを更新する必要があります。valueの値が変更されていない場合は、描画が更新されません。",
useCase: {
variants: {
name: "バリエーション",
description:
"このコンポーネントは数種類のバリエーションをサポートしています。もしカスタマイズが必要な場合、これらのバリエーションをリファクタリングできます。デフォルトは`checkbox`です。",
checkbox: {
label: "`checkbox`バリエーション",
helper: "シンプルなon/offのインプットに使えます。",
},
radio: {
label: "`radio`バリエーション",
helper: "数個のオプションがある場合に使えます。",
},
switch: {
label: "`switch`バリエーション",
helper:
"代表的なon/offのインプットです。他と比べアクセシビリティのサポートが充実しています。",
},
},
statuses: {
name: "ステータス",
description:
"status - これは他コンポーネントの`preset`の似ていますが、これはコンポーネントの機能も変えるpropsです。",
noStatus: "ステータスなし - デフォルトです。",
errorStatus: "エラー - エラーがある際に使えるステータスです。",
disabledStatus: "無効(disabled) - 編集不可となるステータスです",
},
passingContent: {
name: "コンテントを渡す",
description: "コンテントを渡す方法はいくつかあります。",
useCase: {
checkBox: {
label: "`labelTx`から",
helper: "`helperTx`から",
},
checkBoxMultiLine: {
helper: "複数行サポート - Nulla proident consectetur labore sunt ea labore. ",
},
radioChangeSides: {
helper: "左右に変更 - Laborum labore adipisicing in eu ipsum deserunt.",
},
customCheckBox: {
label: "カスタムアイコンも渡せます",
},
switch: {
label: "スイッチはテキストとして読むこともできます。",
helper:
"デフォルトでは、このオプションはフォントの影響を受け、見た目が見苦しくなる可能性がある為`Text`コンポーネントを使用していません。必要に応じてカスタマイズしてください。",
},
switchAid: {
label: "または補助アイコンもつけられます",
},
},
},
styling: {
name: "スタイリング",
description: "このコンポーネントはスタイリングの変更ができます。",
outerWrapper: "1 - インプットの外側のラッパー",
innerWrapper: "2 - インプットの内側のラッパー",
inputDetail: "3 - インプットのそのもの",
labelTx: "ラベルのスタイルも変更できます。",
styleContainer: "もしくは、コンポーネントのコンテナ全体をスタイルすることもできます。",
},
},
},
demoButton: {
description:
"ユーザーにアクションや選択を促すコンポーネントです。`Text`コンポーネントを`Pressable`コンポーネントでラップしています。",
useCase: {
presets: {
name: "プリセット",
description: "数種類のプリセットが用意されています。",
},
passingContent: {
name: "コンテントを渡す",
description: "コンテントを渡す方法はいくつかあります。",
viaTextProps: "`text`から - Billum In",
children: "Childrenから - Irure Reprehenderit",
rightAccessory: "RightAccessoryから - Duis Quis",
leftAccessory: "LeftAccessoryから - Duis Proident",
nestedChildren: "ネストされたchildrenから - proident veniam.",
nestedChildren2: "Ullamco cupidatat officia exercitation velit non ullamco nisi..",
nestedChildren3: "Occaecat aliqua irure proident veniam.",
multiLine:
"Multilineから - consequat veniam veniam reprehenderit. Fugiat id nisi quis duis sunt proident mollit dolor mollit adipisicing proident deserunt.",
},
styling: {
name: "スタイリング",
description: "このコンポーネントはスタイリングの変更ができます。",
styleContainer: "コンテナのスタイル - Exercitation",
styleText: "テキストのスタイル - Ea Anim",
styleAccessories: "アクセサリーのスタイル - enim ea id fugiat anim ad.",
pressedState: "押された状態のスタイル - fugiat anim",
},
disabling: {
name: "無効化",
description:
"このコンポーネントは無効化できます。スタイルも同時に変更され、押した際の挙動も無効化されます。",
standard: "無効化 - standard",
filled: "無効化 - filled",
reversed: "無効化 - reversed",
accessory: "無効化されたアクセサリーのスタイル",
textStyle: "無効化されたテキストのスタイル",
},
},
},
demoListItem: {
description:
"スタイルを指定されたリストの行のコンポーネントです。FlatListやSectionListなどのコンポーネントを使用することもできますし、単体でも使用できます。",
useCase: {
height: {
name: "高さ",
description: "高さの指定ができます。",
defaultHeight: "デフォルトの高さ (56px)",
customHeight: "`height`を使ったカスタムの高さ",
textHeight:
"テキストによって決まった高さ - Reprehenderit incididunt deserunt do do ea labore.",
longText: "テキストを1行に制限する- Reprehenderit incididunt deserunt do do ea labore.",
},
separators: {
name: "セパレーター",
description: "セパレーター/ディバイダーは用意されてるかつ任意です。",
topSeparator: "トップセパレーターのみ",
topAndBottomSeparator: "トップとボトムのセパレーター",
bottomSeparator: "ボトムのセパレーター",
},
icons: {
name: "アイコン",
description: "右または左のアイコンをカスタマイズすることができます。",
leftIcon: "左のアイコン",
rightIcon: "右のアイコン",
leftRightIcons: "左右のアイコン",
},
customLeftRight: {
name: "左右のコンポーネントのカスタマイズ",
description: "左右のコンポーネントをカスタマイズすることができます。",
customLeft: "カスタムされた左コンポーネント",
customRight: "カスタムされた右コンポーネント",
},
passingContent: {
name: "コンテントを渡す",
description: "コンテントを渡す方法はいくつかあります。",
text: "`text`から - reprehenderit sint",
children: "Childrenから - mostrud mollit",
nestedChildren1: "ネストされたchildrenから - proident veniam.",
nestedChildren2: "Ullamco cupidatat officia exercitation velit non ullamco nisi..",
},
listIntegration: {
name: "FlatList に組みこむ場合",
description:
"このコンポーネントはお好みのリスト系のコンポーネントへ容易に組み込むことができます。",
},
styling: {
name: "スタイリング",
description: "このコンポーネントはスタイリングの変更ができます。",
styledText: "スタイルされたテキスト",
styledContainer: "スタイルされたコンテナ(セパレーター)",
tintedIcons: "アイコンに色をつける",
},
},
},
demoCard: {
description:
"カードは関連する情報同士をまとめるのに役立ちます。ListItemが横に情報を表示するのに使え、こちらは縦に表示するのに使えます。",
useCase: {
presets: {
name: "プリセット",
description: "数種類のプリセットが用意されています。",
default: {
heading: "デフォルトのプリセット",
content: "Incididunt magna ut aliquip consectetur mollit dolor.",
footer: "Consectetur nulla non aliquip velit.",
},
reversed: {
heading: "リバースのプリセット",
content: "Reprehenderit occaecat proident amet id laboris.",
footer: "Consectetur tempor ea non labore anim .",
},
},
verticalAlignment: {
name: "縦の位置調整",
description: "カードは用意されたプリセットを使っての縦位置調整ができます。",
top: {
heading: "Top(デフォルト)",
content: "全てのコンテンツは自動的に上に配置されます。",
footer: "Footerも同じように上に配置されます。",
},
center: {
heading: "センター",
content: "全てのコンテンツはカードの高さから見て中央に配置されます。",
footer: "Footerである私も!",
},
spaceBetween: {
heading: "Space Between",
content: "全てのコンテンツは均等に分配されます。",
footer: "Footerの私はここが一番落ち着くね",
},
reversed: {
heading: "Footerのみを下に配置する",
content: "その名の通り、Footerのみを下に配置することができます。",
footer: "Footerは一人で寂しい",
},
},
passingContent: {
name: "コンテントを渡す",
description: "コンテントを渡す方法はいくつかあります。",
heading: "`heading`から",
content: "`content`から",
footer: "`footer`から",
},
customComponent: {
name: "カスタムコンポーネント",
description:
"全てのプリセットはカスタムコンポーネントを使って拡張/変更することができます。",
rightComponent: "右コンポーネント",
leftComponent: "左コンポーネント",
},
style: {
name: "スタイリング",
description: "このコンポーネントはスタイリングの変更ができます。",
heading: "ヘディングのスタイル",
content: "コンテントのスタイル",
footer: "フッターのスタイル",
},
},
},
demoAutoImage: {
description: "リモートまたはデータURIによって自動的にサイズを変更する画像コンポーネントです。",
useCase: {
remoteUri: { name: "リモート URI" },
base64Uri: { name: "Base64 URI" },
scaledToFitDimensions: {
name: "ディメンションにフィットするように拡大する",
description:
"`maxWidth` と/または `maxHeight`を指定することで、アスペクト比を維持したままサイズを変更することができます。`resizeMode: 'contain'`との違いとしては: \n1. 一方のサイズの指定でも良い(両方の指定の必要がない)。 \n2. 画像のコンテナに押し込められるのではなく、画像のディメンションを保ったまま指定したサイズに拡大、縮小を行うことができます。",
heightAuto: "width: 60 / height: auto",
widthAuto: "width: auto / height: 32",
bothManual: "width: 60 / height: 60",
},
},
},
demoText: {
description:
"テキストを表示する為のコンポーネントです。これはReact NativeのTextコンポーネントを内包する高階コンポーネント(Higher Order Component)です。",
useCase: {
presets: {
name: "プリセット",
description: "数種類のプリセットが用意されています。",
default:
"デフォルトのプリセット - Cillum eu laboris in labore. Excepteur mollit tempor reprehenderit fugiat elit et eu consequat laborum.",
bold: "ボールドのプリセット - Tempor et ullamco cupidatat in officia. Nulla ea duis elit id sunt ipsum cillum duis deserunt nostrud ut nostrud id.",
subheading: "サブヘディングのプリセット - In Cupidatat Cillum.",
heading: "ヘディングのプリセット - Voluptate Adipis.",
},
sizes: {
name: "サイズ",
description: "サイズ用のpropsです.",
xs: "xs - Ea ipsum est ea ex sunt.",
sm: "sm - Lorem sunt adipisicin.",
md: "md - Consequat id do lorem.",
lg: "lg - Nostrud ipsum ea.",
xl: "xl - Eiusmod ex excepteur.",
xxl: "xxl - Cillum eu laboris.",
},
weights: {
name: "ウエイト",
description: "ウエイト用のpropです。",
light:
"ライト - Nulla magna incididunt excepteur est occaecat duis culpa dolore cupidatat enim et.",
normal:
"ノーマル - Magna incididunt dolor ut veniam veniam laboris aliqua velit ea incididunt.",
medium: "ミディアム - Non duis laborum quis laboris occaecat culpa cillum.",
semibold: "セミボールド - Exercitation magna nostrud pariatur laborum occaecat aliqua.",
bold: "ボールド - Eiusmod ullamco magna exercitation est excepteur.",
},
passingContent: {
name: "コンテントを渡す",
description: "コンテントを渡す方法はいくつかあります。",
viaText:
"`text`から - Billum in aute fugiat proident nisi pariatur est. Cupidatat anim cillum eiusmod ad. Officia eu magna aliquip labore dolore consequat.",
viaTx: "`tx`から -",
children: "childrenから - Aliqua velit irure reprehenderit eu qui amet veniam consectetur.",
nestedChildren: "ネストされたchildrenから -",
nestedChildren2: "Occaecat aliqua irure proident veniam.",
nestedChildren3: "Ullamco cupidatat officia exercitation velit non ullamco nisi..",
nestedChildren4: "Occaecat aliqua irure proident veniam.",
},
styling: {
name: "スタイリング",
description: "このコンポーネントはスタイリングの変更ができます。",
text: "Consequat ullamco veniam velit mollit proident excepteur aliquip id culpa ipsum velit sint nostrud.",
text2:
"Eiusmod occaecat laboris eu ex veniam ipsum adipisicing consectetur. Magna ullamco adipisicing tempor adipisicing.",
text3:
"Eiusmod occaecat laboris eu ex veniam ipsum adipisicing consectetur. Magna ullamco adipisicing tempor adipisicing.",
},
},
},
demoHeader: {
description:
"様々なスクリーンで登場するコンポーネントです。ナビゲーションのボタンとスクリーンタイトルを含みます。",
useCase: {
actionIcons: {
name: "アクションアイコン",
description: "左右にアイコンを表示させることができます。",
leftIconTitle: "左アイコン",
rightIconTitle: "右アイコン",
bothIconsTitle: "両方のアイコン",
},
actionText: {
name: "アクションテキスト",
description: "左右にテキストを表示させることができます。",
leftTxTitle: "`leftTx`から",
rightTextTitle: "`rightText`から",
},
customActionComponents: {
name: "カスタムアクションコンポーネント",
description:
"アイコンまたはテキスト以外のものが必要な場合は、カスタムのアクションコンポーネントを渡すことができます。",
customLeftActionTitle: "カスタムの左アクション",
},
titleModes: {
name: "タイトルモード",
description:
"タイトルはデフォルトで中央に配置されますが、長すぎるとカットされてしまいます。Flexを使うことでアクションボタンから自動的にポジションを調整することもできます。",
centeredTitle: "Centered Title",
flexTitle: "Flex Title",
},
styling: {
name: "スタイリング",
description: "このコンポーネントはスタイリングの変更ができます。",
styledTitle: "スタイルされたタイトル",
styledWrapperTitle: "スタイルされたラッパー",
tintedIconsTitle: "色付けされたアイコン",
},
},
},
demoEmptyState: {
description:
"表示する為のデータが存在しない場合に使えるコンポーネントです。ユーザーに取るべきアクションをお勧めする際に有用です。",
useCase: {
presets: {
name: "プリセット",
description:
"text/imageのセットを使ってカスタマイズすることができます。これは`generic`のものです。カスタマイズが必要になることを想定して、このコンポーネントにデフォルトのプリセットは存在しません。",
},
passingContent: {
name: "コンテントを渡す",
description: "コンテントを渡す方法はいくつかあります。",
customizeImageHeading: "画像をカスタマイズ",
customizeImageContent: "画像のソースを渡すことができます。",
viaHeadingProp: "`heading`から",
viaContentProp: "`content`から",
viaButtonProp: "`button`から",
},
styling: {
name: "スタイリング",
description: "このコンポーネントはスタイリングの変更ができます。",
},
},
},
}
export default demoJa

View File

@@ -0,0 +1,455 @@
import { DemoTranslations } from "./demo-en"
export const demoKo: DemoTranslations = {
demoIcon: {
description:
"등록된 아이콘을 렌더링하는 컴포넌트입니다. `onPress`가 구현되어 있으면 <TouchableOpacity />로, 그렇지 않으면 <View />로 감쌉니다.",
useCase: {
icons: {
name: "아이콘",
description: "컴포넌트에 등록된 아이콘 목록입니다.",
},
size: {
name: "크기",
description: "크기 속성이 있습니다.",
},
color: {
name: "색상",
description: "색상 속성이 있습니다.",
},
styling: {
name: "스타일링",
description: "컴포넌트는 쉽게 스타일링할 수 있습니다.",
},
},
},
demoTextField: {
description: "TextField 컴포넌트는 텍스트 입력 및 편집을 허용합니다.",
useCase: {
statuses: {
name: "상태",
description:
"다른 컴포넌트의 `preset`과 유사한 상태 속성이 있으며, 컴포넌트의 기능에도 영향을 미칩니다.",
noStatus: {
label: "상태 없음",
helper: "이것이 기본 상태입니다",
placeholder: "텍스트가 여기에 들어갑니다",
},
error: {
label: "오류 상태",
helper: "오류가 있을 때 사용하는 상태입니다",
placeholder: "텍스트가 여기에 들어갑니다",
},
disabled: {
label: "비활성 상태",
helper: "편집 기능을 비활성화하고 텍스트를 표시하지 않습니다",
placeholder: "텍스트가 여기에 들어갑니다",
},
},
passingContent: {
name: "내용 전달",
description: "내용을 전달하는 몇 가지 방법이 있습니다.",
viaLabel: {
labelTx: "`label` 속성으로",
helper: "`helper` 속성으로",
placeholder: "`placeholder` 속성으로",
},
rightAccessory: {
label: "오른쪽 액세서리",
helper: "이 속성은 React 요소를 반환하는 함수를 받습니다.",
},
leftAccessory: {
label: "왼쪽 액세서리",
helper: "이 속성은 React 요소를 반환하는 함수를 받습니다.",
},
supportsMultiline: {
label: "멀티라인 지원",
helper: "멀티라인 텍스트를 위한 더 높은 입력을 활성화합니다.",
},
},
styling: {
name: "스타일링",
description: "컴포넌트는 쉽게 스타일링할 수 있습니다.",
styleInput: {
label: "입력 스타일",
helper: "`style` 속성으로",
},
styleInputWrapper: {
label: "입력 래퍼 스타일",
helper: "`inputWrapperStyle` 속성으로",
},
styleContainer: {
label: "컨테이너 스타일",
helper: "`containerStyle` 속성으로",
},
styleLabel: {
label: "레이블 및 헬퍼 스타일",
helper: "`LabelTextProps` 및 `HelperTextProps` 스타일 속성으로",
},
styleAccessories: {
label: "액세서리 스타일",
helper: "`RightAccessory` 및 `LeftAccessory` 스타일 속성으로",
},
},
},
},
demoToggle: {
description:
"불리언 입력을 렌더링합니다. 사용자가 수행한 작업을 반영하기 위해 값 속성을 업데이트하는 onValueChange 콜백이 필요한 제어된 컴포넌트입니다. 값 속성이 업데이트되지 않으면, 컴포넌트는 사용자 작업의 예상 결과 대신 제공된 값 속성을 계속 렌더링합니다.",
useCase: {
variants: {
name: "변형",
description:
"이 컴포넌트는 몇 가지 변형을 지원합니다. 특정 변형을 대폭 커스터마이즈해야 하는 경우에는 쉽게 리팩토링할 수 있습니다. 기본값은 `체크박스`입니다.",
checkbox: {
label: "`체크박스` 변형",
helper: "단일 켜기/끄기 입력에 사용할 수 있습니다.",
},
radio: {
label: "`라디오` 변형",
helper: "여러 옵션이 있는 경우 사용하십시오.",
},
switch: {
label: "`스위치` 변형",
helper: "더 눈에 띄는 켜기/끄기 입력입니다. 접근성 지원이 더 좋습니다.",
},
},
statuses: {
name: "상태",
description:
"다른 컴포넌트의 `preset`과 유사한 상태 속성이 있으며, 컴포넌트의 기능에도 영향을 미칩니다.",
noStatus: "상태 없음 - 기본 상태",
errorStatus: "오류 상태 - 오류가 있을 때 사용",
disabledStatus: "비활성 상태 - 편집 기능을 비활성화하고 입력을 표시하지 않음",
},
passingContent: {
name: "내용 전달",
description: "내용을 전달하는 몇 가지 방법이 있습니다.",
useCase: {
checkBox: {
label: "`labelTx` 속성으로",
helper: "`helperTx` 속성으로",
},
checkBoxMultiLine: {
helper: "멀티라인 지원 - 멀티라인 지원을 위한 예제 문장입니다. 하나 둘 셋.",
},
radioChangeSides: {
helper: "양쪽을 변경할 수 있습니다 - 양쪽 변경을 위한 예제 문장입니다. 하나 둘 셋.",
},
customCheckBox: {
label: "맞춤 체크박스 아이콘 전달.",
},
switch: {
label: "스위치는 텍스트로 읽을 수 있습니다",
helper:
"기본적으로 이 옵션은 `Text`를 사용하지 않습니다. 폰트에 따라 켜기/끄기 문자가 이상하게 보일 수 있기 때문입니다. 필요에 따라 커스터마이즈하세요.",
},
switchAid: {
label: "또는 아이콘으로 보조",
},
},
},
styling: {
name: "스타일링",
description: "컴포넌트는 쉽게 스타일링할 수 있습니다.",
outerWrapper: "1 - 입력 외부 래퍼 스타일링",
innerWrapper: "2 - 입력 내부 래퍼 스타일링",
inputDetail: "3 - 입력 디테일 스타일링",
labelTx: "labelTx도 스타일링할 수 있습니다",
styleContainer: "또는 전체 컨테이너 스타일링",
},
},
},
demoButton: {
description:
"사용자가 작업을 수행하고 선택을 할 수 있도록 하는 컴포넌트입니다. Text 컴포넌트를 Pressable 컴포넌트로 감쌉니다.",
useCase: {
presets: {
name: "프리셋",
description: "사전 구성된 몇 가지 프리셋이 있습니다.",
},
passingContent: {
name: "내용 전달",
description: "내용을 전달하는 몇 가지 방법이 있습니다.",
viaTextProps: "`text` 속성으로 - 예제 문장입니다.",
children: "자식 - 또 다른 예제 문장입니다.",
rightAccessory: "오른쪽 액세서리 - 예제 문장입니다.",
leftAccessory: "왼쪽 액세서리 - 예제 문장입니다.",
nestedChildren: "중첩 자식 - 별 하나에 추억과 별 하나에 사랑과 별 하나에 쓸쓸함과",
nestedChildren2: "별 하나에 동경과 별 하나에 시와 ",
nestedChildren3: "별 하나에 어머니, 어머니.",
multiLine:
"멀티라인 - 죽는 날까지 하늘을 우러러 한 점 부끄럼이 없기를, 잎새에 이는 바람에도 나는 괴로워했다. 별을 노래하는 마음으로 모든 죽어 가는 것을 사랑해야지 그리고 나한테 주어진 길을 걸어가야겠다. 오늘 밤에도 별이 바람에 스치운다.",
},
styling: {
name: "스타일링",
description: "컴포넌트는 쉽게 스타일링할 수 있습니다.",
styleContainer: "스타일 컨테이너 - 예제 문장",
styleText: "스타일 텍스트 - 예제 문장",
styleAccessories: "스타일 액세서리 - 또 다른 예제 문장",
pressedState: "스타일 눌린 상태 - 예제 문장",
},
disabling: {
name: "비활성화",
description:
"컴포넌트는 비활성화할 수 있으며, 그에 따라 스타일링할 수 있습니다. 누르는 동작이 비활성화됩니다.",
standard: "비활성화 - 표준",
filled: "비활성화 - 채워진",
reversed: "비활성화 - 역방향",
accessory: "비활성화된 액세서리 스타일",
textStyle: "비활성화된 텍스트 스타일",
},
},
},
demoListItem: {
description: "FlatList, SectionList 또는 자체적으로 사용할 수 있는 스타일된 행 컴포넌트입니다.",
useCase: {
height: {
name: "높이",
description: "행은 다른 높이를 가질 수 있습니다.",
defaultHeight: "기본 높이 (56px)",
customHeight: "`height` 속성을 통해 사용자 정의 높이",
textHeight:
"텍스트 내용에 의해 결정된 높이 - 예제를 위한 긴 문장입니다. 하나 둘 셋. 안녕하세요.",
longText:
"긴 텍스트를 한 줄로 제한 - 이것 역시 예제를 위한 긴 문장입니다. 오늘 날씨는 어떤가요?",
},
separators: {
name: "구분선",
description: "구분선 / 디바이더가 사전 구성되어 있으며 선택 사항입니다.",
topSeparator: "상단 구분선만",
topAndBottomSeparator: "상단 및 하단 구분선",
bottomSeparator: "하단 구분선만",
},
icons: {
name: "아이콘",
description: "왼쪽 또는 오른쪽 아이콘을 사용자 정의할 수 있습니다.",
leftIcon: "왼쪽 아이콘",
rightIcon: "오른쪽 아이콘",
leftRightIcons: "왼쪽 및 오른쪽 아이콘",
},
customLeftRight: {
name: "사용자 정의 왼쪽/오른쪽 컴포넌트",
description: "필요시에는 사용자가 정의한 왼쪽/오른쪽 컴포넌트를 전달할 수 있습니다.",
customLeft: "사용자 정의 왼쪽 컴포넌트",
customRight: "사용자 정의 오른쪽 컴포넌트",
},
passingContent: {
name: "내용 전달",
description: "내용을 전달하는 몇 가지 방법이 있습니다.",
text: "`text` 속성으로 - 예제 문장입니다.",
children: "자식 - 또 다른 예제 문장입니다.",
nestedChildren1: "중첩 자식 - 이것도 예제 문장입니다..",
nestedChildren2: "또 다른 예제 문장, 중첩이 된 형태입니다.",
},
listIntegration: {
name: "FlatList 통합",
description: "이 컴포넌트는 선호하는 리스트 인터페이스와 쉽게 통합할 수 있습니다.",
},
styling: {
name: "스타일링",
description: "컴포넌트는 쉽게 스타일링할 수 있습니다.",
styledText: "스타일된 텍스트",
styledContainer: "스타일된 컨테이너 (구분선)",
tintedIcons: "색이 입혀진 아이콘",
},
},
},
demoCard: {
description:
"카드는 관련 정보를 컨테이너에 담아 표시하는 데 유용합니다. ListItem이 내용을 수평으로 표시한다면, 카드는 내용을 수직으로 표시할 수 있습니다.",
useCase: {
presets: {
name: "프리셋",
description: "사전 구성된 몇 가지 프리셋이 있습니다.",
default: {
heading: "기본 프리셋 (기본값)",
content: "예제 문장입니다. 그믐밤 반디불은 부서진 달조각",
footer: "숲으로 가자 달조각을 주으려 숲으로 가자.",
},
reversed: {
heading: "역방향 프리셋",
content: "예제 문장입니다. 그믐밤 반디불은 부서진 달조각",
footer: "숲으로 가자 달조각을 주으려 숲으로 가자.",
},
},
verticalAlignment: {
name: "수직 정렬",
description: "카드는 필요에 따라 미리 구성된 다양한 정렬방법으로 제공됩니다.",
top: {
heading: "상단 (기본값)",
content: "모든 콘텐츠가 자동으로 상단에 정렬됩니다.",
footer: "심지어 푸터도",
},
center: {
heading: "중앙",
content: "콘텐츠는 카드 높이에 상대적으로 중앙에 배치됩니다.",
footer: "나도!",
},
spaceBetween: {
heading: "공간 사이",
content: "모든 콘텐츠가 고르게 간격을 둡니다.",
footer: "나는 내가 있고 싶은 곳에 있어요.",
},
reversed: {
heading: "푸터 강제 하단",
content: "푸터를 원하는 위치에 밀어 넣습니다.",
footer: "여기 너무 외로워요.",
},
},
passingContent: {
name: "내용 전달",
description: "내용을 전달하는 몇 가지 방법이 있습니다.",
heading: "`heading` 속성으로",
content: "`content` 속성으로",
footer: "푸터도 외로워요.",
},
customComponent: {
name: "사용자 정의 컴포넌트",
description:
"사전 구성된 컴포넌트 중 하나를 직접 만든 자신의 컴포넌트로 대체할 수 있습니다. 추가 컴포넌트도 덧붙여 넣을 수 있습니다.",
rightComponent: "오른쪽 컴포넌트",
leftComponent: "왼쪽 컴포넌트",
},
style: {
name: "스타일링",
description: "컴포넌트는 쉽게 스타일링할 수 있습니다.",
heading: "헤딩 스타일링",
content: "컨텐츠 스타일링",
footer: "푸터 스타일링",
},
},
},
demoAutoImage: {
description: "원격 또는 data-uri 이미지의 크기를 자동으로 조정하는 Image 컴포넌트입니다.",
useCase: {
remoteUri: { name: "원격 URI" },
base64Uri: { name: "Base64 URI" },
scaledToFitDimensions: {
name: "치수에 맞게 조정",
description:
"`maxWidth` 단독으로, 혹은 `maxHeight` 속성과 함께 제공하면, 이미지는 비율을 유지하면서 자동으로 크기가 조정됩니다. 이것이 `resizeMode: 'contain'`과 다른 점은 무엇일까요? 첫째, 한쪽 크기만 지정할 수 있습니다. 둘째, 이미지가 이미지 컨테이너 내에 포함되는 대신 원하는 치수에 맞게 조정됩니다.",
heightAuto: "너비: 60 / 높이: 자동",
widthAuto: "너비: 자동 / 높이: 32",
bothManual: "너비: 60 / 높이: 60",
},
},
},
demoText: {
description:
"텍스트 표시가 필요한 경우를 위해, 이 컴포넌트는 기본 React Native 컴포넌트 위에 HOC로 제작되었습니다.",
useCase: {
presets: {
name: "프리셋",
description: "사전 구성된 몇 가지 프리셋이 있습니다.",
default: "기본 프리셋 - 예제 문장입니다. 하나 둘 셋.",
bold: "볼드 프리셋 - 예제 문장입니다. 하나 둘 셋.",
subheading: "서브헤딩 프리셋 - 예제 문장입니다. 하나 둘 셋.",
heading: "헤딩 프리셋 - 예제 문장입니다. 하나 둘 셋.",
},
sizes: {
name: "크기",
description: "크기 속성이 있습니다.",
xs: "xs - 조금 더 작은 크기 속성입니다.",
sm: "sm - 작은 크기 속성입니다.",
md: "md - 중간 크기 속성입니다.",
lg: "lg - 큰 크기 속성입니다.",
xl: "xl - 조금 더 큰 크기 속성입니다.",
xxl: "xxl - 아주 큰 크기 속성입니다.",
},
weights: {
name: "굵기",
description: "굵기 속성이 있습니다.",
light: "가벼움 - 예제 문장입니다. 안녕하세요. 하나 둘 셋.",
normal: "보통 - 예제 문장입니다. 안녕하세요. 하나 둘 셋.",
medium: "중간 - 예제 문장입니다. 안녕하세요. 하나 둘 셋.",
semibold: "세미볼드 - 예제 문장입니다. 안녕하세요. 하나 둘 셋.",
bold: "볼드 - 예제 문장입니다. 안녕하세요. 하나 둘 셋.",
},
passingContent: {
name: "내용 전달",
description: "내용을 전달하는 몇 가지 방법이 있습니다.",
viaText:
"`text` 속성으로 - 죽는 날까지 하늘을 우러러 한 점 부끄럼이 없기를, 잎새에 이는 바람에도 나는 괴로워했다. 별을 노래하는 마음으로 모든 죽어 가는 것을 사랑해야지 그리고 나한테 주어진 길을 걸어가야겠다. 오늘 밤에도 별이 바람에 스치운다.",
viaTx: "`tx` 속성으로",
children: "자식 - 또 다른 예제 문장입니다. 하나 둘 셋.",
nestedChildren: "중첩 자식",
nestedChildren2: "죽는 날까지 하늘을 우러러 한 점 부끄럼이 없기를, ",
nestedChildren3: "잎새에 이는 바람에도 나는 괴로워했다.",
nestedChildren4: "별을 노래하는 마음으로 모든 죽어 가는 것을 사랑해야지.",
},
styling: {
name: "스타일링",
description: "컴포넌트는 쉽게 스타일링할 수 있습니다.",
text: "그리고 나한테 주어진 길을 걸어가야겠다.",
text2: "오늘 밤에도 별이 바람에 스치운다.",
text3: "계속 이어지는 예제 문장입니다. 하나 둘 셋.",
},
},
},
demoHeader: {
description:
"여러 화면에 나타나는 컴포넌트입니다. 네비게이션 버튼과 화면 제목을 포함할 것입니다.",
useCase: {
actionIcons: {
name: "액션 아이콘",
description: "왼쪽 또는 오른쪽 액션 컴포넌트에 아이콘을 쉽게 전달할 수 있습니다.",
leftIconTitle: "왼쪽 아이콘",
rightIconTitle: "오른쪽 아이콘",
bothIconsTitle: "양쪽 아이콘",
},
actionText: {
name: "액션 텍스트",
description: "왼쪽 또는 오른쪽 액션 컴포넌트에 텍스트를 쉽게 전달할 수 있습니다.",
leftTxTitle: "`leftTx`를 통해",
rightTextTitle: "`rightText`를 통해",
},
customActionComponents: {
name: "사용자 정의 액션 컴포넌트",
description:
"아이콘이나 텍스트 옵션이 충분하지 않은 경우, 사용자 정의 액션 컴포넌트를 전달할 수 있습니다.",
customLeftActionTitle: "사용자 정의 왼쪽 액션",
},
titleModes: {
name: "제목 모드",
description:
"제목은 기본적으로 중앙에 고정되지만 너무 길면 잘릴 수 있습니다. 액션 버튼에 맞춰 조정할 수 있습니다.",
centeredTitle: "중앙 제목",
flexTitle: "유연한 제목",
},
styling: {
name: "스타일링",
description: "컴포넌트는 쉽게 스타일링할 수 있습니다.",
styledTitle: "스타일된 제목",
styledWrapperTitle: "스타일된 래퍼",
tintedIconsTitle: "색이 입혀진 아이콘",
},
},
},
demoEmptyState: {
description:
"표시할 데이터가 없을 때 사용할 수 있는 컴포넌트입니다. 사용자가 다음에 무엇을 할지 안내할 수 있습니다.",
useCase: {
presets: {
name: "프리셋",
description:
"다양한 텍스트/이미지 세트를 만들 수 있습니다. `generic`이라는 사전 정의된 세트가 하나 있습니다. 기본값이 없으므로 완전히 사용자 정의된 EmptyState를 원할 경우 사용할 수 있습니다.",
},
passingContent: {
name: "내용 전달",
description: "내용을 전달하는 몇 가지 방법이 있습니다.",
customizeImageHeading: "이미지 맞춤 설정",
customizeImageContent: "어떤 이미지 소스도 전달할 수 있습니다.",
viaHeadingProp: "`heading` 속성으로",
viaContentProp: "`content` 속성으로",
viaButtonProp: "`button` 속성으로",
},
styling: {
name: "스타일링",
description: "컴포넌트는 쉽게 스타일링할 수 있습니다.",
},
},
},
}
export default demoKo

View File

@@ -0,0 +1,442 @@
export const demoZh = {
demoIcon: {
description:
"用于渲染已注册图标的组件。如果提供了 `onPress`,则包装在 <TouchableOpacity /> 中,否则是 <View />。",
useCase: {
icons: {
name: "图标",
description: "组件内注册的图标列表。",
},
size: {
name: "尺寸",
description: "有一个 size 属性。",
},
color: {
name: "颜色",
description: "有一个 color 属性。",
},
styling: {
name: "样式",
description: "组件可以轻松设置样式。",
},
},
},
demoTextField: {
description: "TextField 组件允许输入和编辑文本。",
useCase: {
statuses: {
name: "状态",
description: "有一个 status 属性 - 与其他组件中的 `preset` 类似,但也会影响组件功能。",
noStatus: {
label: "无状态",
helper: "这是默认状态",
placeholder: "在此输入文字",
},
error: {
label: "错误状态",
helper: "出现错误时使用的状态",
placeholder: "在此输入文字",
},
disabled: {
label: "禁用状态",
helper: "禁用编辑并使文字变淡",
placeholder: "在此输入文字",
},
},
passingContent: {
name: "传递内容",
description: "有几种不同的方式来传递内容。",
viaLabel: {
labelTx: "通过 `label` 属性",
helper: "通过 `helper` 属性",
placeholder: "通过 `placeholder` 属性",
},
rightAccessory: {
label: "右侧附件",
helper: "此属性接收一个返回 React 元素的函数。",
},
leftAccessory: {
label: "左侧附件",
helper: "此属性接收一个返回 React 元素的函数。",
},
supportsMultiline: {
label: "支持多行",
helper: "为多行文本启用更高的输入框。",
},
},
styling: {
name: "样式",
description: "组件可以轻松设置样式。",
styleInput: {
label: "输入框样式",
helper: "通过 `style` 属性",
},
styleInputWrapper: {
label: "输入框包装样式",
helper: "通过 `inputWrapperStyle` 属性",
},
styleContainer: {
label: "容器样式",
helper: "通过 `containerStyle` 属性",
},
styleLabel: {
label: "标签和帮助文字样式",
helper: "通过 `LabelTextProps` 和 `HelperTextProps` style 属性",
},
styleAccessories: {
label: "附件样式",
helper: "通过 `RightAccessory` 和 `LeftAccessory` style 属性",
},
},
},
},
demoToggle: {
description:
"渲染一个布尔输入。这是一个受控组件,需要一个 onValueChange 回调来更新 value 属性,以便组件反映用户操作。",
useCase: {
variants: {
name: "变体",
description: "组件支持几种变体。",
checkbox: {
label: "复选框 - 用于多选的方形输入。",
helper: "这个可以打开/关闭。",
},
radio: {
label: "单选框 - 用于单选的圆形输入。",
helper: "这个也可以打开/关闭。",
},
switch: {
label: "开关 - 滑动切换输入。",
helper: "这个同样可以打开/关闭。",
},
},
statuses: {
name: "状态",
description: "有一个 status 属性。",
noStatus: "无状态 - 这是默认状态",
errorStatus: "错误状态 - 用于显示错误",
disabledStatus: "禁用状态 - 切换按钮禁用时",
},
passingContent: {
name: "传递内容",
description: "有几种不同的方式来传递内容。",
useCase: {
checkBox: {
label: "通过 `labelTx` 属性",
helper: "通过 `helperTx` 属性。",
},
checkBoxMultiLine: {
helper: "支持多行帮助文字。",
},
radioChangeSides: {
helper: "你可以更改位置。",
},
customCheckBox: {
label: "如果需要,将图标传递给 checkbox 属性。",
},
switch: {
label: "开关也可以读取各种 TX 属性",
helper: "通过 `helperTx` 属性",
},
switchAid: {
label: "或者自定义组件",
},
},
},
styling: {
name: "样式",
description: "组件可以轻松设置样式。",
outerWrapper: "1 - inputOuterWrapper",
innerWrapper: "2 - inputInnerWrapper",
inputDetail: "3 - inputDetail",
labelTx: "您也可以设置 labelTx 的样式",
styleContainer: "或者,设置整个容器的样式",
},
},
},
demoButton: {
description: "允许用户执行操作并做出选择的组件。包装了内置的 Pressable 组件。",
useCase: {
presets: {
name: "预设",
description: "有几种预设样式。",
},
passingContent: {
name: "传递内容",
description: "有几种不同的方式来传递内容。",
viaTextProps: "通过 `text` 属性 - 纯文本",
children: "子元素 - 嵌套组件",
rightAccessory: "右侧附件",
leftAccessory: "左侧附件",
nestedChildren: "嵌套子元素",
nestedChildren2: "使用嵌套子元素时应使用 `preset='default'`",
nestedChildren3: "支持多层嵌套子元素",
multiLine: "多行文本 - 支持长文本内容自动换行显示",
},
styling: {
name: "样式",
description: "组件可以轻松设置样式。",
styleContainer: "样式容器 - ViewStyle",
styleText: "样式文字 - TextStyle",
styleAccessories: "样式附件 - ViewStyle",
pressedState: "按下状态的样式!",
disabledState: "禁用状态的样式!",
},
disabling: {
name: "禁用",
description: "可以使用 `disabled` 属性禁用组件。",
standard: "禁用 - 标准",
filled: "禁用 - 填充",
reversed: "禁用 - 反转",
accessory: "禁用的附件样式",
textStyle: "禁用的文字样式",
},
},
},
demoListItem: {
description: "用于数据列表或菜单选择的样式化行组件。",
useCase: {
height: {
name: "高度",
description: "行可以有不同的高度。",
defaultHeight: "默认高度 (56px)",
customHeight: "自定义高度通过 `height` 属性",
textHeight: "文字超过一行时也会改变高度 - 看我现在多高!",
longText:
"通过 `topSeparator` 属性,可以添加分隔线。这对于需要顶部分隔线的首个 ListItem 很有用。",
},
separators: {
name: "分隔线",
description: "可以在顶部或底部添加分隔线。",
topSeparator: "只有顶部分隔线",
topAndBottomSeparator: "顶部和底部分隔线",
bottomSeparator: "只有底部分隔线",
},
icons: {
name: "图标",
description: "可以显示左侧或右侧的图标。",
leftIcon: "左侧图标",
rightIcon: "右侧图标",
leftRightIcons: "左右图标",
},
customLeftRight: {
name: "自定义左/右组件",
description: "如果图标不够用,可以传递自定义组件。",
customLeft: "自定义左侧组件",
customRight: "自定义右侧组件",
},
passingContent: {
name: "传递内容",
description: "有几种不同的方式来传递内容。",
text: "通过 `text` 属性 - 纯文本",
children: "子元素 - 嵌套的 Text 组件",
nestedChildren1: "第一行",
nestedChildren2: "第二行",
},
listIntegration: {
name: "与 FlatList 集成",
description: "组件可以轻松与您喜欢的列表界面集成。",
},
styling: {
name: "样式",
description: "组件可以轻松设置样式。",
styledText: "样式化的文字",
styledContainer: "样式化的容器(红色边框)",
tintedIcons: "着色的图标",
},
},
},
demoCard: {
description: "卡片在表面上以相关的、可操作的信息分组来显示内容。",
useCase: {
presets: {
name: "预设",
description: "有几种预设卡片配置。",
default: {
heading: "默认预设",
content: "这是默认预设的内容",
footer: "这是页脚",
},
reversed: {
heading: "反转预设",
content: "这是反转预设的内容",
footer: "这是页脚",
},
},
verticalAlignment: {
name: "垂直对齐",
description: "卡片可以配置为不同的对齐方式。",
top: {
heading: "顶部(默认)",
content: "所有卡片内容垂直对齐到顶部。",
footer: "包括页脚",
},
center: {
heading: "居中",
content: "所有卡片内容垂直居中对齐。",
footer: "包括页脚",
},
spaceBetween: {
heading: "两端对齐",
content: "所有卡片内容垂直两端对齐。",
footer: "包括页脚",
},
reversed: {
heading: "强制页脚底部",
content: "这是通过将 footer 放在 View 内并使用 `auto` flex 实现的。",
footer: "我在底部!",
},
},
passingContent: {
name: "传递内容",
description: "有几种不同的方式来传递内容。",
heading: "通过 `heading` 属性",
content: "通过 `content` 属性",
footer: "通过 `footer` 属性",
},
customComponent: {
name: "自定义组件",
description: "任何预配置的组件都可以替换为您自己的组件。您也可以添加其他组件。",
rightComponent: "右侧组件",
leftComponent: "左侧组件",
},
style: {
name: "样式",
description: "组件可以轻松设置样式。",
heading: "样式化的标题",
content: "样式化的内容",
footer: "样式化的页脚",
},
},
},
demoAutoImage: {
description: "根据源图像的宽高比自动调整大小的图像组件,同时可以指定最大宽度或高度约束。",
useCase: {
remoteUri: { name: "远程 URI" },
base64Uri: { name: "Base64 URI" },
scaledToFitDimensions: {
name: "缩放适应尺寸",
description:
"提供 `maxWidth` 和/或 `maxHeight` 属性,图像将自动缩放并保持宽高比。这与 `resizeMode: 'contain'` 有何不同?首先,您可以只指定一边的尺寸(不必同时指定两边)。其次,图像将缩放以适应所需尺寸,而不仅仅是被包含在其图像容器内。",
heightAuto: "宽度: 60 / 高度: 自动",
widthAuto: "宽度: 自动 / 高度: 32",
bothManual: "宽度: 60 / 高度: 60",
},
},
},
demoText: {
description: "用于显示文本的组件。此组件是对内置 React Native 组件的高阶封装。",
useCase: {
presets: {
name: "预设",
description: "有一些预配置的预设。",
default: "default 预设 - 适用于大多数情况的基本预设。",
bold: "bold 预设 - 用于粗体文本显示。",
subheading: "subheading 预设 - 用于副标题。",
heading: "heading 预设 - 用于标题。",
},
sizes: {
name: "尺寸",
description: "有一个 size 属性。",
xs: "xs - 超小尺寸。",
sm: "sm - 小尺寸。",
md: "md - 中等尺寸。",
lg: "lg - 大尺寸。",
xl: "xl - 超大尺寸。",
xxl: "xxl - 特大尺寸。",
},
weights: {
name: "字重",
description: "有一个 weight 属性。",
light: "light - 细体字重。",
normal: "normal - 常规字重。",
medium: "medium - 中等字重。",
semibold: "semiBold - 半粗字重。",
bold: "bold - 粗体字重。",
},
passingContent: {
name: "传递内容",
description: "有几种不同的方式来传递内容。",
viaText: "通过 `text` 属性 - 传递纯文本内容。",
viaTx: "通过 `tx` 属性 -",
children: "子元素 - 可以传递子组件作为内容。",
nestedChildren: "嵌套子元素 -",
nestedChildren2: "支持多级嵌套。",
nestedChildren3: "可以混合使用不同类型的子元素。",
nestedChildren4: "非常灵活的内容传递方式。",
},
styling: {
name: "样式",
description: "组件可以轻松设置样式。",
text: "可以通过样式属性自定义文本外观。",
text2: "支持多种样式组合,如颜色、大小、字重等。",
text3: "也可以通过覆盖样式来实现更复杂的效果。",
},
},
},
demoHeader: {
description: "出现在许多屏幕顶部的组件。将包含导航按钮和屏幕标题。",
useCase: {
actionIcons: {
name: "动作图标",
description: "您可以通过 `leftIcon` 或 `rightIcon` 属性轻松传递动作。",
leftIconTitle: "左侧图标",
rightIconTitle: "右侧图标",
bothIconsTitle: "两侧图标",
},
actionText: {
name: "动作文字",
description:
"您可以通过 `leftText` 或 `rightText` 属性轻松传递动作。您也可以使用 `leftTx` 或 `rightTx` 传递 Tx 翻译。",
leftTxTitle: "通过 `leftTx`",
rightTextTitle: "通过 `rightText`",
},
customActionComponents: {
name: "自定义动作组件",
description:
"如果图标或文字选项对您的用例不够灵活,您可以使用 `LeftActionComponent` 或 `RightActionComponent` 传递自己的自定义动作组件。",
customLeftActionTitle: "自定义左侧动作",
},
titleModes: {
name: "标题模式",
description: "标题可以强制保持在中心(默认)或在左动作按钮右侧弹性调整。",
centeredTitle: "居中",
flexTitle: "弹性",
},
styling: {
name: "样式",
description: "组件可以轻松设置样式。",
styledTitle: "样式化的标题",
styledWrapperTitle: "包装器样式",
tintedIconsTitle: "着色的图标",
},
},
},
demoEmptyState: {
description: "用于在没有数据显示时使用的组件。可用于指导用户下一步该做什么。",
useCase: {
presets: {
name: "预设",
description: "您可以创建不同的预设。这是默认预设。",
},
passingContent: {
name: "传递内容",
description: "有几种不同的方式来传递内容。",
customizeImageHeading: "自定义图像",
customizeImageContent: "您可以传递任何 `ImageSource` 到 image 属性。",
viaHeadingProp: "通过 `heading` 属性",
viaContentProp: "通过 `content` 属性",
viaButtonProp: "通过 `button` 属性",
},
styling: {
name: "样式",
description: "组件可以轻松设置样式。",
styledHeading: "样式化的标题",
styledContent: "样式化的内容",
styledButton: "样式化的按钮",
},
},
},
}
export default demoZh

306
RN_TEMPLATE/app/i18n/en.ts Normal file
View File

@@ -0,0 +1,306 @@
import demoEn from "./demo-en"
const en = {
common: {
ok: "OK!",
cancel: "Cancel",
save: "Save",
back: "Back",
logOut: "Log Out",
copied: "Copied",
},
welcomeScreen: {
postscript:
"psst — This probably isn't what your app looks like. (Unless your designer handed you these screens, and in that case, ship it!)",
readyForLaunch: "Your app, almost ready for launch!",
exciting: "(ohh, this is exciting!)",
letsGo: "Let's go!",
},
errorScreen: {
title: "Something went wrong!",
friendlySubtitle:
"This is the screen that your users will see in production when an error is thrown. You'll want to customize this message (located in `app/i18n/en.ts`) and probably the layout as well (`app/screens/ErrorScreen`). If you want to remove this entirely, check `app/app.tsx` for the <ErrorBoundary> component.",
reset: "RESET APP",
traceTitle: "Error from %{name} stack",
},
emptyStateComponent: {
generic: {
heading: "So empty... so sad",
content: "No data found yet. Try clicking the button to refresh or reload the app.",
button: "Let's try this again",
},
},
errors: {
invalidEmail: "Invalid email address.",
},
authErrors: {
// Network errors
timeout: "Request timed out. Please try again.",
cannotConnect: "Cannot connect to server. Please check your network.",
serverError: "Server error. Please try again later.",
badData: "Invalid response from server.",
unknownError: "An unknown error occurred.",
// Registration errors (E001-E010)
E001: "Username already exists.",
E002: "Email is already registered.",
E003: "Invalid referral code.",
E004: "No pending registration request.",
E005: "Invalid verification code.",
E006: "Referral code already bound.",
E007: "Cannot use your own referral code.",
// Login errors (E011-E020)
E011: "Invalid email or password.",
E012: "Account temporarily locked. Please try again later.",
E013: "No pending login request.",
E014: "User does not exist or has been disabled.",
E015: "Invalid verification code.",
E016: "Invalid Telegram authentication.",
E017: "Invalid Telegram data format.",
E018: "Google authentication failed.",
E019: "Please provide Google token.",
// Password errors (E021-E030)
E021: "Current password is incorrect.",
E022: "New password cannot be the same as current password.",
E023: "Email is not registered.",
E024: "Account has been disabled.",
E025: "No pending password reset request.",
E026: "Password reset code has expired.",
E027: "Invalid password reset code.",
E028: "User does not exist.",
E029: "No fields provided to update.",
// Email verification errors (E041-E055)
E041: "Sending verification code too frequently. Please wait.",
E042: "Email verification code has expired.",
E043: "Invalid email verification code.",
E044: "Email is already verified.",
E045: "Email is already used by another user.",
E046: "No verified email address.",
E047: "Please send verification code to current email first.",
E048: "Please verify current email first.",
E049: "Please send verification code to new email first.",
E050: "Invalid action parameter.",
},
loginScreen: {
logIn: "Log In",
enterDetails:
"Enter your details below to unlock top secret info. You'll never guess what we've got waiting. Or maybe you will; it's not rocket science here.",
emailFieldLabel: "Email",
passwordFieldLabel: "Password",
emailFieldPlaceholder: "Enter your email address",
passwordFieldPlaceholder: "Super secret password here",
tapToLogIn: "Tap to log in!",
hint: "Hint: you can use any email address and your favorite password :)",
},
navigator: {
componentsTab: "Components",
debugTab: "Debug",
communityTab: "Community",
podcastListTab: "Podcast",
profileTab: "Profile",
},
profileScreen: {
title: "Profile",
guest: "Guest",
uid: "UID",
username: "Username",
referralCode: "Referral Code",
settings: "Settings",
darkMode: "Dark Mode",
notifications: "Notifications",
security: "Security",
account: "Account",
accountStatus: "Account Status",
active: "Active",
inactive: "Inactive",
regular: "Regular",
emailVerified: "Email Verified",
verified: "Verified",
unverified: "Unverified",
loginMethods: "Login Methods",
version: "Version",
changePassword: "Change Password",
changeEmail: "Change Email",
editNickname: "Edit Nickname",
editProfile: "Edit Profile",
nicknamePlaceholder: "Enter your nickname",
nickname: "Nickname",
tapToChangeAvatar: "Tap to change avatar",
about: "About",
},
changePasswordScreen: {
title: "Change Password",
description: "Enter your current password and choose a new password.",
oldPassword: "Current Password",
newPassword: "New Password",
confirmPassword: "Confirm New Password",
logoutOtherDevices: "Log out from other devices",
submit: "Change Password",
success: "Success",
successMessage: "Your password has been changed successfully.",
oldPasswordRequired: "Current password is required.",
newPasswordRequired: "New password is required.",
passwordTooShort: "Password must be at least 6 characters.",
passwordMismatch: "Passwords do not match.",
samePassword: "New password cannot be the same as current password.",
},
changeEmailScreen: {
title: "Change Email",
step1Label: "Verify Email",
step2Label: "Bind New Email",
step1Title: "Step 1: Verify Current Email",
step1Description: "We will send a verification code to your current email address.",
step2Title: "Step 2: Bind New Email",
step2Description: "Enter your new email address and verify it.",
currentEmail: "Current Email",
newEmail: "New Email Address",
verificationCode: "Verification Code",
sendCode: "Send Verification Code",
sendCodeToNewEmail: "Send Code to New Email",
verify: "Verify",
confirmNewEmail: "Confirm New Email",
resendCode: "Resend Code",
success: "Success",
successMessage: "Your email has been changed successfully.",
codeRequired: "Verification code is required.",
codeInvalid: "Verification code must be 6 digits.",
newEmailRequired: "New email address is required.",
emailInvalid: "Please enter a valid email address.",
sameEmail: "New email cannot be the same as current email.",
},
settingsScreen: {
title: "Settings",
appearance: "Appearance",
theme: "Theme",
darkMode: "Dark Mode",
language: "Language",
currentLanguage: "Language",
},
languageScreen: {
title: "Language",
selectHint: "Select your preferred language",
},
themeScreen: {
title: "Theme",
selectHint: "Select your preferred theme",
system: "System",
light: "Light",
dark: "Dark",
},
securityScreen: {
title: "Security",
description: "Manage your account security settings.",
changePassword: "Change Password",
changeEmail: "Change Email",
// Session management
activeSessions: "Active Sessions",
activeSessionsDescription: "Devices currently logged into your account",
currentDevice: "Current Device",
lastActive: "Last active",
loginMethod: "Login via",
logoutDevice: "Logout",
logoutAllOther: "Logout All Other Devices",
logoutAllOtherDescription: "This will log out all devices except the current one.",
noOtherSessions: "No other active sessions",
sessionRevoked: "Device has been logged out",
sessionsRevoked: "{{count}} devices have been logged out",
confirmLogout: "Confirm Logout",
confirmLogoutMessage: "Are you sure you want to log out this device?",
confirmLogoutAllMessage: "Are you sure you want to log out all other devices?",
deviceTypes: {
desktop: "Desktop",
mobile: "Mobile",
tablet: "Tablet",
unknown: "Unknown Device",
},
},
aboutScreen: {
title: "About",
appInfo: "App Information",
appName: "App Name",
version: "Version",
buildVersion: "Build Version",
appId: "App ID",
appVersion: "App Version",
legal: "Legal",
privacyPolicy: "Privacy Policy",
termsOfService: "Terms of Service",
},
communityScreen: {
title: "Connect with the community",
tagLine:
"Plug in to Infinite Red's community of React Native engineers and level up your app development with us!",
joinUsOnSlackTitle: "Join us on Slack",
joinUsOnSlack:
"Wish there was a place to connect with React Native engineers around the world? Join the conversation in the Infinite Red Community Slack! Our growing community is a safe space to ask questions, learn from others, and grow your network.",
joinSlackLink: "Join the Slack Community",
makeIgniteEvenBetterTitle: "Make Ignite even better",
makeIgniteEvenBetter:
"Have an idea to make Ignite even better? We're happy to hear that! We're always looking for others who want to help us build the best React Native tooling out there. Join us over on GitHub to join us in building the future of Ignite.",
contributeToIgniteLink: "Contribute to Ignite",
theLatestInReactNativeTitle: "The latest in React Native",
theLatestInReactNative: "We're here to keep you current on all React Native has to offer.",
reactNativeRadioLink: "React Native Radio",
reactNativeNewsletterLink: "React Native Newsletter",
reactNativeLiveLink: "React Native Live",
chainReactConferenceLink: "Chain React Conference",
hireUsTitle: "Hire Infinite Red for your next project",
hireUs:
"Whether it's running a full project or getting teams up to speed with our hands-on training, Infinite Red can help with just about any React Native project.",
hireUsLink: "Send us a message",
},
showroomScreen: {
jumpStart: "Components to jump start your project!",
lorem2Sentences:
"Nulla cupidatat deserunt amet quis aliquip nostrud do adipisicing. Adipisicing excepteur elit laborum Lorem adipisicing do duis.",
demoHeaderTxExample: "Yay",
demoViaTxProp: "Via `tx` Prop",
demoViaSpecifiedTxProp: "Via `{{prop}}Tx` Prop",
},
demoDebugScreen: {
howTo: "HOW TO",
title: "Debug",
tagLine:
"Congratulations, you've got a very advanced React Native app template here. Take advantage of this boilerplate!",
reactotron: "Send to Reactotron",
reportBugs: "Report Bugs",
demoList: "Demo List",
demoPodcastList: "Demo Podcast List",
androidReactotronHint:
"If this doesn't work, ensure the Reactotron desktop app is running, run adb reverse tcp:9090 tcp:9090 from your terminal, and reload the app.",
iosReactotronHint:
"If this doesn't work, ensure the Reactotron desktop app is running and reload app.",
macosReactotronHint:
"If this doesn't work, ensure the Reactotron desktop app is running and reload app.",
webReactotronHint:
"If this doesn't work, ensure the Reactotron desktop app is running and reload app.",
windowsReactotronHint:
"If this doesn't work, ensure the Reactotron desktop app is running and reload app.",
},
demoPodcastListScreen: {
title: "React Native Radio episodes",
onlyFavorites: "Only Show Favorites",
favoriteButton: "Favorite",
unfavoriteButton: "Unfavorite",
accessibility: {
cardHint:
"Double tap to listen to the episode. Double tap and hold to {{action}} this episode.",
switch: "Switch on to only show favorites",
favoriteAction: "Toggle Favorite",
favoriteIcon: "Episode not favorited",
unfavoriteIcon: "Episode favorited",
publishLabel: "Published {{date}}",
durationLabel: "Duration: {{hours}} hours {{minutes}} minutes {{seconds}} seconds",
},
noFavoritesEmptyState: {
heading: "This looks a bit empty",
content:
"No favorites have been added yet. Tap the heart on an episode to add it to your favorites!",
},
},
...demoEn,
}
export default en
export type Translations = typeof en

303
RN_TEMPLATE/app/i18n/es.ts Normal file
View File

@@ -0,0 +1,303 @@
import demoEs from "./demo-es"
import { Translations } from "./en"
const es: Translations = {
common: {
ok: "OK",
cancel: "Cancelar",
save: "Guardar",
back: "Volver",
logOut: "Cerrar sesión",
copied: "Copiado",
},
welcomeScreen: {
postscript:
"psst — Esto probablemente no es cómo se va a ver tu app. (A menos que tu diseñador te haya enviado estas pantallas, y en ese caso, ¡lánzalas en producción!)",
readyForLaunch: "Tu app, casi lista para su lanzamiento",
exciting: "(¡ohh, esto es emocionante!)",
letsGo: "¡Vamos!",
},
errorScreen: {
title: "¡Algo salió mal!",
friendlySubtitle:
"Esta es la pantalla que verán tus usuarios en producción cuando haya un error. Vas a querer personalizar este mensaje (que está ubicado en `app/i18n/es.ts`) y probablemente también su diseño (`app/screens/ErrorScreen`). Si quieres eliminarlo completamente, revisa `app/app.tsx` y el componente <ErrorBoundary>.",
reset: "REINICIA LA APP",
traceTitle: "Error desde %{name}",
},
emptyStateComponent: {
generic: {
heading: "Muy vacío... muy triste",
content:
"No se han encontrado datos por el momento. Intenta darle clic en el botón para refrescar o recargar la app.",
button: "Intentemos de nuevo",
},
},
errors: {
invalidEmail: "Email inválido.",
},
authErrors: {
timeout: "Tiempo de espera agotado. Por favor, inténtelo de nuevo.",
cannotConnect: "No se puede conectar al servidor. Por favor, verifique su red.",
serverError: "Error del servidor. Por favor, inténtelo más tarde.",
badData: "Respuesta inválida del servidor.",
unknownError: "Se produjo un error desconocido.",
E001: "El nombre de usuario ya existe.",
E002: "El correo electrónico ya está registrado.",
E003: "Código de referencia inválido.",
E004: "No hay solicitud de registro pendiente.",
E005: "Código de verificación inválido.",
E006: "Código de referencia ya vinculado.",
E007: "No puedes usar tu propio código de referencia.",
E011: "Correo electrónico o contraseña inválidos.",
E012: "Cuenta bloqueada temporalmente. Por favor, inténtelo más tarde.",
E013: "No hay solicitud de inicio de sesión pendiente.",
E014: "El usuario no existe o ha sido deshabilitado.",
E015: "Código de verificación inválido.",
E016: "Autenticación de Telegram inválida.",
E017: "Formato de datos de Telegram inválido.",
E018: "Autenticación de Google fallida.",
E019: "Por favor, proporcione el token de Google.",
E021: "La contraseña actual es incorrecta.",
E022: "La nueva contraseña no puede ser igual a la actual.",
E023: "El correo electrónico no está registrado.",
E024: "La cuenta ha sido deshabilitada.",
E025: "No hay solicitud de restablecimiento de contraseña pendiente.",
E026: "El código de restablecimiento de contraseña ha expirado.",
E027: "Código de restablecimiento de contraseña inválido.",
E028: "El usuario no existe.",
E029: "No se proporcionaron campos para actualizar.",
E041: "Enviando código de verificación con demasiada frecuencia. Por favor, espere.",
E042: "El código de verificación de correo electrónico ha expirado.",
E043: "Código de verificación de correo electrónico inválido.",
E044: "El correo electrónico ya está verificado.",
E045: "El correo electrónico ya está siendo utilizado por otro usuario.",
E046: "No hay dirección de correo electrónico verificada.",
E047: "Por favor, envíe primero el código de verificación al correo actual.",
E048: "Por favor, verifique primero el correo electrónico actual.",
E049: "Por favor, envíe primero el código de verificación al nuevo correo.",
E050: "Parámetro de acción inválido.",
},
loginScreen: {
logIn: "Iniciar sesión",
enterDetails:
"Ingresa tus datos a continuación para desbloquear información ultra secreta. Nunca vas a adivinar lo que te espera al otro lado. O quizás si lo harás; la verdad no hay mucha ciencia alrededor.",
emailFieldLabel: "Email",
passwordFieldLabel: "Contraseña",
emailFieldPlaceholder: "Ingresa tu email",
passwordFieldPlaceholder: "Contraseña super secreta aquí",
tapToLogIn: "¡Presiona acá para iniciar sesión!",
hint: "Consejo: puedes usar cualquier email y tu contraseña preferida :)",
},
navigator: {
componentsTab: "Componentes",
debugTab: "Debug",
communityTab: "Comunidad",
podcastListTab: "Podcasts",
profileTab: "Perfil",
},
profileScreen: {
title: "Perfil",
guest: "Invitado",
uid: "UID",
username: "Nombre de usuario",
referralCode: "Código de referencia",
settings: "Configuración",
darkMode: "Modo oscuro",
notifications: "Notificaciones",
security: "Seguridad",
account: "Cuenta",
accountStatus: "Estado de la cuenta",
active: "Activo",
inactive: "Inactivo",
regular: "Regular",
emailVerified: "Email verificado",
verified: "Verificado",
unverified: "No verificado",
loginMethods: "Métodos de inicio de sesión",
version: "Versión",
changePassword: "Cambiar contraseña",
changeEmail: "Cambiar email",
editNickname: "Editar apodo",
editProfile: "Editar perfil",
nicknamePlaceholder: "Introduce tu apodo",
nickname: "Apodo",
tapToChangeAvatar: "Toca para cambiar avatar",
about: "Acerca de",
},
changePasswordScreen: {
title: "Cambiar contraseña",
description: "Introduce tu contraseña actual y elige una nueva.",
oldPassword: "Contraseña actual",
newPassword: "Nueva contraseña",
confirmPassword: "Confirmar nueva contraseña",
logoutOtherDevices: "Cerrar sesión en otros dispositivos",
submit: "Cambiar contraseña",
success: "Éxito",
successMessage: "Tu contraseña ha sido cambiada exitosamente.",
oldPasswordRequired: "La contraseña actual es requerida.",
newPasswordRequired: "La nueva contraseña es requerida.",
passwordTooShort: "La contraseña debe tener al menos 6 caracteres.",
passwordMismatch: "Las contraseñas no coinciden.",
samePassword: "La nueva contraseña no puede ser igual a la actual.",
},
changeEmailScreen: {
title: "Cambiar email",
step1Label: "Verificar",
step2Label: "Vincular",
step1Title: "Paso 1: Verificar email actual",
step1Description: "Enviaremos un código de verificación a tu email actual.",
step2Title: "Paso 2: Vincular nuevo email",
step2Description: "Introduce tu nueva dirección de email y verifícala.",
currentEmail: "Email actual",
newEmail: "Nueva dirección de email",
verificationCode: "Código de verificación",
sendCode: "Enviar código de verificación",
sendCodeToNewEmail: "Enviar código al nuevo email",
verify: "Verificar",
confirmNewEmail: "Confirmar nuevo email",
resendCode: "Reenviar código",
success: "Éxito",
successMessage: "Tu email ha sido cambiado exitosamente.",
codeRequired: "El código de verificación es requerido.",
codeInvalid: "El código de verificación debe tener 6 dígitos.",
newEmailRequired: "La nueva dirección de email es requerida.",
emailInvalid: "Por favor introduce una dirección de email válida.",
sameEmail: "El nuevo email no puede ser igual al actual.",
},
settingsScreen: {
title: "Configuración",
appearance: "Apariencia",
theme: "Tema",
darkMode: "Modo Oscuro",
language: "Idioma",
currentLanguage: "Idioma",
},
languageScreen: {
title: "Idioma",
selectHint: "Selecciona tu idioma preferido",
},
themeScreen: {
title: "Tema",
selectHint: "Selecciona tu tema preferido",
system: "Sistema",
light: "Claro",
dark: "Oscuro",
},
securityScreen: {
title: "Seguridad",
description: "Administra la configuración de seguridad de tu cuenta.",
changePassword: "Cambiar Contraseña",
changeEmail: "Cambiar Email",
// Session management
activeSessions: "Active Sessions",
activeSessionsDescription: "Devices currently logged into your account",
currentDevice: "Current Device",
lastActive: "Last active",
loginMethod: "Login via",
logoutDevice: "Logout",
logoutAllOther: "Logout All Other Devices",
logoutAllOtherDescription: "This will log out all devices except the current one.",
noOtherSessions: "No other active sessions",
sessionRevoked: "Device has been logged out",
sessionsRevoked: "{{count}} devices have been logged out",
confirmLogout: "Confirm Logout",
confirmLogoutMessage: "Are you sure you want to log out this device?",
confirmLogoutAllMessage: "Are you sure you want to log out all other devices?",
deviceTypes: {
desktop: "Desktop",
mobile: "Mobile",
tablet: "Tablet",
unknown: "Unknown Device",
},
},
aboutScreen: {
title: "Acerca de",
appInfo: "Información de la App",
appName: "Nombre de la App",
version: "Versión",
buildVersion: "Versión de Compilación",
appId: "ID de la App",
appVersion: "Versión de la App",
legal: "Legal",
privacyPolicy: "Política de Privacidad",
termsOfService: "Términos de Servicio",
},
communityScreen: {
title: "Conecta con la comunidad",
tagLine:
"Únete a la comunidad React Native con los ingenieros de Infinite Red y mejora con nosotros tus habilidades para el desarrollo de apps.",
joinUsOnSlackTitle: "Únete a nosotros en Slack",
joinUsOnSlack:
"¿Quieres conectar con desarrolladores de React Native de todo el mundo? Únete a la conversación en nuestra comunidad de Slack. Nuestra comunidad, que crece día a día, es un espacio seguro para hacer preguntas, aprender de los demás y ampliar tu red.",
joinSlackLink: "Únete a la comunidad de Slack",
makeIgniteEvenBetterTitle: "Haz que Ignite sea aún mejor",
makeIgniteEvenBetter:
"¿Tienes una idea para hacer que Ignite sea aún mejor? ¡Nos encantaría escucharla! Estamos siempre buscando personas que quieran ayudarnos a construir las mejores herramientas para React Native. Únete a nosotros en GitHub para ayudarnos a construir el futuro de Ignite.",
contributeToIgniteLink: "Contribuir a Ignite",
theLatestInReactNativeTitle: "Lo último en el mundo de React Native",
theLatestInReactNative:
"Estamos aquí para mantenerte al día con todo lo que React Native tiene para ofrecer.",
reactNativeRadioLink: "React Native Radio",
reactNativeNewsletterLink: "Newsletter de React Native",
reactNativeLiveLink: "React Native Live",
chainReactConferenceLink: "Conferencia Chain React",
hireUsTitle: "Trabaja con Infinite Red en tu próximo proyecto",
hireUs:
"Ya sea para gestionar un proyecto de inicio a fin o educación a equipos a través de nuestros cursos y capacitación práctica, Infinite Red puede ayudarte en casi cualquier proyecto de React Native.",
hireUsLink: "Envíanos un mensaje",
},
showroomScreen: {
jumpStart: "Componentes para comenzar tu proyecto",
lorem2Sentences:
"Nulla cupidatat deserunt amet quis aliquip nostrud do adipisicing. Adipisicing excepteur elit laborum Lorem adipisicing do duis.",
demoHeaderTxExample: "Yay",
demoViaTxProp: "A través de el atributo `tx`",
demoViaSpecifiedTxProp: "A través de el atributo específico `{{prop}}Tx`",
},
demoDebugScreen: {
howTo: "CÓMO HACERLO",
title: "Debug",
tagLine:
"Felicidades, aquí tienes una propuesta de arquitectura y base de código avanzada para una app en React Native. ¡Disfrutalos!",
reactotron: "Enviar a Reactotron",
reportBugs: "Reportar errores",
demoList: "Lista demo",
demoPodcastList: "Lista demo de podcasts",
androidReactotronHint:
"Si esto no funciona, asegúrate de que la app de escritorio de Reactotron se esté ejecutando, corre adb reverse tcp:9090 tcp:9090 desde tu terminal, y luego recarga la app.",
iosReactotronHint:
"Si esto no funciona, asegúrate de que la app de escritorio de Reactotron se esté ejecutando, y luego recarga la app.",
macosReactotronHint:
"Si esto no funciona, asegúrate de que la app de escritorio de Reactotron se esté ejecutando, y luego recarga la app.",
webReactotronHint:
"Si esto no funciona, asegúrate de que la app de escritorio de Reactotron se esté ejecutando, y luego recarga la app.",
windowsReactotronHint:
"Si esto no funciona, asegúrate de que la app de escritorio de Reactotron se esté ejecutando, y luego recarga la app.",
},
demoPodcastListScreen: {
title: "Episodios de React Native Radio",
onlyFavorites: "Mostrar solo favoritos",
favoriteButton: "Favorito",
unfavoriteButton: "No favorito",
accessibility: {
cardHint:
"Haz doble clic para escuchar el episodio. Haz doble clic y mantén presionado para {{action}} este episodio.",
switch: "Activa para mostrar solo favoritos",
favoriteAction: "Cambiar a favorito",
favoriteIcon: "Episodio no favorito",
unfavoriteIcon: "Episodio favorito",
publishLabel: "Publicado el {{date}}",
durationLabel: "Duración: {{hours}} horas {{minutes}} minutos {{seconds}} segundos",
},
noFavoritesEmptyState: {
heading: "Esto está un poco vacío",
content:
"No se han agregado episodios favoritos todavía. ¡Presiona el corazón dentro de un episodio para agregarlo a tus favoritos!",
},
},
...demoEs,
}
export default es

303
RN_TEMPLATE/app/i18n/fr.ts Normal file
View File

@@ -0,0 +1,303 @@
import demoFr from "./demo-fr"
import { Translations } from "./en"
const fr: Translations = {
common: {
ok: "OK !",
cancel: "Annuler",
save: "Enregistrer",
back: "Retour",
logOut: "Déconnexion",
copied: "Copié",
},
welcomeScreen: {
postscript:
"psst — Ce n'est probablement pas à quoi ressemble votre application. (À moins que votre designer ne vous ait donné ces écrans, dans ce cas, mettez la en prod !)",
readyForLaunch: "Votre application, presque prête pour le lancement !",
exciting: "(ohh, c'est excitant !)",
letsGo: "Allons-y !",
},
errorScreen: {
title: "Quelque chose s'est mal passé !",
friendlySubtitle:
"C'est l'écran que vos utilisateurs verront en production lorsqu'une erreur sera lancée. Vous voudrez personnaliser ce message (situé dans `app/i18n/fr.ts`) et probablement aussi la mise en page (`app/screens/ErrorScreen`). Si vous voulez le supprimer complètement, vérifiez `app/app.tsx` pour le composant <ErrorBoundary>.",
reset: "RÉINITIALISER L'APPLICATION",
traceTitle: "Erreur depuis %{name}",
},
emptyStateComponent: {
generic: {
heading: "Si vide... si triste",
content:
"Aucune donnée trouvée pour le moment. Essayez de cliquer sur le bouton pour rafraîchir ou recharger l'application.",
button: "Essayons à nouveau",
},
},
errors: {
invalidEmail: "Adresse e-mail invalide.",
},
authErrors: {
timeout: "Délai d'attente dépassé. Veuillez réessayer.",
cannotConnect: "Impossible de se connecter au serveur. Veuillez vérifier votre réseau.",
serverError: "Erreur serveur. Veuillez réessayer plus tard.",
badData: "Réponse invalide du serveur.",
unknownError: "Une erreur inconnue s'est produite.",
E001: "Ce nom d'utilisateur existe déjà.",
E002: "Cet e-mail est déjà enregistré.",
E003: "Code de parrainage invalide.",
E004: "Aucune demande d'inscription en attente.",
E005: "Code de vérification invalide.",
E006: "Code de parrainage déjà lié.",
E007: "Vous ne pouvez pas utiliser votre propre code de parrainage.",
E011: "E-mail ou mot de passe invalide.",
E012: "Compte temporairement verrouillé. Veuillez réessayer plus tard.",
E013: "Aucune demande de connexion en attente.",
E014: "L'utilisateur n'existe pas ou a été désactivé.",
E015: "Code de vérification invalide.",
E016: "Authentification Telegram invalide.",
E017: "Format de données Telegram invalide.",
E018: "Échec de l'authentification Google.",
E019: "Veuillez fournir le token Google.",
E021: "Le mot de passe actuel est incorrect.",
E022: "Le nouveau mot de passe ne peut pas être identique à l'actuel.",
E023: "L'e-mail n'est pas enregistré.",
E024: "Le compte a été désactivé.",
E025: "Aucune demande de réinitialisation de mot de passe en attente.",
E026: "Le code de réinitialisation du mot de passe a expiré.",
E027: "Code de réinitialisation du mot de passe invalide.",
E028: "L'utilisateur n'existe pas.",
E029: "Aucun champ fourni pour la mise à jour.",
E041: "Envoi du code de vérification trop fréquent. Veuillez patienter.",
E042: "Le code de vérification de l'e-mail a expiré.",
E043: "Code de vérification de l'e-mail invalide.",
E044: "L'e-mail est déjà vérifié.",
E045: "L'e-mail est déjà utilisé par un autre utilisateur.",
E046: "Aucune adresse e-mail vérifiée.",
E047: "Veuillez d'abord envoyer le code de vérification à l'e-mail actuel.",
E048: "Veuillez d'abord vérifier l'e-mail actuel.",
E049: "Veuillez d'abord envoyer le code de vérification au nouvel e-mail.",
E050: "Paramètre d'action invalide.",
},
loginScreen: {
logIn: "Se connecter",
enterDetails:
"Entrez vos informations ci-dessous pour débloquer des informations top secrètes. Vous ne devinerez jamais ce que nous avons en attente. Ou peut-être que vous le ferez ; ce n'est pas de la science spatiale ici.",
emailFieldLabel: "E-mail",
passwordFieldLabel: "Mot de passe",
emailFieldPlaceholder: "Entrez votre adresse e-mail",
passwordFieldPlaceholder: "Mot de passe super secret ici",
tapToLogIn: "Appuyez pour vous connecter!",
hint: "Astuce : vous pouvez utiliser n'importe quelle adresse e-mail et votre mot de passe préféré :)",
},
navigator: {
componentsTab: "Composants",
debugTab: "Débogage",
communityTab: "Communauté",
podcastListTab: "Podcasts",
profileTab: "Profil",
},
profileScreen: {
title: "Profil",
guest: "Invité",
uid: "UID",
username: "Nom d'utilisateur",
referralCode: "Code de parrainage",
settings: "Paramètres",
darkMode: "Mode sombre",
notifications: "Notifications",
security: "Sécurité",
account: "Compte",
accountStatus: "Statut du compte",
active: "Actif",
inactive: "Inactif",
regular: "Standard",
emailVerified: "Email vérifié",
verified: "Vérifié",
unverified: "Non vérifié",
loginMethods: "Méthodes de connexion",
version: "Version",
changePassword: "Changer le mot de passe",
changeEmail: "Changer l'email",
editNickname: "Modifier le pseudo",
editProfile: "Modifier le profil",
nicknamePlaceholder: "Entrez votre pseudo",
nickname: "Pseudo",
tapToChangeAvatar: "Appuyez pour changer l'avatar",
about: "À propos",
},
changePasswordScreen: {
title: "Changer le mot de passe",
description: "Entrez votre mot de passe actuel et choisissez un nouveau.",
oldPassword: "Mot de passe actuel",
newPassword: "Nouveau mot de passe",
confirmPassword: "Confirmer le nouveau mot de passe",
logoutOtherDevices: "Déconnecter les autres appareils",
submit: "Changer le mot de passe",
success: "Succès",
successMessage: "Votre mot de passe a été changé avec succès.",
oldPasswordRequired: "Le mot de passe actuel est requis.",
newPasswordRequired: "Le nouveau mot de passe est requis.",
passwordTooShort: "Le mot de passe doit contenir au moins 6 caractères.",
passwordMismatch: "Les mots de passe ne correspondent pas.",
samePassword: "Le nouveau mot de passe ne peut pas être identique à l'actuel.",
},
changeEmailScreen: {
title: "Changer l'email",
step1Label: "Vérifier",
step2Label: "Lier",
step1Title: "Étape 1 : Vérifier l'email actuel",
step1Description: "Nous enverrons un code de vérification à votre email actuel.",
step2Title: "Étape 2 : Lier le nouvel email",
step2Description: "Entrez votre nouvelle adresse email et vérifiez-la.",
currentEmail: "Email actuel",
newEmail: "Nouvelle adresse email",
verificationCode: "Code de vérification",
sendCode: "Envoyer le code de vérification",
sendCodeToNewEmail: "Envoyer le code au nouvel email",
verify: "Vérifier",
confirmNewEmail: "Confirmer le nouvel email",
resendCode: "Renvoyer le code",
success: "Succès",
successMessage: "Votre email a été changé avec succès.",
codeRequired: "Le code de vérification est requis.",
codeInvalid: "Le code de vérification doit contenir 6 chiffres.",
newEmailRequired: "La nouvelle adresse email est requise.",
emailInvalid: "Veuillez entrer une adresse email valide.",
sameEmail: "Le nouvel email ne peut pas être identique à l'actuel.",
},
settingsScreen: {
title: "Paramètres",
appearance: "Apparence",
theme: "Thème",
darkMode: "Mode Sombre",
language: "Langue",
currentLanguage: "Langue",
},
languageScreen: {
title: "Langue",
selectHint: "Sélectionnez votre langue préférée",
},
themeScreen: {
title: "Thème",
selectHint: "Sélectionnez votre thème préféré",
system: "Système",
light: "Clair",
dark: "Sombre",
},
securityScreen: {
title: "Sécurité",
description: "Gérez les paramètres de sécurité de votre compte.",
changePassword: "Changer le Mot de Passe",
changeEmail: "Changer l'Email",
// Session management
activeSessions: "Active Sessions",
activeSessionsDescription: "Devices currently logged into your account",
currentDevice: "Current Device",
lastActive: "Last active",
loginMethod: "Login via",
logoutDevice: "Logout",
logoutAllOther: "Logout All Other Devices",
logoutAllOtherDescription: "This will log out all devices except the current one.",
noOtherSessions: "No other active sessions",
sessionRevoked: "Device has been logged out",
sessionsRevoked: "{{count}} devices have been logged out",
confirmLogout: "Confirm Logout",
confirmLogoutMessage: "Are you sure you want to log out this device?",
confirmLogoutAllMessage: "Are you sure you want to log out all other devices?",
deviceTypes: {
desktop: "Desktop",
mobile: "Mobile",
tablet: "Tablet",
unknown: "Unknown Device",
},
},
aboutScreen: {
title: "À propos",
appInfo: "Informations de l'Application",
appName: "Nom de l'Application",
version: "Version",
buildVersion: "Version de Compilation",
appId: "ID de l'Application",
appVersion: "Version de l'Application",
legal: "Mentions Légales",
privacyPolicy: "Politique de Confidentialité",
termsOfService: "Conditions d'Utilisation",
},
communityScreen: {
title: "Connectez-vous avec la communauté",
tagLine:
"Rejoignez la communauté d'ingénieurs React Native d'Infinite Red et améliorez votre développement d'applications avec nous !",
joinUsOnSlackTitle: "Rejoignez-nous sur Slack",
joinUsOnSlack:
"Vous souhaitez vous connecter avec des ingénieurs React Native du monde entier ? Rejoignez la conversation dans la communauté Slack d'Infinite Red ! Notre communauté en pleine croissance est un espace sûr pour poser des questions, apprendre des autres et développer votre réseau.",
joinSlackLink: "Rejoindre la communauté Slack",
makeIgniteEvenBetterTitle: "Rendre Ignite encore meilleur",
makeIgniteEvenBetter:
"Vous avez une idée pour rendre Ignite encore meilleur ? Nous sommes heureux de l'entendre ! Nous cherchons toujours des personnes qui veulent nous aider à construire les meilleurs outils React Native. Rejoignez-nous sur GitHub pour nous aider à construire l'avenir d'Ignite.",
contributeToIgniteLink: "Contribuer à Ignite",
theLatestInReactNativeTitle: "Les dernières nouvelles de React Native",
theLatestInReactNative:
"Nous sommes là pour vous tenir au courant de tout ce que React Native a à offrir.",
reactNativeRadioLink: "React Native Radio",
reactNativeNewsletterLink: "React Native Newsletter",
reactNativeLiveLink: "React Native Live",
chainReactConferenceLink: "Conférence Chain React",
hireUsTitle: "Engagez Infinite Red pour votre prochain projet",
hireUs:
"Que ce soit pour gérer un projet complet ou pour former des équipes à notre formation pratique, Infinite Red peut vous aider pour presque tous les projets React Native.",
hireUsLink: "Envoyez-nous un message",
},
showroomScreen: {
jumpStart: "Composants pour démarrer votre projet !",
lorem2Sentences:
"Nulla cupidatat deserunt amet quis aliquip nostrud do adipisicing. Adipisicing excepteur elit laborum Lorem adipisicing do duis.",
demoHeaderTxExample: "Yay",
demoViaTxProp: "Via la propriété `tx`",
demoViaSpecifiedTxProp: "Via la propriété `{{prop}}Tx` spécifiée",
},
demoDebugScreen: {
howTo: "COMMENT FAIRE",
title: "Débugage",
tagLine:
"Félicitations, vous avez un modèle d'application React Native très avancé ici. Profitez de cette base de code !",
reactotron: "Envoyer à Reactotron",
reportBugs: "Signaler des bugs",
demoList: "Liste de démonstration",
demoPodcastList: "Liste de podcasts de démonstration",
androidReactotronHint:
"Si cela ne fonctionne pas, assurez-vous que l'application de bureau Reactotron est en cours d'exécution, exécutez adb reverse tcp:9090 tcp:9090 à partir de votre terminal, puis rechargez l'application.",
iosReactotronHint:
"Si cela ne fonctionne pas, assurez-vous que l'application de bureau Reactotron est en cours d'exécution, puis rechargez l'application.",
macosReactotronHint:
"Si cela ne fonctionne pas, assurez-vous que l'application de bureau Reactotron est en cours d'exécution, puis rechargez l'application.",
webReactotronHint:
"Si cela ne fonctionne pas, assurez-vous que l'application de bureau Reactotron est en cours d'exécution, puis rechargez l'application.",
windowsReactotronHint:
"Si cela ne fonctionne pas, assurez-vous que l'application de bureau Reactotron est en cours d'exécution, puis rechargez l'application.",
},
demoPodcastListScreen: {
title: "Épisodes de Radio React Native",
onlyFavorites: "Afficher uniquement les favoris",
favoriteButton: "Favori",
unfavoriteButton: "Non favori",
accessibility: {
cardHint:
"Double-cliquez pour écouter l'épisode. Double-cliquez et maintenez pour {{action}} cet épisode.",
switch: "Activez pour afficher uniquement les favoris",
favoriteAction: "Basculer en favori",
favoriteIcon: "Épisode non favori",
unfavoriteIcon: "Épisode favori",
publishLabel: "Publié le {{date}}",
durationLabel: "Durée : {{hours}} heures {{minutes}} minutes {{seconds}} secondes",
},
noFavoritesEmptyState: {
heading: "C'est un peu vide ici",
content:
"Aucun favori n'a été ajouté pour le moment. Appuyez sur le cœur d'un épisode pour l'ajouter à vos favoris !",
},
},
...demoFr,
}
export default fr

301
RN_TEMPLATE/app/i18n/hi.ts Normal file
View File

@@ -0,0 +1,301 @@
import demoHi from "./demo-hi"
import { Translations } from "./en"
const hi: Translations = {
common: {
ok: "ठीक है!",
cancel: "रद्द करें",
save: "सहेजें",
back: "वापस",
logOut: "लॉग आउट",
copied: "कॉपी किया गया",
},
welcomeScreen: {
postscript:
"psst - शायद आपका ऐप ऐसा नहीं दिखता है। (जब तक कि आपके डिजाइनर ने आपको ये स्क्रीन नहीं दी हों, और उस स्थिति में, इसे लॉन्च करें!)",
readyForLaunch: "आपका ऐप, लगभग लॉन्च के लिए तैयार है!",
exciting: "(ओह, यह रोमांचक है!)",
letsGo: "चलो चलते हैं!",
},
errorScreen: {
title: "कुछ गलत हो गया!",
friendlySubtitle:
"यह वह स्क्रीन है जो आपके उपयोगकर्ता संचालन में देखेंगे जब कोई त्रुटि होगी। आप इस संदेश को बदलना चाहेंगे (जो `app/i18n/hi.ts` में स्थित है) और शायद लेआउट भी (`app/screens/ErrorScreen`)। यदि आप इसे पूरी तरह से हटाना चाहते हैं, तो `app/app.tsx` में <ErrorBoundary> कंपोनेंट की जांच करें।",
reset: "ऐप रीसेट करें",
traceTitle: "%{name} स्टैक से त्रुटि",
},
emptyStateComponent: {
generic: {
heading: "इतना खाली... इतना उदास",
content: "अभी तक कोई डेटा नहीं मिला। रीफ्रेश करने या ऐप को पुनः लोड करने के लिए बटन दबाएं।",
button: "चलो फिर से कोशिश करते हैं",
},
},
errors: {
invalidEmail: "अमान्य ईमेल पता।",
},
authErrors: {
timeout: "Request timed out. Please try again.",
cannotConnect: "Cannot connect to server. Please check your network.",
serverError: "Server error. Please try again later.",
badData: "Invalid response from server.",
unknownError: "An unknown error occurred.",
E001: "Username already exists.",
E002: "Email is already registered.",
E003: "Invalid referral code.",
E004: "No pending registration request.",
E005: "Invalid verification code.",
E006: "Referral code already bound.",
E007: "Cannot use your own referral code.",
E011: "Invalid email or password.",
E012: "Account temporarily locked. Please try again later.",
E013: "No pending login request.",
E014: "User does not exist or has been disabled.",
E015: "Invalid verification code.",
E016: "Invalid Telegram authentication.",
E017: "Invalid Telegram data format.",
E018: "Google authentication failed.",
E019: "Please provide Google token.",
E021: "Current password is incorrect.",
E022: "New password cannot be the same as current password.",
E023: "Email is not registered.",
E024: "Account has been disabled.",
E025: "No pending password reset request.",
E026: "Password reset code has expired.",
E027: "Invalid password reset code.",
E028: "User does not exist.",
E029: "No fields provided to update.",
E041: "Sending verification code too frequently. Please wait.",
E042: "Email verification code has expired.",
E043: "Invalid email verification code.",
E044: "Email is already verified.",
E045: "Email is already used by another user.",
E046: "No verified email address.",
E047: "Please send verification code to current email first.",
E048: "Please verify current email first.",
E049: "Please send verification code to new email first.",
E050: "Invalid action parameter.",
},
loginScreen: {
logIn: "लॉग इन करें",
enterDetails:
"सर्वश्रेष्ठ रहस्य पता करने के लिए नीचे अपना विवरण दर्ज करें। आप कभी अनुमान नहीं लगा पाएंगे कि हमारे पास क्या इंतजार कर रहा है। या शायद आप कर सकते हैं; यह रॉकेट साइंस नहीं है।",
emailFieldLabel: "ईमेल",
passwordFieldLabel: "पासवर्ड",
emailFieldPlaceholder: "अपना ईमेल पता दर्ज करें",
passwordFieldPlaceholder: "सुपर सीक्रेट पासवर्ड यहाँ",
tapToLogIn: "लॉग इन करने के लिए टैप करें!",
hint: "संकेत: आप किसी भी ईमेल पते और अपने पसंदीदा पासवर्ड का उपयोग कर सकते हैं :)",
},
navigator: {
componentsTab: "कंपोनेंट्स",
debugTab: "डीबग",
communityTab: "समुदाय",
podcastListTab: "पॉडकास्ट",
profileTab: "प्रोफ़ाइल",
},
profileScreen: {
title: "प्रोफ़ाइल",
guest: "अतिथि",
uid: "UID",
username: "उपयोगकर्ता नाम",
referralCode: "रेफरल कोड",
settings: "सेटिंग्स",
darkMode: "डार्क मोड",
notifications: "सूचनाएं",
security: "सुरक्षा",
account: "खाता",
accountStatus: "खाते की स्थिति",
active: "सक्रिय",
inactive: "निष्क्रिय",
regular: "सामान्य",
emailVerified: "ईमेल सत्यापित",
verified: "सत्यापित",
unverified: "असत्यापित",
loginMethods: "लॉगिन तरीके",
version: "संस्करण",
changePassword: "पासवर्ड बदलें",
changeEmail: "ईमेल बदलें",
editNickname: "उपनाम संपादित करें",
editProfile: "प्रोफ़ाइल संपादित करें",
nicknamePlaceholder: "अपना उपनाम दर्ज करें",
nickname: "उपनाम",
tapToChangeAvatar: "अवतार बदलने के लिए टैप करें",
about: "के बारे में",
},
changePasswordScreen: {
title: "पासवर्ड बदलें",
description: "अपना वर्तमान पासवर्ड दर्ज करें और एक नया पासवर्ड चुनें।",
oldPassword: "वर्तमान पासवर्ड",
newPassword: "नया पासवर्ड",
confirmPassword: "नए पासवर्ड की पुष्टि करें",
logoutOtherDevices: "अन्य उपकरणों से लॉग आउट करें",
submit: "पासवर्ड बदलें",
success: "सफल",
successMessage: "आपका पासवर्ड सफलतापूर्वक बदल दिया गया है।",
oldPasswordRequired: "वर्तमान पासवर्ड आवश्यक है।",
newPasswordRequired: "नया पासवर्ड आवश्यक है।",
passwordTooShort: "पासवर्ड कम से कम 6 अक्षरों का होना चाहिए।",
passwordMismatch: "पासवर्ड मेल नहीं खाते।",
samePassword: "नया पासवर्ड वर्तमान पासवर्ड जैसा नहीं हो सकता।",
},
changeEmailScreen: {
title: "ईमेल बदलें",
step1Label: "सत्यापित करें",
step2Label: "जोड़ें",
step1Title: "चरण 1: वर्तमान ईमेल सत्यापित करें",
step1Description: "हम आपके वर्तमान ईमेल पते पर एक सत्यापन कोड भेजेंगे।",
step2Title: "चरण 2: नया ईमेल जोड़ें",
step2Description: "अपना नया ईमेल पता दर्ज करें और इसे सत्यापित करें।",
currentEmail: "वर्तमान ईमेल",
newEmail: "नया ईमेल पता",
verificationCode: "सत्यापन कोड",
sendCode: "सत्यापन कोड भेजें",
sendCodeToNewEmail: "नए ईमेल पर कोड भेजें",
verify: "सत्यापित करें",
confirmNewEmail: "नए ईमेल की पुष्टि करें",
resendCode: "कोड फिर से भेजें",
success: "सफल",
successMessage: "आपका ईमेल सफलतापूर्वक बदल दिया गया है।",
codeRequired: "सत्यापन कोड आवश्यक है।",
codeInvalid: "सत्यापन कोड 6 अंकों का होना चाहिए।",
newEmailRequired: "नया ईमेल पता आवश्यक है।",
emailInvalid: "कृपया एक वैध ईमेल पता दर्ज करें।",
sameEmail: "नया ईमेल वर्तमान ईमेल जैसा नहीं हो सकता।",
},
settingsScreen: {
title: "सेटिंग्स",
appearance: "दिखावट",
theme: "थीम",
darkMode: "डार्क मोड",
language: "भाषा",
currentLanguage: "भाषा",
},
languageScreen: {
title: "भाषा",
selectHint: "अपनी पसंदीदा भाषा चुनें",
},
themeScreen: {
title: "थीम",
selectHint: "अपनी पसंदीदा थीम चुनें",
system: "सिस्टम",
light: "लाइट",
dark: "डार्क",
},
securityScreen: {
title: "सुरक्षा",
description: "अपने खाते की सुरक्षा सेटिंग्स प्रबंधित करें।",
changePassword: "पासवर्ड बदलें",
changeEmail: "ईमेल बदलें",
// Session management
activeSessions: "Active Sessions",
activeSessionsDescription: "Devices currently logged into your account",
currentDevice: "Current Device",
lastActive: "Last active",
loginMethod: "Login via",
logoutDevice: "Logout",
logoutAllOther: "Logout All Other Devices",
logoutAllOtherDescription: "This will log out all devices except the current one.",
noOtherSessions: "No other active sessions",
sessionRevoked: "Device has been logged out",
sessionsRevoked: "{{count}} devices have been logged out",
confirmLogout: "Confirm Logout",
confirmLogoutMessage: "Are you sure you want to log out this device?",
confirmLogoutAllMessage: "Are you sure you want to log out all other devices?",
deviceTypes: {
desktop: "Desktop",
mobile: "Mobile",
tablet: "Tablet",
unknown: "Unknown Device",
},
},
aboutScreen: {
title: "के बारे में",
appInfo: "ऐप जानकारी",
appName: "ऐप का नाम",
version: "संस्करण",
buildVersion: "बिल्ड संस्करण",
appId: "ऐप आईडी",
appVersion: "ऐप संस्करण",
legal: "कानूनी",
privacyPolicy: "गोपनीयता नीति",
termsOfService: "सेवा की शर्तें",
},
communityScreen: {
title: "समुदाय से जुड़ें",
tagLine:
"Infinite Red के React Native इंजीनियरों के समुदाय से जुड़ें और हमारे साथ अपने ऐप विकास को बेहतर बनाएं!",
joinUsOnSlackTitle: "Slack पर हमसे जुड़ें",
joinUsOnSlack:
"क्या आप चाहते हैं कि दुनिया भर के React Native इंजीनियरों से जुड़ने के लिए कोई जगह हो? Infinite Red Community Slack में बातचीत में शामिल हों! हमारा बढ़ता हुआ समुदाय प्रश्न पूछने, दूसरों से सीखने और अपने नेटवर्क को बढ़ाने के लिए एक सुरक्षित स्थान है।",
joinSlackLink: "Slack समुदाय में शामिल हों",
makeIgniteEvenBetterTitle: "Ignite को और बेहतर बनाएं",
makeIgniteEvenBetter:
"Ignite को और बेहतर बनाने का कोई विचार है? हमें यह सुनकर खुशी होगी! हम हमेशा ऐसे लोगों की तलाश में रहते हैं जो हमें सर्वश्रेष्ठ React Native टूलिंग बनाने में मदद करना चाहते हैं। Ignite के भविष्य को बनाने में हमारे साथ शामिल होने के लिए GitHub पर हमसे जुड़ें।",
contributeToIgniteLink: "Ignite में योगदान दें",
theLatestInReactNativeTitle: "React Native में नवीनतम",
theLatestInReactNative: "हम आपको React Native के सभी प्रस्तावों पर अपडेट रखने के लिए यहां हैं।",
reactNativeRadioLink: "React Native रेडियो",
reactNativeNewsletterLink: "React Native न्यूजलेटर",
reactNativeLiveLink: "React Native लाइव",
chainReactConferenceLink: "Chain React कॉन्फ्रेंस",
hireUsTitle: "अपने अगले प्रोजेक्ट के लिए Infinite Red को काम पर रखें",
hireUs:
"चाहे वह एक पूरा प्रोजेक्ट चलाना हो या हमारे हैंड्स-ऑन प्रशिक्षण के साथ टीमों को गति देना हो, Infinite Red लगभग किसी भी React Native प्रोजेक्ट में मदद कर सकता है।",
hireUsLink: "हमें एक संदेश भेजें",
},
showroomScreen: {
jumpStart: "अपने प्रोजेक्ट को जंप स्टार्ट करने के लिए कंपोनेंट्स!",
lorem2Sentences:
"कोई भी काम जो आप नहीं करना चाहते, उसे करने के लिए किसी और को ढूंढना चाहिए। जो लोग दूसरों की मदद करते हैं, वे खुद की भी मदद करते हैं।",
demoHeaderTxExample: "हाँ",
demoViaTxProp: "`tx` प्रॉप के माध्यम से",
demoViaSpecifiedTxProp: "`{{prop}}Tx` प्रॉप के माध्यम से",
},
demoDebugScreen: {
howTo: "कैसे करें",
title: "डीबग",
tagLine:
"बधाई हो, आपके पास यहां एक बहुत उन्नत React Native ऐप टेम्पलेट है। इस बॉयलरप्लेट का लाभ उठाएं!",
reactotron: "Reactotron को भेजें",
reportBugs: "बग्स की रिपोर्ट करें",
demoList: "डेमो सूची",
demoPodcastList: "डेमो पॉडकास्ट सूची",
androidReactotronHint:
"यदि यह काम नहीं करता है, तो सुनिश्चित करें कि Reactotron डेस्कटॉप ऐप चल रहा है, अपने टर्मिनल से adb reverse tcp:9090 tcp:9090 चलाएं, और ऐप को पुनः लोड करें।",
iosReactotronHint:
"यदि यह काम नहीं करता है, तो सुनिश्चित करें कि Reactotron डेस्कटॉप ऐप चल रहा है और ऐप को पुनः लोड करें।",
macosReactotronHint:
"यदि यह काम नहीं करता है, तो सुनिश्चित करें कि Reactotron डेस्कटॉप ऐप चल रहा है और ऐप को पुनः लोड करें।",
webReactotronHint:
"यदि यह काम नहीं करता है, तो सुनिश्चित करें कि Reactotron डेस्कटॉप ऐप चल रहा है और ऐप को पुनः लोड करें।",
windowsReactotronHint:
"यदि यह काम नहीं करता है, तो सुनिश्चित करें कि Reactotron डेस्कटॉप ऐप चल रहा है और ऐप को पुनः लोड करें।",
},
demoPodcastListScreen: {
title: "React Native रेडियो एपिसोड",
onlyFavorites: "केवल पसंदीदा दिखाएं",
favoriteButton: "पसंदीदा",
unfavoriteButton: "नापसंद",
accessibility: {
cardHint:
"एपिसोड सुनने के लिए डबल टैप करें। इस एपिसोड को {{action}} करने के लिए डबल टैप करें और होल्ड करें।",
switch: "केवल पसंदीदा दिखाने के लिए स्विच करें",
favoriteAction: "पसंदीदा टॉगल करें",
favoriteIcon: "एपिसोड पसंदीदा नहीं है",
unfavoriteIcon: "एपिसोड पसंदीदा है",
publishLabel: "{{date}} को प्रकाशित",
durationLabel: "अवधि: {{hours}} घंटे {{minutes}} मिनट {{seconds}} सेकंड",
},
noFavoritesEmptyState: {
heading: "यह थोड़ा खाली लगता है",
content:
"अभी तक कोई पसंदीदा नहीं जोड़ा गया है। इसे अपने पसंदीदा में जोड़ने के लिए किसी एपिसोड पर दिल पर टैप करें!",
},
},
...demoHi,
}
export default hi

View File

@@ -0,0 +1,87 @@
import { I18nManager } from "react-native"
import * as Localization from "expo-localization"
import i18n from "i18next"
import { initReactI18next } from "react-i18next"
import "intl-pluralrules"
// if English isn't your default language, move Translations to the appropriate language file.
import ar from "./ar"
import en, { Translations } from "./en"
import es from "./es"
import fr from "./fr"
import hi from "./hi"
import ja from "./ja"
import ko from "./ko"
import zh from "./zh"
const fallbackLocale = "en-US"
const systemLocales = Localization.getLocales()
const resources = { ar, en, ko, es, fr, ja, hi, zh }
const supportedTags = Object.keys(resources)
// Checks to see if the device locale matches any of the supported locales
// Device locale may be more specific and still match (e.g., en-US matches en)
const systemTagMatchesSupportedTags = (deviceTag: string) => {
const primaryTag = deviceTag.split("-")[0]
return supportedTags.includes(primaryTag)
}
const pickSupportedLocale: () => Localization.Locale | undefined = () => {
return systemLocales.find((locale) => systemTagMatchesSupportedTags(locale.languageTag))
}
const locale = pickSupportedLocale()
export let isRTL = false
// Need to set RTL ASAP to ensure the app is rendered correctly. Waiting for i18n to init is too late.
if (locale?.languageTag && locale?.textDirection === "rtl") {
I18nManager.allowRTL(true)
isRTL = true
} else {
I18nManager.allowRTL(false)
}
export const initI18n = async () => {
i18n.use(initReactI18next)
await i18n.init({
resources,
lng: locale?.languageTag ?? fallbackLocale,
fallbackLng: fallbackLocale,
interpolation: {
escapeValue: false,
},
})
return i18n
}
/**
* Builds up valid keypaths for translations.
*/
export type TxKeyPath = RecursiveKeyOf<Translations>
// via: https://stackoverflow.com/a/65333050
type RecursiveKeyOf<TObj extends object> = {
[TKey in keyof TObj & (string | number)]: RecursiveKeyOfHandleValue<TObj[TKey], `${TKey}`, true>
}[keyof TObj & (string | number)]
type RecursiveKeyOfInner<TObj extends object> = {
[TKey in keyof TObj & (string | number)]: RecursiveKeyOfHandleValue<TObj[TKey], `${TKey}`, false>
}[keyof TObj & (string | number)]
type RecursiveKeyOfHandleValue<
TValue,
Text extends string,
IsFirstLevel extends boolean,
> = TValue extends any[]
? Text
: TValue extends object
? IsFirstLevel extends true
? Text | `${Text}:${RecursiveKeyOfInner<TValue>}`
: Text | `${Text}.${RecursiveKeyOfInner<TValue>}`
: Text

301
RN_TEMPLATE/app/i18n/ja.ts Normal file
View File

@@ -0,0 +1,301 @@
import demoJa from "./demo-ja"
import { Translations } from "./en"
const ja: Translations = {
common: {
ok: "OK",
cancel: "キャンセル",
save: "保存",
back: "戻る",
logOut: "ログアウト",
copied: "コピーしました",
},
welcomeScreen: {
postscript:
"注目! — このアプリはお好みの見た目では無いかもしれません(デザイナーがこのスクリーンを送ってこない限りは。もしそうなら公開しちゃいましょう!)",
readyForLaunch: "このアプリはもう少しで公開できます!",
exciting: "(楽しみですね!)",
letsGo: "レッツゴー!",
},
errorScreen: {
title: "問題が発生しました",
friendlySubtitle:
"本番では、エラーが投げられた時にこのページが表示されます。もし使うならこのメッセージに変更を加えてください(`app/i18n/jp.ts`)レイアウトはこちらで変更できます(`app/screens/ErrorScreen`)。もしこのスクリーンを取り除きたい場合は、`app/app.tsx`にある<ErrorBoundary>コンポーネントをチェックしてください",
reset: "リセット",
traceTitle: "エラーのスタック: %{name}",
},
emptyStateComponent: {
generic: {
heading: "静かだ...悲しい。",
content:
"データが見つかりません。ボタンを押してアプリをリロード、またはリフレッシュしてください。",
button: "もう一度やってみよう",
},
},
errors: {
invalidEmail: "有効なメールアドレスを入力してください.",
},
authErrors: {
timeout: "リクエストがタイムアウトしました。再度お試しください。",
cannotConnect: "サーバーに接続できません。ネットワークを確認してください。",
serverError: "サーバーエラーが発生しました。後ほど再度お試しください。",
badData: "サーバーからの応答が無効です。",
unknownError: "不明なエラーが発生しました。",
E001: "このユーザー名は既に使用されています。",
E002: "このメールアドレスは既に登録されています。",
E003: "無効な紹介コードです。",
E004: "保留中の登録リクエストがありません。",
E005: "認証コードが正しくありません。",
E006: "紹介コードは既に登録されています。",
E007: "自分の紹介コードは使用できません。",
E011: "メールアドレスまたはパスワードが正しくありません。",
E012: "アカウントが一時的にロックされています。後ほど再度お試しください。",
E013: "保留中のログインリクエストがありません。",
E014: "ユーザーが存在しないか、無効になっています。",
E015: "認証コードが正しくありません。",
E016: "Telegram認証が無効です。",
E017: "Telegramデータの形式が正しくありません。",
E018: "Google認証に失敗しました。",
E019: "Googleトークンを入力してください。",
E021: "現在のパスワードが正しくありません。",
E022: "新しいパスワードは現在のパスワードと同じにできません。",
E023: "このメールアドレスは登録されていません。",
E024: "アカウントが無効になっています。",
E025: "保留中のパスワードリセットリクエストがありません。",
E026: "パスワードリセットコードの有効期限が切れています。",
E027: "パスワードリセットコードが正しくありません。",
E028: "ユーザーが存在しません。",
E029: "更新するフィールドが提供されていません。",
E041: "認証コードの送信が頻繁すぎます。しばらくお待ちください。",
E042: "メール認証コードの有効期限が切れています。",
E043: "メール認証コードが正しくありません。",
E044: "このメールアドレスは既に認証されています。",
E045: "このメールアドレスは他のユーザーが使用しています。",
E046: "認証済みのメールアドレスがありません。",
E047: "まず現在のメールアドレスに認証コードを送信してください。",
E048: "まず現在のメールアドレスを認証してください。",
E049: "まず新しいメールアドレスに認証コードを送信してください。",
E050: "無効なactionパラメータです。",
},
loginScreen: {
logIn: "ログイン",
enterDetails:
"ここにあなたの情報を入力してトップシークレットをアンロックしましょう。何が待ち構えているか予想もつかないはずです。はたまたそうでも無いかも - ロケットサイエンスほど複雑なものではありません。",
emailFieldLabel: "メールアドレス",
passwordFieldLabel: "パスワード",
emailFieldPlaceholder: "メールアドレスを入力してください",
passwordFieldPlaceholder: "パスワードを入力してください",
tapToLogIn: "タップしてログインしよう!",
hint: "ヒント: お好みのメールアドレスとパスワードを使ってください :)",
},
navigator: {
componentsTab: "コンポーネント",
debugTab: "デバッグ",
communityTab: "コミュニティ",
podcastListTab: "ポッドキャスト",
profileTab: "プロフィール",
},
profileScreen: {
title: "プロフィール",
guest: "ゲスト",
uid: "UID",
username: "ユーザー名",
referralCode: "紹介コード",
settings: "設定",
darkMode: "ダークモード",
notifications: "通知",
security: "セキュリティ",
account: "アカウント",
accountStatus: "アカウント状態",
active: "アクティブ",
inactive: "非アクティブ",
regular: "一般",
emailVerified: "メール認証",
verified: "認証済み",
unverified: "未認証",
loginMethods: "ログイン方法",
version: "バージョン",
changePassword: "パスワード変更",
changeEmail: "メールアドレス変更",
editNickname: "ニックネーム編集",
editProfile: "プロフィール編集",
nicknamePlaceholder: "ニックネームを入力",
nickname: "ニックネーム",
tapToChangeAvatar: "タップしてアバターを変更",
about: "このアプリについて",
},
changePasswordScreen: {
title: "パスワード変更",
description: "現在のパスワードを入力し、新しいパスワードを選択してください。",
oldPassword: "現在のパスワード",
newPassword: "新しいパスワード",
confirmPassword: "新しいパスワードの確認",
logoutOtherDevices: "他のデバイスからログアウト",
submit: "パスワードを変更",
success: "成功",
successMessage: "パスワードが正常に変更されました。",
oldPasswordRequired: "現在のパスワードは必須です。",
newPasswordRequired: "新しいパスワードは必須です。",
passwordTooShort: "パスワードは6文字以上である必要があります。",
passwordMismatch: "パスワードが一致しません。",
samePassword: "新しいパスワードは現在のパスワードと同じにできません。",
},
changeEmailScreen: {
title: "メールアドレス変更",
step1Label: "メール確認",
step2Label: "新規紐付け",
step1Title: "ステップ1現在のメールを確認",
step1Description: "現在のメールアドレスに確認コードを送信します。",
step2Title: "ステップ2新しいメールを紐付け",
step2Description: "新しいメールアドレスを入力して確認してください。",
currentEmail: "現在のメールアドレス",
newEmail: "新しいメールアドレス",
verificationCode: "確認コード",
sendCode: "確認コードを送信",
sendCodeToNewEmail: "新しいメールにコードを送信",
verify: "確認",
confirmNewEmail: "新しいメールを確認",
resendCode: "コードを再送信",
success: "成功",
successMessage: "メールアドレスが正常に変更されました。",
codeRequired: "確認コードは必須です。",
codeInvalid: "確認コードは6桁である必要があります。",
newEmailRequired: "新しいメールアドレスは必須です。",
emailInvalid: "有効なメールアドレスを入力してください。",
sameEmail: "新しいメールは現在のメールと同じにできません。",
},
settingsScreen: {
title: "設定",
appearance: "外観",
theme: "テーマ",
darkMode: "ダークモード",
language: "言語",
currentLanguage: "言語",
},
languageScreen: {
title: "言語",
selectHint: "ご希望の言語を選択してください",
},
themeScreen: {
title: "テーマ",
selectHint: "ご希望のテーマを選択してください",
system: "システム",
light: "ライト",
dark: "ダーク",
},
securityScreen: {
title: "セキュリティ",
description: "アカウントのセキュリティ設定を管理します。",
changePassword: "パスワード変更",
changeEmail: "メール変更",
// Session management
activeSessions: "Active Sessions",
activeSessionsDescription: "Devices currently logged into your account",
currentDevice: "Current Device",
lastActive: "Last active",
loginMethod: "Login via",
logoutDevice: "Logout",
logoutAllOther: "Logout All Other Devices",
logoutAllOtherDescription: "This will log out all devices except the current one.",
noOtherSessions: "No other active sessions",
sessionRevoked: "Device has been logged out",
sessionsRevoked: "{{count}} devices have been logged out",
confirmLogout: "Confirm Logout",
confirmLogoutMessage: "Are you sure you want to log out this device?",
confirmLogoutAllMessage: "Are you sure you want to log out all other devices?",
deviceTypes: {
desktop: "Desktop",
mobile: "Mobile",
tablet: "Tablet",
unknown: "Unknown Device",
},
},
aboutScreen: {
title: "このアプリについて",
appInfo: "アプリ情報",
appName: "アプリ名",
version: "バージョン",
buildVersion: "ビルドバージョン",
appId: "アプリID",
appVersion: "アプリバージョン",
legal: "法的情報",
privacyPolicy: "プライバシーポリシー",
termsOfService: "利用規約",
},
communityScreen: {
title: "コミュニティと繋がろう",
tagLine:
"Infinite RedのReact Nativeエンジニアコミュニティに接続して、一緒にあなたのアプリ開発をレベルアップしましょう",
joinUsOnSlackTitle: "私たちのSlackに参加しましょう",
joinUsOnSlack:
"世界中のReact Nativeエンジニアと繋がりたいを思いませんかInfinite RedのコミュニティSlackに参加しましょう私達のコミュニティは安全に質問ができ、お互いから学び、あなたのネットワークを広げることができます。",
joinSlackLink: "Slackコミュニティに参加する",
makeIgniteEvenBetterTitle: "Igniteをより良くする",
makeIgniteEvenBetter:
"Igniteをより良くする為のアイデアはありますか? そうであれば聞きたいです! 私たちはいつでも最良のReact Nativeのツールを開発する為に助けを求めています。GitHubで私たちと一緒にIgniteの未来を作りましょう。",
contributeToIgniteLink: "Igniteにコントリビュートする",
theLatestInReactNativeTitle: "React Nativeの今",
theLatestInReactNative: "React Nativeの現在をあなたにお届けします。",
reactNativeRadioLink: "React Native Radio",
reactNativeNewsletterLink: "React Native Newsletter",
reactNativeLiveLink: "React Native Live",
chainReactConferenceLink: "Chain React Conference",
hireUsTitle: "あなたの次のプロジェクトでInfinite Redと契約する",
hireUs:
"それがプロジェクト全体でも、チームにトレーニングをしてあげたい時でも、Infinite RedはReact Nativeのことであればなんでもお手伝いができます。",
hireUsLink: "メッセージを送る",
},
showroomScreen: {
jumpStart: "あなたのプロジェクトをスタートさせるコンポーネントです!",
lorem2Sentences:
"Nulla cupidatat deserunt amet quis aliquip nostrud do adipisicing. Adipisicing excepteur elit laborum Lorem adipisicing do duis.",
demoHeaderTxExample: "Yay",
demoViaTxProp: "`tx`から",
demoViaSpecifiedTxProp: "`{{prop}}Tx`から",
},
demoDebugScreen: {
howTo: "ハウツー",
title: "デバッグ",
tagLine:
"おめでとうございます、あなたはとてもハイレベルなReact Nativeのテンプレートを使ってます。このボイラープレートを活用してください",
reactotron: "Reactotronに送る",
reportBugs: "バグをレポートする",
demoList: "デモリスト",
demoPodcastList: "デモのポッドキャストリスト",
androidReactotronHint:
"もし動かなければ、Reactotronのデスクトップアプリが実行されていることを確認して, このコマンドをターミナルで実行した後、アプリをアプリをリロードしてください。 adb reverse tcp:9090 tcp:9090",
iosReactotronHint:
"もし動かなければ、Reactotronのデスクトップアプリが実行されていることを確認して、アプリをリロードしてください。",
macosReactotronHint:
"もし動かなければ、Reactotronのデスクトップアプリが実行されていることを確認して、アプリをリロードしてください。",
webReactotronHint:
"もし動かなければ、Reactotronのデスクトップアプリが実行されていることを確認して、アプリをリロードしてください。",
windowsReactotronHint:
"もし動かなければ、Reactotronのデスクトップアプリが実行されていることを確認して、アプリをリロードしてください。",
},
demoPodcastListScreen: {
title: "React Native Radioのエピソード",
onlyFavorites: "お気に入り表示",
favoriteButton: "お気に入り",
unfavoriteButton: "お気に入りを外す",
accessibility: {
cardHint: "ダブルタップで再生します。 ダブルタップと長押しで {{action}}",
switch: "スイッチオンでお気に入りを表示する",
favoriteAction: "お気に入りの切り替え",
favoriteIcon: "お気に入りのエピソードではありません",
unfavoriteIcon: "お気に入りのエピソードです",
publishLabel: "公開日 {{date}}",
durationLabel: "再生時間: {{hours}} 時間 {{minutes}} 分 {{seconds}} 秒",
},
noFavoritesEmptyState: {
heading: "どうやら空っぽのようですね",
content:
"お気に入りのエピソードがまだありません。エピソードにあるハートマークにタップして、お気に入りに追加しましょう!",
},
},
...demoJa,
}
export default ja

300
RN_TEMPLATE/app/i18n/ko.ts Normal file
View File

@@ -0,0 +1,300 @@
import demoKo from "./demo-ko"
import { Translations } from "./en"
const ko: Translations = {
common: {
ok: "확인!",
cancel: "취소",
save: "저장",
back: "뒤로",
logOut: "로그아웃",
copied: "복사됨",
},
welcomeScreen: {
postscript:
"잠깐! — 지금 보시는 것은 아마도 당신의 앱의 모양새가 아닐겁니다. (디자이너분이 이렇게 건내주셨다면 모를까요. 만약에 그렇다면, 이대로 가져갑시다!) ",
readyForLaunch: "출시 준비가 거의 끝난 나만의 앱!",
exciting: "(오, 이거 신나는데요!)",
letsGo: "가보자구요!",
},
errorScreen: {
title: "뭔가 잘못되었습니다!",
friendlySubtitle:
"이 화면은 오류가 발생할 때 프로덕션에서 사용자에게 표시됩니다. 이 메시지를 커스터마이징 할 수 있고(해당 파일은 `app/i18n/ko.ts` 에 있습니다) 레이아웃도 마찬가지로 수정할 수 있습니다(`app/screens/error`). 만약 이 오류화면을 완전히 없에버리고 싶다면 `app/app.tsx` 파일에서 <ErrorBoundary> 컴포넌트를 확인하기 바랍니다.",
reset: "초기화",
traceTitle: "%{name} 스택에서의 오류",
},
emptyStateComponent: {
generic: {
heading: "너무 텅 비어서.. 너무 슬퍼요..",
content: "데이터가 없습니다. 버튼을 눌러서 리프레쉬 하시거나 앱을 리로드하세요.",
button: "다시 시도해봅시다",
},
},
errors: {
invalidEmail: "잘못된 이메일 주소 입니다.",
},
authErrors: {
timeout: "요청 시간이 초과되었습니다. 다시 시도해주세요.",
cannotConnect: "서버에 연결할 수 없습니다. 네트워크를 확인해주세요.",
serverError: "서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.",
badData: "서버 응답이 올바르지 않습니다.",
unknownError: "알 수 없는 오류가 발생했습니다.",
E001: "이미 사용 중인 사용자명입니다.",
E002: "이미 등록된 이메일입니다.",
E003: "유효하지 않은 추천 코드입니다.",
E004: "대기 중인 가입 요청이 없습니다.",
E005: "잘못된 인증 코드입니다.",
E006: "이미 추천 코드가 등록되어 있습니다.",
E007: "자신의 추천 코드는 사용할 수 없습니다.",
E011: "이메일 또는 비밀번호가 올바르지 않습니다.",
E012: "계정이 일시적으로 잠겼습니다. 잠시 후 다시 시도해주세요.",
E013: "대기 중인 로그인 요청이 없습니다.",
E014: "존재하지 않거나 비활성화된 사용자입니다.",
E015: "잘못된 인증 코드입니다.",
E016: "유효하지 않은 Telegram 인증입니다.",
E017: "Telegram 데이터 형식이 올바르지 않습니다.",
E018: "Google 인증에 실패했습니다.",
E019: "Google 토큰을 입력해주세요.",
E021: "현재 비밀번호가 올바르지 않습니다.",
E022: "새 비밀번호는 현재 비밀번호와 같을 수 없습니다.",
E023: "등록되지 않은 이메일입니다.",
E024: "계정이 비활성화되었습니다.",
E025: "대기 중인 비밀번호 재설정 요청이 없습니다.",
E026: "비밀번호 재설정 코드가 만료되었습니다.",
E027: "잘못된 비밀번호 재설정 코드입니다.",
E028: "존재하지 않는 사용자입니다.",
E029: "수정할 필드가 제공되지 않았습니다.",
E041: "인증 코드 전송이 너무 빈번합니다. 잠시 기다려주세요.",
E042: "이메일 인증 코드가 만료되었습니다.",
E043: "잘못된 이메일 인증 코드입니다.",
E044: "이미 인증된 이메일입니다.",
E045: "이미 다른 사용자가 사용 중인 이메일입니다.",
E046: "인증된 이메일이 없습니다.",
E047: "먼저 현재 이메일로 인증 코드를 전송해주세요.",
E048: "먼저 현재 이메일을 인증해주세요.",
E049: "먼저 새 이메일로 인증 코드를 전송해주세요.",
E050: "잘못된 action 매개변수입니다.",
},
loginScreen: {
logIn: "로그인",
enterDetails:
"일급비밀 정보를 해제하기 위해 상세 정보를 입력하세요. 무엇이 기다리고 있는지 절대 모를겁니다. 혹은 알 수 있을지도 모르겠군요. 엄청 복잡한 뭔가는 아닙니다.",
emailFieldLabel: "이메일",
passwordFieldLabel: "비밀번호",
emailFieldPlaceholder: "이메일을 입력하세요",
passwordFieldPlaceholder: "엄청 비밀스러운 암호를 입력하세요",
tapToLogIn: "눌러서 로그인 하기!",
hint: "힌트: 가장 좋아하는 암호와 아무런 아무 이메일 주소나 사용할 수 있어요 :)",
},
navigator: {
componentsTab: "컴포넌트",
debugTab: "디버그",
communityTab: "커뮤니티",
podcastListTab: "팟캐스트",
profileTab: "프로필",
},
profileScreen: {
title: "프로필",
guest: "게스트",
uid: "UID",
username: "사용자 이름",
referralCode: "추천 코드",
settings: "설정",
darkMode: "다크 모드",
notifications: "알림",
security: "보안",
account: "계정",
accountStatus: "계정 상태",
active: "활성",
inactive: "비활성",
regular: "일반",
emailVerified: "이메일 인증",
verified: "인증됨",
unverified: "미인증",
loginMethods: "로그인 방법",
version: "버전",
changePassword: "비밀번호 변경",
changeEmail: "이메일 변경",
editNickname: "닉네임 수정",
editProfile: "프로필 수정",
nicknamePlaceholder: "닉네임을 입력하세요",
nickname: "닉네임",
tapToChangeAvatar: "아바타 변경하려면 탭하세요",
about: "정보",
},
changePasswordScreen: {
title: "비밀번호 변경",
description: "현재 비밀번호를 입력하고 새 비밀번호를 선택하세요.",
oldPassword: "현재 비밀번호",
newPassword: "새 비밀번호",
confirmPassword: "새 비밀번호 확인",
logoutOtherDevices: "다른 기기에서 로그아웃",
submit: "비밀번호 변경",
success: "성공",
successMessage: "비밀번호가 성공적으로 변경되었습니다.",
oldPasswordRequired: "현재 비밀번호가 필요합니다.",
newPasswordRequired: "새 비밀번호가 필요합니다.",
passwordTooShort: "비밀번호는 최소 6자 이상이어야 합니다.",
passwordMismatch: "비밀번호가 일치하지 않습니다.",
samePassword: "새 비밀번호는 현재 비밀번호와 같을 수 없습니다.",
},
changeEmailScreen: {
title: "이메일 변경",
step1Label: "이메일 인증",
step2Label: "새 이메일 연결",
step1Title: "1단계: 현재 이메일 확인",
step1Description: "현재 이메일 주소로 인증 코드를 보내드립니다.",
step2Title: "2단계: 새 이메일 연결",
step2Description: "새 이메일 주소를 입력하고 인증하세요.",
currentEmail: "현재 이메일",
newEmail: "새 이메일 주소",
verificationCode: "인증 코드",
sendCode: "인증 코드 보내기",
sendCodeToNewEmail: "새 이메일로 코드 보내기",
verify: "확인",
confirmNewEmail: "새 이메일 확인",
resendCode: "코드 재전송",
success: "성공",
successMessage: "이메일이 성공적으로 변경되었습니다.",
codeRequired: "인증 코드가 필요합니다.",
codeInvalid: "인증 코드는 6자리여야 합니다.",
newEmailRequired: "새 이메일 주소가 필요합니다.",
emailInvalid: "올바른 이메일 주소를 입력하세요.",
sameEmail: "새 이메일은 현재 이메일과 같을 수 없습니다.",
},
settingsScreen: {
title: "설정",
appearance: "외관",
theme: "테마",
darkMode: "다크 모드",
language: "언어",
currentLanguage: "언어",
},
languageScreen: {
title: "언어",
selectHint: "원하는 언어를 선택하세요",
},
themeScreen: {
title: "테마",
selectHint: "원하는 테마를 선택하세요",
system: "시스템",
light: "라이트",
dark: "다크",
},
securityScreen: {
title: "보안",
description: "계정 보안 설정을 관리합니다.",
changePassword: "비밀번호 변경",
changeEmail: "이메일 변경",
// Session management
activeSessions: "Active Sessions",
activeSessionsDescription: "Devices currently logged into your account",
currentDevice: "Current Device",
lastActive: "Last active",
loginMethod: "Login via",
logoutDevice: "Logout",
logoutAllOther: "Logout All Other Devices",
logoutAllOtherDescription: "This will log out all devices except the current one.",
noOtherSessions: "No other active sessions",
sessionRevoked: "Device has been logged out",
sessionsRevoked: "{{count}} devices have been logged out",
confirmLogout: "Confirm Logout",
confirmLogoutMessage: "Are you sure you want to log out this device?",
confirmLogoutAllMessage: "Are you sure you want to log out all other devices?",
deviceTypes: {
desktop: "Desktop",
mobile: "Mobile",
tablet: "Tablet",
unknown: "Unknown Device",
},
},
aboutScreen: {
title: "정보",
appInfo: "앱 정보",
appName: "앱 이름",
version: "버전",
buildVersion: "빌드 버전",
appId: "앱 ID",
appVersion: "앱 버전",
legal: "법률",
privacyPolicy: "개인정보처리방침",
termsOfService: "서비스 이용약관",
},
communityScreen: {
title: "커뮤니티와 함께해요",
tagLine:
"전문적인 React Native 엔지니어들로 구성된 Infinite Red 커뮤니티에 접속해서 함께 개발 실력을 향상시켜 보세요!",
joinUsOnSlackTitle: "Slack 에 참여하세요",
joinUsOnSlack:
"전 세계 React Native 엔지니어들과 함께할 수 있는 곳이 있었으면 좋겠죠? Infinite Red Community Slack 에서 대화에 참여하세요! 우리의 성장하는 커뮤니티는 질문을 던지고, 다른 사람들로부터 배우고, 네트워크를 확장할 수 있는 안전한 공간입니다. ",
joinSlackLink: "Slack 에 참여하기",
makeIgniteEvenBetterTitle: "Ignite 을 향상시켜요",
makeIgniteEvenBetter:
"Ignite 을 더 좋게 만들 아이디어가 있나요? 기쁜 소식이네요. 우리는 항상 최고의 React Native 도구를 구축하는데 도움을 줄 수 있는 분들을 찾고 있습니다. GitHub 에서 Ignite 의 미래를 만들어 가는것에 함께해 주세요.",
contributeToIgniteLink: "Ignite 에 기여하기",
theLatestInReactNativeTitle: "React Native 의 최신정보",
theLatestInReactNative: "React Native 가 제공하는 모든 최신 정보를 알려드립니다.",
reactNativeRadioLink: "React Native 라디오",
reactNativeNewsletterLink: "React Native 뉴스레터",
reactNativeLiveLink: "React Native 라이브 스트리밍",
chainReactConferenceLink: "Chain React 컨퍼런스",
hireUsTitle: "다음 프로젝트에 Infinite Red 를 고용하세요",
hireUs:
"프로젝트 전체를 수행하든, 실무 교육을 통해 팀의 개발 속도에 박차를 가하든 상관없이, Infinite Red 는 React Native 프로젝트의 모든 분야의 에서 도움을 드릴 수 있습니다.",
hireUsLink: "메세지 보내기",
},
showroomScreen: {
jumpStart: "프로젝트를 바로 시작할 수 있는 컴포넌트들!",
lorem2Sentences:
"별 하나에 추억과, 별 하나에 사랑과, 별 하나에 쓸쓸함과, 별 하나에 동경(憧憬)과, 별 하나에 시와, 별 하나에 어머니, 어머니",
demoHeaderTxExample: "야호",
demoViaTxProp: "`tx` Prop 을 통해",
demoViaSpecifiedTxProp: "`{{prop}}Tx` Prop 을 통해",
},
demoDebugScreen: {
howTo: "사용방법",
title: "디버그",
tagLine:
"축하합니다. 여기 아주 고급스러운 React Native 앱 템플릿이 있습니다. 이 보일러 플레이트를 사용해보세요!",
reactotron: "Reactotron 으로 보내기",
reportBugs: "버그 보고하기",
demoList: "데모 목록",
demoPodcastList: "데모 팟캐스트 목록",
androidReactotronHint:
"만약에 동작하지 않는 경우, Reactotron 데스크탑 앱이 실행중인지 확인 후, 터미널에서 adb reverse tcp:9090 tcp:9090 을 실행한 다음 앱을 다시 실행해보세요.",
iosReactotronHint:
"만약에 동작하지 않는 경우, Reactotron 데스크탑 앱이 실행중인지 확인 후 앱을 다시 실행해보세요.",
macosReactotronHint:
"만약에 동작하지 않는 경우, Reactotron 데스크탑 앱이 실행중인지 확인 후 앱을 다시 실행해보세요.",
webReactotronHint:
"만약에 동작하지 않는 경우, Reactotron 데스크탑 앱이 실행중인지 확인 후 앱을 다시 실행해보세요.",
windowsReactotronHint:
"만약에 동작하지 않는 경우, Reactotron 데스크탑 앱이 실행중인지 확인 후 앱을 다시 실행해보세요.",
},
demoPodcastListScreen: {
title: "React Native 라디오 에피소드",
onlyFavorites: "즐겨찾기만 보기",
favoriteButton: "즐겨찾기",
unfavoriteButton: "즐겨찾기 해제",
accessibility: {
cardHint:
"에피소드를 들으려면 두 번 탭하세요. 이 에피소드를 좋아하거나 싫어하려면 두 번 탭하고 길게 누르세요.",
switch: "즐겨찾기를 사용하려면 스위치를 사용하세요.",
favoriteAction: "즐겨찾기 토글",
favoriteIcon: "좋아하는 에피소드",
unfavoriteIcon: "즐겨찾기하지 않은 에피소드",
publishLabel: "{{date}} 에 발행됨",
durationLabel: "소요시간: {{hours}}시간 {{minutes}}분 {{seconds}}초",
},
noFavoritesEmptyState: {
heading: "조금 텅 비어 있네요.",
content: "즐겨찾기가 없습니다. 에피소드에 있는 하트를 눌러서 즐겨찾기에 추가하세요.",
},
},
...demoKo,
}
export default ko

View File

@@ -0,0 +1,33 @@
import i18n from "i18next"
import type { TOptions } from "i18next"
import { TxKeyPath } from "."
/**
* Translates text.
* @param {TxKeyPath} key - The i18n key.
* @param {TOptions} options - The i18n options.
* @returns {string} - The translated text.
* @example
* Translations:
*
* ```en.ts
* {
* "hello": "Hello, {{name}}!"
* }
* ```
*
* Usage:
* ```ts
* import { translate } from "./i18n"
*
* translate("hello", { name: "world" })
* // => "Hello world!"
* ```
*/
export function translate(key: TxKeyPath, options?: TOptions): string {
if (i18n.isInitialized) {
return i18n.t(key, options)
}
return key
}

297
RN_TEMPLATE/app/i18n/zh.ts Normal file
View File

@@ -0,0 +1,297 @@
import demoZh from "./demo-zh"
import { Translations } from "./en"
const zh: Translations = {
common: {
ok: "确定",
cancel: "取消",
save: "保存",
back: "返回",
logOut: "退出登录",
copied: "已复制",
},
welcomeScreen: {
postscript:
"提示 — 这可能不是您的应用最终的样子。(除非您的设计师就是这样设计的,那就直接发布吧!)",
readyForLaunch: "您的应用即将准备好发布!",
exciting: "(哦,这太令人兴奋了!)",
letsGo: "开始吧!",
},
errorScreen: {
title: "出错了!",
friendlySubtitle:
"这是用户在生产环境中遇到错误时会看到的页面。您可以自定义此消息(位于 `app/i18n/zh.ts`)以及布局(`app/screens/ErrorScreen`)。如果您想完全删除此页面,请检查 `app/app.tsx` 中的 <ErrorBoundary> 组件。",
reset: "重置应用",
traceTitle: "来自 %{name} 堆栈的错误",
},
emptyStateComponent: {
generic: {
heading: "空空如也...好伤心",
content: "暂无数据。请尝试点击按钮刷新或重新加载应用。",
button: "再试一次",
},
},
errors: {
invalidEmail: "无效的邮箱地址。",
},
authErrors: {
// 网络错误
timeout: "请求超时,请重试。",
cannotConnect: "无法连接服务器,请检查网络。",
serverError: "服务器错误,请稍后重试。",
badData: "服务器响应无效。",
unknownError: "发生未知错误。",
// 注册错误 (E001-E010)
E001: "用户名已存在。",
E002: "邮箱已被注册。",
E003: "推荐码无效。",
E004: "没有待验证的注册请求。",
E005: "验证码错误。",
E006: "已绑定过邀请码。",
E007: "不能绑定自己的邀请码。",
// 登录错误 (E011-E020)
E011: "邮箱或密码错误。",
E012: "账户已被临时锁定,请稍后重试。",
E013: "没有待验证的登录请求。",
E014: "用户不存在或已被禁用。",
E015: "验证码错误。",
E016: "Telegram 认证数据无效。",
E017: "Telegram 数据格式错误。",
E018: "Google 认证失败。",
E019: "请提供 Google Token。",
// 密码错误 (E021-E030)
E021: "当前密码错误。",
E022: "新密码不能与当前密码相同。",
E023: "该邮箱未注册。",
E024: "账号已被禁用。",
E025: "没有待处理的找回密码请求。",
E026: "找回密码验证码已过期。",
E027: "找回密码验证码错误。",
E028: "用户不存在。",
E029: "没有提供要修改的字段。",
// 邮箱验证错误 (E041-E055)
E041: "发送验证码过于频繁,请稍后重试。",
E042: "邮箱验证码已过期。",
E043: "邮箱验证码错误。",
E044: "邮箱已验证。",
E045: "邮箱已被其他用户使用。",
E046: "没有已验证的邮箱。",
E047: "请先发送当前邮箱验证码。",
E048: "需要先验证当前邮箱。",
E049: "请先发送新邮箱验证码。",
E050: "无效的 action 参数。",
},
loginScreen: {
logIn: "登录",
enterDetails: "请输入您的账号信息以继续。",
emailFieldLabel: "邮箱",
passwordFieldLabel: "密码",
emailFieldPlaceholder: "请输入邮箱地址",
passwordFieldPlaceholder: "请输入密码",
tapToLogIn: "点击登录",
hint: "提示:您可以使用任何邮箱地址和密码 :)",
},
navigator: {
componentsTab: "组件",
debugTab: "调试",
communityTab: "社区",
podcastListTab: "播客",
profileTab: "我的",
},
profileScreen: {
title: "个人中心",
guest: "游客",
uid: "UID",
username: "用户名",
referralCode: "邀请码",
settings: "设置",
darkMode: "深色模式",
notifications: "通知",
security: "安全",
account: "账户",
accountStatus: "账户状态",
active: "正常",
inactive: "未激活",
regular: "普通用户",
emailVerified: "邮箱验证",
verified: "已验证",
unverified: "未验证",
loginMethods: "登录方式",
version: "版本",
changePassword: "修改密码",
changeEmail: "更换邮箱",
editNickname: "修改昵称",
editProfile: "编辑资料",
nicknamePlaceholder: "请输入昵称",
nickname: "昵称",
tapToChangeAvatar: "点击更换头像",
about: "关于",
},
changePasswordScreen: {
title: "修改密码",
description: "请输入当前密码并设置新密码。",
oldPassword: "当前密码",
newPassword: "新密码",
confirmPassword: "确认新密码",
logoutOtherDevices: "退出其他设备登录",
submit: "修改密码",
success: "成功",
successMessage: "密码修改成功。",
oldPasswordRequired: "请输入当前密码。",
newPasswordRequired: "请输入新密码。",
passwordTooShort: "密码至少需要6个字符。",
passwordMismatch: "两次输入的密码不一致。",
samePassword: "新密码不能与当前密码相同。",
},
changeEmailScreen: {
title: "更换邮箱",
step1Label: "验证邮箱",
step2Label: "绑定新邮箱",
step1Title: "第一步:验证当前邮箱",
step1Description: "我们将向您的当前邮箱发送验证码。",
step2Title: "第二步:绑定新邮箱",
step2Description: "输入您的新邮箱地址并进行验证。",
currentEmail: "当前邮箱",
newEmail: "新邮箱地址",
verificationCode: "验证码",
sendCode: "发送验证码",
sendCodeToNewEmail: "发送验证码到新邮箱",
verify: "验证",
confirmNewEmail: "确认新邮箱",
resendCode: "重新发送验证码",
success: "成功",
successMessage: "邮箱更换成功。",
codeRequired: "请输入验证码。",
codeInvalid: "验证码必须是6位数字。",
newEmailRequired: "请输入新邮箱地址。",
emailInvalid: "请输入有效的邮箱地址。",
sameEmail: "新邮箱不能与当前邮箱相同。",
},
settingsScreen: {
title: "设置",
appearance: "外观",
theme: "主题",
darkMode: "深色模式",
language: "语言",
currentLanguage: "语言",
},
languageScreen: {
title: "语言",
selectHint: "选择您的首选语言",
},
themeScreen: {
title: "主题",
selectHint: "选择您的首选主题",
system: "跟随系统",
light: "浅色",
dark: "深色",
},
securityScreen: {
title: "安全",
description: "管理您的账户安全设置。",
changePassword: "修改密码",
changeEmail: "更换邮箱",
// Session management
activeSessions: "活跃会话",
activeSessionsDescription: "当前登录您账户的设备",
currentDevice: "当前设备",
lastActive: "最后活跃",
loginMethod: "登录方式",
logoutDevice: "退出",
logoutAllOther: "退出所有其他设备",
logoutAllOtherDescription: "这将退出除当前设备外的所有设备。",
noOtherSessions: "没有其他活跃会话",
sessionRevoked: "设备已退出登录",
sessionsRevoked: "已退出 {{count}} 个设备",
confirmLogout: "确认退出",
confirmLogoutMessage: "确定要退出此设备吗?",
confirmLogoutAllMessage: "确定要退出所有其他设备吗?",
deviceTypes: {
desktop: "桌面端",
mobile: "移动端",
tablet: "平板",
unknown: "未知设备",
},
},
aboutScreen: {
title: "关于",
appInfo: "应用信息",
appName: "应用名称",
version: "版本号",
buildVersion: "构建版本",
appId: "应用 ID",
appVersion: "应用版本",
legal: "法律条款",
privacyPolicy: "隐私政策",
termsOfService: "服务条款",
},
communityScreen: {
title: "加入社区",
tagLine: "加入 Infinite Red 的 React Native 工程师社区,与我们一起提升您的应用开发技能!",
joinUsOnSlackTitle: "加入 Slack",
joinUsOnSlack:
"想要与世界各地的 React Native 工程师交流吗?加入 Infinite Red 社区 Slack我们不断壮大的社区是一个安全的空间您可以在这里提问、向他人学习并扩展您的人脉网络。",
joinSlackLink: "加入 Slack 社区",
makeIgniteEvenBetterTitle: "让 Ignite 更好",
makeIgniteEvenBetter:
"有让 Ignite 变得更好的想法吗?我们很高兴听到!我们一直在寻找想要帮助我们构建最好的 React Native 工具的人。加入我们的 GitHub与我们一起构建 Ignite 的未来。",
contributeToIgniteLink: "为 Ignite 做贡献",
theLatestInReactNativeTitle: "React Native 最新动态",
theLatestInReactNative: "我们在这里为您提供 React Native 的所有最新动态。",
reactNativeRadioLink: "React Native Radio",
reactNativeNewsletterLink: "React Native Newsletter",
reactNativeLiveLink: "React Native Live",
chainReactConferenceLink: "Chain React 大会",
hireUsTitle: "为您的下一个项目聘请 Infinite Red",
hireUs:
"无论是完整运行一个项目还是通过我们的实践培训帮助团队加速Infinite Red 几乎可以帮助任何 React Native 项目。",
hireUsLink: "发送消息",
},
showroomScreen: {
jumpStart: "快速启动您的项目的组件!",
lorem2Sentences: "这是一段示例文本,用于展示组件的效果。这是第二句话。",
demoHeaderTxExample: "好耶",
demoViaTxProp: "通过 `tx` 属性",
demoViaSpecifiedTxProp: "通过 `{{prop}}Tx` 属性",
},
demoDebugScreen: {
howTo: "使用方法",
title: "调试",
tagLine: "恭喜,您拥有了一个非常先进的 React Native 应用模板。好好利用它吧!",
reactotron: "发送到 Reactotron",
reportBugs: "报告 Bug",
demoList: "演示列表",
demoPodcastList: "播客列表演示",
androidReactotronHint:
"如果无法正常工作,请确保 Reactotron 桌面应用正在运行,从终端运行 adb reverse tcp:9090 tcp:9090然后重新加载应用。",
iosReactotronHint: "如果无法正常工作,请确保 Reactotron 桌面应用正在运行,然后重新加载应用。",
macosReactotronHint: "如果无法正常工作,请确保 Reactotron 桌面应用正在运行,然后重新加载应用。",
webReactotronHint: "如果无法正常工作,请确保 Reactotron 桌面应用正在运行,然后重新加载应用。",
windowsReactotronHint:
"如果无法正常工作,请确保 Reactotron 桌面应用正在运行,然后重新加载应用。",
},
demoPodcastListScreen: {
title: "React Native Radio 节目",
onlyFavorites: "只显示收藏",
favoriteButton: "收藏",
unfavoriteButton: "取消收藏",
accessibility: {
cardHint: "双击收听节目。双击并长按可{{action}}此节目。",
switch: "打开开关只显示收藏",
favoriteAction: "切换收藏状态",
favoriteIcon: "节目未收藏",
unfavoriteIcon: "节目已收藏",
publishLabel: "发布于 {{date}}",
durationLabel: "时长:{{hours}}小时{{minutes}}分钟{{seconds}}秒",
},
noFavoritesEmptyState: {
heading: "这里看起来有点空",
content: "还没有收藏。点击节目上的心形图标将其添加到收藏!",
},
},
...demoZh,
}
export default zh

View File

@@ -0,0 +1,103 @@
/**
* The app navigator (formerly "AppNavigator" and "MainNavigator") is used for the primary
* navigation flows of your app.
* Generally speaking, it will contain an auth flow (registration, login, forgot password)
* and a "main" flow which the user will use once logged in.
*/
import { NavigationContainer } from "@react-navigation/native"
import { createNativeStackNavigator } from "@react-navigation/native-stack"
import Config from "@/config"
import { useAuth } from "@/context/AuthContext"
import { AboutScreen } from "@/screens/AboutScreen"
import { AuthWelcomeScreen } from "@/screens/AuthWelcomeScreen"
import { ChangeEmailScreen } from "@/screens/ChangeEmailScreen"
import { ChangePasswordScreen } from "@/screens/ChangePasswordScreen"
import { ErrorBoundary } from "@/screens/ErrorScreen/ErrorBoundary"
import { ForgotPasswordScreen } from "@/screens/ForgotPasswordScreen"
import { LanguageScreen } from "@/screens/LanguageScreen"
import { LoginScreen } from "@/screens/LoginScreen"
import { ProfileScreen } from "@/screens/ProfileScreen"
import { RegisterScreen } from "@/screens/RegisterScreen"
import { SecurityScreen } from "@/screens/SecurityScreen"
import { SessionManagementScreen } from "@/screens/SessionManagementScreen"
import { SettingsScreen } from "@/screens/SettingsScreen"
import { ThemeScreen } from "@/screens/ThemeScreen"
import { WelcomeScreen } from "@/screens/WelcomeScreen"
import { useAppTheme } from "@/theme/context"
import { MainNavigator } from "./MainNavigator"
import type { AppStackParamList, NavigationProps } from "./navigationTypes"
import { navigationRef, useBackButtonHandler } from "./navigationUtilities"
/**
* This is a list of all the route names that will exit the app if the back button
* is pressed while in that screen. Only affects Android.
*/
const exitRoutes = Config.exitRoutes
// Documentation: https://reactnavigation.org/docs/stack-navigator/
const Stack = createNativeStackNavigator<AppStackParamList>()
const AppStack = () => {
const { isAuthenticated } = useAuth()
const {
theme: { colors },
} = useAppTheme()
return (
<Stack.Navigator
screenOptions={{
headerShown: false,
navigationBarColor: colors.background,
contentStyle: {
backgroundColor: colors.background,
},
}}
initialRouteName={isAuthenticated ? "Welcome" : "AuthWelcome"}
>
{isAuthenticated ? (
<>
<Stack.Screen name="Welcome" component={WelcomeScreen} />
<Stack.Screen name="Main" component={MainNavigator} />
<Stack.Screen name="Profile" component={ProfileScreen} />
<Stack.Screen name="Settings" component={SettingsScreen} />
<Stack.Screen name="Security" component={SecurityScreen} />
<Stack.Screen name="SessionManagement" component={SessionManagementScreen} />
<Stack.Screen name="ChangePassword" component={ChangePasswordScreen} />
<Stack.Screen name="ChangeEmail" component={ChangeEmailScreen} />
<Stack.Screen name="About" component={AboutScreen} />
<Stack.Screen name="Language" component={LanguageScreen} />
<Stack.Screen name="Theme" component={ThemeScreen} />
</>
) : (
<>
<Stack.Screen name="AuthWelcome" component={AuthWelcomeScreen} />
<Stack.Screen name="Login" component={LoginScreen} />
<Stack.Screen name="Register" component={RegisterScreen} />
<Stack.Screen name="ForgotPassword" component={ForgotPasswordScreen} />
</>
)}
{/** 🔥 Your screens go here */}
{/* IGNITE_GENERATOR_ANCHOR_APP_STACK_SCREENS */}
</Stack.Navigator>
)
}
export const AppNavigator = (props: NavigationProps) => {
const { navigationTheme } = useAppTheme()
useBackButtonHandler((routeName) => exitRoutes.includes(routeName))
return (
<NavigationContainer ref={navigationRef} theme={navigationTheme} {...props}>
<ErrorBoundary catchErrors={Config.catchErrors}>
<AppStack />
</ErrorBoundary>
</NavigationContainer>
)
}

View File

@@ -0,0 +1,350 @@
import { useEffect, useRef, useState } from "react"
import {
Animated,
Dimensions,
Platform,
Pressable,
TextStyle,
TouchableOpacity,
View,
ViewStyle,
} from "react-native"
import { BottomTabBarProps, createBottomTabNavigator } from "@react-navigation/bottom-tabs"
import { useNavigation } from "@react-navigation/native"
import { NativeStackNavigationProp } from "@react-navigation/native-stack"
import {
PanGestureHandler,
PanGestureHandlerStateChangeEvent,
State,
} from "react-native-gesture-handler"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { Avatar } from "@/components/Avatar"
import { Icon, IconTypes } from "@/components/Icon"
import { Text } from "@/components/Text"
import { useAuth } from "@/context/AuthContext"
import { translate } from "@/i18n/translate"
import { CommunityScreen } from "@/screens/CommunityScreen"
import { ShowroomScreen } from "@/screens/ShowroomScreen/ShowroomScreen"
import { useAppTheme } from "@/theme/context"
import type { ThemedStyle } from "@/theme/types"
import { s, fs } from "@/utils/responsive"
import type { AppStackParamList, MainTabParamList } from "./navigationTypes"
const Tab = createBottomTabNavigator<MainTabParamList>()
const { width: SCREEN_WIDTH } = Dimensions.get("window")
// Tab configuration
const TAB_CONFIG: { name: keyof MainTabParamList; icon: IconTypes }[] = [
{ name: "Showroom", icon: "home" },
{ name: "Community", icon: "barChart" },
]
/**
* Header left component with profile avatar button
*/
function HeaderProfileButton() {
const { user } = useAuth()
const navigation = useNavigation<NativeStackNavigationProp<AppStackParamList>>()
const handlePress = () => {
navigation.navigate("Profile")
}
return (
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
<Avatar
uri={user?.profile?.avatar}
fallback={user?.profile?.nickname || user?.username || "U"}
size={s(36)}
/>
</TouchableOpacity>
)
}
/**
* Custom floating capsule tab bar with glass effect and gesture support
*/
function FloatingTabBar({ state, navigation }: BottomTabBarProps) {
const insets = useSafeAreaInsets()
const { themed, theme } = useAppTheme()
const tabIndex = state.index
const tabCount = TAB_CONFIG.length
const containerWidth = SCREEN_WIDTH - theme.spacing.xl * 2
const buttonWidth = (containerWidth - theme.spacing.xs * 2) / tabCount
// 动画值
const basePos = useRef(new Animated.Value(tabIndex)).current
const gestureX = useRef(new Animated.Value(0)).current
// 视觉上的活跃索引,用于控制图标颜色
const [visualIndex, setVisualIndex] = useState(tabIndex)
// 同步系统索引到视觉索引
useEffect(() => {
setVisualIndex(tabIndex)
Animated.spring(basePos, {
toValue: tabIndex,
useNativeDriver: true,
tension: 100,
friction: 12,
}).start()
}, [tabIndex, basePos])
// 监听手势位移,实时更新图标视觉状态
useEffect(() => {
const listenerId = gestureX.addListener(({ value }) => {
// 计算当前指示器中心位置对应的索引
const currentIndicatorPos = tabIndex * buttonWidth + value
const newVisualIndex = Math.max(
0,
Math.min(TAB_CONFIG.length - 1, Math.round(currentIndicatorPos / buttonWidth)),
)
if (newVisualIndex !== visualIndex) {
setVisualIndex(newVisualIndex)
}
})
return () => gestureX.removeListener(listenerId)
}, [tabIndex, visualIndex, buttonWidth, gestureX])
// 手势结束处理
const onHandlerStateChange = (event: PanGestureHandlerStateChangeEvent) => {
if (event.nativeEvent.state === State.END) {
const { translationX, velocityX } = event.nativeEvent
const movedTabs = Math.round(translationX / buttonWidth)
// 快速滑动检测
let finalMovedTabs = movedTabs
if (Math.abs(velocityX) > 500 && Math.abs(translationX) > 20) {
finalMovedTabs = velocityX > 0 ? -1 : 1 // 注意方向:向右滑是上一个,向左滑是下一个
}
// 计算新索引
let newIndex = tabIndex + finalMovedTabs
newIndex = Math.max(0, Math.min(TAB_CONFIG.length - 1, newIndex))
if (newIndex !== tabIndex) {
gestureX.flattenOffset()
requestAnimationFrame(() => {
navigation.navigate(TAB_CONFIG[newIndex].name)
})
}
// 弹簧动画复位
Animated.spring(gestureX, {
toValue: 0,
useNativeDriver: true,
tension: 100,
friction: 15,
}).start()
}
}
// 指示器位置 = 基准位置 + 手势偏移
const indicatorTranslateX = Animated.add(
basePos.interpolate({
inputRange: TAB_CONFIG.map((_, i) => i),
outputRange: TAB_CONFIG.map((_, i) => theme.spacing.xs + i * buttonWidth),
}),
gestureX,
)
return (
<View style={$tabBarOverlay}>
<View style={[themed($tabBarContainer), { bottom: insets.bottom + theme.spacing.sm }]}>
<PanGestureHandler
onGestureEvent={Animated.event([{ nativeEvent: { translationX: gestureX } }], {
useNativeDriver: true,
})}
onHandlerStateChange={onHandlerStateChange}
activeOffsetX={[-15, 15]}
>
<Animated.View style={themed($tabBarGlass)}>
{/* 滑动指示器 */}
<Animated.View
style={[
themed($tabIndicator),
{
width: buttonWidth,
transform: [{ translateX: indicatorTranslateX }],
},
]}
/>
{/* Tab 按钮 */}
{TAB_CONFIG.map((tab, index) => {
// 图标颜色取决于 visualIndex (手势实时计算) 而不是 state.index
const isActive = visualIndex === index
return (
<Pressable
key={tab.name}
style={$tabButton}
onPress={() => {
if (tabIndex !== index) {
navigation.navigate(tab.name)
}
}}
>
<Icon
icon={tab.icon}
size={s(24)}
color={isActive ? theme.colors.tint : theme.colors.textDim}
strokeWidth={1.5}
/>
</Pressable>
)
})}
</Animated.View>
</PanGestureHandler>
</View>
</View>
)
}
/**
* Custom header component with absolutely centered title
*/
function CustomHeader({ title }: { title: string }) {
const { themed } = useAppTheme()
const insets = useSafeAreaInsets()
return (
<View
style={[themed($headerContainer), { paddingTop: insets.top, height: s(56) + insets.top }]}
>
{/* Absolutely centered title */}
<View style={[$headerTitleWrapper, { top: insets.top, height: s(56) }]}>
<Text style={themed($headerTitle)}>{title}</Text>
</View>
{/* Left side - avatar */}
<View style={$headerLeft}>
<HeaderProfileButton />
</View>
{/* Right side - future icons go here */}
<View style={$headerRight} />
</View>
)
}
/**
* This is the main navigator with a floating capsule tab bar.
*/
export function MainNavigator() {
return (
<Tab.Navigator
screenOptions={{
header: ({ options }) => <CustomHeader title={options.title || ""} />,
tabBarHideOnKeyboard: true,
tabBarStyle: { display: "none" },
}}
tabBar={(props) => <FloatingTabBar {...props} />}
>
<Tab.Screen
name="Showroom"
component={ShowroomScreen}
options={{
title: translate("navigator:componentsTab"),
}}
/>
<Tab.Screen
name="Community"
component={CommunityScreen}
options={{
title: translate("navigator:communityTab"),
}}
/>
</Tab.Navigator>
)
}
// Styles
const $headerContainer: ThemedStyle<ViewStyle> = ({ colors, spacing }) => ({
backgroundColor: colors.background,
height: s(56),
flexDirection: "row",
alignItems: "center",
paddingHorizontal: spacing.lg,
})
const $headerTitleWrapper: ViewStyle = {
position: "absolute",
left: 0,
right: 0,
alignItems: "center",
justifyContent: "center",
pointerEvents: "none",
}
const $headerTitle: ThemedStyle<TextStyle> = ({ colors, typography }) => ({
color: colors.text,
fontFamily: typography.primary.medium,
fontSize: fs(17),
})
const $headerLeft: ViewStyle = {
zIndex: 1,
}
const $headerRight: ViewStyle = {
marginLeft: "auto",
zIndex: 1,
}
const $tabBarOverlay: ViewStyle = {
position: "absolute",
bottom: 0,
left: 0,
right: 0,
alignItems: "center",
}
const $tabBarContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
width: SCREEN_WIDTH - spacing.xl * 2,
alignItems: "center",
})
const $tabBarGlass: ThemedStyle<ViewStyle> = ({ colors, spacing, isDark }) => ({
flexDirection: "row",
// 半透明背景模拟毛玻璃效果
backgroundColor: isDark ? "rgba(30, 30, 30, 0.8)" : "rgba(255, 255, 255, 0.8)",
borderRadius: s(28),
height: s(56),
width: "100%",
alignItems: "center",
paddingHorizontal: spacing.xs,
// iOS 阴影
...(Platform.OS === "ios" && {
shadowColor: colors.palette.neutral800,
shadowOffset: { width: 0, height: s(4) },
shadowOpacity: 0.15,
shadowRadius: s(12),
}),
// Android 阴影
elevation: 8,
// 边框增加玻璃感
borderWidth: 1,
borderColor: isDark ? "rgba(255, 255, 255, 0.1)" : "rgba(0, 0, 0, 0.05)",
})
const $tabIndicator: ThemedStyle<ViewStyle> = ({ isDark }) => ({
position: "absolute",
height: s(48),
// 选择器半透明
backgroundColor: isDark ? "rgba(255, 255, 255, 0.15)" : "rgba(0, 0, 0, 0.08)",
borderRadius: s(24),
top: s(4),
left: 0,
})
const $tabButton: ViewStyle = {
flex: 1,
height: "100%",
alignItems: "center",
justifyContent: "center",
zIndex: 1,
}

View File

@@ -0,0 +1,49 @@
import { ComponentProps } from "react"
import { BottomTabScreenProps } from "@react-navigation/bottom-tabs"
import {
CompositeScreenProps,
NavigationContainer,
NavigatorScreenParams,
} from "@react-navigation/native"
import { NativeStackScreenProps } from "@react-navigation/native-stack"
// Main Tab Navigator types
export type MainTabParamList = {
Showroom: { queryIndex?: string; itemIndex?: string }
Community: undefined
}
// App Stack Navigator types
export type AppStackParamList = {
Welcome: undefined
AuthWelcome: undefined
Login: undefined
Register: undefined
ForgotPassword: undefined
Main: NavigatorScreenParams<MainTabParamList>
Profile: undefined
Settings: undefined
Security: undefined
ChangePassword: undefined
ChangeEmail: undefined
SessionManagement: undefined
About: undefined
Language: undefined
Theme: undefined
// 🔥 Your screens go here
// IGNITE_GENERATOR_ANCHOR_APP_STACK_PARAM_LIST
}
export type AppStackScreenProps<T extends keyof AppStackParamList> = NativeStackScreenProps<
AppStackParamList,
T
>
export type MainTabScreenProps<T extends keyof MainTabParamList> = CompositeScreenProps<
BottomTabScreenProps<MainTabParamList, T>,
AppStackScreenProps<keyof AppStackParamList>
>
export interface NavigationProps extends Partial<
ComponentProps<typeof NavigationContainer<AppStackParamList>>
> {}

View File

@@ -0,0 +1,208 @@
import { useState, useEffect, useRef } from "react"
import { BackHandler, Linking, Platform } from "react-native"
import {
NavigationState,
PartialState,
createNavigationContainerRef,
} from "@react-navigation/native"
import Config from "@/config"
import type { PersistNavigationConfig } from "@/config/config.base"
import * as storage from "@/utils/storage"
import { useIsMounted } from "@/utils/useIsMounted"
import type { AppStackParamList, NavigationProps } from "./navigationTypes"
type Storage = typeof storage
/**
* Reference to the root App Navigator.
*
* If needed, you can use this to access the navigation object outside of a
* `NavigationContainer` context. However, it's recommended to use the `useNavigation` hook whenever possible.
* @see [Navigating Without Navigation Prop]{@link https://reactnavigation.org/docs/navigating-without-navigation-prop/}
*
* The types on this reference will only let you reference top level navigators. If you have
* nested navigators, you'll need to use the `useNavigation` with the stack navigator's ParamList type.
*/
export const navigationRef = createNavigationContainerRef<AppStackParamList>()
/**
* Gets the current screen from any navigation state.
* @param {NavigationState | PartialState<NavigationState>} state - The navigation state to traverse.
* @returns {string} - The name of the current screen.
*/
export function getActiveRouteName(state: NavigationState | PartialState<NavigationState>): string {
const route = state.routes[state.index ?? 0]
// Found the active route -- return the name
if (!route.state) return route.name as keyof AppStackParamList
// Recursive call to deal with nested routers
return getActiveRouteName(route.state as NavigationState<AppStackParamList>)
}
const iosExit = () => false
/**
* Hook that handles Android back button presses and forwards those on to
* the navigation or allows exiting the app.
* @see [BackHandler]{@link https://reactnative.dev/docs/backhandler}
* @param {(routeName: string) => boolean} canExit - Function that returns whether we can exit the app.
* @returns {void}
*/
export function useBackButtonHandler(canExit: (routeName: string) => boolean) {
// The reason we're using a ref here is because we need to be able
// to update the canExit function without re-setting up all the listeners
const canExitRef = useRef(Platform.OS !== "android" ? iosExit : canExit)
useEffect(() => {
canExitRef.current = canExit
}, [canExit])
useEffect(() => {
// We'll fire this when the back button is pressed on Android.
const onBackPress = () => {
if (!navigationRef.isReady()) {
return false
}
// grab the current route
const routeName = getActiveRouteName(navigationRef.getRootState())
// are we allowed to exit?
if (canExitRef.current(routeName)) {
// exit and let the system know we've handled the event
BackHandler.exitApp()
return true
}
// we can't exit, so let's turn this into a back action
if (navigationRef.canGoBack()) {
navigationRef.goBack()
return true
}
return false
}
// Subscribe when we come to life
const subscription = BackHandler.addEventListener("hardwareBackPress", onBackPress)
// Unsubscribe when we're done
return () => subscription.remove()
}, [])
}
/**
* This helper function will determine whether we should enable navigation persistence
* based on a config setting and the __DEV__ environment (dev or prod).
* @param {PersistNavigationConfig} persistNavigation - The config setting for navigation persistence.
* @returns {boolean} - Whether to restore navigation state by default.
*/
function navigationRestoredDefaultState(persistNavigation: PersistNavigationConfig) {
if (persistNavigation === "always") return false
if (persistNavigation === "dev" && __DEV__) return false
if (persistNavigation === "prod" && !__DEV__) return false
// all other cases, disable restoration by returning true
return true
}
/**
* Custom hook for persisting navigation state.
* @param {Storage} storage - The storage utility to use.
* @param {string} persistenceKey - The key to use for storing the navigation state.
* @returns {object} - The navigation state and persistence functions.
*/
export function useNavigationPersistence(storage: Storage, persistenceKey: string) {
const [initialNavigationState, setInitialNavigationState] =
useState<NavigationProps["initialState"]>()
const isMounted = useIsMounted()
const initNavState = navigationRestoredDefaultState(Config.persistNavigation)
const [isRestored, setIsRestored] = useState(initNavState)
const routeNameRef = useRef<keyof AppStackParamList | undefined>(undefined)
const onNavigationStateChange = (state: NavigationState | undefined) => {
const previousRouteName = routeNameRef.current
if (state !== undefined) {
const currentRouteName = getActiveRouteName(state)
if (previousRouteName !== currentRouteName) {
// track screens.
if (__DEV__) {
console.log(currentRouteName)
}
}
// Save the current route name for later comparison
routeNameRef.current = currentRouteName as keyof AppStackParamList
// Persist state to storage
storage.save(persistenceKey, state)
}
}
const restoreState = async () => {
try {
const initialUrl = await Linking.getInitialURL()
// Only restore the state if app has not started from a deep link
if (!initialUrl) {
const state = (await storage.load(persistenceKey)) as NavigationProps["initialState"] | null
if (state) setInitialNavigationState(state)
}
} finally {
if (isMounted()) setIsRestored(true)
}
}
useEffect(() => {
if (!isRestored) restoreState()
// runs once on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return { onNavigationStateChange, restoreState, isRestored, initialNavigationState }
}
/**
* use this to navigate without the navigation
* prop. If you have access to the navigation prop, do not use this.
* @see {@link https://reactnavigation.org/docs/navigating-without-navigation-prop/}
* @param {unknown} name - The name of the route to navigate to.
* @param {unknown} params - The params to pass to the route.
*/
export function navigate(name: unknown, params?: unknown) {
if (navigationRef.isReady()) {
// @ts-expect-error
navigationRef.navigate(name as never, params as never)
}
}
/**
* This function is used to go back in a navigation stack, if it's possible to go back.
* If the navigation stack can't go back, nothing happens.
* The navigationRef variable is a React ref that references a navigation object.
* The navigationRef variable is set in the App component.
*/
export function goBack() {
if (navigationRef.isReady() && navigationRef.canGoBack()) {
navigationRef.goBack()
}
}
/**
* resetRoot will reset the root navigation state to the given params.
* @param {Parameters<typeof navigationRef.resetRoot>[0]} state - The state to reset the root to.
* @returns {void}
*/
export function resetRoot(
state: Parameters<typeof navigationRef.resetRoot>[0] = { index: 0, routes: [] },
) {
if (navigationRef.isReady()) {
navigationRef.resetRoot(state)
}
}

View File

@@ -0,0 +1,217 @@
import { FC, useCallback, useState } from "react"
import { Image, ImageStyle, TextStyle, View, ViewStyle } from "react-native"
import * as Application from "expo-application"
import * as WebBrowser from "expo-web-browser"
import { Icon } from "@/components/Icon"
import { ListItem } from "@/components/ListItem"
import { Modal } from "@/components/Modal"
import { Screen } from "@/components/Screen"
import { Text } from "@/components/Text"
import { translate } from "@/i18n/translate"
import { AppStackScreenProps } from "@/navigators/navigationTypes"
import { useAppTheme } from "@/theme/context"
import { $styles } from "@/theme/styles"
import type { ThemedStyle } from "@/theme/types"
import { s } from "@/utils/responsive"
import { useHeader } from "@/utils/useHeader"
const appLogo = require("@assets/images/logo.png")
const usingHermes = typeof HermesInternal === "object" && HermesInternal !== null
export const AboutScreen: FC<AppStackScreenProps<"About">> = function AboutScreen({ navigation }) {
const { themed, theme } = useAppTheme()
const [showVersionDetails, setShowVersionDetails] = useState(false)
// @ts-expect-error
const usingFabric = global.nativeFabricUIManager != null
const appVersion = Application.nativeApplicationVersion || "1.0.0"
const openPrivacyPolicy = useCallback(async () => {
// TODO: Replace with actual privacy policy URL
await WebBrowser.openBrowserAsync("https://example.com/privacy")
}, [])
const openTermsOfService = useCallback(async () => {
// TODO: Replace with actual terms of service URL
await WebBrowser.openBrowserAsync("https://example.com/terms")
}, [])
useHeader(
{
titleTx: "aboutScreen:title",
leftIcon: "back",
onLeftPress: () => navigation.goBack(),
},
[],
)
return (
<Screen preset="fixed" contentContainerStyle={$styles.flex1}>
{/* Logo Section */}
<View style={themed($logoSection)}>
<Image style={themed($logo)} source={appLogo} resizeMode="contain" />
<Text size="lg" weight="medium" style={themed($appName)}>
{Application.applicationName}
</Text>
<Text size="sm" style={themed($versionText)}>
v{appVersion}
</Text>
</View>
{/* List Items Section */}
<View style={themed($listContainer)}>
<ListItem
tx="aboutScreen:privacyPolicy"
leftIcon="shield"
leftIconColor={theme.colors.tint}
rightIcon="caretRight"
onPress={openPrivacyPolicy}
/>
<ListItem
tx="aboutScreen:termsOfService"
leftIcon="fileText"
leftIconColor={theme.colors.tint}
rightIcon="caretRight"
onPress={openTermsOfService}
/>
<ListItem
tx="aboutScreen:version"
leftIcon="info"
leftIconColor={theme.colors.tint}
RightComponent={
<View style={$rightContainer}>
<Text size="xs" style={themed($versionBadge)}>
v{appVersion}
</Text>
<Icon icon="caretRight" size={24} color={theme.colors.textDim} />
</View>
}
onPress={() => setShowVersionDetails(true)}
/>
</View>
{/* Version Details Modal */}
<Modal
visible={showVersionDetails}
onClose={() => setShowVersionDetails(false)}
titleTx="aboutScreen:appInfo"
confirmButtonProps={{
tx: "common:ok",
onPress: () => setShowVersionDetails(false),
}}
>
<View style={themed($infoRow)}>
<Text size="sm" style={themed($infoLabel)}>
{translate("aboutScreen:appName")}
</Text>
<Text size="sm">{Application.applicationName}</Text>
</View>
<View style={themed($infoRow)}>
<Text size="sm" style={themed($infoLabel)}>
{translate("aboutScreen:version")}
</Text>
<Text size="sm">{Application.nativeApplicationVersion}</Text>
</View>
<View style={themed($infoRow)}>
<Text size="sm" style={themed($infoLabel)}>
{translate("aboutScreen:buildVersion")}
</Text>
<Text size="sm">{Application.nativeBuildVersion}</Text>
</View>
<View style={themed($infoRow)}>
<Text size="sm" style={themed($infoLabel)}>
{translate("aboutScreen:appId")}
</Text>
<Text size="sm" style={themed($infoValue)}>
{Application.applicationId}
</Text>
</View>
<View style={themed($infoRow)}>
<Text size="sm" style={themed($infoLabel)}>
Hermes
</Text>
<Text size="sm">{usingHermes ? "Enabled" : "Disabled"}</Text>
</View>
<View style={themed($infoRowLast)}>
<Text size="sm" style={themed($infoLabel)}>
Fabric
</Text>
<Text size="sm">{usingFabric ? "Enabled" : "Disabled"}</Text>
</View>
</Modal>
</Screen>
)
}
const $logoSection: ThemedStyle<ViewStyle> = ({ spacing }) => ({
alignItems: "center",
paddingHorizontal: spacing.lg,
paddingTop: spacing.xxxl,
paddingBottom: spacing.xl,
})
const $listContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
flex: 1,
paddingHorizontal: spacing.lg,
})
const $logo: ThemedStyle<ImageStyle> = ({ spacing }) => ({
height: s(100),
width: s(100),
marginBottom: spacing.md,
})
const $appName: ThemedStyle<TextStyle> = ({ spacing }) => ({
marginBottom: spacing.xs,
})
const $versionText: ThemedStyle<TextStyle> = ({ colors }) => ({
color: colors.textDim,
})
const $rightContainer: ViewStyle = {
flexDirection: "row",
alignItems: "center",
}
const $versionBadge: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
color: colors.textDim,
marginRight: spacing.xs,
})
const $infoRow: ThemedStyle<ViewStyle> = ({ spacing }) => ({
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: "rgba(0,0,0,0.05)",
})
const $infoRowLast: ThemedStyle<ViewStyle> = ({ spacing }) => ({
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: spacing.sm,
})
const $infoLabel: ThemedStyle<TextStyle> = ({ colors }) => ({
color: colors.textDim,
flexShrink: 0,
marginRight: s(16),
})
const $infoValue: ThemedStyle<TextStyle> = () => ({
flex: 1,
textAlign: "right",
})

View File

@@ -0,0 +1,88 @@
import { FC } from "react"
import { TextStyle, View, ViewStyle } from "react-native"
import { Button } from "@/components/Button"
import { Screen } from "@/components/Screen"
import { Text } from "@/components/Text"
import type { AppStackScreenProps } from "@/navigators/navigationTypes"
import { useAppTheme } from "@/theme/context"
import type { ThemedStyle } from "@/theme/types"
interface AuthWelcomeScreenProps extends AppStackScreenProps<"AuthWelcome"> {}
export const AuthWelcomeScreen: FC<AuthWelcomeScreenProps> = ({ navigation }) => {
const { themed } = useAppTheme()
const handleLogin = () => {
navigation.navigate("Login")
}
const handleRegister = () => {
navigation.navigate("Register")
}
return (
<Screen
preset="fixed"
contentContainerStyle={themed($screenContainer)}
safeAreaEdges={["top", "bottom"]}
>
<View style={themed($content)}>
<Text text="Welcome" preset="heading" style={themed($title)} />
<Text
text="Sign in to continue or create a new account"
preset="subheading"
style={themed($subtitle)}
/>
</View>
<View style={themed($buttonContainer)}>
<Button
testID="login-button"
text="Log In"
style={themed($button)}
preset="reversed"
onPress={handleLogin}
/>
<Button
testID="register-button"
text="Create Account"
style={themed($button)}
preset="default"
onPress={handleRegister}
/>
</View>
</Screen>
)
}
const $screenContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
flex: 1,
paddingHorizontal: spacing.lg,
justifyContent: "space-between",
})
const $content: ThemedStyle<ViewStyle> = ({ spacing }) => ({
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingBottom: spacing.xxl,
})
const $title: ThemedStyle<TextStyle> = ({ spacing }) => ({
marginBottom: spacing.sm,
textAlign: "center",
})
const $subtitle: ThemedStyle<TextStyle> = () => ({
textAlign: "center",
})
const $buttonContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
paddingBottom: spacing.xl,
gap: spacing.md,
})
const $button: ThemedStyle<ViewStyle> = () => ({
width: "100%",
})

View File

@@ -0,0 +1,540 @@
import { FC, useCallback, useEffect, useRef, useState } from "react"
import { TextStyle, View, ViewStyle } from "react-native"
import { KeyboardAwareScrollView } from "react-native-keyboard-controller"
import { Button } from "@/components/Button"
import { Dialog } from "@/components/Dialog"
import { Screen } from "@/components/Screen"
import { Text } from "@/components/Text"
import { TextField } from "@/components/TextField"
import { useAuth } from "@/context/AuthContext"
import { translate } from "@/i18n/translate"
import { AppStackScreenProps } from "@/navigators/navigationTypes"
import { useAppTheme } from "@/theme/context"
import { $styles } from "@/theme/styles"
import type { ThemedStyle } from "@/theme/types"
import { s } from "@/utils/responsive"
import { useHeader } from "@/utils/useHeader"
const COUNTDOWN_SECONDS = 60
export const ChangeEmailScreen: FC<AppStackScreenProps<"ChangeEmail">> =
function ChangeEmailScreen({ navigation }) {
const {
user,
emailChangeStep,
sendEmailCode,
verifyCurrentEmail,
bindNewEmail,
resetEmailChangeStep,
isLoading,
error,
clearError,
} = useAuth()
const { themed } = useAppTheme()
// Step 1: Verify current email
const [currentEmailCode, setCurrentEmailCode] = useState("")
// Step 2: Bind new email
const [newEmail, setNewEmail] = useState("")
const [newEmailCode, setNewEmailCode] = useState("")
const [newEmailCodeSent, setNewEmailCodeSent] = useState(false)
const [localError, setLocalError] = useState("")
const [successDialogVisible, setSuccessDialogVisible] = useState(false)
// Countdown timers
const [currentEmailCountdown, setCurrentEmailCountdown] = useState(0)
const [newEmailCountdown, setNewEmailCountdown] = useState(0)
const currentEmailTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const newEmailTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Start countdown for current email
const startCurrentEmailCountdown = useCallback(() => {
setCurrentEmailCountdown(COUNTDOWN_SECONDS)
if (currentEmailTimerRef.current) {
clearInterval(currentEmailTimerRef.current)
}
currentEmailTimerRef.current = setInterval(() => {
setCurrentEmailCountdown((prev) => {
if (prev <= 1) {
if (currentEmailTimerRef.current) {
clearInterval(currentEmailTimerRef.current)
currentEmailTimerRef.current = null
}
return 0
}
return prev - 1
})
}, 1000)
}, [])
// Start countdown for new email
const startNewEmailCountdown = useCallback(() => {
setNewEmailCountdown(COUNTDOWN_SECONDS)
if (newEmailTimerRef.current) {
clearInterval(newEmailTimerRef.current)
}
newEmailTimerRef.current = setInterval(() => {
setNewEmailCountdown((prev) => {
if (prev <= 1) {
if (newEmailTimerRef.current) {
clearInterval(newEmailTimerRef.current)
newEmailTimerRef.current = null
}
return 0
}
return prev - 1
})
}, 1000)
}, [])
// Reset state when unmounting
useEffect(() => {
return () => {
resetEmailChangeStep()
if (currentEmailTimerRef.current) {
clearInterval(currentEmailTimerRef.current)
}
if (newEmailTimerRef.current) {
clearInterval(newEmailTimerRef.current)
}
}
}, [resetEmailChangeStep])
// Step 1: Send verification code to current email
const handleSendCurrentEmailCode = useCallback(async () => {
clearError()
setLocalError("")
if (user?.email) {
const success = await sendEmailCode(user.email)
if (success) {
startCurrentEmailCountdown()
}
}
}, [sendEmailCode, clearError, user?.email, startCurrentEmailCountdown])
// Step 1: Verify current email code
const handleVerifyCurrentEmail = useCallback(async () => {
clearError()
setLocalError("")
if (!currentEmailCode) {
setLocalError(translate("changeEmailScreen:codeRequired"))
return
}
if (currentEmailCode.length !== 6) {
setLocalError(translate("changeEmailScreen:codeInvalid"))
return
}
await verifyCurrentEmail(currentEmailCode)
}, [currentEmailCode, verifyCurrentEmail, clearError])
// Step 2: Send verification code to new email
const handleSendNewEmailCode = useCallback(async () => {
clearError()
setLocalError("")
if (!newEmail) {
setLocalError(translate("changeEmailScreen:newEmailRequired"))
return
}
// Basic email validation
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) {
setLocalError(translate("changeEmailScreen:emailInvalid"))
return
}
if (newEmail.toLowerCase() === user?.email?.toLowerCase()) {
setLocalError(translate("changeEmailScreen:sameEmail"))
return
}
const success = await sendEmailCode(newEmail)
if (success) {
setNewEmailCodeSent(true)
startNewEmailCountdown()
}
}, [newEmail, user?.email, sendEmailCode, clearError, startNewEmailCountdown])
// Step 2: Bind new email
const handleBindNewEmail = useCallback(async () => {
clearError()
setLocalError("")
if (!newEmailCode) {
setLocalError(translate("changeEmailScreen:codeRequired"))
return
}
if (newEmailCode.length !== 6) {
setLocalError(translate("changeEmailScreen:codeInvalid"))
return
}
const success = await bindNewEmail(newEmail, newEmailCode)
if (success) {
setSuccessDialogVisible(true)
}
}, [newEmail, newEmailCode, bindNewEmail, clearError])
const displayError = localError || error
// Render Step 1 content (without buttons)
const renderStep1Content = () => (
<View style={themed($content)}>
<Text tx="changeEmailScreen:step1Title" preset="subheading" style={themed($stepTitle)} />
<Text tx="changeEmailScreen:step1Description" style={themed($description)} />
<View style={themed($currentEmailContainer)}>
<Text size="xs" style={themed($currentEmailLabel)}>
{translate("changeEmailScreen:currentEmail")}
</Text>
<Text preset="bold" style={themed($currentEmailText)}>
{user?.email || ""}
</Text>
</View>
{emailChangeStep !== "idle" && (
<>
<TextField
labelTx="changeEmailScreen:verificationCode"
value={currentEmailCode}
onChangeText={setCurrentEmailCode}
containerStyle={themed($inputContainer)}
keyboardType="number-pad"
maxLength={6}
placeholder="000000"
/>
{displayError ? (
<Text size="sm" style={themed($errorText)}>
{displayError}
</Text>
) : null}
</>
)}
</View>
)
// Render Step 2 content (without buttons)
const renderStep2Content = () => (
<View style={themed($content)}>
<Text tx="changeEmailScreen:step2Title" preset="subheading" style={themed($stepTitle)} />
<Text tx="changeEmailScreen:step2Description" style={themed($description)} />
<TextField
labelTx="changeEmailScreen:newEmail"
value={newEmail}
onChangeText={setNewEmail}
containerStyle={themed($inputContainer)}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
editable={!newEmailCodeSent}
/>
{!newEmailCodeSent ? (
displayError ? (
<Text size="sm" style={themed($errorText)}>
{displayError}
</Text>
) : null
) : (
<>
<TextField
labelTx="changeEmailScreen:verificationCode"
value={newEmailCode}
onChangeText={setNewEmailCode}
containerStyle={themed($inputContainer)}
keyboardType="number-pad"
maxLength={6}
placeholder="000000"
/>
{displayError ? (
<Text size="sm" style={themed($errorText)}>
{displayError}
</Text>
) : null}
</>
)}
</View>
)
// Render bottom buttons for Step 1
const renderStep1Buttons = () => {
if (emailChangeStep === "idle") {
return (
<Button
tx="changeEmailScreen:sendCode"
preset="reversed"
onPress={handleSendCurrentEmailCode}
disabled={isLoading}
loading={isLoading}
/>
)
}
return (
<>
<Button
tx="changeEmailScreen:verify"
preset="reversed"
onPress={handleVerifyCurrentEmail}
disabled={isLoading}
loading={isLoading}
/>
<Button
text={
currentEmailCountdown > 0
? `${translate("changeEmailScreen:resendCode")} (${currentEmailCountdown}s)`
: undefined
}
tx={currentEmailCountdown > 0 ? undefined : "changeEmailScreen:resendCode"}
preset="default"
style={themed($resendButton)}
onPress={handleSendCurrentEmailCode}
disabled={isLoading || currentEmailCountdown > 0}
/>
</>
)
}
// Render bottom buttons for Step 2
const renderStep2Buttons = () => {
if (!newEmailCodeSent) {
return (
<Button
tx="changeEmailScreen:sendCodeToNewEmail"
preset="reversed"
onPress={handleSendNewEmailCode}
disabled={isLoading || !newEmail}
loading={isLoading}
/>
)
}
return (
<>
<Button
tx="changeEmailScreen:confirmNewEmail"
preset="reversed"
onPress={handleBindNewEmail}
disabled={isLoading}
loading={isLoading}
/>
<Button
text={
newEmailCountdown > 0
? `${translate("changeEmailScreen:resendCode")} (${newEmailCountdown}s)`
: undefined
}
tx={newEmailCountdown > 0 ? undefined : "changeEmailScreen:resendCode"}
preset="default"
style={themed($resendButton)}
onPress={handleSendNewEmailCode}
disabled={isLoading || newEmailCountdown > 0}
/>
</>
)
}
useHeader(
{
title: translate("changeEmailScreen:title"),
leftIcon: "back",
onLeftPress: () => navigation.goBack(),
},
[],
)
return (
<Screen preset="fixed" safeAreaEdges={["bottom"]} contentContainerStyle={$styles.flex1}>
<KeyboardAwareScrollView
contentContainerStyle={[$styles.container, themed($container)]}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{/* Step indicator */}
<View style={themed($stepIndicator)}>
{/* Step 1 */}
<View style={themed($stepItem)}>
<View
style={[themed($stepDot), emailChangeStep !== "bind-new" && themed($stepDotActive)]}
>
<Text
style={[
themed($stepNumber),
emailChangeStep !== "bind-new" && themed($stepNumberActive),
]}
>
1
</Text>
</View>
<Text
size="xxs"
style={[
themed($stepLabel),
emailChangeStep !== "bind-new" && themed($stepLabelActive),
]}
>
{translate("changeEmailScreen:step1Label")}
</Text>
</View>
{/* Line */}
<View style={themed($stepLine)} />
{/* Step 2 */}
<View style={themed($stepItem)}>
<View
style={[themed($stepDot), emailChangeStep === "bind-new" && themed($stepDotActive)]}
>
<Text
style={[
themed($stepNumber),
emailChangeStep === "bind-new" && themed($stepNumberActive),
]}
>
2
</Text>
</View>
<Text
size="xxs"
style={[
themed($stepLabel),
emailChangeStep === "bind-new" && themed($stepLabelActive),
]}
>
{translate("changeEmailScreen:step2Label")}
</Text>
</View>
</View>
{emailChangeStep === "bind-new" ? renderStep2Content() : renderStep1Content()}
</KeyboardAwareScrollView>
{/* Fixed Bottom Buttons */}
<View style={themed($bottomContainer)}>
{emailChangeStep === "bind-new" ? renderStep2Buttons() : renderStep1Buttons()}
</View>
{/* Success Dialog */}
<Dialog
visible={successDialogVisible}
onClose={() => {
setSuccessDialogVisible(false)
navigation.goBack()
}}
titleTx="changeEmailScreen:success"
messageTx="changeEmailScreen:successMessage"
onConfirm={() => {
setSuccessDialogVisible(false)
navigation.goBack()
}}
/>
</Screen>
)
}
const $container: ThemedStyle<ViewStyle> = ({ spacing }) => ({
paddingTop: spacing.lg, // 覆盖 $styles.container 的 56px使用 useHeader 时只需 24px
})
const $content: ThemedStyle<ViewStyle> = () => ({})
const $stepIndicator: ThemedStyle<ViewStyle> = ({ spacing }) => ({
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "center",
marginBottom: spacing.lg,
})
const $stepItem: ThemedStyle<ViewStyle> = () => ({
alignItems: "center",
width: s(80),
})
const $stepDot: ThemedStyle<ViewStyle> = ({ colors }) => ({
width: s(32),
height: s(32),
borderRadius: s(16),
backgroundColor: colors.palette.neutral300,
justifyContent: "center",
alignItems: "center",
})
const $stepDotActive: ThemedStyle<ViewStyle> = ({ colors }) => ({
backgroundColor: colors.tint,
})
const $stepNumber: ThemedStyle<TextStyle> = ({ colors }) => ({
color: colors.textDim,
fontWeight: "bold",
})
const $stepNumberActive: ThemedStyle<TextStyle> = ({ colors }) => ({
color: colors.palette.neutral100,
})
const $stepLabel: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
color: colors.textDim,
marginTop: spacing.xs,
textAlign: "center",
})
const $stepLabelActive: ThemedStyle<TextStyle> = ({ colors }) => ({
color: colors.tint,
fontWeight: "600",
})
const $stepLine: ThemedStyle<ViewStyle> = ({ colors }) => ({
width: s(40),
height: 2,
backgroundColor: colors.palette.neutral300,
marginTop: s(15),
})
const $stepTitle: ThemedStyle<TextStyle> = ({ spacing }) => ({
marginBottom: spacing.xs,
})
const $description: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
color: colors.textDim,
marginBottom: spacing.lg,
})
const $currentEmailContainer: ThemedStyle<ViewStyle> = ({ colors, spacing }) => ({
backgroundColor: colors.palette.neutral200,
borderRadius: s(8),
padding: spacing.md,
marginBottom: spacing.lg,
})
const $currentEmailLabel: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
color: colors.textDim,
marginBottom: spacing.xxs,
})
const $currentEmailText: ThemedStyle<TextStyle> = () => ({})
const $inputContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
marginBottom: spacing.md,
})
const $errorText: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
color: colors.error,
marginBottom: spacing.md,
})
const $bottomContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
paddingHorizontal: spacing.lg,
paddingBottom: spacing.md,
})
const $resendButton: ThemedStyle<ViewStyle> = ({ spacing }) => ({
marginTop: spacing.sm,
})

View File

@@ -0,0 +1,216 @@
import { FC, useCallback, useState } from "react"
import { TextStyle, View, ViewStyle } from "react-native"
import { KeyboardAwareScrollView } from "react-native-keyboard-controller"
import { Button } from "@/components/Button"
import { Dialog } from "@/components/Dialog"
import { PressableIcon } from "@/components/Icon"
import { Screen } from "@/components/Screen"
import { Text } from "@/components/Text"
import { TextField } from "@/components/TextField"
import { Switch } from "@/components/Toggle/Switch"
import { useAuth } from "@/context/AuthContext"
import { translate } from "@/i18n/translate"
import { AppStackScreenProps } from "@/navigators/navigationTypes"
import { useAppTheme } from "@/theme/context"
import { $styles } from "@/theme/styles"
import type { ThemedStyle } from "@/theme/types"
import { s } from "@/utils/responsive"
import { useHeader } from "@/utils/useHeader"
export const ChangePasswordScreen: FC<AppStackScreenProps<"ChangePassword">> =
function ChangePasswordScreen({ navigation }) {
const { changePassword, isLoading, error, clearError } = useAuth()
const {
themed,
theme: { colors },
} = useAppTheme()
const [oldPassword, setOldPassword] = useState("")
const [newPassword, setNewPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [logoutOtherDevices, setLogoutOtherDevices] = useState(false)
const [isOldPasswordHidden, setIsOldPasswordHidden] = useState(true)
const [isNewPasswordHidden, setIsNewPasswordHidden] = useState(true)
const [isConfirmPasswordHidden, setIsConfirmPasswordHidden] = useState(true)
const [localError, setLocalError] = useState("")
const [successDialogVisible, setSuccessDialogVisible] = useState(false)
const handleChangePassword = useCallback(async () => {
clearError()
setLocalError("")
// Validate
if (!oldPassword) {
setLocalError(translate("changePasswordScreen:oldPasswordRequired"))
return
}
if (!newPassword) {
setLocalError(translate("changePasswordScreen:newPasswordRequired"))
return
}
if (newPassword.length < 6) {
setLocalError(translate("changePasswordScreen:passwordTooShort"))
return
}
if (newPassword !== confirmPassword) {
setLocalError(translate("changePasswordScreen:passwordMismatch"))
return
}
if (oldPassword === newPassword) {
setLocalError(translate("changePasswordScreen:samePassword"))
return
}
const success = await changePassword(oldPassword, newPassword, logoutOtherDevices)
if (success) {
setSuccessDialogVisible(true)
}
}, [oldPassword, newPassword, confirmPassword, logoutOtherDevices, changePassword, clearError])
const displayError = localError || error
useHeader(
{
title: translate("changePasswordScreen:title"),
leftIcon: "back",
onLeftPress: () => navigation.goBack(),
},
[],
)
return (
<Screen preset="fixed" safeAreaEdges={["bottom"]} contentContainerStyle={$styles.flex1}>
<KeyboardAwareScrollView
contentContainerStyle={[$styles.container, themed($container)]}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<Text tx="changePasswordScreen:description" style={themed($description)} />
<TextField
labelTx="changePasswordScreen:oldPassword"
value={oldPassword}
onChangeText={setOldPassword}
containerStyle={themed($inputContainer)}
secureTextEntry={isOldPasswordHidden}
RightAccessory={(props) => (
<PressableIcon
icon={isOldPasswordHidden ? "view" : "hidden"}
color={colors.palette.neutral800}
containerStyle={props.style}
size={s(20)}
onPress={() => setIsOldPasswordHidden(!isOldPasswordHidden)}
/>
)}
/>
<TextField
labelTx="changePasswordScreen:newPassword"
value={newPassword}
onChangeText={setNewPassword}
containerStyle={themed($inputContainer)}
secureTextEntry={isNewPasswordHidden}
RightAccessory={(props) => (
<PressableIcon
icon={isNewPasswordHidden ? "view" : "hidden"}
color={colors.palette.neutral800}
containerStyle={props.style}
size={s(20)}
onPress={() => setIsNewPasswordHidden(!isNewPasswordHidden)}
/>
)}
/>
<TextField
labelTx="changePasswordScreen:confirmPassword"
value={confirmPassword}
onChangeText={setConfirmPassword}
containerStyle={themed($inputContainer)}
secureTextEntry={isConfirmPasswordHidden}
RightAccessory={(props) => (
<PressableIcon
icon={isConfirmPasswordHidden ? "view" : "hidden"}
color={colors.palette.neutral800}
containerStyle={props.style}
size={s(20)}
onPress={() => setIsConfirmPasswordHidden(!isConfirmPasswordHidden)}
/>
)}
/>
<View style={themed($switchContainer)}>
<Text tx="changePasswordScreen:logoutOtherDevices" style={themed($switchLabel)} />
<Switch value={logoutOtherDevices} onValueChange={setLogoutOtherDevices} />
</View>
{displayError ? (
<Text size="sm" style={themed($errorText)}>
{displayError}
</Text>
) : null}
</KeyboardAwareScrollView>
{/* Fixed Bottom Button */}
<View style={themed($bottomContainer)}>
<Button
tx="changePasswordScreen:submit"
preset="reversed"
onPress={handleChangePassword}
disabled={isLoading}
loading={isLoading}
/>
</View>
{/* Success Dialog */}
<Dialog
visible={successDialogVisible}
onClose={() => {
setSuccessDialogVisible(false)
navigation.goBack()
}}
titleTx="changePasswordScreen:success"
messageTx="changePasswordScreen:successMessage"
onConfirm={() => {
setSuccessDialogVisible(false)
navigation.goBack()
}}
/>
</Screen>
)
}
const $container: ThemedStyle<ViewStyle> = ({ spacing }) => ({
paddingTop: spacing.lg, // 覆盖 $styles.container 的 56px使用 useHeader 时只需 24px
})
const $description: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
color: colors.textDim,
marginBottom: spacing.lg,
})
const $inputContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
marginBottom: spacing.md,
})
const $switchContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: spacing.md,
paddingVertical: spacing.sm,
})
const $switchLabel: ThemedStyle<TextStyle> = () => ({
flex: 1,
})
const $errorText: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
color: colors.error,
marginBottom: spacing.md,
})
const $bottomContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
paddingHorizontal: spacing.lg,
paddingBottom: spacing.md,
})

View File

@@ -0,0 +1,70 @@
import { FC } from "react"
import { TextStyle } from "react-native"
import { ListItem } from "@/components/ListItem"
import { Screen } from "@/components/Screen"
import { Text } from "@/components/Text"
import type { MainTabScreenProps } from "@/navigators/navigationTypes"
import { useAppTheme } from "@/theme/context"
import { $styles } from "@/theme/styles"
import type { ThemedStyle } from "@/theme/types"
import { openLinkInBrowser } from "@/utils/openLinkInBrowser"
const COMMUNITY_LINKS = [
{
icon: "slack" as const,
titleTx: "communityScreen:joinSlackLink" as const,
url: "https://community.infinite.red/",
},
{
icon: "github" as const,
titleTx: "communityScreen:contributeToIgniteLink" as const,
url: "https://github.com/infinitered/ignite",
},
{
icon: "globe" as const,
titleTx: "communityScreen:hireUsLink" as const,
url: "https://infinite.red/",
},
]
export const CommunityScreen: FC<MainTabScreenProps<"Community">> = () => {
const { themed } = useAppTheme()
return (
<Screen preset="scroll" contentContainerStyle={$styles.container}>
<Text preset="heading" tx="communityScreen:title" style={themed($title)} />
<Text tx="communityScreen:tagLine" style={themed($description)} />
<Text
preset="subheading"
tx="communityScreen:joinUsOnSlackTitle"
style={themed($sectionTitle)}
/>
<Text tx="communityScreen:joinUsOnSlack" style={themed($description)} />
{COMMUNITY_LINKS.map((link, index) => (
<ListItem
key={link.url}
tx={link.titleTx}
leftIcon={link.icon}
rightIcon="caretRight"
onPress={() => openLinkInBrowser(link.url)}
bottomSeparator={index < COMMUNITY_LINKS.length - 1}
/>
))}
</Screen>
)
}
const $title: ThemedStyle<TextStyle> = ({ spacing }) => ({
marginBottom: spacing.sm,
})
const $description: ThemedStyle<TextStyle> = ({ spacing }) => ({
marginBottom: spacing.lg,
})
const $sectionTitle: ThemedStyle<TextStyle> = ({ spacing }) => ({
marginTop: spacing.xxl,
})

View File

@@ -0,0 +1,76 @@
import { Component, ErrorInfo, ReactNode } from "react"
import { ErrorDetails } from "./ErrorDetails"
interface Props {
children: ReactNode
catchErrors: "always" | "dev" | "prod" | "never"
}
interface State {
error: Error | null
errorInfo: ErrorInfo | null
}
/**
* This component handles whenever the user encounters a JS error in the
* app. It follows the "error boundary" pattern in React. We're using a
* class component because according to the documentation, only class
* components can be error boundaries.
* @see [Documentation and Examples]{@link https://docs.infinite.red/ignite-cli/concept/Error-Boundary/}
* @see [React Error Boundaries]{@link https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary}
* @param {Props} props - The props for the `ErrorBoundary` component.
* @returns {JSX.Element} The rendered `ErrorBoundary` component.
*/
export class ErrorBoundary extends Component<Props, State> {
state = { error: null, errorInfo: null }
// If an error in a child is encountered, this will run
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// Only set errors if enabled
if (!this.isEnabled()) {
return
}
// Catch errors in any components below and re-render with error message
this.setState({
error,
errorInfo,
})
// You can also log error messages to an error reporting service here
// This is a great place to put BugSnag, Sentry, crashlytics, etc:
// reportCrash(error)
}
// Reset the error back to null
resetError = () => {
this.setState({ error: null, errorInfo: null })
}
// To avoid unnecessary re-renders
shouldComponentUpdate(nextProps: Readonly<Props>, nextState: Readonly<State>): boolean {
return nextState.error !== this.state.error
}
// Only enable if we're catching errors in the right environment
isEnabled(): boolean {
return (
this.props.catchErrors === "always" ||
(this.props.catchErrors === "dev" && __DEV__) ||
(this.props.catchErrors === "prod" && !__DEV__)
)
}
// Render an error UI if there's an error; otherwise, render children
render() {
return this.isEnabled() && this.state.error ? (
<ErrorDetails
onReset={this.resetError}
error={this.state.error}
errorInfo={this.state.errorInfo}
/>
) : (
this.props.children
)
}
}

View File

@@ -0,0 +1,99 @@
import { ErrorInfo } from "react"
import { ScrollView, TextStyle, View, ViewStyle } from "react-native"
import { Button } from "@/components/Button"
import { Icon } from "@/components/Icon"
import { Screen } from "@/components/Screen"
import { Text } from "@/components/Text"
import { useAppTheme } from "@/theme/context"
import type { ThemedStyle } from "@/theme/types"
import { s } from "@/utils/responsive"
export interface ErrorDetailsProps {
error: Error
errorInfo: ErrorInfo | null
onReset(): void
}
/**
* Renders the error details screen.
* @param {ErrorDetailsProps} props - The props for the `ErrorDetails` component.
* @returns {JSX.Element} The rendered `ErrorDetails` component.
*/
export function ErrorDetails(props: ErrorDetailsProps) {
const { themed } = useAppTheme()
return (
<Screen
preset="fixed"
safeAreaEdges={["top", "bottom"]}
contentContainerStyle={themed($contentContainer)}
>
<View style={$topSection}>
<Icon icon="ladybug" size={s(64)} />
<Text style={themed($heading)} preset="subheading" tx="errorScreen:title" />
<Text tx="errorScreen:friendlySubtitle" />
</View>
<ScrollView
style={themed($errorSection)}
contentContainerStyle={themed($errorSectionContentContainer)}
>
<Text style={themed($errorContent)} weight="bold" text={`${props.error}`.trim()} />
<Text
selectable
style={themed($errorBacktrace)}
text={`${props.errorInfo?.componentStack ?? ""}`.trim()}
/>
</ScrollView>
<Button
preset="reversed"
style={themed($resetButton)}
onPress={props.onReset}
tx="errorScreen:reset"
/>
</Screen>
)
}
const $contentContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
alignItems: "center",
paddingHorizontal: spacing.lg,
paddingTop: spacing.xl,
flex: 1,
})
const $topSection: ViewStyle = {
flex: 1,
alignItems: "center",
}
const $heading: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
color: colors.error,
marginBottom: spacing.md,
})
const $errorSection: ThemedStyle<ViewStyle> = ({ colors, spacing }) => ({
flex: 2,
backgroundColor: colors.separator,
marginVertical: spacing.md,
borderRadius: s(6),
})
const $errorSectionContentContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
padding: spacing.md,
})
const $errorContent: ThemedStyle<TextStyle> = ({ colors }) => ({
color: colors.error,
})
const $errorBacktrace: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
marginTop: spacing.md,
color: colors.textDim,
})
const $resetButton: ThemedStyle<ViewStyle> = ({ colors, spacing }) => ({
backgroundColor: colors.error,
paddingHorizontal: spacing.xxl,
})

View File

@@ -0,0 +1,351 @@
import { ComponentType, FC, useCallback, useEffect, useMemo, useRef, useState } from "react"
// eslint-disable-next-line no-restricted-imports
import { TextInput, TextStyle, View, ViewStyle } from "react-native"
import { useFocusEffect } from "@react-navigation/native"
import { Button } from "@/components/Button"
import { PressableIcon } from "@/components/Icon"
import { Screen } from "@/components/Screen"
import { Text } from "@/components/Text"
import { TextField, type TextFieldAccessoryProps } from "@/components/TextField"
import { useAuth } from "@/context/AuthContext"
import type { AppStackScreenProps } from "@/navigators/navigationTypes"
import { useAppTheme } from "@/theme/context"
import type { ThemedStyle } from "@/theme/types"
import { s } from "@/utils/responsive"
interface ForgotPasswordScreenProps extends AppStackScreenProps<"ForgotPassword"> {}
export const ForgotPasswordScreen: FC<ForgotPasswordScreenProps> = ({ navigation }) => {
const emailInput = useRef<TextInput>(null)
const verificationCodeInput = useRef<TextInput>(null)
const newPasswordInput = useRef<TextInput>(null)
const [email, setEmail] = useState("")
const [verificationCode, setVerificationCode] = useState("")
const [newPassword, setNewPassword] = useState("")
const [isPasswordHidden, setIsPasswordHidden] = useState(true)
const [resendCountdown, setResendCountdown] = useState(0)
const {
forgotPasswordStep,
pendingEmail,
isLoading,
error,
forgotPassword,
resendForgotPasswordCode,
resetPassword,
resetForgotPasswordStep,
clearError,
} = useAuth()
const {
themed,
theme: { colors },
} = useAppTheme()
// Email validation
const emailError = useMemo(() => {
if (!email || email.length === 0) return ""
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return "Please enter a valid email"
return ""
}, [email])
// New password validation - requires uppercase, lowercase, number, and special character
const newPasswordError = useMemo(() => {
if (!newPassword || newPassword.length === 0) return ""
if (newPassword.length < 8) return "Password must be at least 8 characters"
if (!/[A-Z]/.test(newPassword)) return "Password must contain an uppercase letter"
if (!/[a-z]/.test(newPassword)) return "Password must contain a lowercase letter"
if (!/[0-9]/.test(newPassword)) return "Password must contain a number"
if (!/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(newPassword))
return "Password must contain a special character"
return ""
}, [newPassword])
// Countdown timer for resend button
useEffect(() => {
if (resendCountdown > 0) {
const timer = setTimeout(() => setResendCountdown(resendCountdown - 1), 1000)
return () => clearTimeout(timer)
}
return undefined
}, [resendCountdown])
// Clear error when screen comes into focus
useFocusEffect(
useCallback(() => {
clearError()
}, [clearError]),
)
// Clear error when inputs change (not when error changes)
const prevInputsRef = useRef({ email, verificationCode, newPassword })
useEffect(() => {
const prev = prevInputsRef.current
// Only clear error if one of the input fields actually changed
if (
prev.email !== email ||
prev.verificationCode !== verificationCode ||
prev.newPassword !== newPassword
) {
if (error) clearError()
prevInputsRef.current = { email, verificationCode, newPassword }
}
}, [email, verificationCode, newPassword, error, clearError])
// Handle forgot password (step 1)
const handleForgotPassword = useCallback(async () => {
if (!email || emailError) return
const success = await forgotPassword(email)
if (success) {
setResendCountdown(60)
}
}, [email, emailError, forgotPassword])
// Handle reset password (step 2)
const handleResetPassword = useCallback(async () => {
if (!verificationCode || verificationCode.length !== 6 || !newPassword || newPasswordError)
return
const success = await resetPassword(verificationCode, newPassword)
if (success) {
// Reset state and navigate back to login
resetForgotPasswordStep()
navigation.navigate("Login")
}
}, [
verificationCode,
newPassword,
newPasswordError,
resetPassword,
resetForgotPasswordStep,
navigation,
])
// Handle resend code
const handleResendCode = useCallback(async () => {
if (resendCountdown > 0) return
const success = await resendForgotPasswordCode()
if (success) {
setResendCountdown(60)
}
}, [resendCountdown, resendForgotPasswordCode])
// Handle back to email step
const handleBackToEmail = useCallback(() => {
resetForgotPasswordStep()
setVerificationCode("")
setNewPassword("")
}, [resetForgotPasswordStep])
// Navigate to login
const handleGoToLogin = useCallback(() => {
resetForgotPasswordStep()
navigation.navigate("Login")
}, [navigation, resetForgotPasswordStep])
// Password visibility toggle
const PasswordRightAccessory: ComponentType<TextFieldAccessoryProps> = useMemo(
() =>
function PasswordRightAccessory(props: TextFieldAccessoryProps) {
return (
<PressableIcon
icon={isPasswordHidden ? "view" : "hidden"}
color={colors.palette.neutral800}
containerStyle={props.style}
size={s(20)}
onPress={() => setIsPasswordHidden(!isPasswordHidden)}
/>
)
},
[isPasswordHidden, colors.palette.neutral800],
)
// Render email step (step 1)
const renderEmailStep = () => (
<>
<Text
testID="forgot-password-heading"
text="Forgot Password"
preset="heading"
style={themed($title)}
/>
<Text
text="Enter your email address and we'll send you a verification code"
preset="subheading"
style={themed($subtitle)}
/>
{error ? <Text text={error} size="sm" style={themed($errorText)} /> : null}
<TextField
ref={emailInput}
value={email}
onChangeText={setEmail}
containerStyle={themed($textField)}
autoCapitalize="none"
autoComplete="email"
autoCorrect={false}
keyboardType="email-address"
label="Email"
placeholder="Enter your email"
helper={emailError}
status={emailError ? "error" : undefined}
onSubmitEditing={handleForgotPassword}
/>
<Button
testID="send-code-button"
text="Send Verification Code"
style={themed($button)}
preset="reversed"
onPress={handleForgotPassword}
disabled={isLoading || !email || !!emailError}
loading={isLoading}
/>
<View style={themed($loginLinkContainer)}>
<Text text="Remember your password? " size="sm" />
<Button testID="go-to-login-button" text="Log In" preset="link" onPress={handleGoToLogin} />
</View>
</>
)
// Render reset step (step 2)
const renderResetStep = () => (
<>
<Text
testID="reset-password-heading"
text="Reset Password"
preset="heading"
style={themed($title)}
/>
<Text
text={`We've sent a verification code to ${pendingEmail}`}
preset="subheading"
style={themed($subtitle)}
/>
{error ? <Text text={error} size="sm" style={themed($errorText)} /> : null}
<TextField
ref={verificationCodeInput}
value={verificationCode}
onChangeText={(text) => setVerificationCode(text.replace(/[^0-9]/g, "").slice(0, 6))}
containerStyle={themed($textField)}
autoCapitalize="none"
autoComplete="one-time-code"
autoCorrect={false}
keyboardType="number-pad"
label="Verification Code"
placeholder="Enter 6-digit code"
maxLength={6}
onSubmitEditing={() => newPasswordInput.current?.focus()}
/>
<TextField
ref={newPasswordInput}
value={newPassword}
onChangeText={setNewPassword}
containerStyle={themed($textField)}
autoCapitalize="none"
autoComplete="password-new"
autoCorrect={false}
secureTextEntry={isPasswordHidden}
label="New Password"
placeholder="Enter your new password"
helper={newPasswordError}
status={newPasswordError ? "error" : undefined}
onSubmitEditing={handleResetPassword}
RightAccessory={PasswordRightAccessory}
/>
<Button
testID="reset-password-button"
text="Reset Password"
style={themed($button)}
preset="reversed"
onPress={handleResetPassword}
disabled={isLoading || verificationCode.length !== 6 || !newPassword || !!newPasswordError}
loading={isLoading}
/>
<View style={themed($resendContainer)}>
<Text text="Didn't receive the code? " size="sm" />
<Button
testID="resend-button"
text={resendCountdown > 0 ? `Resend in ${resendCountdown}s` : "Resend Code"}
preset="link"
onPress={handleResendCode}
disabled={isLoading || resendCountdown > 0}
/>
</View>
<Button
testID="back-button"
text="Back"
preset="default"
style={themed($backButton)}
onPress={handleBackToEmail}
disabled={isLoading}
/>
</>
)
return (
<Screen
preset="auto"
contentContainerStyle={themed($screenContentContainer)}
safeAreaEdges={["top", "bottom"]}
>
{forgotPasswordStep === "email" ? renderEmailStep() : renderResetStep()}
</Screen>
)
}
const $screenContentContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
paddingVertical: spacing.xxl,
paddingHorizontal: spacing.lg,
})
const $title: ThemedStyle<TextStyle> = ({ spacing }) => ({
marginBottom: spacing.sm,
})
const $subtitle: ThemedStyle<TextStyle> = ({ spacing }) => ({
marginBottom: spacing.lg,
})
const $errorText: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
color: colors.error,
marginBottom: spacing.md,
})
const $textField: ThemedStyle<ViewStyle> = ({ spacing }) => ({
marginBottom: spacing.lg,
})
const $button: ThemedStyle<ViewStyle> = ({ spacing }) => ({
marginTop: spacing.xs,
})
const $loginLinkContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
marginTop: spacing.xl,
})
const $resendContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
marginTop: spacing.lg,
})
const $backButton: ThemedStyle<ViewStyle> = ({ spacing }) => ({
marginTop: spacing.lg,
})

View File

@@ -0,0 +1,108 @@
import { FC, useCallback } from "react"
import { TextStyle, View, ViewStyle } from "react-native"
import i18n from "i18next"
import { Icon } from "@/components/Icon"
import { ListItem } from "@/components/ListItem"
import { Screen } from "@/components/Screen"
import { Text } from "@/components/Text"
import { translate } from "@/i18n/translate"
import { AppStackScreenProps } from "@/navigators/navigationTypes"
import { useAppTheme } from "@/theme/context"
import { $styles } from "@/theme/styles"
import type { ThemedStyle } from "@/theme/types"
import { s } from "@/utils/responsive"
import { useHeader } from "@/utils/useHeader"
// Available languages with their native names
const LANGUAGES = [
{ code: "en", name: "English", nativeName: "English" },
{ code: "zh", name: "Chinese", nativeName: "中文" },
{ code: "ja", name: "Japanese", nativeName: "日本語" },
{ code: "ko", name: "Korean", nativeName: "한국어" },
{ code: "es", name: "Spanish", nativeName: "Español" },
{ code: "fr", name: "French", nativeName: "Français" },
{ code: "ar", name: "Arabic", nativeName: "العربية" },
{ code: "hi", name: "Hindi", nativeName: "हिन्दी" },
]
export const LanguageScreen: FC<AppStackScreenProps<"Language">> = function LanguageScreen({
navigation,
}) {
const { themed, theme } = useAppTheme()
const currentLanguage = i18n.language?.split("-")[0] || "en"
const handleSelectLanguage = useCallback(
(langCode: string) => {
if (langCode !== currentLanguage) {
i18n.changeLanguage(langCode)
}
navigation.goBack()
},
[currentLanguage, navigation],
)
useHeader(
{
title: translate("languageScreen:title"),
leftIcon: "back",
onLeftPress: () => navigation.goBack(),
},
[],
)
return (
<Screen
preset="scroll"
safeAreaEdges={["bottom"]}
contentContainerStyle={[$styles.container, themed($container)]}
>
<Text size="xs" style={themed($hint)}>
{translate("languageScreen:selectHint")}
</Text>
<View style={themed($listContainer)}>
{LANGUAGES.map((lang) => {
const isSelected = currentLanguage === lang.code
return (
<ListItem
key={lang.code}
text={lang.nativeName}
textStyle={isSelected ? themed($selectedText) : undefined}
RightComponent={
isSelected ? <Icon icon="check" size={20} color={theme.colors.tint} /> : undefined
}
onPress={() => handleSelectLanguage(lang.code)}
style={themed($listItem)}
/>
)
})}
</View>
</Screen>
)
}
const $container: ThemedStyle<ViewStyle> = ({ spacing }) => ({
paddingTop: spacing.md,
})
const $hint: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
color: colors.textDim,
marginBottom: spacing.md,
})
const $listContainer: ThemedStyle<ViewStyle> = ({ colors }) => ({
backgroundColor: colors.palette.neutral200,
borderRadius: s(8),
overflow: "hidden",
})
const $listItem: ThemedStyle<ViewStyle> = () => ({
// Item styling handled by ListItem
})
const $selectedText: ThemedStyle<TextStyle> = ({ colors }) => ({
color: colors.tint,
fontWeight: "600",
})

View File

@@ -0,0 +1,430 @@
import { ComponentType, FC, useCallback, useEffect, useMemo, useRef, useState } from "react"
// eslint-disable-next-line no-restricted-imports
import { Image, ImageStyle, TextInput, TextStyle, View, ViewStyle } from "react-native"
import { useFocusEffect } from "@react-navigation/native"
import { Button } from "@/components/Button"
import { Icon, PressableIcon } from "@/components/Icon"
import { Screen } from "@/components/Screen"
import { Text } from "@/components/Text"
import { TextField, type TextFieldAccessoryProps } from "@/components/TextField"
import { useAuth } from "@/context/AuthContext"
import type { AppStackScreenProps } from "@/navigators/navigationTypes"
import { useAppTheme } from "@/theme/context"
import type { ThemedStyle } from "@/theme/types"
import { s } from "@/utils/responsive"
interface LoginScreenProps extends AppStackScreenProps<"Login"> {}
export const LoginScreen: FC<LoginScreenProps> = ({ navigation }) => {
const authPasswordInput = useRef<TextInput>(null)
const verificationCodeInput = useRef<TextInput>(null)
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [verificationCode, setVerificationCode] = useState("")
const [isPasswordHidden, setIsPasswordHidden] = useState(true)
const [resendCountdown, setResendCountdown] = useState(0)
const {
loginStep,
pendingEmail,
isLoading,
error,
preLogin,
resendCode,
verifyLogin,
googleLogin,
resetLoginStep,
clearError,
} = useAuth()
const {
themed,
theme: { colors },
} = useAppTheme()
// Email validation
const emailError = useMemo(() => {
if (!email || email.length === 0) return ""
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return "Please enter a valid email"
return ""
}, [email])
// Password validation - requires uppercase, lowercase, number, and special character
const passwordError = useMemo(() => {
if (!password || password.length === 0) return ""
if (password.length < 8) return "Password must be at least 8 characters"
if (!/[A-Z]/.test(password)) return "Password must contain an uppercase letter"
if (!/[a-z]/.test(password)) return "Password must contain a lowercase letter"
if (!/[0-9]/.test(password)) return "Password must contain a number"
if (!/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password))
return "Password must contain a special character"
return ""
}, [password])
// Countdown timer for resend button
useEffect(() => {
if (resendCountdown > 0) {
const timer = setTimeout(() => setResendCountdown(resendCountdown - 1), 1000)
return () => clearTimeout(timer)
}
return undefined
}, [resendCountdown])
// Auto-focus verification code input when entering verification step
useEffect(() => {
if (loginStep === "verification") {
// Small delay to ensure the input is rendered
const timer = setTimeout(() => {
verificationCodeInput.current?.focus()
}, 100)
return () => clearTimeout(timer)
}
return undefined
}, [loginStep])
// Reset state when screen comes into focus (e.g., navigating back from another screen)
useFocusEffect(
useCallback(() => {
// Clear any lingering errors from previous screens
clearError()
// Reset countdown and verification code when screen is focused
// but only if we're on credentials step (not in the middle of verification)
if (loginStep === "credentials") {
setResendCountdown(0)
setVerificationCode("")
}
}, [loginStep, clearError]),
)
// Clear error when inputs change (not when error changes)
const prevInputsRef = useRef({ email, password, verificationCode })
useEffect(() => {
const prev = prevInputsRef.current
// Only clear error if one of the input fields actually changed
if (
prev.email !== email ||
prev.password !== password ||
prev.verificationCode !== verificationCode
) {
if (error) clearError()
prevInputsRef.current = { email, password, verificationCode }
}
}, [email, password, verificationCode, error, clearError])
// Handle pre-login (step 1)
const handlePreLogin = useCallback(async () => {
if (!email || emailError || !password) return
const success = await preLogin(email, password)
if (success) {
setResendCountdown(60)
}
}, [email, emailError, password, preLogin])
// Handle verify login (step 2)
const handleVerifyLogin = useCallback(async () => {
if (!verificationCode || verificationCode.length !== 6) return
await verifyLogin(verificationCode)
}, [verificationCode, verifyLogin])
// Handle resend code
const handleResendCode = useCallback(async () => {
if (resendCountdown > 0) return
const success = await resendCode()
if (success) {
setResendCountdown(60)
}
}, [resendCountdown, resendCode])
// Handle Google login
const handleGoogleLogin = useCallback(async () => {
await googleLogin()
}, [googleLogin])
// Handle back to credentials step
const handleBackToCredentials = useCallback(() => {
resetLoginStep()
setVerificationCode("")
}, [resetLoginStep])
// Navigate to register
const handleGoToRegister = useCallback(() => {
navigation.navigate("Register")
}, [navigation])
// Navigate to forgot password
const handleGoToForgotPassword = useCallback(() => {
navigation.navigate("ForgotPassword")
}, [navigation])
// Password visibility toggle
const PasswordRightAccessory: ComponentType<TextFieldAccessoryProps> = useMemo(
() =>
function PasswordRightAccessory(props: TextFieldAccessoryProps) {
return (
<PressableIcon
icon={isPasswordHidden ? "view" : "hidden"}
color={colors.palette.neutral800}
containerStyle={props.style}
size={s(20)}
onPress={() => setIsPasswordHidden(!isPasswordHidden)}
/>
)
},
[isPasswordHidden, colors.palette.neutral800],
)
// Render credentials step (step 1)
const renderCredentialsStep = () => (
<>
<Text testID="login-heading" text="Log In" preset="heading" style={themed($logIn)} />
<Text
text="Enter your credentials to continue"
preset="subheading"
style={themed($enterDetails)}
/>
{error ? <Text text={error} size="sm" style={themed($errorText)} /> : null}
<TextField
value={email}
onChangeText={setEmail}
containerStyle={themed($textField)}
autoCapitalize="none"
autoComplete="email"
autoCorrect={false}
keyboardType="email-address"
label="Email"
placeholder="Enter your email"
helper={emailError}
status={emailError ? "error" : undefined}
onSubmitEditing={() => authPasswordInput.current?.focus()}
/>
<TextField
ref={authPasswordInput}
value={password}
onChangeText={setPassword}
containerStyle={themed($textField)}
autoCapitalize="none"
autoComplete="password"
autoCorrect={false}
secureTextEntry={isPasswordHidden}
label="Password"
placeholder="Enter your password"
helper={passwordError}
status={passwordError ? "error" : undefined}
onSubmitEditing={handlePreLogin}
RightAccessory={PasswordRightAccessory}
/>
<View style={themed($forgotPasswordContainer)}>
<Button
testID="forgot-password-button"
text="Forgot Password?"
preset="link"
onPress={handleGoToForgotPassword}
/>
</View>
<Button
testID="login-button"
text="Log In"
style={themed($tapButton)}
preset="reversed"
onPress={handlePreLogin}
disabled={isLoading || !email || !!emailError || !password || !!passwordError}
loading={isLoading}
/>
<View style={themed($dividerContainer)}>
<View style={themed($dividerLine)} />
<Text text="OR" size="xs" style={themed($dividerText)} />
<View style={themed($dividerLine)} />
</View>
<Button
testID="google-login-button"
style={themed($googleButton)}
preset="default"
onPress={handleGoogleLogin}
disabled={isLoading}
LeftAccessory={() => (
<Image source={require("@assets/images/google-logo.png")} style={themed($googleLogo)} />
)}
>
<Text text="Continue with Google" style={themed($googleButtonText)} />
</Button>
<View style={themed($registerLinkContainer)}>
<Text text="Don't have an account? " size="sm" />
<Button
testID="go-to-register-button"
text="Register"
preset="link"
onPress={handleGoToRegister}
/>
</View>
</>
)
// Render verification step (step 2)
const renderVerificationStep = () => (
<>
<Text
testID="verification-heading"
text="Verify Your Email"
preset="heading"
style={themed($logIn)}
/>
<Text
text={`We've sent a verification code to ${pendingEmail}`}
preset="subheading"
style={themed($enterDetails)}
/>
{error ? <Text text={error} size="sm" style={themed($errorText)} /> : null}
<TextField
ref={verificationCodeInput}
value={verificationCode}
onChangeText={(text) => setVerificationCode(text.replace(/[^0-9]/g, "").slice(0, 6))}
containerStyle={themed($textField)}
autoCapitalize="none"
autoComplete="one-time-code"
autoCorrect={false}
keyboardType="number-pad"
label="Verification Code"
placeholder="Enter 6-digit code"
maxLength={6}
onSubmitEditing={handleVerifyLogin}
/>
<Button
testID="verify-button"
text="Verify"
style={themed($tapButton)}
preset="reversed"
onPress={handleVerifyLogin}
disabled={isLoading || verificationCode.length !== 6}
loading={isLoading}
/>
<View style={themed($resendContainer)}>
<Text text="Didn't receive the code? " size="sm" />
<Button
testID="resend-button"
text={resendCountdown > 0 ? `Resend in ${resendCountdown}s` : "Resend Code"}
preset="link"
onPress={handleResendCode}
disabled={isLoading || resendCountdown > 0}
/>
</View>
<Button
testID="back-button"
text="Back to Login"
preset="default"
style={themed($backButton)}
onPress={handleBackToCredentials}
disabled={isLoading}
/>
</>
)
return (
<Screen
preset="auto"
contentContainerStyle={themed($screenContentContainer)}
safeAreaEdges={["top", "bottom"]}
>
{loginStep === "credentials" ? renderCredentialsStep() : renderVerificationStep()}
</Screen>
)
}
const $screenContentContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
paddingVertical: spacing.xxl,
paddingHorizontal: spacing.lg,
})
const $logIn: ThemedStyle<TextStyle> = ({ spacing }) => ({
marginBottom: spacing.sm,
})
const $enterDetails: ThemedStyle<TextStyle> = ({ spacing }) => ({
marginBottom: spacing.lg,
})
const $errorText: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
color: colors.error,
marginBottom: spacing.md,
})
const $textField: ThemedStyle<ViewStyle> = ({ spacing }) => ({
marginBottom: spacing.lg,
})
const $tapButton: ThemedStyle<ViewStyle> = ({ spacing }) => ({
marginTop: spacing.xs,
})
const $dividerContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
flexDirection: "row",
alignItems: "center",
marginVertical: spacing.xl,
})
const $dividerLine: ThemedStyle<ViewStyle> = ({ colors }) => ({
flex: 1,
height: 1,
backgroundColor: colors.separator,
})
const $dividerText: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
color: colors.textDim,
marginHorizontal: spacing.md,
})
const $googleButton: ThemedStyle<ViewStyle> = ({ colors, spacing }) => ({
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.background,
paddingVertical: spacing.sm,
})
const $googleLogo: ThemedStyle<ImageStyle> = ({ spacing }) => ({
width: s(20),
height: s(20),
marginRight: spacing.xs,
})
const $googleButtonText: ThemedStyle<TextStyle> = ({ colors }) => ({
color: colors.text,
})
const $resendContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
marginTop: spacing.lg,
})
const $backButton: ThemedStyle<ViewStyle> = ({ spacing }) => ({
marginTop: spacing.lg,
})
const $registerLinkContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
marginTop: spacing.xl,
})
const $forgotPasswordContainer: ThemedStyle<ViewStyle> = () => ({
alignItems: "flex-end",
})

View File

@@ -0,0 +1,556 @@
import { FC, useCallback, useEffect, useRef, useState } from "react"
import {
ActivityIndicator,
Animated,
TextStyle,
TouchableOpacity,
View,
ViewStyle,
} from "react-native"
import * as Application from "expo-application"
import * as Clipboard from "expo-clipboard"
import * as ImagePicker from "expo-image-picker"
import { Avatar } from "@/components/Avatar"
import { Button } from "@/components/Button"
import { Icon, PressableIcon } from "@/components/Icon"
import { ListItem } from "@/components/ListItem"
import { Modal } from "@/components/Modal"
import { Screen } from "@/components/Screen"
import { Text } from "@/components/Text"
import { TextField } from "@/components/TextField"
import { useAuth } from "@/context/AuthContext"
import { translate } from "@/i18n/translate"
import { AppStackScreenProps } from "@/navigators/navigationTypes"
import { uploadFile } from "@/services/api/uploadApi"
import { useAppTheme } from "@/theme/context"
import { $styles } from "@/theme/styles"
import type { ThemedStyle } from "@/theme/types"
import { s } from "@/utils/responsive"
import { useHeader } from "@/utils/useHeader"
export const ProfileScreen: FC<AppStackScreenProps<"Profile">> = function ProfileScreen({
navigation,
}) {
const { user, updateProfile, isLoading, error, clearError } = useAuth()
const { themed, theme } = useAppTheme()
const copyToastAnim = useRef(new Animated.Value(0)).current
const copyToastTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
return () => {
if (copyToastTimeout.current) {
clearTimeout(copyToastTimeout.current)
}
}
}, [])
const showCopyToast = useCallback(() => {
if (copyToastTimeout.current) {
clearTimeout(copyToastTimeout.current)
}
copyToastAnim.setValue(0)
Animated.timing(copyToastAnim, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}).start()
copyToastTimeout.current = setTimeout(() => {
Animated.timing(copyToastAnim, {
toValue: 0,
duration: 200,
useNativeDriver: true,
}).start()
}, 1600)
}, [copyToastAnim])
// Copy to clipboard
const copyToClipboard = useCallback(
async (text: string) => {
const normalizedText = text.trim()
if (!normalizedText) return
await Clipboard.setStringAsync(normalizedText)
showCopyToast()
},
[showCopyToast],
)
// Edit profile modal state
const [isEditProfileVisible, setIsEditProfileVisible] = useState(false)
const [nickname, setNickname] = useState(user?.profile?.nickname || "")
const [uploadedAvatarUrl, setUploadedAvatarUrl] = useState<string | null>(null)
const [isUploading, setIsUploading] = useState(false)
// Check if there are any changes to save
const hasChanges =
nickname.trim() !== (user?.profile?.nickname || "") || uploadedAvatarUrl !== null
// Open edit profile modal
const openEditProfile = useCallback(() => {
setNickname(user?.profile?.nickname || "")
setUploadedAvatarUrl(null)
clearError()
setIsEditProfileVisible(true)
}, [user?.profile?.nickname, clearError])
// Pick image from library and upload immediately
const pickImage = useCallback(async () => {
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync()
if (!permissionResult.granted) {
return
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
aspect: [1, 1],
quality: 0.8,
})
if (!result.canceled && result.assets && result.assets.length > 0) {
const asset = result.assets[0]
const uri = asset.uri
const fileName = asset.fileName || `avatar_${Date.now()}.jpg`
const mimeType = asset.mimeType || "image/jpeg"
// Upload immediately after selection
setIsUploading(true)
try {
const uploadResult = await uploadFile(uri, fileName, mimeType)
if (uploadResult.kind === "ok") {
setUploadedAvatarUrl(uploadResult.url)
} else {
console.error("Upload failed:", uploadResult.message)
}
} finally {
setIsUploading(false)
}
}
}, [])
// Save profile (avatar already uploaded, just update profile)
const handleSaveProfile = useCallback(async () => {
if (!nickname.trim()) return
const success = await updateProfile({
nickname: nickname.trim(),
...(uploadedAvatarUrl && { avatar: uploadedAvatarUrl }),
})
if (success) {
setIsEditProfileVisible(false)
setUploadedAvatarUrl(null)
}
}, [nickname, uploadedAvatarUrl, updateProfile])
// Navigate to settings screen
const navigateToSettings = useCallback(() => {
navigation.navigate("Settings")
}, [navigation])
// Navigate to security screen
const navigateToSecurity = useCallback(() => {
navigation.navigate("Security")
}, [navigation])
// Navigate to about screen
const navigateToAbout = useCallback(() => {
navigation.navigate("About")
}, [navigation])
// 使用 useHeader hook 设置导航栏
useHeader(
{
title: translate("profileScreen:title"),
leftIcon: "back",
onLeftPress: () => navigation.goBack(),
rightIcon: "settings",
onRightPress: navigateToSettings,
},
[navigateToSettings],
)
// Get display avatar - either newly uploaded or existing
const displayAvatar = uploadedAvatarUrl || user?.profile?.avatar
return (
<Screen
preset="scroll"
safeAreaEdges={["bottom"]}
contentContainerStyle={[$styles.container, themed($container)]}
>
{/* User Profile Header - Horizontal Layout */}
<TouchableOpacity
style={themed($profileHeader)}
onPress={openEditProfile}
activeOpacity={0.7}
>
{/* Left: Avatar */}
<Avatar
uri={user?.profile?.avatar}
fallback={user?.profile?.nickname || user?.username || "U"}
size={s(80)}
/>
{/* Middle: User Info */}
<View style={themed($userInfoContainer)}>
{/* Top: UID */}
<Text size="xs" style={themed($uidText)}>
UID: {user?.userId || "-"}
</Text>
{/* Middle: Name */}
<Text preset="bold" size="lg" style={themed($userName)}>
{user?.profile?.nickname || user?.username || translate("profileScreen:guest")}
</Text>
{/* Bottom: Account Status Badge */}
<View
style={[
$statusBadge,
{ backgroundColor: user?.status === "active" ? "#34C75920" : "#FF3B3020" },
]}
>
<Text
size="xxs"
style={[
$statusBadgeLabel,
{ color: user?.status === "active" ? "#34C759" : "#FF3B30" },
]}
>
{user?.status === "active"
? translate("profileScreen:regular")
: translate("profileScreen:inactive")}
</Text>
</View>
</View>
{/* Right: Edit Arrow */}
<Icon icon="caretRight" size={s(24)} color={theme.colors.textDim} />
</TouchableOpacity>
{/* Settings Section */}
<View style={themed($settingsSection)}>
<ListItem
tx="profileScreen:security"
leftIcon="lock"
leftIconColor={theme.colors.tint}
rightIcon="caretRight"
onPress={navigateToSecurity}
/>
<ListItem
tx="profileScreen:about"
leftIcon="info"
leftIconColor={theme.colors.tint}
RightComponent={
<View style={$rightContainer}>
<Text size="xs" style={themed($versionText)}>
v{Application.nativeApplicationVersion || "1.0.0"}
</Text>
<Icon icon="caretRight" size={s(24)} color={theme.colors.textDim} />
</View>
}
onPress={navigateToAbout}
/>
</View>
{/* Edit Profile Modal */}
<Modal
visible={isEditProfileVisible}
onClose={() => setIsEditProfileVisible(false)}
preset="bottom"
titleTx="profileScreen:editProfile"
FooterComponent={
<View style={$modalFooter}>
<Button
tx="common:save"
preset="reversed"
style={$fullWidthButton}
onPress={handleSaveProfile}
disabled={isLoading || isUploading || !nickname.trim() || !hasChanges}
loading={isLoading}
/>
<Button
tx="common:cancel"
preset="default"
style={$fullWidthButton}
onPress={() => setIsEditProfileVisible(false)}
disabled={isUploading || isLoading}
/>
</View>
}
>
<View style={themed($modalContent)}>
<Animated.View
pointerEvents="none"
style={[
themed($inlineToast),
{
opacity: copyToastAnim,
transform: [
{
translateY: copyToastAnim.interpolate({
inputRange: [0, 1],
outputRange: [-8, 0],
}),
},
],
},
]}
>
<Text size="xs" text={translate("common:copied")} style={themed($inlineToastText)} />
</Animated.View>
{/* Avatar Picker */}
<TouchableOpacity
onPress={pickImage}
style={themed($avatarPickerContainer)}
activeOpacity={0.7}
disabled={isUploading}
>
<View>
<Avatar
uri={displayAvatar}
fallback={nickname || user?.username || "U"}
size={s(80)}
/>
{isUploading && (
<View style={themed($avatarLoadingOverlay)}>
<ActivityIndicator size="small" color="#fff" />
</View>
)}
</View>
<View style={themed($avatarPickerBadge)}>
<Icon icon="camera" size={s(14)} color={theme.colors.palette.neutral100} />
</View>
</TouchableOpacity>
<Text size="xs" style={themed($avatarHint)}>
{translate("profileScreen:tapToChangeAvatar")}
</Text>
{/* UID (Read-only with copy) */}
<TouchableOpacity
style={themed($readOnlyField)}
onPress={() => user?.userId && copyToClipboard(String(user.userId))}
activeOpacity={0.7}
>
<Text size="xs" style={themed($readOnlyLabel)}>
{translate("profileScreen:uid")}
</Text>
<View style={$readOnlyRow}>
<Text style={themed($readOnlyValue)}>{user?.userId || "-"}</Text>
{user?.userId && (
<PressableIcon
icon="copy"
size={18}
color={theme.colors.textDim}
onPress={() => copyToClipboard(String(user.userId))}
/>
)}
</View>
</TouchableOpacity>
{/* Username (Read-only) */}
<View style={themed($readOnlyField)}>
<Text size="xs" style={themed($readOnlyLabel)}>
{translate("profileScreen:username")}
</Text>
<Text style={themed($readOnlyValue)}>{user?.username || "-"}</Text>
</View>
{/* Referral Code (Read-only with copy) */}
<TouchableOpacity
style={themed($readOnlyField)}
onPress={() => user?.referralCode && copyToClipboard(user.referralCode)}
activeOpacity={0.7}
>
<Text size="xs" style={themed($readOnlyLabel)}>
{translate("profileScreen:referralCode")}
</Text>
<View style={$readOnlyRow}>
<Text style={themed($readOnlyValue)}>{user?.referralCode || "-"}</Text>
{user?.referralCode && (
<PressableIcon
icon="copy"
size={18}
color={theme.colors.textDim}
onPress={() => copyToClipboard(user.referralCode!)}
/>
)}
</View>
</TouchableOpacity>
{/* Nickname Input (Editable) */}
<TextField
value={nickname}
onChangeText={setNickname}
containerStyle={themed($inputContainer)}
placeholder={translate("profileScreen:nicknamePlaceholder")}
labelTx="profileScreen:nickname"
/>
{error ? (
<Text size="xs" style={themed($errorText)}>
{error}
</Text>
) : null}
</View>
</Modal>
</Screen>
)
}
const $container: ThemedStyle<ViewStyle> = ({ spacing }) => ({
paddingTop: spacing.lg,
paddingBottom: spacing.xxl,
})
const $profileHeader: ThemedStyle<ViewStyle> = ({ spacing }) => ({
flexDirection: "row",
alignItems: "center",
marginBottom: spacing.md,
})
const $userInfoContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
flex: 1,
marginLeft: spacing.md,
justifyContent: "center",
})
const $uidText: ThemedStyle<TextStyle> = ({ colors }) => ({
color: colors.textDim,
marginBottom: s(2),
})
const $statusBadge: ViewStyle = {
paddingHorizontal: s(8),
paddingVertical: s(3),
borderRadius: s(4),
marginTop: s(6),
alignSelf: "flex-start",
}
const $statusBadgeLabel: TextStyle = {
fontWeight: "600",
}
const $userName: ThemedStyle<TextStyle> = () => ({
// Name in horizontal layout - no margin needed
})
const $settingsSection: ThemedStyle<ViewStyle> = ({ spacing }) => ({
marginTop: spacing.lg,
})
const $avatarPickerContainer: ThemedStyle<ViewStyle> = () => ({
alignSelf: "center",
marginBottom: s(8),
})
const $avatarLoadingOverlay: ThemedStyle<ViewStyle> = () => ({
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: s(40),
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "center",
alignItems: "center",
})
const $avatarPickerBadge: ThemedStyle<ViewStyle> = ({ colors }) => ({
position: "absolute",
bottom: 0,
right: 0,
width: s(28),
height: s(28),
borderRadius: s(14),
backgroundColor: colors.tint,
justifyContent: "center",
alignItems: "center",
})
const $modalFooter: ViewStyle = {
width: "100%",
gap: s(12),
}
const $fullWidthButton: ViewStyle = {
width: "100%",
}
const $avatarHint: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
color: colors.textDim,
textAlign: "center",
marginBottom: spacing.md,
})
const $modalContent: ThemedStyle<ViewStyle> = ({ spacing }) => ({
position: "relative",
paddingTop: spacing.xl,
})
const $inlineToast: ThemedStyle<ViewStyle> = ({ colors, spacing }) => ({
position: "absolute",
top: 0,
alignSelf: "center",
backgroundColor: colors.palette.primary500,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: s(8),
shadowColor: "#000",
shadowOffset: { width: 0, height: s(2) },
shadowOpacity: 0.25,
shadowRadius: s(4),
elevation: 5,
zIndex: 1,
})
const $inlineToastText: ThemedStyle<TextStyle> = ({ colors }) => ({
color: colors.palette.neutral100,
textAlign: "center",
})
const $readOnlyField: ThemedStyle<ViewStyle> = ({ spacing }) => ({
marginBottom: spacing.md,
})
const $readOnlyRow: ViewStyle = {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
}
const $readOnlyLabel: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
color: colors.textDim,
marginBottom: spacing.xxs,
})
const $readOnlyValue: ThemedStyle<TextStyle> = ({ colors }) => ({
color: colors.text,
})
const $inputContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
marginBottom: spacing.sm,
})
const $errorText: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
color: colors.error,
marginBottom: spacing.sm,
})
const $rightContainer: ViewStyle = {
flexDirection: "row",
alignItems: "center",
}
const $versionText: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
color: colors.textDim,
marginRight: spacing.xs,
})

View File

@@ -0,0 +1,421 @@
import { ComponentType, FC, useCallback, useEffect, useMemo, useRef, useState } from "react"
// eslint-disable-next-line no-restricted-imports
import { Image, ImageStyle, TextInput, TextStyle, View, ViewStyle } from "react-native"
import { useFocusEffect } from "@react-navigation/native"
import { Button } from "@/components/Button"
import { Icon, PressableIcon } from "@/components/Icon"
import { Screen } from "@/components/Screen"
import { Text } from "@/components/Text"
import { TextField, type TextFieldAccessoryProps } from "@/components/TextField"
import { useAuth } from "@/context/AuthContext"
import type { AppStackScreenProps } from "@/navigators/navigationTypes"
import { useAppTheme } from "@/theme/context"
import type { ThemedStyle } from "@/theme/types"
import { s } from "@/utils/responsive"
interface RegisterScreenProps extends AppStackScreenProps<"Register"> {}
export const RegisterScreen: FC<RegisterScreenProps> = ({ navigation }) => {
const usernameInput = useRef<TextInput>(null)
const emailInput = useRef<TextInput>(null)
const passwordInput = useRef<TextInput>(null)
const verificationCodeInput = useRef<TextInput>(null)
const [username, setUsername] = useState("")
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [verificationCode, setVerificationCode] = useState("")
const [isPasswordHidden, setIsPasswordHidden] = useState(true)
const [resendCountdown, setResendCountdown] = useState(0)
const {
registerStep,
pendingEmail,
isLoading,
error,
preRegister,
resendRegisterCode,
verifyRegister,
resetRegisterStep,
googleLogin,
clearError,
} = useAuth()
const {
themed,
theme: { colors },
} = useAppTheme()
// Validation
const usernameError = useMemo(() => {
if (!username || username.length === 0) return ""
if (username.length < 3) return "Username must be at least 3 characters"
if (username.length > 30) return "Username must be less than 30 characters"
return ""
}, [username])
const emailError = useMemo(() => {
if (!email || email.length === 0) return ""
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return "Please enter a valid email"
return ""
}, [email])
// Password validation - requires uppercase, lowercase, number, and special character
const passwordError = useMemo(() => {
if (!password || password.length === 0) return ""
if (password.length < 8) return "Password must be at least 8 characters"
if (!/[A-Z]/.test(password)) return "Password must contain an uppercase letter"
if (!/[a-z]/.test(password)) return "Password must contain a lowercase letter"
if (!/[0-9]/.test(password)) return "Password must contain a number"
if (!/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password))
return "Password must contain a special character"
return ""
}, [password])
// Countdown timer for resend button
useEffect(() => {
if (resendCountdown > 0) {
const timer = setTimeout(() => setResendCountdown(resendCountdown - 1), 1000)
return () => clearTimeout(timer)
}
return undefined
}, [resendCountdown])
// Clear error when screen comes into focus (e.g., navigating from login screen)
useFocusEffect(
useCallback(() => {
clearError()
}, [clearError]),
)
// Clear error when inputs change (not when error changes)
const prevInputsRef = useRef({ username, email, password, verificationCode })
useEffect(() => {
const prev = prevInputsRef.current
// Only clear error if one of the input fields actually changed
if (
prev.username !== username ||
prev.email !== email ||
prev.password !== password ||
prev.verificationCode !== verificationCode
) {
if (error) clearError()
prevInputsRef.current = { username, email, password, verificationCode }
}
}, [username, email, password, verificationCode, error, clearError])
// Handle pre-register (step 1)
const handlePreRegister = useCallback(async () => {
if (!username || usernameError || !email || emailError || !password || passwordError) return
const success = await preRegister(username, password, email)
if (success) {
setResendCountdown(60)
}
}, [username, usernameError, email, emailError, password, passwordError, preRegister])
// Handle verify register (step 2)
const handleVerifyRegister = useCallback(async () => {
if (!verificationCode || verificationCode.length !== 6) return
await verifyRegister(verificationCode)
}, [verificationCode, verifyRegister])
// Handle resend code
const handleResendCode = useCallback(async () => {
if (resendCountdown > 0) return
const success = await resendRegisterCode()
if (success) {
setResendCountdown(60)
}
}, [resendCountdown, resendRegisterCode])
// Handle back to credentials step
const handleBackToCredentials = useCallback(() => {
resetRegisterStep()
setVerificationCode("")
}, [resetRegisterStep])
// Navigate to login
const handleGoToLogin = useCallback(() => {
navigation.navigate("Login")
}, [navigation])
// Handle Google login
const handleGoogleLogin = useCallback(async () => {
await googleLogin()
}, [googleLogin])
// Password visibility toggle
const PasswordRightAccessory: ComponentType<TextFieldAccessoryProps> = useMemo(
() =>
function PasswordRightAccessory(props: TextFieldAccessoryProps) {
return (
<PressableIcon
icon={isPasswordHidden ? "view" : "hidden"}
color={colors.palette.neutral800}
containerStyle={props.style}
size={s(20)}
onPress={() => setIsPasswordHidden(!isPasswordHidden)}
/>
)
},
[isPasswordHidden, colors.palette.neutral800],
)
// Render credentials step (step 1)
const renderCredentialsStep = () => (
<>
<Text
testID="register-heading"
text="Create Account"
preset="heading"
style={themed($title)}
/>
<Text
text="Fill in your details to get started"
preset="subheading"
style={themed($subtitle)}
/>
{error ? <Text text={error} size="sm" style={themed($errorText)} /> : null}
<TextField
ref={usernameInput}
value={username}
onChangeText={setUsername}
containerStyle={themed($textField)}
autoCapitalize="none"
autoComplete="username"
autoCorrect={false}
label="Username"
placeholder="Enter your username"
helper={usernameError}
status={usernameError ? "error" : undefined}
onSubmitEditing={() => emailInput.current?.focus()}
/>
<TextField
ref={emailInput}
value={email}
onChangeText={setEmail}
containerStyle={themed($textField)}
autoCapitalize="none"
autoComplete="email"
autoCorrect={false}
keyboardType="email-address"
label="Email"
placeholder="Enter your email"
helper={emailError}
status={emailError ? "error" : undefined}
onSubmitEditing={() => passwordInput.current?.focus()}
/>
<TextField
ref={passwordInput}
value={password}
onChangeText={setPassword}
containerStyle={themed($textField)}
autoCapitalize="none"
autoComplete="password-new"
autoCorrect={false}
secureTextEntry={isPasswordHidden}
label="Password"
placeholder="Enter your password"
helper={passwordError}
status={passwordError ? "error" : undefined}
onSubmitEditing={handlePreRegister}
RightAccessory={PasswordRightAccessory}
/>
<Button
testID="register-button"
text="Create Account"
style={themed($button)}
preset="reversed"
onPress={handlePreRegister}
disabled={
isLoading ||
!username ||
!!usernameError ||
!email ||
!!emailError ||
!password ||
!!passwordError
}
loading={isLoading}
/>
<View style={themed($dividerContainer)}>
<View style={themed($dividerLine)} />
<Text text="OR" size="xs" style={themed($dividerText)} />
<View style={themed($dividerLine)} />
</View>
<Button
testID="google-login-button"
text="Continue with Google"
style={themed($googleButton)}
preset="default"
onPress={handleGoogleLogin}
disabled={isLoading}
LeftAccessory={() => (
<Image source={require("@assets/images/google-logo.png")} style={themed($googleLogo)} />
)}
/>
<View style={themed($loginLinkContainer)}>
<Text text="Already have an account? " size="sm" />
<Button testID="go-to-login-button" text="Log In" preset="link" onPress={handleGoToLogin} />
</View>
</>
)
// Render verification step (step 2)
const renderVerificationStep = () => (
<>
<Text
testID="verification-heading"
text="Verify Your Email"
preset="heading"
style={themed($title)}
/>
<Text
text={`We've sent a verification code to ${pendingEmail}`}
preset="subheading"
style={themed($subtitle)}
/>
{error ? <Text text={error} size="sm" style={themed($errorText)} /> : null}
<TextField
ref={verificationCodeInput}
value={verificationCode}
onChangeText={(text) => setVerificationCode(text.replace(/[^0-9]/g, "").slice(0, 6))}
containerStyle={themed($textField)}
autoCapitalize="none"
autoComplete="one-time-code"
autoCorrect={false}
keyboardType="number-pad"
label="Verification Code"
placeholder="Enter 6-digit code"
maxLength={6}
onSubmitEditing={handleVerifyRegister}
/>
<Button
testID="verify-button"
text="Verify"
style={themed($button)}
preset="reversed"
onPress={handleVerifyRegister}
disabled={isLoading || verificationCode.length !== 6}
loading={isLoading}
/>
<View style={themed($resendContainer)}>
<Text text="Didn't receive the code? " size="sm" />
<Button
testID="resend-button"
text={resendCountdown > 0 ? `Resend in ${resendCountdown}s` : "Resend Code"}
preset="link"
onPress={handleResendCode}
disabled={isLoading || resendCountdown > 0}
/>
</View>
<Button
testID="back-button"
text="Back"
preset="default"
style={themed($backButton)}
onPress={handleBackToCredentials}
disabled={isLoading}
/>
</>
)
return (
<Screen
preset="auto"
contentContainerStyle={themed($screenContentContainer)}
safeAreaEdges={["top", "bottom"]}
>
{registerStep === "credentials" ? renderCredentialsStep() : renderVerificationStep()}
</Screen>
)
}
const $screenContentContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
paddingVertical: spacing.xxl,
paddingHorizontal: spacing.lg,
})
const $title: ThemedStyle<TextStyle> = ({ spacing }) => ({
marginBottom: spacing.sm,
})
const $subtitle: ThemedStyle<TextStyle> = ({ spacing }) => ({
marginBottom: spacing.lg,
})
const $errorText: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
color: colors.error,
marginBottom: spacing.md,
})
const $textField: ThemedStyle<ViewStyle> = ({ spacing }) => ({
marginBottom: spacing.lg,
})
const $button: ThemedStyle<ViewStyle> = ({ spacing }) => ({
marginTop: spacing.xs,
})
const $loginLinkContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
marginTop: spacing.xl,
})
const $resendContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
marginTop: spacing.lg,
})
const $backButton: ThemedStyle<ViewStyle> = ({ spacing }) => ({
marginTop: spacing.lg,
})
const $dividerContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
flexDirection: "row",
alignItems: "center",
marginTop: spacing.xl,
marginBottom: spacing.lg,
})
const $dividerLine: ThemedStyle<ViewStyle> = ({ colors }) => ({
flex: 1,
height: 1,
backgroundColor: colors.border,
})
const $dividerText: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
color: colors.textDim,
marginHorizontal: spacing.md,
})
const $googleButton: ThemedStyle<ViewStyle> = () => ({
borderWidth: 1,
})
const $googleLogo: ThemedStyle<ImageStyle> = ({ spacing }) => ({
width: s(20),
height: s(20),
marginRight: spacing.sm,
})

View File

@@ -0,0 +1,81 @@
import { FC, useCallback } from "react"
import { TextStyle, ViewStyle } from "react-native"
import { ListItem } from "@/components/ListItem"
import { Screen } from "@/components/Screen"
import { translate } from "@/i18n/translate"
import { AppStackScreenProps } from "@/navigators/navigationTypes"
import { useAppTheme } from "@/theme/context"
import { $styles } from "@/theme/styles"
import type { ThemedStyle } from "@/theme/types"
import { useHeader } from "@/utils/useHeader"
export const SecurityScreen: FC<AppStackScreenProps<"Security">> = function SecurityScreen({
navigation,
}) {
const { themed, theme } = useAppTheme()
const navigateToChangePassword = useCallback(() => {
navigation.navigate("ChangePassword")
}, [navigation])
const navigateToChangeEmail = useCallback(() => {
navigation.navigate("ChangeEmail")
}, [navigation])
const navigateToSessionManagement = useCallback(() => {
navigation.navigate("SessionManagement")
}, [navigation])
useHeader(
{
title: translate("securityScreen:title"),
leftIcon: "back",
onLeftPress: () => navigation.goBack(),
},
[],
)
return (
<Screen
preset="scroll"
safeAreaEdges={["bottom"]}
contentContainerStyle={[$styles.container, themed($container)]}
>
<ListItem
tx="securityScreen:changePassword"
textStyle={themed($listItemText)}
leftIcon="lock"
leftIconColor={theme.colors.tint}
rightIcon="caretRight"
onPress={navigateToChangePassword}
/>
<ListItem
tx="securityScreen:changeEmail"
textStyle={themed($listItemText)}
leftIcon="mail"
leftIconColor={theme.colors.tint}
rightIcon="caretRight"
onPress={navigateToChangeEmail}
/>
<ListItem
tx="securityScreen:activeSessions"
textStyle={themed($listItemText)}
leftIcon="smartphone"
leftIconColor={theme.colors.tint}
rightIcon="caretRight"
onPress={navigateToSessionManagement}
/>
</Screen>
)
}
const $container: ThemedStyle<ViewStyle> = ({ spacing }) => ({
paddingTop: spacing.lg,
})
const $listItemText: ThemedStyle<TextStyle> = () => ({
// Default styling
})

View File

@@ -0,0 +1,515 @@
import { FC, useCallback, useEffect, useMemo, useState } from "react"
import {
ActivityIndicator,
Dimensions,
RefreshControl,
ScrollView,
TextStyle,
TouchableOpacity,
View,
ViewStyle,
} from "react-native"
import { Button } from "@/components/Button"
import { Dialog } from "@/components/Dialog"
import { Icon } from "@/components/Icon"
import { Screen } from "@/components/Screen"
import { Skeleton, SkeletonContainer } from "@/components/Skeleton"
import { Text } from "@/components/Text"
import { useAuth } from "@/context/AuthContext"
import { translate } from "@/i18n/translate"
import { AppStackScreenProps } from "@/navigators/navigationTypes"
import { authApi } from "@/services/api/authApi"
import type { Session } from "@/services/api/authTypes"
import { useAppTheme } from "@/theme/context"
import { $styles } from "@/theme/styles"
import type { ThemedStyle } from "@/theme/types"
import { s } from "@/utils/responsive"
import { useHeader } from "@/utils/useHeader"
export const SessionManagementScreen: FC<AppStackScreenProps<"SessionManagement">> =
function SessionManagementScreen({ navigation }) {
const { themed, theme } = useAppTheme()
const { accessToken } = useAuth()
// Session state
const [sessions, setSessions] = useState<Session[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isRefreshing, setIsRefreshing] = useState(false)
const [revokingSessionId, setRevokingSessionId] = useState<string | null>(null)
const [isRevokingAll, setIsRevokingAll] = useState(false)
// Dialog state
const [confirmDialogVisible, setConfirmDialogVisible] = useState(false)
const [confirmAllDialogVisible, setConfirmAllDialogVisible] = useState(false)
const [successDialogVisible, setSuccessDialogVisible] = useState(false)
const [successMessage, setSuccessMessage] = useState("")
const [pendingSessionId, setPendingSessionId] = useState<string | null>(null)
useHeader(
{
title: translate("securityScreen:activeSessions"),
leftIcon: "back",
onLeftPress: () => navigation.goBack(),
},
[],
)
// Fetch sessions
const fetchSessions = useCallback(
async (showLoading = true) => {
if (!accessToken) return
if (showLoading) setIsLoading(true)
try {
const result = await authApi.getSessions(accessToken, { active: true })
if (result.kind === "ok" && result.data.data?.sessions) {
setSessions(result.data.data.sessions)
}
} catch (e) {
if (__DEV__) console.error("Failed to fetch sessions:", e)
} finally {
setIsLoading(false)
setIsRefreshing(false)
}
},
[accessToken],
)
// Initial fetch
useEffect(() => {
fetchSessions()
}, [fetchSessions])
// Pull to refresh
const onRefresh = useCallback(() => {
setIsRefreshing(true)
fetchSessions(false)
}, [fetchSessions])
// Show confirm dialog for single session
const handleRevokeSession = useCallback(
(sessionId: string) => {
if (!accessToken) return
setPendingSessionId(sessionId)
setConfirmDialogVisible(true)
},
[accessToken],
)
// Actually revoke a single session
const confirmRevokeSession = useCallback(async () => {
if (!accessToken || !pendingSessionId) return
setConfirmDialogVisible(false)
setRevokingSessionId(pendingSessionId)
try {
const result = await authApi.revokeSession(accessToken, pendingSessionId)
if (result.kind === "ok") {
setSessions((prev) => prev.filter((s) => s.id !== pendingSessionId))
setSuccessMessage(translate("securityScreen:sessionRevoked"))
setSuccessDialogVisible(true)
}
} catch (e) {
if (__DEV__) console.error("Failed to revoke session:", e)
} finally {
setRevokingSessionId(null)
setPendingSessionId(null)
}
}, [accessToken, pendingSessionId])
// Show confirm dialog for all other sessions
const handleRevokeAllOther = useCallback(() => {
if (!accessToken) return
setConfirmAllDialogVisible(true)
}, [accessToken])
// Actually revoke all other sessions
const confirmRevokeAllOther = useCallback(async () => {
if (!accessToken) return
setConfirmAllDialogVisible(false)
setIsRevokingAll(true)
try {
const result = await authApi.revokeOtherSessions(accessToken)
if (result.kind === "ok") {
const revokedCount = result.data.data?.revokedCount || 0
// Keep only current session
setSessions((prev) => prev.filter((s) => s.isCurrent))
if (revokedCount > 0) {
setSuccessMessage(translate("securityScreen:sessionsRevoked", { count: revokedCount }))
setSuccessDialogVisible(true)
}
}
} catch (e) {
if (__DEV__) console.error("Failed to revoke other sessions:", e)
} finally {
setIsRevokingAll(false)
}
}, [accessToken])
// Get device icon based on device type
const getDeviceIcon = (deviceType: string): "monitor" | "smartphone" | "tablet" => {
switch (deviceType.toLowerCase()) {
case "desktop":
return "monitor"
case "mobile":
return "smartphone"
case "tablet":
return "tablet"
default:
return "monitor"
}
}
// Format last active time
const formatLastActive = (dateString: string): string => {
const date = new Date(dateString)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMins < 1) return "Just now"
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
if (diffDays < 7) return `${diffDays}d ago`
return date.toLocaleDateString()
}
// Get other sessions (not current)
const otherSessions = sessions.filter((s) => !s.isCurrent)
const currentSession = sessions.find((s) => s.isCurrent)
return (
<Screen preset="fixed" safeAreaEdges={["bottom"]} contentContainerStyle={$styles.flex1}>
<ScrollView
contentContainerStyle={[$styles.container, themed($container)]}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={onRefresh}
tintColor={theme.colors.tint}
/>
}
>
<Text
size="xs"
tx="securityScreen:activeSessionsDescription"
style={themed($description)}
/>
{isLoading ? (
<SessionListSkeleton />
) : (
<>
{/* Current Session */}
{currentSession && (
<View style={[themed($sessionCard), themed($currentSessionCard)]}>
<View style={$sessionHeader}>
<View style={themed($deviceIconContainer)}>
<Icon
icon={getDeviceIcon(currentSession.deviceInfo.deviceType)}
size={20}
color={theme.colors.tint}
/>
</View>
<View style={$sessionInfo}>
<View style={$sessionTitleRow}>
<Text preset="bold" size="sm">
{currentSession.deviceInfo.deviceName ||
`${currentSession.deviceInfo.os} - ${currentSession.deviceInfo.browser}`}
</Text>
<View style={themed($currentBadge)}>
<Text size="xxs" style={themed($currentBadgeText)}>
{translate("securityScreen:currentDevice")}
</Text>
</View>
</View>
<Text size="xs" style={themed($sessionDetail)}>
{currentSession.location?.city
? `${currentSession.location.city} · ${currentSession.ipAddress}`
: currentSession.ipAddress}
</Text>
<Text size="xs" style={themed($sessionDetail)}>
{translate("securityScreen:lastActive")}:{" "}
{formatLastActive(currentSession.lastActiveAt)}
</Text>
</View>
</View>
</View>
)}
{/* Other Sessions */}
{otherSessions.length > 0
? otherSessions.map((session) => (
<View key={session.id} style={themed($sessionCard)}>
<View style={$sessionHeader}>
<View style={themed($deviceIconContainerDim)}>
<Icon
icon={getDeviceIcon(session.deviceInfo.deviceType)}
size={20}
color={theme.colors.textDim}
/>
</View>
<View style={$sessionInfo}>
<Text preset="bold" size="sm">
{session.deviceInfo.deviceName ||
`${session.deviceInfo.os} - ${session.deviceInfo.browser}`}
</Text>
<Text size="xs" style={themed($sessionDetail)}>
{session.location?.city
? `${session.location.city} · ${session.ipAddress}`
: session.ipAddress}
</Text>
<Text size="xs" style={themed($sessionDetail)}>
{translate("securityScreen:lastActive")}:{" "}
{formatLastActive(session.lastActiveAt)}
</Text>
</View>
<TouchableOpacity
onPress={() => handleRevokeSession(session.id)}
disabled={revokingSessionId === session.id}
style={themed($logoutButton)}
>
{revokingSessionId === session.id ? (
<ActivityIndicator size="small" color={theme.colors.error} />
) : (
<Text size="xs" style={themed($logoutButtonText)}>
{translate("securityScreen:logoutDevice")}
</Text>
)}
</TouchableOpacity>
</View>
</View>
))
: !currentSession && (
<View style={themed($emptyState)}>
<Text
size="sm"
tx="securityScreen:noOtherSessions"
style={themed($emptyStateText)}
/>
</View>
)}
</>
)}
</ScrollView>
{/* Fixed Bottom Button - Only show when there are other sessions */}
{otherSessions.length > 0 && (
<View style={themed($bottomContainer)}>
<Button
tx="securityScreen:logoutAllOther"
preset="reversed"
onPress={handleRevokeAllOther}
disabled={isRevokingAll}
loading={isRevokingAll}
/>
<Text
size="xxs"
tx="securityScreen:logoutAllOtherDescription"
style={themed($logoutAllDescription)}
/>
</View>
)}
{/* Confirm Single Session Logout Dialog */}
<Dialog
visible={confirmDialogVisible}
onClose={() => setConfirmDialogVisible(false)}
preset="destructive"
titleTx="securityScreen:confirmLogout"
messageTx="securityScreen:confirmLogoutMessage"
confirmTx="securityScreen:logoutDevice"
onConfirm={confirmRevokeSession}
onCancel={() => setConfirmDialogVisible(false)}
/>
{/* Confirm All Sessions Logout Dialog */}
<Dialog
visible={confirmAllDialogVisible}
onClose={() => setConfirmAllDialogVisible(false)}
preset="destructive"
titleTx="securityScreen:confirmLogout"
messageTx="securityScreen:confirmLogoutAllMessage"
confirmTx="securityScreen:logoutAllOther"
onConfirm={confirmRevokeAllOther}
onCancel={() => setConfirmAllDialogVisible(false)}
/>
{/* Success Dialog */}
<Dialog
visible={successDialogVisible}
onClose={() => setSuccessDialogVisible(false)}
message={successMessage}
onConfirm={() => setSuccessDialogVisible(false)}
/>
</Screen>
)
}
const $container: ThemedStyle<ViewStyle> = ({ spacing }) => ({
paddingTop: spacing.lg,
})
const $description: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
color: colors.textDim,
marginBottom: spacing.lg,
})
const $sessionCard: ThemedStyle<ViewStyle> = ({ colors, spacing }) => ({
backgroundColor: colors.palette.neutral100,
borderRadius: s(12),
padding: spacing.md,
marginBottom: spacing.md,
borderWidth: 1,
borderColor: colors.palette.neutral300,
})
const $currentSessionCard: ThemedStyle<ViewStyle> = ({ colors }) => ({
borderColor: colors.tint,
borderWidth: 1.5,
})
const $deviceIconContainer: ThemedStyle<ViewStyle> = ({ colors }) => ({
width: s(40),
height: s(40),
borderRadius: s(20),
backgroundColor: `${colors.tint}15`,
justifyContent: "center",
alignItems: "center",
})
const $deviceIconContainerDim: ThemedStyle<ViewStyle> = ({ colors }) => ({
width: s(40),
height: s(40),
borderRadius: s(20),
backgroundColor: colors.palette.neutral200,
justifyContent: "center",
alignItems: "center",
})
const $sessionHeader: ViewStyle = {
flexDirection: "row",
alignItems: "center",
}
const $sessionInfo: ViewStyle = {
flex: 1,
marginLeft: s(14),
}
const $sessionTitleRow: ViewStyle = {
flexDirection: "row",
alignItems: "center",
flexWrap: "wrap",
gap: s(8),
}
const $currentBadge: ThemedStyle<ViewStyle> = ({ colors }) => ({
backgroundColor: colors.tint,
paddingHorizontal: s(6),
paddingVertical: s(2),
borderRadius: s(4),
})
const $currentBadgeText: ThemedStyle<TextStyle> = ({ colors }) => ({
color: colors.palette.neutral100,
fontWeight: "600",
})
const $sessionDetail: ThemedStyle<TextStyle> = ({ colors }) => ({
color: colors.textDim,
marginTop: s(2),
})
const $logoutButton: ThemedStyle<ViewStyle> = ({ colors, spacing }) => ({
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: s(4),
borderWidth: 1,
borderColor: colors.error,
})
const $logoutButtonText: ThemedStyle<TextStyle> = ({ colors }) => ({
color: colors.error,
fontWeight: "600",
})
const $bottomContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
paddingHorizontal: spacing.lg,
paddingBottom: spacing.md,
})
const $logoutAllDescription: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
color: colors.textDim,
textAlign: "center",
marginTop: spacing.xs,
})
const $emptyState: ThemedStyle<ViewStyle> = ({ spacing }) => ({
padding: spacing.xl,
alignItems: "center",
})
const $emptyStateText: ThemedStyle<TextStyle> = ({ colors }) => ({
color: colors.textDim,
})
// Card height: padding (16*2) + icon (40) = 72, plus marginBottom (16) = 88
const SKELETON_CARD_HEIGHT = 88
// Header height + description + container padding approximation
const SKELETON_HEADER_OFFSET = 120
/**
* Skeleton list that fills the screen with diagonal shimmer effect
*/
function SessionListSkeleton() {
const { themed, theme } = useAppTheme()
const skeletonCount = useMemo(() => {
const screenHeight = Dimensions.get("window").height
const availableHeight = screenHeight - SKELETON_HEADER_OFFSET
return Math.ceil(availableHeight / SKELETON_CARD_HEIGHT)
}, [])
return (
<SkeletonContainer>
{Array.from({ length: skeletonCount }).map((_, index) => (
<View key={index} style={themed($sessionCard)}>
<View style={$sessionHeader}>
{/* Device icon - 40x40 circle */}
<Skeleton width={40} height={40} radius="round" />
{/* Session info */}
<View style={$sessionInfo}>
{/* Device name - bold sm (16px) */}
<Skeleton width="65%" height={16} radius={4} />
{/* Location/IP - xs (14px), marginTop: 2 */}
<Skeleton
width="50%"
height={14}
radius={4}
style={{ marginTop: theme.spacing.xxs }}
/>
{/* Last active - xs (14px), marginTop: 2 */}
<Skeleton
width="45%"
height={14}
radius={4}
style={{ marginTop: theme.spacing.xxs }}
/>
</View>
</View>
</View>
))}
</SkeletonContainer>
)
}

View File

@@ -0,0 +1,170 @@
import { FC, useCallback, useState } from "react"
import { TextStyle, View, ViewStyle } from "react-native"
import i18n from "i18next"
import { useMMKVString } from "react-native-mmkv"
import { Button } from "@/components/Button"
import { Dialog } from "@/components/Dialog"
import { Icon } from "@/components/Icon"
import { ListItem } from "@/components/ListItem"
import { Screen } from "@/components/Screen"
import { Text } from "@/components/Text"
import { useAuth } from "@/context/AuthContext"
import { translate } from "@/i18n/translate"
import { AppStackScreenProps } from "@/navigators/navigationTypes"
import { useAppTheme } from "@/theme/context"
import { $styles } from "@/theme/styles"
import type { ThemedStyle } from "@/theme/types"
import { s } from "@/utils/responsive"
import { storage } from "@/utils/storage"
import { useHeader } from "@/utils/useHeader"
// Language display names
const LANGUAGE_NAMES: Record<string, string> = {
en: "English",
zh: "中文",
ja: "日本語",
ko: "한국어",
es: "Español",
fr: "Français",
ar: "العربية",
hi: "हिन्दी",
}
// Theme display names
const THEME_NAMES: Record<string, string> = {
system: "System",
light: "Light",
dark: "Dark",
}
export const SettingsScreen: FC<AppStackScreenProps<"Settings">> = function SettingsScreen({
navigation,
}) {
const { themed, theme } = useAppTheme()
const { logout } = useAuth()
const [showLogoutDialog, setShowLogoutDialog] = useState(false)
// Read the stored theme preference directly from MMKV
const [themeScheme] = useMMKVString("ignite.themeScheme", storage)
// Get current theme name for display: if no stored value, it's "system"
const currentThemeName =
themeScheme === "light" || themeScheme === "dark"
? THEME_NAMES[themeScheme]
: THEME_NAMES.system
const handleLogoutPress = useCallback(() => {
setShowLogoutDialog(true)
}, [])
const handleLogoutConfirm = useCallback(() => {
setShowLogoutDialog(false)
logout()
}, [logout])
useHeader(
{
title: translate("settingsScreen:title"),
leftIcon: "back",
onLeftPress: () => navigation.goBack(),
},
[],
)
return (
<Screen preset="fixed" safeAreaEdges={["bottom"]} contentContainerStyle={$styles.flex1}>
{/* Content */}
<View style={[$styles.container, themed($container), $styles.flex1]}>
{/* Appearance Section */}
<Text preset="subheading" tx="settingsScreen:appearance" style={themed($sectionTitle)} />
<ListItem
tx="settingsScreen:theme"
leftIcon="moon"
leftIconColor={theme.colors.tint}
RightComponent={
<View style={$rightContainer}>
<Text size="xs" style={themed($themeText)}>
{currentThemeName}
</Text>
<Icon icon="caretRight" size={s(24)} color={theme.colors.textDim} />
</View>
}
onPress={() => navigation.navigate("Theme")}
/>
{/* Language Section */}
<Text
preset="subheading"
tx="settingsScreen:language"
style={themed($sectionTitleWithMargin)}
/>
<ListItem
tx="settingsScreen:currentLanguage"
leftIcon="globe"
leftIconColor={theme.colors.tint}
RightComponent={
<View style={$rightContainer}>
<Text size="xs" style={themed($languageText)}>
{LANGUAGE_NAMES[i18n.language?.split("-")[0] || "en"] || "English"}
</Text>
<Icon icon="caretRight" size={s(24)} color={theme.colors.textDim} />
</View>
}
onPress={() => navigation.navigate("Language")}
/>
</View>
{/* Logout Button - Fixed at bottom */}
<View style={themed($bottomContainer)}>
<Button tx="common:logOut" preset="reversed" onPress={handleLogoutPress} />
</View>
{/* Logout Confirmation Dialog */}
<Dialog
visible={showLogoutDialog}
onClose={() => setShowLogoutDialog(false)}
preset="destructive"
titleTx="securityScreen:confirmLogout"
messageTx="securityScreen:confirmLogoutMessage"
confirmTx="common:logOut"
onConfirm={handleLogoutConfirm}
onCancel={() => setShowLogoutDialog(false)}
/>
</Screen>
)
}
const $container: ThemedStyle<ViewStyle> = ({ spacing }) => ({
paddingTop: spacing.lg,
})
const $sectionTitle: ThemedStyle<TextStyle> = () => ({
// First section doesn't need top margin
})
const $sectionTitleWithMargin: ThemedStyle<TextStyle> = ({ spacing }) => ({
marginTop: spacing.xxl,
})
const $rightContainer: ViewStyle = {
flexDirection: "row",
alignItems: "center",
}
const $themeText: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
color: colors.textDim,
marginRight: spacing.xs,
})
const $languageText: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
color: colors.textDim,
marginRight: spacing.xs,
})
const $bottomContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
paddingHorizontal: spacing.lg,
paddingBottom: spacing.md,
})

View File

@@ -0,0 +1,65 @@
/* eslint-disable react-native/no-inline-styles */
import { StyleProp, View, ViewStyle } from "react-native"
import { useAppTheme } from "@/theme/context"
import type { ThemedStyle } from "@/theme/types"
import { s } from "@/utils/responsive"
interface DemoDividerProps {
type?: "vertical" | "horizontal"
size?: number
style?: StyleProp<ViewStyle>
line?: boolean
}
/**
* @param {DemoDividerProps} props - The props for the `DemoDivider` component.
* @returns {JSX.Element} The rendered `DemoDivider` component.
*/
export function DemoDivider(props: DemoDividerProps) {
const { type = "horizontal", size = 10, line = false, style: $styleOverride } = props
const { themed } = useAppTheme()
return (
<View
style={[
$divider,
type === "horizontal" && { height: size },
type === "vertical" && { width: size },
$styleOverride,
]}
>
{line && (
<View
style={[
themed($line),
type === "horizontal" && {
width: s(150),
height: 1,
marginStart: s(-75),
marginTop: -1,
},
type === "vertical" && {
height: s(50),
width: 1,
marginTop: s(-25),
marginStart: -1,
},
]}
/>
)}
</View>
)
}
const $divider: ViewStyle = {
flexGrow: 0,
flexShrink: 0,
}
const $line: ThemedStyle<ViewStyle> = ({ colors }) => ({
backgroundColor: colors.border,
position: "absolute",
left: "50%",
top: "50%",
})

View File

@@ -0,0 +1,51 @@
import { ReactNode } from "react"
import { TextStyle, View, ViewStyle } from "react-native"
import { Text } from "@/components/Text"
import type { TxKeyPath } from "@/i18n"
import { translate } from "@/i18n/translate"
import { useAppTheme } from "@/theme/context"
import { $styles } from "@/theme/styles"
import type { ThemedStyle } from "@/theme/types"
import { s } from "@/utils/responsive"
interface DemoUseCaseProps {
name: TxKeyPath
description?: TxKeyPath
layout?: "column" | "row"
itemStyle?: ViewStyle
children: ReactNode
}
/**
* @param {DemoUseCaseProps} props - The props for the `DemoUseCase` component.
* @returns {JSX.Element} The rendered `DemoUseCase` component.
*/
export function DemoUseCase(props: DemoUseCaseProps) {
const { name, description, children, layout = "column", itemStyle = {} } = props
const { themed } = useAppTheme()
return (
<View>
<Text style={themed($name)}>{translate(name)}</Text>
{description && <Text style={themed($description)}>{translate(description)}</Text>}
<View style={[itemStyle, layout === "row" && $styles.row, themed($item)]}>{children}</View>
</View>
)
}
const $description: ThemedStyle<TextStyle> = ({ spacing }) => ({
marginTop: spacing.md,
})
const $item: ThemedStyle<ViewStyle> = ({ colors, spacing }) => ({
backgroundColor: colors.palette.neutral100,
borderRadius: s(8),
padding: spacing.lg,
marginVertical: spacing.md,
})
const $name: ThemedStyle<TextStyle> = ({ typography }) => ({
fontFamily: typography.primary.bold,
})

View File

@@ -0,0 +1,119 @@
import { Pressable, PressableProps, ViewStyle, Platform } from "react-native"
import { useDrawerProgress } from "react-native-drawer-layout"
import Animated, { interpolate, interpolateColor, useAnimatedStyle } from "react-native-reanimated"
import { isRTL } from "@/i18n"
import { useAppTheme } from "@/theme/context"
import { s } from "@/utils/responsive"
interface DrawerIconButtonProps extends PressableProps {}
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
/**
* @param {DrawerIconButtonProps} props - The props for the `DrawerIconButton` component.
* @returns {JSX.Element} The rendered `DrawerIconButton` component.
*/
export function DrawerIconButton(props: DrawerIconButtonProps) {
const { ...PressableProps } = props
const progress = useDrawerProgress()
const isWeb = Platform.OS === "web"
const {
theme: { colors },
themed,
} = useAppTheme()
const animatedContainerStyles = useAnimatedStyle(() => {
const translateX = interpolate(progress.value, [0, 1], [0, isRTL ? 60 : -60])
return {
transform: [{ translateX }],
}
})
const animatedTopBarStyles = useAnimatedStyle(() => {
const backgroundColor = interpolateColor(progress.value, [0, 1], [colors.text, colors.tint])
const marginStart = interpolate(progress.value, [0, 1], [0, -11.5])
const rotate = interpolate(progress.value, [0, 1], [0, isRTL ? 45 : -45])
const marginBottom = interpolate(progress.value, [0, 1], [0, -2])
const width = interpolate(progress.value, [0, 1], [18, 12])
const marginHorizontal =
isWeb && isRTL
? { marginRight: marginStart }
: {
marginLeft: marginStart,
}
return {
...marginHorizontal,
backgroundColor,
marginBottom,
width,
transform: [{ rotate: `${rotate}deg` }],
}
})
const animatedMiddleBarStyles = useAnimatedStyle(() => {
const backgroundColor = interpolateColor(progress.value, [0, 1], [colors.text, colors.tint])
const width = interpolate(progress.value, [0, 1], [18, 16])
return {
backgroundColor,
width,
}
})
const animatedBottomBarStyles = useAnimatedStyle(() => {
const marginTop = interpolate(progress.value, [0, 1], [4, 2])
const backgroundColor = interpolateColor(progress.value, [0, 1], [colors.text, colors.tint])
const marginStart = interpolate(progress.value, [0, 1], [0, -11.5])
const rotate = interpolate(progress.value, [0, 1], [0, isRTL ? -45 : 45])
const width = interpolate(progress.value, [0, 1], [18, 12])
const marginHorizontal =
isWeb && isRTL
? { marginRight: marginStart }
: {
marginLeft: marginStart,
}
return {
...marginHorizontal,
backgroundColor,
width,
marginTop,
transform: [{ rotate: `${rotate}deg` }],
}
})
return (
<AnimatedPressable {...PressableProps} style={[$container, animatedContainerStyles]}>
<Animated.View style={[$topBar, animatedTopBarStyles]} />
<Animated.View style={[themed($middleBar), animatedMiddleBarStyles]} />
<Animated.View style={[$bottomBar, animatedBottomBarStyles]} />
</AnimatedPressable>
)
}
const barHeight = 2
const $container: ViewStyle = {
alignItems: "center",
height: s(56),
justifyContent: "center",
width: s(56),
}
const $topBar: ViewStyle = {
height: barHeight,
}
const $middleBar: ViewStyle = {
height: barHeight,
marginTop: s(4),
}
const $bottomBar: ViewStyle = {
height: barHeight,
}

View File

@@ -0,0 +1,57 @@
import { forwardRef, ReactElement, ReactNode, useCallback } from "react"
import { ScrollViewProps, SectionList, SectionListProps } from "react-native"
import { KeyboardAwareScrollView } from "react-native-keyboard-controller"
import { DEFAULT_BOTTOM_OFFSET } from "@/components/Screen"
type SectionType<ItemType> = {
name: string
description: string
data: ItemType[]
}
type SectionListWithKeyboardAwareScrollViewProps<ItemType> = SectionListProps<ItemType> & {
/* Optional function to pass a custom scroll component */
renderScrollComponent?: (props: ScrollViewProps) => ReactNode
/* Optional additional offset between TextInput bottom edge and keyboard top edge. See https://kirillzyusko.github.io/react-native-keyboard-controller/docs/api/components/keyboard-aware-scroll-view#bottomoffset */
bottomOffset?: number
/* The sections to be rendered in the list */
sections: SectionType<ItemType>[]
/* Function to render the header for each section */
renderSectionHeader: ({ section }: { section: SectionType<ItemType> }) => React.ReactNode
}
function SectionListWithKeyboardAwareScrollView<ItemType = any>(
{
renderScrollComponent,
bottomOffset = DEFAULT_BOTTOM_OFFSET,
contentContainerStyle,
...props
}: SectionListWithKeyboardAwareScrollViewProps<ItemType>,
ref: React.Ref<SectionList<ItemType>>,
): ReactElement {
const defaultRenderScrollComponent = useCallback(
(props: ScrollViewProps) => (
<KeyboardAwareScrollView
contentContainerStyle={contentContainerStyle}
bottomOffset={bottomOffset}
{...props}
/>
),
[contentContainerStyle, bottomOffset],
)
return (
<SectionList
{...props}
ref={ref}
renderScrollComponent={renderScrollComponent ?? defaultRenderScrollComponent}
/>
)
}
export default forwardRef(SectionListWithKeyboardAwareScrollView) as <ItemType = any>(
props: SectionListWithKeyboardAwareScrollViewProps<ItemType> & {
ref?: React.Ref<SectionList<ItemType>>
},
) => ReactElement

View File

@@ -0,0 +1,320 @@
import { FC, ReactElement, useCallback, useEffect, useRef, useState } from "react"
import {
FlatList,
Image,
ImageStyle,
Platform,
SectionList,
TextStyle,
View,
ViewStyle,
} from "react-native"
import { Link, RouteProp, useRoute } from "@react-navigation/native"
import { Drawer } from "react-native-drawer-layout"
import { ListItem } from "@/components/ListItem"
import { Screen } from "@/components/Screen"
import { Text } from "@/components/Text"
import { TxKeyPath, isRTL } from "@/i18n"
import { translate } from "@/i18n/translate"
import { MainTabParamList, MainTabScreenProps } from "@/navigators/navigationTypes"
import { useAppTheme } from "@/theme/context"
import { $styles } from "@/theme/styles"
import type { ThemedStyle } from "@/theme/types"
import { s, fs } from "@/utils/responsive"
import { useSafeAreaInsetsStyle } from "@/utils/useSafeAreaInsetsStyle"
import * as Demos from "./demos"
import { DrawerIconButton } from "./DrawerIconButton"
import SectionListWithKeyboardAwareScrollView from "./SectionListWithKeyboardAwareScrollView"
const logo = require("@assets/images/logo.png")
interface DemoListItem {
item: { name: string; useCases: string[] }
sectionIndex: number
handleScroll?: (sectionIndex: number, itemIndex?: number) => void
}
const slugify = (str: string) =>
str
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, "")
.replace(/[\s_-]+/g, "-")
.replace(/^-+|-+$/g, "")
/**
* Type-safe utility to check if an unknown object has a valid string property.
* This is particularly useful in React 19 where props are typed as unknown by default.
* The function safely narrows down the type by checking both property existence and type.
* @param props - The unknown props to check.
* @param propName - The name of the property to check.
* @returns Whether the property is a valid string.
*/
function hasValidStringProp(props: unknown, propName: string): boolean {
return (
props !== null &&
typeof props === "object" &&
propName in props &&
typeof (props as Record<string, unknown>)[propName] === "string"
)
}
const WebListItem: FC<DemoListItem> = ({ item, sectionIndex }) => {
const sectionSlug = item.name.toLowerCase()
const { themed } = useAppTheme()
return (
<View>
<Link screen="Showroom" params={{ queryIndex: sectionSlug }} style={themed($menuContainer)}>
<Text preset="bold">{item.name}</Text>
</Link>
{item.useCases.map((u) => {
const itemSlug = slugify(u)
return (
<Link
key={`section${sectionIndex}-${u}`}
screen="Showroom"
params={{ queryIndex: sectionSlug, itemIndex: itemSlug }}
>
<Text>{u}</Text>
</Link>
)
})}
</View>
)
}
const NativeListItem: FC<DemoListItem> = ({ item, sectionIndex, handleScroll }) => {
const { themed } = useAppTheme()
return (
<View>
<Text
onPress={() => handleScroll?.(sectionIndex)}
preset="bold"
style={themed($menuContainer)}
>
{item.name}
</Text>
{item.useCases.map((u, index) => (
<ListItem
key={`section${sectionIndex}-${u}`}
onPress={() => handleScroll?.(sectionIndex, index)}
text={u}
rightIcon={isRTL ? "caretLeft" : "caretRight"}
/>
))}
</View>
)
}
const ShowroomListItem = Platform.select({ web: WebListItem, default: NativeListItem })
const isAndroid = Platform.OS === "android"
export const ShowroomScreen: FC<MainTabScreenProps<"Showroom">> = function ShowroomScreen(_props) {
const [open, setOpen] = useState(false)
const timeout = useRef<ReturnType<typeof setTimeout>>(null)
const listRef = useRef<SectionList>(null)
const menuRef = useRef<FlatList<DemoListItem["item"]>>(null)
const route = useRoute<RouteProp<MainTabParamList, "Showroom">>()
const params = route.params
const { themed, theme } = useAppTheme()
const toggleDrawer = useCallback(() => {
if (!open) {
setOpen(true)
} else {
setOpen(false)
}
}, [open])
const handleScroll = useCallback((sectionIndex: number, itemIndex = 0) => {
try {
listRef.current?.scrollToLocation({
animated: true,
itemIndex,
sectionIndex,
viewPosition: 0.25,
})
} catch (e) {
console.error(e)
}
}, [])
// handle Web links
useEffect(() => {
if (params !== undefined && Object.keys(params).length > 0) {
const demoValues = Object.values(Demos)
const findSectionIndex = demoValues.findIndex(
(x) => x.name.toLowerCase() === params.queryIndex,
)
let findItemIndex = 0
if (params.itemIndex) {
try {
findItemIndex = demoValues[findSectionIndex].data({ themed, theme }).findIndex((u) => {
if (hasValidStringProp(u.props, "name")) {
return slugify(translate((u.props as { name: TxKeyPath }).name)) === params.itemIndex
}
return false
})
} catch (err) {
console.error(err)
}
}
handleScroll(findSectionIndex, findItemIndex)
}
}, [handleScroll, params, theme, themed])
const scrollToIndexFailed = (info: {
index: number
highestMeasuredFrameIndex: number
averageItemLength: number
}) => {
listRef.current?.getScrollResponder()?.scrollToEnd()
timeout.current = setTimeout(
() =>
listRef.current?.scrollToLocation({
animated: true,
itemIndex: info.index,
sectionIndex: 0,
}),
50,
)
}
useEffect(() => {
return () => {
if (timeout.current) {
clearTimeout(timeout.current)
}
}
}, [])
const $drawerInsets = useSafeAreaInsetsStyle(["top"])
return (
<Drawer
open={open}
onOpen={() => setOpen(true)}
onClose={() => setOpen(false)}
drawerType="back"
drawerPosition={isRTL ? "right" : "left"}
renderDrawerContent={() => (
<View style={themed([$drawer, $drawerInsets])}>
<View style={themed($logoContainer)}>
<Image source={logo} style={$logoImage} />
</View>
<FlatList<DemoListItem["item"]>
ref={menuRef}
contentContainerStyle={themed($listContentContainer)}
data={Object.values(Demos).map((d) => ({
name: d.name,
useCases: d.data({ theme, themed }).map((u) => {
if (hasValidStringProp(u.props, "name")) {
return translate((u.props as { name: TxKeyPath }).name)
}
return ""
}),
}))}
keyExtractor={(item) => item.name}
renderItem={({ item, index: sectionIndex }) => (
<ShowroomListItem {...{ item, sectionIndex, handleScroll }} />
)}
/>
</View>
)}
>
<Screen
preset="fixed"
contentContainerStyle={$styles.flex1}
{...(isAndroid ? { KeyboardAvoidingViewProps: { behavior: undefined } } : {})}
>
<DrawerIconButton onPress={toggleDrawer} />
<SectionListWithKeyboardAwareScrollView
ref={listRef}
contentContainerStyle={themed($sectionListContentContainer)}
stickySectionHeadersEnabled={false}
sections={Object.values(Demos).map((d) => ({
name: d.name,
description: d.description,
data: [d.data({ theme, themed })],
}))}
renderItem={({ item, index: sectionIndex }) => (
<View>
{item.map((demo: ReactElement, demoIndex: number) => (
<View key={`${sectionIndex}-${demoIndex}`}>{demo}</View>
))}
</View>
)}
renderSectionFooter={() => <View style={themed($demoUseCasesSpacer)} />}
ListHeaderComponent={
<View style={themed($heading)}>
<Text preset="heading" tx="showroomScreen:jumpStart" />
</View>
}
onScrollToIndexFailed={scrollToIndexFailed}
renderSectionHeader={({ section }) => {
return (
<View>
<Text preset="heading" style={themed($demoItemName)}>
{section.name}
</Text>
<Text style={themed($demoItemDescription)}>{translate(section.description)}</Text>
</View>
)
}}
/>
</Screen>
</Drawer>
)
}
const $drawer: ThemedStyle<ViewStyle> = ({ colors }) => ({
backgroundColor: colors.background,
flex: 1,
})
const $listContentContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
paddingHorizontal: spacing.lg,
})
const $sectionListContentContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
paddingHorizontal: spacing.lg,
})
const $heading: ThemedStyle<ViewStyle> = ({ spacing }) => ({
marginBottom: spacing.xxxl,
})
const $logoImage: ImageStyle = {
height: s(42),
width: s(77),
}
const $logoContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
alignSelf: "flex-start",
justifyContent: "center",
height: s(56),
paddingHorizontal: spacing.lg,
})
const $menuContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
paddingBottom: spacing.xs,
paddingTop: spacing.lg,
})
const $demoItemName: ThemedStyle<TextStyle> = ({ spacing }) => ({
fontSize: fs(24),
marginBottom: spacing.md,
})
const $demoItemDescription: ThemedStyle<TextStyle> = ({ spacing }) => ({
marginBottom: spacing.xxl,
})
const $demoUseCasesSpacer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
paddingBottom: spacing.xxl,
})

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,226 @@
/* eslint-disable react/jsx-key */
import { TextStyle, View, ViewStyle } from "react-native"
import { Button } from "@/components/Button"
import { Icon } from "@/components/Icon"
import { Text } from "@/components/Text"
import { translate } from "@/i18n/translate"
import type { ThemedStyle } from "@/theme/types"
import { DemoDivider } from "../DemoDivider"
import { Demo } from "./types"
import { DemoUseCase } from "../DemoUseCase"
const ICON_SIZE = 24
const $customButtonStyle: ThemedStyle<ViewStyle> = ({ colors }) => ({
backgroundColor: colors.error,
height: 100,
})
const $customButtonPressedStyle: ThemedStyle<ViewStyle> = ({ colors }) => ({
backgroundColor: colors.error,
})
const $customButtonTextStyle: ThemedStyle<TextStyle> = ({ colors, typography }) => ({
color: colors.error,
fontFamily: typography.primary.bold,
textDecorationLine: "underline",
textDecorationColor: colors.error,
})
const $customButtonPressedTextStyle: ThemedStyle<TextStyle> = ({ colors }) => ({
color: colors.palette.neutral100,
})
const $customButtonRightAccessoryStyle: ThemedStyle<ViewStyle> = ({ colors }) => ({
width: "53%",
height: "200%",
backgroundColor: colors.error,
position: "absolute",
top: 0,
right: 0,
})
const $disabledOpacity: ViewStyle = { opacity: 0.5 }
const $disabledButtonTextStyle: ThemedStyle<TextStyle> = ({ colors }) => ({
color: colors.palette.neutral100,
textDecorationColor: colors.palette.neutral100,
})
export const DemoButton: Demo = {
name: "Button",
description: "demoButton:description",
data: ({ themed, theme }) => [
<DemoUseCase
name="demoButton:useCase.presets.name"
description="demoButton:useCase.presets.description"
>
<Button>Default - Laboris In Labore</Button>
<DemoDivider />
<Button preset="filled">Filled - Laboris Ex</Button>
<DemoDivider />
<Button preset="reversed">Reversed - Ad Ipsum</Button>
</DemoUseCase>,
<DemoUseCase
name="demoButton:useCase.passingContent.name"
description="demoButton:useCase.passingContent.description"
>
<Button text={translate("demoButton:useCase.passingContent.viaTextProps")} />
<DemoDivider />
<Button tx="showroomScreen:demoViaTxProp" />
<DemoDivider />
<Button>{translate("demoButton:useCase.passingContent.children")}</Button>
<DemoDivider />
<Button
preset="filled"
RightAccessory={(props) => (
<Icon containerStyle={props.style} size={ICON_SIZE} icon="ladybug" />
)}
>
{translate("demoButton:useCase.passingContent.rightAccessory")}
</Button>
<DemoDivider />
<Button
preset="filled"
LeftAccessory={(props) => (
<Icon containerStyle={props.style} size={ICON_SIZE} icon="ladybug" />
)}
>
{translate("demoButton:useCase.passingContent.leftAccessory")}
</Button>
<DemoDivider />
<Button>
<Text>
<Text preset="bold">{translate("demoButton:useCase.passingContent.nestedChildren")}</Text>
{` `}
<Text preset="default">
{translate("demoButton:useCase.passingContent.nestedChildren2")}
</Text>
{` `}
<Text preset="bold">
{translate("demoButton:useCase.passingContent.nestedChildren3")}
</Text>
</Text>
</Button>
<DemoDivider />
<Button
preset="reversed"
RightAccessory={(props) => (
<Icon containerStyle={props.style} size={ICON_SIZE} icon="ladybug" />
)}
LeftAccessory={(props) => (
<Icon containerStyle={props.style} size={ICON_SIZE} icon="ladybug" />
)}
>
{translate("demoButton:useCase.passingContent.multiLine")}
</Button>
</DemoUseCase>,
<DemoUseCase
name="demoButton:useCase.styling.name"
description="demoButton:useCase.styling.description"
>
<Button style={themed($customButtonStyle)}>
{translate("demoButton:useCase.styling.styleContainer")}
</Button>
<DemoDivider />
<Button preset="filled" textStyle={themed($customButtonTextStyle)}>
{translate("demoButton:useCase.styling.styleText")}
</Button>
<DemoDivider />
<Button
preset="reversed"
RightAccessory={() => <View style={themed($customButtonRightAccessoryStyle)} />}
>
{translate("demoButton:useCase.styling.styleAccessories")}
</Button>
<DemoDivider />
<Button
pressedStyle={themed($customButtonPressedStyle)}
pressedTextStyle={themed($customButtonPressedTextStyle)}
RightAccessory={(props) => (
<Icon
containerStyle={props.style}
size={ICON_SIZE}
color={props.pressableState.pressed ? theme.colors.palette.neutral100 : undefined}
icon="ladybug"
/>
)}
>
{translate("demoButton:useCase.styling.pressedState")}
</Button>
</DemoUseCase>,
<DemoUseCase
name="demoButton:useCase.disabling.name"
description="demoButton:useCase.disabling.description"
>
<Button
disabled
disabledStyle={$disabledOpacity}
pressedStyle={themed($customButtonPressedStyle)}
pressedTextStyle={themed($customButtonPressedTextStyle)}
>
{translate("demoButton:useCase.disabling.standard")}
</Button>
<DemoDivider />
<Button
disabled
preset="filled"
disabledStyle={$disabledOpacity}
pressedStyle={themed($customButtonPressedStyle)}
pressedTextStyle={themed($customButtonPressedTextStyle)}
>
{translate("demoButton:useCase.disabling.filled")}
</Button>
<DemoDivider />
<Button
disabled
preset="reversed"
disabledStyle={$disabledOpacity}
pressedStyle={themed($customButtonPressedStyle)}
pressedTextStyle={themed($customButtonPressedTextStyle)}
>
{translate("demoButton:useCase.disabling.reversed")}
</Button>
<DemoDivider />
<Button
disabled
pressedStyle={themed($customButtonPressedStyle)}
pressedTextStyle={themed($customButtonPressedTextStyle)}
RightAccessory={(props) => (
<View
style={
props.disabled
? [themed($customButtonRightAccessoryStyle), $disabledOpacity]
: themed($customButtonRightAccessoryStyle)
}
/>
)}
>
{translate("demoButton:useCase.disabling.accessory")}
</Button>
<DemoDivider />
<Button
disabled
preset="filled"
disabledTextStyle={themed([$customButtonTextStyle, $disabledButtonTextStyle])}
pressedStyle={themed($customButtonPressedStyle)}
pressedTextStyle={themed($customButtonPressedTextStyle)}
>
{translate("demoButton:useCase.disabling.textStyle")}
</Button>
</DemoUseCase>,
],
}

View File

@@ -0,0 +1,180 @@
/* eslint-disable react/jsx-key, react-native/no-inline-styles */
import { AutoImage } from "@/components/AutoImage"
import { Button } from "@/components/Button"
import { Card } from "@/components/Card"
import { Icon } from "@/components/Icon"
import { s } from "@/utils/responsive"
import { DemoDivider } from "../DemoDivider"
import { Demo } from "./types"
import { DemoUseCase } from "../DemoUseCase"
export const DemoCard: Demo = {
name: "Card",
description: "demoCard:description",
data: ({ theme }) => [
<DemoUseCase
name="demoCard:useCase.presets.name"
description="demoCard:useCase.presets.description"
>
<Card
headingTx="demoCard:useCase.presets.default.heading"
contentTx="demoCard:useCase.presets.default.content"
footerTx="demoCard:useCase.presets.default.footer"
/>
<DemoDivider />
<Card
headingTx="demoCard:useCase.presets.reversed.heading"
contentTx="demoCard:useCase.presets.reversed.content"
footerTx="demoCard:useCase.presets.reversed.footer"
preset="reversed"
/>
</DemoUseCase>,
<DemoUseCase
name="demoCard:useCase.verticalAlignment.name"
description="demoCard:useCase.verticalAlignment.description"
>
<Card
headingTx="demoCard:useCase.verticalAlignment.top.heading"
contentTx="demoCard:useCase.verticalAlignment.top.content"
footerTx="demoCard:useCase.verticalAlignment.top.footer"
style={{ minHeight: s(160) }}
/>
<DemoDivider />
<Card
headingTx="demoCard:useCase.verticalAlignment.center.heading"
verticalAlignment="center"
preset="reversed"
contentTx="demoCard:useCase.verticalAlignment.center.content"
footerTx="demoCard:useCase.verticalAlignment.center.footer"
style={{ minHeight: s(160) }}
/>
<DemoDivider />
<Card
headingTx="demoCard:useCase.verticalAlignment.spaceBetween.heading"
verticalAlignment="space-between"
contentTx="demoCard:useCase.verticalAlignment.spaceBetween.content"
footerTx="demoCard:useCase.verticalAlignment.spaceBetween.footer"
style={{ minHeight: s(160) }}
/>
<DemoDivider />
<Card
preset="reversed"
headingTx="demoCard:useCase.verticalAlignment.reversed.heading"
verticalAlignment="force-footer-bottom"
contentTx="demoCard:useCase.verticalAlignment.reversed.content"
footerTx="demoCard:useCase.verticalAlignment.reversed.footer"
style={{ minHeight: s(160) }}
/>
</DemoUseCase>,
<DemoUseCase
name="demoCard:useCase.passingContent.name"
description="demoCard:useCase.passingContent.description"
>
<Card
headingTx="demoCard:useCase.passingContent.heading"
contentTx="demoCard:useCase.passingContent.content"
footerTx="demoCard:useCase.passingContent.footer"
/>
<DemoDivider />
<Card
preset="reversed"
headingTx="showroomScreen:demoViaSpecifiedTxProp"
headingTxOptions={{ prop: "heading" }}
contentTx="showroomScreen:demoViaSpecifiedTxProp"
contentTxOptions={{ prop: "content" }}
footerTx="showroomScreen:demoViaSpecifiedTxProp"
footerTxOptions={{ prop: "footer" }}
/>
</DemoUseCase>,
<DemoUseCase
name="demoCard:useCase.customComponent.name"
description="demoCard:useCase.customComponent.description"
>
<Card
HeadingComponent={
<Button
preset="reversed"
text="HeadingComponent"
LeftAccessory={(props) => <Icon containerStyle={props.style} icon="ladybug" />}
/>
}
ContentComponent={
<Button
style={{ marginVertical: theme.spacing.sm }}
text="ContentComponent"
LeftAccessory={(props) => <Icon containerStyle={props.style} icon="ladybug" />}
/>
}
FooterComponent={
<Button
preset="reversed"
text="FooterComponent"
LeftAccessory={(props) => <Icon containerStyle={props.style} icon="ladybug" />}
/>
}
/>
<DemoDivider />
<Card
headingTx="demoCard:useCase.customComponent.rightComponent"
verticalAlignment="center"
RightComponent={
<AutoImage
maxWidth={s(80)}
maxHeight={s(60)}
style={{ alignSelf: "center" }}
source={{
uri: "https://user-images.githubusercontent.com/1775841/184508739-f90d0ce5-7219-42fd-a91f-3382d016eae0.png",
}}
/>
}
/>
<DemoDivider />
<Card
preset="reversed"
headingTx="demoCard:useCase.customComponent.leftComponent"
verticalAlignment="center"
LeftComponent={
<AutoImage
maxWidth={s(80)}
maxHeight={s(60)}
style={{ alignSelf: "center" }}
source={{
uri: "https://user-images.githubusercontent.com/1775841/184508739-f90d0ce5-7219-42fd-a91f-3382d016eae0.png",
}}
/>
}
/>
</DemoUseCase>,
<DemoUseCase
name="demoCard:useCase.style.name"
description="demoCard:useCase.style.description"
>
<Card
headingTx="demoCard:useCase.style.heading"
headingStyle={{ color: theme.colors.error }}
contentTx="demoCard:useCase.style.content"
contentStyle={{
backgroundColor: theme.colors.error,
color: theme.colors.palette.neutral100,
}}
footerTx="demoCard:useCase.style.footer"
footerStyle={{
textDecorationLine: "underline line-through",
textDecorationStyle: "dashed",
color: theme.colors.error,
textDecorationColor: theme.colors.error,
}}
style={{
shadowRadius: s(5),
shadowColor: theme.colors.error,
shadowOpacity: 0.5,
}}
/>
</DemoUseCase>,
],
}

View File

@@ -0,0 +1,77 @@
/* eslint-disable react/jsx-key, react-native/no-inline-styles */
import { EmptyState } from "@/components/EmptyState"
import { s } from "@/utils/responsive"
import { DemoDivider } from "../DemoDivider"
import { Demo } from "./types"
import { DemoUseCase } from "../DemoUseCase"
export const DemoEmptyState: Demo = {
name: "EmptyState",
description: "demoEmptyState:description",
data: ({ theme }) => [
<DemoUseCase
name="demoEmptyState:useCase.presets.name"
description="demoEmptyState:useCase.presets.description"
>
<EmptyState preset="generic" />
</DemoUseCase>,
<DemoUseCase
name="demoEmptyState:useCase.passingContent.name"
description="demoEmptyState:useCase.passingContent.description"
>
<EmptyState
imageSource={require("@assets/images/logo.png")}
headingTx="demoEmptyState:useCase.passingContent.customizeImageHeading"
contentTx="demoEmptyState:useCase.passingContent.customizeImageContent"
/>
<DemoDivider size={30} line />
<EmptyState
headingTx="demoEmptyState:useCase.passingContent.viaHeadingProp"
contentTx="demoEmptyState:useCase.passingContent.viaContentProp"
buttonTx="demoEmptyState:useCase.passingContent.viaButtonProp"
/>
<DemoDivider size={30} line />
<EmptyState
headingTx="showroomScreen:demoViaSpecifiedTxProp"
headingTxOptions={{ prop: "heading" }}
contentTx="showroomScreen:demoViaSpecifiedTxProp"
contentTxOptions={{ prop: "content" }}
buttonTx="showroomScreen:demoViaSpecifiedTxProp"
buttonTxOptions={{ prop: "button" }}
/>
</DemoUseCase>,
<DemoUseCase
name="demoEmptyState:useCase.styling.name"
description="demoEmptyState:useCase.styling.description"
>
<EmptyState
preset="generic"
style={{ backgroundColor: theme.colors.error, paddingVertical: s(20) }}
imageStyle={{ height: s(75), tintColor: theme.colors.palette.neutral100 }}
ImageProps={{ resizeMode: "contain" }}
headingStyle={{
color: theme.colors.palette.neutral100,
textDecorationLine: "underline",
textDecorationColor: theme.colors.palette.neutral100,
}}
contentStyle={{
color: theme.colors.palette.neutral100,
textDecorationLine: "underline",
textDecorationColor: theme.colors.palette.neutral100,
}}
buttonStyle={{ alignSelf: "center", backgroundColor: theme.colors.palette.neutral100 }}
buttonTextStyle={{ color: theme.colors.error }}
ButtonProps={{
preset: "reversed",
}}
/>
</DemoUseCase>,
],
}

View File

@@ -0,0 +1,150 @@
/* eslint-disable react/jsx-key, react-native/no-inline-styles */
import { TextStyle, View, ViewStyle } from "react-native"
import { Header } from "@/components/Header"
import { Icon } from "@/components/Icon"
import { $styles } from "@/theme/styles"
import type { ThemedStyle } from "@/theme/types"
import { s } from "@/utils/responsive"
import { DemoDivider } from "../DemoDivider"
import { Demo } from "./types"
import { DemoUseCase } from "../DemoUseCase"
const $rightAlignTitle: TextStyle = {
textAlign: "right",
}
const $customLeftAction: ThemedStyle<ViewStyle> = ({ colors }) => ({
backgroundColor: colors.error,
flexGrow: 0,
flexBasis: s(100),
height: "100%",
flexWrap: "wrap",
overflow: "hidden",
})
const $customTitle: ThemedStyle<TextStyle> = ({ colors }) => ({
textDecorationLine: "underline line-through",
textDecorationStyle: "dashed",
color: colors.error,
textDecorationColor: colors.error,
})
const $customWhiteTitle: ThemedStyle<TextStyle> = ({ colors }) => ({
color: colors.palette.neutral100,
})
export const DemoHeader: Demo = {
name: "Header",
description: "demoHeader:description",
data: ({ theme, themed }) => [
<DemoUseCase
name="demoHeader:useCase.actionIcons.name"
description="demoHeader:useCase.actionIcons.description"
>
<Header
titleTx="demoHeader:useCase.actionIcons.leftIconTitle"
leftIcon="ladybug"
safeAreaEdges={[]}
/>
<DemoDivider size={24} />
<Header
titleTx="demoHeader:useCase.actionIcons.rightIconTitle"
rightIcon="ladybug"
safeAreaEdges={[]}
/>
<DemoDivider size={24} />
<Header
titleTx="demoHeader:useCase.actionIcons.bothIconsTitle"
leftIcon="ladybug"
rightIcon="ladybug"
safeAreaEdges={[]}
/>
</DemoUseCase>,
<DemoUseCase
name="demoHeader:useCase.actionText.name"
description="demoHeader:useCase.actionText.description"
>
<Header
titleTx="demoHeader:useCase.actionText.leftTxTitle"
leftTx="showroomScreen:demoHeaderTxExample"
safeAreaEdges={[]}
/>
<DemoDivider size={24} />
<Header
titleTx="demoHeader:useCase.actionText.rightTextTitle"
rightText="Yay"
safeAreaEdges={[]}
/>
</DemoUseCase>,
<DemoUseCase
name="demoHeader:useCase.customActionComponents.name"
description="demoHeader:useCase.customActionComponents.description"
>
<Header
titleTx="demoHeader:useCase.customActionComponents.customLeftActionTitle"
titleMode="flex"
titleStyle={$rightAlignTitle}
LeftActionComponent={
<View style={themed([$styles.row, $customLeftAction])}>
{Array.from({ length: 20 }, (x, i) => i).map((i) => (
<Icon key={i} icon="ladybug" color={theme.colors.palette.neutral100} size={s(20)} />
))}
</View>
}
safeAreaEdges={[]}
/>
</DemoUseCase>,
<DemoUseCase
name="demoHeader:useCase.titleModes.name"
description="demoHeader:useCase.titleModes.description"
>
<Header
titleTx="demoHeader:useCase.titleModes.centeredTitle"
leftIcon="ladybug"
rightText="Hooray"
safeAreaEdges={[]}
/>
<DemoDivider size={24} />
<Header
titleTx="demoHeader:useCase.titleModes.flexTitle"
titleMode="flex"
leftIcon="ladybug"
rightText="Hooray"
safeAreaEdges={[]}
/>
</DemoUseCase>,
<DemoUseCase
name="demoHeader:useCase.styling.name"
description="demoHeader:useCase.styling.description"
>
<Header
titleTx="demoHeader:useCase.styling.styledTitle"
titleStyle={themed($customTitle)}
safeAreaEdges={[]}
/>
<DemoDivider size={24} />
<Header
titleTx="demoHeader:useCase.styling.styledWrapperTitle"
titleStyle={themed($customWhiteTitle)}
backgroundColor={theme.colors.error}
style={{ height: s(35) }}
safeAreaEdges={[]}
/>
<DemoDivider size={24} />
<Header
titleTx="demoHeader:useCase.styling.tintedIconsTitle"
titleStyle={themed($customWhiteTitle)}
backgroundColor={theme.colors.error}
leftIcon="ladybug"
leftIconColor={theme.colors.palette.neutral100}
safeAreaEdges={[]}
/>
</DemoUseCase>,
],
}

View File

@@ -0,0 +1,109 @@
/* eslint-disable react/jsx-key */
import { TextStyle, View, ViewStyle } from "react-native"
import { Icon, iconRegistry, type IconTypes } from "@/components/Icon"
import { Text } from "@/components/Text"
import { $styles } from "@/theme/styles"
import type { ThemedStyle } from "@/theme/types"
import { Demo } from "./types"
import { DemoUseCase } from "../DemoUseCase"
const $demoIconContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
padding: spacing.xs,
})
const $iconTile: ThemedStyle<ViewStyle> = ({ spacing }) => ({
width: "33.333%",
alignItems: "center",
paddingVertical: spacing.xs,
})
const $iconTileLabel: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
marginTop: spacing.xxs,
color: colors.textDim,
})
const $customIconContainer: ThemedStyle<ViewStyle> = ({ colors, spacing }) => ({
padding: spacing.md,
backgroundColor: colors.palette.angry500,
})
export const DemoIcon: Demo = {
name: "Icon",
description: "demoIcon:description",
data: ({ theme, themed }) => [
<DemoUseCase
name="demoIcon:useCase.icons.name"
description="demoIcon:useCase.icons.description"
layout="row"
itemStyle={$styles.flexWrap}
>
{Object.keys(iconRegistry).map((icon) => (
<View key={icon} style={themed($iconTile)}>
<Icon icon={icon as IconTypes} color={theme.colors.tint} size={35} />
<Text size="xs" style={themed($iconTileLabel)}>
{icon}
</Text>
</View>
))}
</DemoUseCase>,
<DemoUseCase
name="demoIcon:useCase.size.name"
description="demoIcon:useCase.size.description"
layout="row"
>
<Icon icon="ladybug" containerStyle={themed($demoIconContainer)} />
<Icon icon="ladybug" size={35} containerStyle={themed($demoIconContainer)} />
<Icon icon="ladybug" size={50} containerStyle={themed($demoIconContainer)} />
<Icon icon="ladybug" size={75} containerStyle={themed($demoIconContainer)} />
</DemoUseCase>,
<DemoUseCase
name="demoIcon:useCase.color.name"
description="demoIcon:useCase.color.description"
layout="row"
>
<Icon
icon="ladybug"
color={theme.colors.palette.accent500}
containerStyle={themed($demoIconContainer)}
/>
<Icon
icon="ladybug"
color={theme.colors.palette.primary500}
containerStyle={themed($demoIconContainer)}
/>
<Icon
icon="ladybug"
color={theme.colors.palette.secondary500}
containerStyle={themed($demoIconContainer)}
/>
<Icon
icon="ladybug"
color={theme.colors.palette.neutral700}
containerStyle={themed($demoIconContainer)}
/>
<Icon
icon="ladybug"
color={theme.colors.palette.angry500}
containerStyle={themed($demoIconContainer)}
/>
</DemoUseCase>,
<DemoUseCase
name="demoIcon:useCase.styling.name"
description="demoIcon:useCase.styling.description"
layout="row"
>
<Icon
icon="ladybug"
color={theme.colors.palette.neutral100}
size={40}
containerStyle={themed($customIconContainer)}
/>
</DemoUseCase>,
],
}

View File

@@ -0,0 +1,217 @@
/* eslint-disable react/jsx-key */
import { TextStyle, View, ViewStyle } from "react-native"
import { FlatList } from "react-native-gesture-handler"
import { Icon } from "@/components/Icon"
import { ListItem } from "@/components/ListItem"
import { Text } from "@/components/Text"
import { translate } from "@/i18n/translate"
import { $styles } from "@/theme/styles"
import type { ThemedStyle } from "@/theme/types"
import { s } from "@/utils/responsive"
import { DemoDivider } from "../DemoDivider"
import { Demo } from "./types"
import { DemoUseCase } from "../DemoUseCase"
const listData =
`Tempor Id Ea Aliqua Pariatur Aliquip. Irure Minim Voluptate Consectetur Consequat Sint Esse Proident Irure. Nostrud Elit Veniam Nostrud Excepteur Minim Deserunt Quis Dolore Velit Nulla Irure Voluptate Tempor. Occaecat Amet Laboris Nostrud Qui Do Quis Lorem Ex Elit Fugiat Deserunt. In Pariatur Excepteur Exercitation Ex Incididunt Qui Mollit Dolor Sit Non. Culpa Officia Minim Cillum Exercitation Voluptate Proident Laboris Et Est Reprehenderit Quis Pariatur Nisi`
.split(".")
.map((item) => item.trim())
const $customLeft: ThemedStyle<ViewStyle> = ({ colors }) => ({
backgroundColor: colors.error,
flexGrow: 0,
flexBasis: s(60),
height: "100%",
flexWrap: "wrap",
overflow: "hidden",
})
const $customTextStyle: ThemedStyle<TextStyle> = ({ colors }) => ({
color: colors.error,
})
const $customTouchableStyle: ThemedStyle<ViewStyle> = ({ colors }) => ({
backgroundColor: colors.error,
})
const $customContainerStyle: ThemedStyle<ViewStyle> = ({ colors }) => ({
borderTopWidth: 5,
borderTopColor: colors.palette.neutral100,
})
const $listStyle: ThemedStyle<ViewStyle> = ({ colors, spacing }) => ({
height: s(148),
paddingHorizontal: spacing.xs,
backgroundColor: colors.palette.neutral200,
})
export const DemoListItem: Demo = {
name: "ListItem",
description: "demoListItem:description",
data: ({ theme, themed }) => [
<DemoUseCase
name="demoListItem:useCase.height.name"
description="demoListItem:useCase.height.description"
>
<ListItem topSeparator>{translate("demoListItem:useCase.height.defaultHeight")}</ListItem>
<ListItem topSeparator height={s(100)}>
{translate("demoListItem:useCase.height.customHeight")}
</ListItem>
<ListItem topSeparator>{translate("demoListItem:useCase.height.textHeight")}</ListItem>
<ListItem topSeparator bottomSeparator TextProps={{ numberOfLines: 1 }}>
{translate("demoListItem:useCase.height.longText")}
</ListItem>
</DemoUseCase>,
<DemoUseCase
name="demoListItem:useCase.separators.name"
description="demoListItem:useCase.separators.description"
>
<ListItem topSeparator>{translate("demoListItem:useCase.separators.topSeparator")}</ListItem>
<DemoDivider size={40} />
<ListItem topSeparator bottomSeparator>
{translate("demoListItem:useCase.separators.topAndBottomSeparator")}
</ListItem>
<DemoDivider size={40} />
<ListItem bottomSeparator>
{translate("demoListItem:useCase.separators.bottomSeparator")}
</ListItem>
</DemoUseCase>,
<DemoUseCase
name="demoListItem:useCase.icons.name"
description="demoListItem:useCase.icons.description"
>
<ListItem topSeparator leftIcon="ladybug">
{translate("demoListItem:useCase.icons.leftIcon")}
</ListItem>
<ListItem topSeparator rightIcon="ladybug">
{translate("demoListItem:useCase.icons.rightIcon")}
</ListItem>
<ListItem topSeparator bottomSeparator rightIcon="ladybug" leftIcon="ladybug">
{translate("demoListItem:useCase.icons.leftRightIcons")}
</ListItem>
</DemoUseCase>,
<DemoUseCase
name="demoListItem:useCase.customLeftRight.name"
description="demoListItem:useCase.customLeftRight.description"
>
<ListItem
topSeparator
LeftComponent={
<View style={themed([$styles.row, $customLeft, { marginEnd: theme.spacing.md }])}>
{Array.from({ length: 9 }, (x, i) => i).map((i) => (
<Icon key={i} icon="ladybug" color={theme.colors.palette.neutral100} size={s(20)} />
))}
</View>
}
>
{translate("demoListItem:useCase.customLeftRight.customLeft")}
</ListItem>
<ListItem
topSeparator
bottomSeparator
RightComponent={
<View style={themed([$styles.row, $customLeft, { marginStart: theme.spacing.md }])}>
{Array.from({ length: 9 }, (x, i) => i).map((i) => (
<Icon key={i} icon="ladybug" color={theme.colors.palette.neutral100} size={s(20)} />
))}
</View>
}
>
{translate("demoListItem:useCase.customLeftRight.customRight")}
</ListItem>
</DemoUseCase>,
<DemoUseCase
name="demoListItem:useCase.passingContent.name"
description="demoListItem:useCase.passingContent.description"
>
<ListItem topSeparator text={translate("demoListItem:useCase.passingContent.children")} />
<ListItem topSeparator tx="showroomScreen:demoViaTxProp" />
<ListItem topSeparator>{translate("demoListItem:useCase.passingContent.children")}</ListItem>
<ListItem topSeparator bottomSeparator>
<Text>
<Text preset="bold">
{translate("demoListItem:useCase.passingContent.nestedChildren1")}
</Text>
{` `}
<Text preset="default">
{translate("demoListItem:useCase.passingContent.nestedChildren2")}
</Text>
</Text>
</ListItem>
</DemoUseCase>,
<DemoUseCase
name="demoListItem:useCase.listIntegration.name"
description="demoListItem:useCase.listIntegration.description"
>
<View style={themed($listStyle)}>
<FlatList<string>
data={listData}
keyExtractor={(item, index) => `${item}-${index}`}
renderItem={({ item, index }) => (
<ListItem
text={item}
rightIcon="caretRight"
TextProps={{ numberOfLines: 1 }}
topSeparator={index !== 0}
/>
)}
/>
</View>
</DemoUseCase>,
<DemoUseCase
name="demoListItem:useCase.styling.name"
description="demoListItem:useCase.styling.description"
>
<ListItem topSeparator textStyle={themed($customTextStyle)}>
{translate("demoListItem:useCase.styling.styledText")}
</ListItem>
<ListItem
topSeparator
textStyle={{ color: theme.colors.palette.neutral100 }}
style={themed($customTouchableStyle)}
>
{translate("demoListItem:useCase.styling.styledText")}
</ListItem>
<ListItem
topSeparator
textStyle={{ color: theme.colors.palette.neutral100 }}
style={themed($customTouchableStyle)}
containerStyle={themed($customContainerStyle)}
>
{translate("demoListItem:useCase.styling.styledContainer")}
</ListItem>
<ListItem
topSeparator
textStyle={{ color: theme.colors.palette.neutral100 }}
style={themed($customTouchableStyle)}
containerStyle={themed($customContainerStyle)}
rightIcon="ladybug"
leftIcon="ladybug"
rightIconColor={theme.colors.palette.neutral100}
leftIconColor={theme.colors.palette.neutral100}
>
{translate("demoListItem:useCase.styling.tintedIcons")}
</ListItem>
</DemoUseCase>,
],
}

View File

@@ -0,0 +1,142 @@
/* eslint-disable react/jsx-key, react-native/no-inline-styles */
import { Text } from "@/components/Text"
import { translate } from "@/i18n/translate"
import { DemoDivider } from "../DemoDivider"
import { Demo } from "./types"
import { DemoUseCase } from "../DemoUseCase"
export const DemoText: Demo = {
name: "Text",
description: "demoText:description",
data: ({ theme }) => [
<DemoUseCase
name="demoText:useCase.presets.name"
description="demoText:useCase.presets.description"
>
<Text>{translate("demoText:useCase.presets.default")}</Text>
<DemoDivider />
<Text preset="bold">{translate("demoText:useCase.presets.bold")}</Text>
<DemoDivider />
<Text preset="subheading">{translate("demoText:useCase.presets.subheading")}</Text>
<DemoDivider />
<Text preset="heading">{translate("demoText:useCase.presets.heading")}</Text>
</DemoUseCase>,
<DemoUseCase
name="demoText:useCase.sizes.name"
description="demoText:useCase.sizes.description"
>
<Text size="xs">{translate("demoText:useCase.sizes.xs")}</Text>
<DemoDivider />
<Text size="sm">{translate("demoText:useCase.sizes.sm")}</Text>
<DemoDivider />
<Text size="md">{translate("demoText:useCase.sizes.md")}</Text>
<DemoDivider />
<Text size="lg">{translate("demoText:useCase.sizes.lg")}</Text>
<DemoDivider />
<Text size="xl">{translate("demoText:useCase.sizes.xl")}</Text>
<DemoDivider />
<Text size="xxl">{translate("demoText:useCase.sizes.xxl")}</Text>
</DemoUseCase>,
<DemoUseCase
name="demoText:useCase.weights.name"
description="demoText:useCase.weights.description"
>
<Text weight="light">{translate("demoText:useCase.weights.light")}</Text>
<DemoDivider />
<Text weight="normal">{translate("demoText:useCase.weights.normal")}</Text>
<DemoDivider />
<Text weight="medium">{translate("demoText:useCase.weights.medium")}</Text>
<DemoDivider />
<Text weight="semiBold">{translate("demoText:useCase.weights.semibold")}</Text>
<DemoDivider />
<Text weight="bold">{translate("demoText:useCase.weights.bold")}</Text>
</DemoUseCase>,
<DemoUseCase
name="demoText:useCase.passingContent.name"
description="demoText:useCase.passingContent.description"
>
<Text text={translate("demoText:useCase.passingContent.viaText")} />
<DemoDivider />
<Text>
<Text tx="demoText:useCase.passingContent.viaTx" />
<Text tx="showroomScreen:lorem2Sentences" />
</Text>
<DemoDivider />
<Text>{translate("demoText:useCase.passingContent.children")}</Text>
<DemoDivider />
<Text>
<Text>{translate("demoText:useCase.passingContent.nestedChildren")}</Text>
<Text preset="bold">{translate("demoText:useCase.passingContent.nestedChildren2")}</Text>
{` `}
<Text preset="default">{translate("demoText:useCase.passingContent.nestedChildren3")}</Text>
{` `}
<Text preset="bold"> {translate("demoText:useCase.passingContent.nestedChildren4")}</Text>
</Text>
</DemoUseCase>,
<DemoUseCase
name="demoText:useCase.styling.name"
description="demoText:useCase.styling.description"
>
<Text>
<Text style={{ color: theme.colors.error }}>
{translate("demoText:useCase.styling.text")}
</Text>
{` `}
<Text
style={{
color: theme.colors.palette.neutral100,
backgroundColor: theme.colors.error,
}}
>
{translate("demoText:useCase.styling.text2")}
</Text>
{` `}
<Text
style={{
textDecorationLine: "underline line-through",
textDecorationStyle: "dashed",
color: theme.colors.error,
textDecorationColor: theme.colors.error,
}}
>
{translate("demoText:useCase.styling.text3")}
</Text>
</Text>
</DemoUseCase>,
],
}

View File

@@ -0,0 +1,231 @@
/* eslint-disable react/jsx-key */
import { TextStyle, ViewStyle } from "react-native"
import { Icon } from "@/components/Icon"
import { TextField } from "@/components/TextField"
import type { ThemedStyle } from "@/theme/types"
import { DemoDivider } from "../DemoDivider"
import { Demo } from "./types"
import { DemoUseCase } from "../DemoUseCase"
const $customInputStyle: ThemedStyle<TextStyle> = ({ colors }) => ({
backgroundColor: colors.error,
color: colors.palette.neutral100,
})
const $customInputWrapperStyle: ThemedStyle<ViewStyle> = ({ colors }) => ({
backgroundColor: colors.error,
borderColor: colors.palette.neutral800,
})
const $customContainerStyle: ThemedStyle<ViewStyle> = ({ colors }) => ({
backgroundColor: colors.error,
})
const $customLabelAndHelperStyle: ThemedStyle<TextStyle> = ({ colors }) => ({
color: colors.palette.neutral100,
})
const $customInputWithAbsoluteAccessoriesStyle: ThemedStyle<ViewStyle> = ({ spacing }) => ({
marginHorizontal: spacing.xxl,
})
const $customLeftAccessoryStyle: ThemedStyle<ViewStyle> = ({ colors }) => ({
backgroundColor: colors.error,
position: "absolute",
left: 0,
})
const $customRightAccessoryStyle: ThemedStyle<ViewStyle> = ({ colors }) => ({
backgroundColor: colors.error,
position: "absolute",
right: 0,
})
export const DemoTextField: Demo = {
name: "TextField",
description: "demoTextField:description",
data: ({ themed }) => [
<DemoUseCase
name="demoTextField:useCase.statuses.name"
description="demoTextField:useCase.statuses.description"
>
<TextField
value="Labore occaecat in id eu commodo aliquip occaecat veniam officia pariatur."
labelTx="demoTextField:useCase.statuses.noStatus.label"
labelTxOptions={{ prop: "label" }}
helperTx="demoTextField:useCase.statuses.noStatus.helper"
helperTxOptions={{ prop: "helper" }}
placeholderTx="demoTextField:useCase.statuses.noStatus.placeholder"
placeholderTxOptions={{ prop: "placeholder" }}
/>
<DemoDivider size={24} />
<TextField
status="error"
value="Est Lorem duis sunt sunt duis proident minim elit dolore incididunt pariatur eiusmod anim cillum."
labelTx="demoTextField:useCase.statuses.error.label"
labelTxOptions={{ prop: "label" }}
helperTx="demoTextField:useCase.statuses.error.helper"
helperTxOptions={{ prop: "helper" }}
placeholderTx="demoTextField:useCase.statuses.error.placeholder"
placeholderTxOptions={{ prop: "placeholder" }}
/>
<DemoDivider size={24} />
<TextField
status="disabled"
value="Eu ipsum mollit non minim voluptate nulla fugiat aliqua ullamco aute consectetur nulla nulla amet."
labelTx="demoTextField:useCase.statuses.disabled.label"
labelTxOptions={{ prop: "label" }}
helperTx="demoTextField:useCase.statuses.disabled.helper"
helperTxOptions={{ prop: "helper" }}
placeholderTx="demoTextField:useCase.statuses.disabled.placeholder"
placeholderTxOptions={{ prop: "placeholder" }}
/>
</DemoUseCase>,
<DemoUseCase
name="demoTextField:useCase.passingContent.name"
description="demoTextField:useCase.passingContent.description"
>
<TextField
labelTx="demoTextField:useCase.passingContent.viaLabel.labelTx"
labelTxOptions={{ prop: "label" }}
helperTx="demoTextField:useCase.passingContent.viaLabel.helper"
helperTxOptions={{ prop: "helper" }}
placeholderTx="demoTextField:useCase.passingContent.viaLabel.placeholder"
placeholderTxOptions={{ prop: "placeholder" }}
/>
<DemoDivider size={24} />
<TextField
labelTx="showroomScreen:demoViaSpecifiedTxProp"
labelTxOptions={{ prop: "label" }}
helperTx="showroomScreen:demoViaSpecifiedTxProp"
helperTxOptions={{ prop: "helper" }}
placeholderTx="showroomScreen:demoViaSpecifiedTxProp"
placeholderTxOptions={{ prop: "placeholder" }}
/>
<DemoDivider size={24} />
<TextField
value="Reprehenderit Lorem magna non consequat ullamco cupidatat."
labelTx="demoTextField:useCase.passingContent.rightAccessory.label"
labelTxOptions={{ prop: "label" }}
helperTx="demoTextField:useCase.passingContent.rightAccessory.helper"
helperTxOptions={{ prop: "helper" }}
RightAccessory={(props) => <Icon icon="ladybug" containerStyle={props.style} size={21} />}
/>
<DemoDivider size={24} />
<TextField
labelTx="demoTextField:useCase.passingContent.leftAccessory.label"
labelTxOptions={{ prop: "label" }}
helperTx="demoTextField:useCase.passingContent.leftAccessory.helper"
helperTxOptions={{ prop: "helper" }}
value="Eiusmod exercitation mollit elit magna occaecat eiusmod Lorem minim veniam."
LeftAccessory={(props) => <Icon icon="ladybug" containerStyle={props.style} size={21} />}
/>
<DemoDivider size={24} />
<TextField
labelTx="demoTextField:useCase.passingContent.supportsMultiline.label"
labelTxOptions={{ prop: "label" }}
helperTx="demoTextField:useCase.passingContent.supportsMultiline.helper"
helperTxOptions={{ prop: "helper" }}
value="Eiusmod exercitation mollit elit magna occaecat eiusmod Lorem minim veniam. Laborum Lorem velit velit minim irure ad in ut adipisicing consectetur."
multiline
RightAccessory={(props) => <Icon icon="ladybug" containerStyle={props.style} size={21} />}
/>
</DemoUseCase>,
<DemoUseCase
name="demoTextField:useCase.styling.name"
description="demoTextField:useCase.styling.description"
>
<TextField
labelTx="demoTextField:useCase.styling.styleInput.label"
labelTxOptions={{ prop: "label" }}
helperTx="demoTextField:useCase.styling.styleInput.helper"
helperTxOptions={{ prop: "helper" }}
value="Laborum cupidatat aliquip sunt sunt voluptate sint sit proident sunt mollit exercitation ullamco ea elit."
style={themed($customInputStyle)}
/>
<DemoDivider size={24} />
<TextField
labelTx="demoTextField:useCase.styling.styleInputWrapper.label"
labelTxOptions={{ prop: "label" }}
helperTx="demoTextField:useCase.styling.styleInputWrapper.helper"
helperTxOptions={{ prop: "helper" }}
value="Aute velit esse dolore pariatur exercitation irure nulla do sunt in duis mollit duis et."
inputWrapperStyle={themed($customInputWrapperStyle)}
style={themed($customInputStyle)}
/>
<DemoDivider size={24} />
<TextField
labelTx="demoTextField:useCase.styling.styleContainer.label"
labelTxOptions={{ prop: "label" }}
helperTx="demoTextField:useCase.styling.styleContainer.helper"
helperTxOptions={{ prop: "helper" }}
value="Aliquip proident commodo adipisicing non adipisicing Lorem excepteur ullamco voluptate laborum."
style={themed($customInputStyle)}
containerStyle={themed($customContainerStyle)}
inputWrapperStyle={themed($customInputWrapperStyle)}
/>
<DemoDivider size={24} />
<TextField
labelTx="demoTextField:useCase.styling.styleLabel.label"
labelTxOptions={{ prop: "label" }}
helperTx="demoTextField:useCase.styling.styleLabel.helper"
helperTxOptions={{ prop: "helper" }}
value="Ex culpa in consectetur dolor irure velit."
style={themed($customInputStyle)}
containerStyle={themed($customContainerStyle)}
inputWrapperStyle={themed($customInputWrapperStyle)}
HelperTextProps={{ style: themed($customLabelAndHelperStyle) }}
LabelTextProps={{ style: themed($customLabelAndHelperStyle) }}
/>
<DemoDivider size={24} />
<TextField
labelTx="demoTextField:useCase.styling.styleAccessories.label"
labelTxOptions={{ prop: "label" }}
helperTx="demoTextField:useCase.styling.styleAccessories.helper"
helperTxOptions={{ prop: "helper" }}
value="Aute nisi dolore fugiat anim mollit nulla ex minim ipsum ex elit."
style={themed($customInputWithAbsoluteAccessoriesStyle)}
LeftAccessory={() => (
<Icon
icon="ladybug"
containerStyle={themed($customLeftAccessoryStyle)}
color="white"
size={41}
/>
)}
RightAccessory={() => (
<Icon
icon="ladybug"
containerStyle={themed($customRightAccessoryStyle)}
color="white"
size={41}
/>
)}
/>
</DemoUseCase>,
],
}

View File

@@ -0,0 +1,348 @@
/* eslint-disable react/jsx-key, react-native/no-inline-styles */
import { useState } from "react"
import { TextStyle, View, ViewStyle } from "react-native"
import { Text } from "@/components/Text"
import { Checkbox, CheckboxToggleProps } from "@/components/Toggle/Checkbox"
import { Radio, RadioToggleProps } from "@/components/Toggle/Radio"
import { Switch, SwitchToggleProps } from "@/components/Toggle/Switch"
import { translate } from "@/i18n/translate"
import { $styles } from "@/theme/styles"
import type { ThemedStyle } from "@/theme/types"
import { s } from "@/utils/responsive"
import { DemoDivider } from "../DemoDivider"
import { Demo } from "./types"
import { DemoUseCase } from "../DemoUseCase"
function ControlledCheckbox(props: CheckboxToggleProps) {
const [value, setValue] = useState(props.value || false)
return <Checkbox {...props} value={value} onPress={() => setValue(!value)} />
}
function ControlledRadio(props: RadioToggleProps) {
const [value, setValue] = useState(props.value || false)
return <Radio {...props} value={value} onPress={() => setValue(!value)} />
}
function ControlledSwitch(props: SwitchToggleProps) {
const [value, setValue] = useState(props.value || false)
return <Switch {...props} value={value} onPress={() => setValue(!value)} />
}
const $centeredOneThirdCol: ViewStyle = {
width: "33.33333%",
alignItems: "center",
justifyContent: "center",
}
const $centeredText: ThemedStyle<TextStyle> = ({ spacing }) => ({
textAlign: "center",
width: "100%",
marginTop: spacing.xs,
})
export const DemoToggle: Demo = {
name: "Toggle",
description: "demoToggle:description",
data: ({ theme, themed }) => [
<DemoUseCase
name="demoToggle:useCase.variants.name"
description="demoToggle:useCase.variants.description"
>
<ControlledCheckbox
labelTx="demoToggle:useCase.variants.checkbox.label"
helperTx="demoToggle:useCase.variants.checkbox.helper"
/>
<DemoDivider size={24} />
<ControlledRadio
labelTx="demoToggle:useCase.variants.radio.label"
helperTx="demoToggle:useCase.variants.radio.helper"
/>
<DemoDivider size={24} />
<ControlledSwitch
labelTx="demoToggle:useCase.variants.switch.label"
helperTx="demoToggle:useCase.variants.switch.helper"
/>
</DemoUseCase>,
<DemoUseCase
name="demoToggle:useCase.statuses.name"
description="demoToggle:useCase.statuses.description"
layout="row"
itemStyle={$styles.flexWrap}
>
<ControlledCheckbox containerStyle={$centeredOneThirdCol} />
<ControlledRadio containerStyle={$centeredOneThirdCol} />
<ControlledSwitch containerStyle={$centeredOneThirdCol} />
<DemoDivider style={{ width: "100%" }} />
<ControlledCheckbox value containerStyle={$centeredOneThirdCol} />
<ControlledRadio value containerStyle={$centeredOneThirdCol} />
<ControlledSwitch value containerStyle={$centeredOneThirdCol} />
<Text preset="formHelper" style={themed($centeredText)}>
{translate("demoToggle:useCase.statuses.noStatus")}
</Text>
<DemoDivider size={24} style={{ width: "100%" }} />
<ControlledCheckbox status="error" containerStyle={$centeredOneThirdCol} />
<ControlledRadio status="error" containerStyle={$centeredOneThirdCol} />
<ControlledSwitch status="error" containerStyle={$centeredOneThirdCol} />
<DemoDivider style={{ width: "100%" }} />
<ControlledCheckbox value status="error" containerStyle={$centeredOneThirdCol} />
<ControlledRadio value status="error" containerStyle={$centeredOneThirdCol} />
<ControlledSwitch value status="error" containerStyle={$centeredOneThirdCol} />
<Text preset="formHelper" style={themed($centeredText)}>
{translate("demoToggle:useCase.statuses.errorStatus")}
</Text>
<DemoDivider size={24} style={{ width: "100%" }} />
<ControlledCheckbox status="disabled" containerStyle={$centeredOneThirdCol} />
<ControlledRadio status="disabled" containerStyle={$centeredOneThirdCol} />
<ControlledSwitch status="disabled" containerStyle={$centeredOneThirdCol} />
<DemoDivider style={{ width: "100%" }} />
<ControlledCheckbox value status="disabled" containerStyle={$centeredOneThirdCol} />
<ControlledRadio value status="disabled" containerStyle={$centeredOneThirdCol} />
<ControlledSwitch value status="disabled" containerStyle={$centeredOneThirdCol} />
<Text preset="formHelper" style={themed($centeredText)}>
{translate("demoToggle:useCase.statuses.disabledStatus")}
</Text>
</DemoUseCase>,
<DemoUseCase
name="demoToggle:useCase.passingContent.name"
description="demoToggle:useCase.passingContent.description"
>
<ControlledCheckbox
value
labelTx="demoToggle:useCase.passingContent.useCase.checkBox.label"
helperTx="demoToggle:useCase.passingContent.useCase.checkBox.helper"
/>
<DemoDivider size={24} />
<ControlledRadio
value
labelTx="showroomScreen:demoViaSpecifiedTxProp"
labelTxOptions={{ prop: "label" }}
helperTx="showroomScreen:demoViaSpecifiedTxProp"
helperTxOptions={{ prop: "helper" }}
/>
<DemoDivider size={24} />
<ControlledCheckbox
value
labelTx="demoToggle:useCase.passingContent.useCase.checkBoxMultiLine.helper"
editable={false}
/>
<DemoDivider size={24} />
<ControlledRadio
value
labelTx="demoToggle:useCase.passingContent.useCase.radioChangeSides.helper"
labelPosition="left"
/>
<DemoDivider size={24} />
<ControlledCheckbox
value
status="error"
icon="ladybug"
labelTx="demoToggle:useCase.passingContent.useCase.customCheckBox.label"
/>
<DemoDivider size={24} />
<ControlledSwitch
value
accessibilityMode="text"
labelTx="demoToggle:useCase.passingContent.useCase.switch.label"
status="error"
helperTx="demoToggle:useCase.passingContent.useCase.switch.helper"
/>
<DemoDivider size={24} />
<ControlledSwitch
value
labelPosition="left"
accessibilityMode="icon"
labelTx="demoToggle:useCase.passingContent.useCase.switchAid.label"
/>
</DemoUseCase>,
<DemoUseCase
name="demoToggle:useCase.styling.name"
description="demoToggle:useCase.styling.description"
layout="row"
itemStyle={$styles.flexWrap}
>
<ControlledCheckbox
containerStyle={$centeredOneThirdCol}
inputOuterStyle={{
width: s(50),
height: s(50),
backgroundColor: theme.colors.palette.accent300,
borderColor: theme.colors.palette.accent500,
}}
/>
<ControlledRadio
containerStyle={$centeredOneThirdCol}
inputOuterStyle={{
width: s(50),
height: s(50),
borderRadius: s(25),
backgroundColor: theme.colors.palette.accent300,
borderColor: theme.colors.palette.accent500,
}}
/>
<ControlledSwitch
containerStyle={$centeredOneThirdCol}
inputOuterStyle={{
width: s(70),
height: s(50),
borderRadius: s(25),
backgroundColor: theme.colors.palette.accent300,
borderColor: theme.colors.palette.accent500,
}}
/>
<Text preset="formHelper" style={themed($centeredText)}>
{translate("demoToggle:useCase.styling.outerWrapper")}
</Text>
<DemoDivider style={{ width: "100%" }} />
<ControlledCheckbox
value
containerStyle={$centeredOneThirdCol}
inputOuterStyle={{
width: s(50),
height: s(50),
backgroundColor: theme.colors.palette.accent300,
borderColor: theme.colors.palette.accent500,
}}
inputInnerStyle={{
backgroundColor: theme.colors.palette.accent500,
}}
/>
<ControlledRadio
value
containerStyle={$centeredOneThirdCol}
inputOuterStyle={{
width: s(50),
height: s(50),
borderRadius: s(25),
backgroundColor: theme.colors.palette.accent300,
borderColor: theme.colors.palette.accent500,
}}
inputInnerStyle={{
backgroundColor: theme.colors.palette.accent500,
}}
/>
<ControlledSwitch
value
containerStyle={$centeredOneThirdCol}
inputOuterStyle={{
width: s(70),
height: s(50),
borderRadius: s(25),
backgroundColor: theme.colors.palette.accent300,
borderColor: theme.colors.palette.accent500,
}}
inputInnerStyle={{
backgroundColor: theme.colors.palette.accent500,
paddingLeft: s(10),
paddingRight: s(10),
}}
/>
<Text preset="formHelper" style={themed($centeredText)}>
{translate("demoToggle:useCase.styling.innerWrapper")}
</Text>
<DemoDivider style={{ width: "100%" }} />
<ControlledCheckbox
value
icon="ladybug"
containerStyle={$centeredOneThirdCol}
inputOuterStyle={{
width: s(50),
height: s(50),
backgroundColor: theme.colors.palette.accent300,
borderColor: theme.colors.palette.accent500,
}}
inputInnerStyle={{
backgroundColor: theme.colors.palette.accent500,
}}
/>
<ControlledRadio
value
containerStyle={$centeredOneThirdCol}
inputOuterStyle={{
width: s(50),
height: s(50),
borderRadius: s(25),
backgroundColor: theme.colors.palette.accent300,
borderColor: theme.colors.palette.accent500,
}}
inputInnerStyle={{
backgroundColor: theme.colors.palette.accent500,
}}
inputDetailStyle={{
backgroundColor: theme.colors.tint,
height: s(36),
width: s(36),
borderRadius: s(18),
}}
/>
<ControlledSwitch
value
containerStyle={$centeredOneThirdCol}
inputOuterStyle={{
width: s(70),
height: s(50),
borderRadius: s(25),
backgroundColor: theme.colors.palette.accent300,
borderColor: theme.colors.palette.accent500,
}}
inputInnerStyle={{
backgroundColor: theme.colors.tint,
paddingLeft: s(10),
paddingRight: s(10),
}}
inputDetailStyle={{
backgroundColor: theme.colors.palette.accent300,
height: s(36),
width: s(18),
borderRadius: s(36),
}}
accessibilityMode="icon"
/>
<Text preset="formHelper" style={themed($centeredText)}>
{translate("demoToggle:useCase.styling.inputDetail")}
</Text>
<DemoDivider size={32} style={{ width: "100%" }} />
<View style={{ width: "100%" }}>
<ControlledRadio
value
labelTx="demoToggle:useCase.styling.labelTx"
LabelTextProps={{ size: "xs", weight: "bold" }}
status="error"
labelStyle={{
backgroundColor: theme.colors.error,
color: theme.colors.palette.neutral100,
paddingHorizontal: s(5),
}}
/>
</View>
<DemoDivider size={24} style={{ width: "100%" }} />
<View style={{ width: "100%" }}>
<ControlledRadio
value
labelPosition="left"
containerStyle={{ padding: s(10), backgroundColor: theme.colors.error }}
labelTx="demoToggle:useCase.styling.styleContainer"
status="error"
labelStyle={{ color: theme.colors.palette.neutral100 }}
/>
</View>
</DemoUseCase>,
],
}

View File

@@ -0,0 +1,10 @@
export * from "./DemoIcon"
export * from "./DemoTextField"
export * from "./DemoToggle"
export * from "./DemoButton"
export * from "./DemoListItem"
export * from "./DemoCard"
export * from "./DemoAutoImage"
export * from "./DemoText"
export * from "./DemoHeader"
export * from "./DemoEmptyState"

View File

@@ -0,0 +1,10 @@
import { ReactElement } from "react"
import { TxKeyPath } from "@/i18n"
import type { Theme } from "@/theme/types"
export interface Demo {
name: string
description: TxKeyPath
data: ({ themed, theme }: { themed: any; theme: Theme }) => ReactElement[]
}

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