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,81 @@
import i18n from "i18next"
import type { TxKeyPath } from "@/i18n"
// Error code pattern: E001, E011, E021, etc.
const ERROR_CODE_PATTERN = /^E\d{3}$/
// Map API problem kinds to i18n keys
const PROBLEM_KIND_MAP: Record<string, TxKeyPath> = {
"timeout": "authErrors:timeout",
"cannot-connect": "authErrors:cannotConnect",
"server": "authErrors:serverError",
"bad-data": "authErrors:badData",
"unauthorized": "authErrors:E011", // Default to invalid credentials
"forbidden": "authErrors:E024", // Default to account disabled
"not-found": "authErrors:E028", // Default to user not found
"rejected": "authErrors:unknownError",
"unknown": "authErrors:unknownError",
}
/**
* Translates an API error response to a localized error message.
*
* @param errorCode - The error code from the API response (e.g., "E011")
* @param message - The fallback message if no translation is found
* @param problemKind - The API problem kind (e.g., "timeout", "cannot-connect")
* @returns The translated error message
*/
export function translateAuthError(
errorCode?: string,
message?: string,
problemKind?: string,
): string {
// 1. Try to translate by error code (e.g., E001, E011)
if (errorCode && ERROR_CODE_PATTERN.test(errorCode)) {
const key = `authErrors:${errorCode}` as TxKeyPath
const translated = i18n.t(key)
// If translation exists (not returning the key itself), use it
if (translated && translated !== key && !translated.startsWith("authErrors:")) {
return translated
}
}
// 2. Try to translate by problem kind (e.g., timeout, cannot-connect)
if (problemKind && PROBLEM_KIND_MAP[problemKind]) {
return i18n.t(PROBLEM_KIND_MAP[problemKind])
}
// 3. Return the original message or a default error
return message || i18n.t("authErrors:unknownError")
}
/**
* Gets the i18n key for an error code.
* Useful for displaying error messages with the Text component's tx prop.
*
* @param errorCode - The error code from the API response
* @returns The i18n key path or undefined if not found
*/
export function getAuthErrorKey(errorCode?: string): TxKeyPath | undefined {
if (errorCode && ERROR_CODE_PATTERN.test(errorCode)) {
return `authErrors:${errorCode}` as TxKeyPath
}
return undefined
}
/**
* Checks if an error code is a known auth error.
*
* @param errorCode - The error code to check
* @returns True if the error code is a known auth error
*/
export function isKnownAuthError(errorCode?: string): boolean {
if (!errorCode || !ERROR_CODE_PATTERN.test(errorCode)) {
return false
}
const key = `authErrors:${errorCode}` as TxKeyPath
const translated = i18n.t(key)
return translated !== key && !translated.startsWith("authErrors:")
}

View File

@@ -0,0 +1,62 @@
/**
* If you're using Sentry
* Expo https://docs.expo.dev/guides/using-sentry/
*/
// import * as Sentry from "@sentry/react-native"
/**
* If you're using Crashlytics: https://rnfirebase.io/crashlytics/usage
*/
// import crashlytics from "@react-native-firebase/crashlytics"
/**
* If you're using Bugsnag:
* RN https://docs.bugsnag.com/platforms/react-native/)
* Expo https://docs.bugsnag.com/platforms/react-native/expo/
*/
// import Bugsnag from "@bugsnag/react-native"
// import Bugsnag from "@bugsnag/expo"
/**
* This is where you put your crash reporting service initialization code to call in `./app/app.tsx`
*/
export const initCrashReporting = () => {
// Sentry.init({
// dsn: "YOUR DSN HERE",
// debug: true, // If `true`, Sentry will try to print out useful debugging information if something goes wrong with sending the event. Set it to `false` in production
// })
// Bugsnag.start("YOUR API KEY")
}
/**
* Error classifications used to sort errors on error reporting services.
*/
export enum ErrorType {
/**
* An error that would normally cause a red screen in dev
* and force the user to sign out and restart.
*/
FATAL = "Fatal",
/**
* An error caught by try/catch where defined using Reactotron.tron.error.
*/
HANDLED = "Handled",
}
/**
* Manually report a handled error.
*/
export const reportCrash = (error: Error, type: ErrorType = ErrorType.FATAL) => {
if (__DEV__) {
// Log to console and Reactotron in development
const message = error.message || "Unknown"
console.error(error)
console.log(message, type)
} else {
// In production, utilize crash reporting service of choice below:
// RN
// Sentry.captureException(error)
// crashlytics().recordError(error)
// Bugsnag.notify(error)
}
}

View File

@@ -0,0 +1,6 @@
/**
* A "modern" sleep statement.
*
* @param ms The number of milliseconds to wait.
*/
export const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

View File

@@ -0,0 +1,49 @@
// Note the syntax of these imports from the date-fns library.
// If you import with the syntax: import { format } from "date-fns" the ENTIRE library
// will be included in your production bundle (even if you only use one function).
// This is because react-native does not support tree-shaking.
import { format } from "date-fns/format"
import type { Locale } from "date-fns/locale"
import { parseISO } from "date-fns/parseISO"
import i18n from "i18next"
type Options = Parameters<typeof format>[2]
let dateFnsLocale: Locale
export const loadDateFnsLocale = () => {
const primaryTag = i18n.language.split("-")[0]
switch (primaryTag) {
case "en":
dateFnsLocale = require("date-fns/locale/en-US").default
break
case "ar":
dateFnsLocale = require("date-fns/locale/ar").default
break
case "ko":
dateFnsLocale = require("date-fns/locale/ko").default
break
case "es":
dateFnsLocale = require("date-fns/locale/es").default
break
case "fr":
dateFnsLocale = require("date-fns/locale/fr").default
break
case "hi":
dateFnsLocale = require("date-fns/locale/hi").default
break
case "ja":
dateFnsLocale = require("date-fns/locale/ja").default
break
default:
dateFnsLocale = require("date-fns/locale/en-US").default
break
}
}
export const formatDate = (date: string, dateFormat?: string, options?: Options) => {
const dateOptions = {
...options,
locale: dateFnsLocale,
}
return format(parseISO(date), dateFormat ?? "MMM dd, yyyy", dateOptions)
}

View File

@@ -0,0 +1,3 @@
// Only import react-native-gesture-handler on native platforms
// https://reactnavigation.org/docs/drawer-navigator/#installation
import "react-native-gesture-handler"

View File

@@ -0,0 +1,6 @@
// Don't import react-native-gesture-handler on web
// https://reactnavigation.org/docs/drawer-navigator/#installation
// This however is needed at the moment
// https://github.com/software-mansion/react-native-gesture-handler/issues/2402
import "setimmediate"

View File

@@ -0,0 +1,8 @@
import { Linking } from "react-native"
/**
* Helper for opening a give URL in an external browser.
*/
export function openLinkInBrowser(url: string) {
Linking.canOpenURL(url).then((canOpen) => canOpen && Linking.openURL(url))
}

View File

@@ -0,0 +1,14 @@
import { Dimensions, PixelRatio } from "react-native"
const DESIGN_WIDTH = 400
const { width: SCREEN_WIDTH } = Dimensions.get("window")
/** 布局尺寸缩放padding、margin、width、height、borderRadius 等) */
export function s(size: number): number {
return PixelRatio.roundToNearestPixel(size * (SCREEN_WIDTH / DESIGN_WIDTH))
}
/** 字体缩放(平方根阻尼,避免大屏字体过大) */
export function fs(size: number): number {
return PixelRatio.roundToNearestPixel(size * Math.pow(SCREEN_WIDTH / DESIGN_WIDTH, 0.5))
}

View File

@@ -0,0 +1,82 @@
import { MMKV } from "react-native-mmkv"
export const storage = new MMKV()
/**
* Loads a string from storage.
*
* @param key The key to fetch.
*/
export function loadString(key: string): string | null {
try {
return storage.getString(key) ?? null
} catch {
// not sure why this would fail... even reading the RN docs I'm unclear
return null
}
}
/**
* Saves a string to storage.
*
* @param key The key to fetch.
* @param value The value to store.
*/
export function saveString(key: string, value: string): boolean {
try {
storage.set(key, value)
return true
} catch {
return false
}
}
/**
* Loads something from storage and runs it thru JSON.parse.
*
* @param key The key to fetch.
*/
export function load<T>(key: string): T | null {
let almostThere: string | null = null
try {
almostThere = loadString(key)
return JSON.parse(almostThere ?? "") as T
} catch {
return (almostThere as T) ?? null
}
}
/**
* Saves an object to storage.
*
* @param key The key to fetch.
* @param value The value to store.
*/
export function save(key: string, value: unknown): boolean {
try {
saveString(key, JSON.stringify(value))
return true
} catch {
return false
}
}
/**
* Removes something from storage.
*
* @param key The key to kill.
*/
export function remove(key: string): void {
try {
storage.delete(key)
} catch {}
}
/**
* Burn it all to the ground.
*/
export function clear(): void {
try {
storage.clearAll()
} catch {}
}

View File

@@ -0,0 +1,61 @@
import { load, loadString, save, saveString, clear, remove, storage } from "."
const VALUE_OBJECT = { x: 1 }
const VALUE_STRING = JSON.stringify(VALUE_OBJECT)
describe("MMKV Storage", () => {
beforeEach(() => {
storage.clearAll()
storage.set("string", "string")
storage.set("object", JSON.stringify(VALUE_OBJECT))
})
it("should be defined", () => {
expect(storage).toBeDefined()
})
it("should have default keys", () => {
expect(storage.getAllKeys()).toEqual(["string", "object"])
})
it("should load data", () => {
expect(load<object>("object")).toEqual(VALUE_OBJECT)
expect(loadString("object")).toEqual(VALUE_STRING)
expect(load<string>("string")).toEqual("string")
expect(loadString("string")).toEqual("string")
})
it("should save strings", () => {
saveString("string", "new string")
expect(loadString("string")).toEqual("new string")
})
it("should save objects", () => {
save("object", { y: 2 })
expect(load<object>("object")).toEqual({ y: 2 })
save("object", { z: 3, also: true })
expect(load<object>("object")).toEqual({ z: 3, also: true })
})
it("should save strings and objects", () => {
saveString("object", "new string")
expect(loadString("object")).toEqual("new string")
})
it("should remove data", () => {
remove("object")
expect(load<object>("object")).toBeNull()
expect(storage.getAllKeys()).toEqual(["string"])
remove("string")
expect(load<string>("string")).toBeNull()
expect(storage.getAllKeys()).toEqual([])
})
it("should clear all data", () => {
expect(storage.getAllKeys()).toEqual(["string", "object"])
clear()
expect(storage.getAllKeys()).toEqual([])
})
})

View File

@@ -0,0 +1,39 @@
import { useEffect, useLayoutEffect } from "react"
import { Platform } from "react-native"
import { useNavigation } from "@react-navigation/native"
import { useTranslation } from "react-i18next"
import { Header, HeaderProps } from "@/components/Header"
/**
* A hook that can be used to easily set the Header of a react-navigation screen from within the screen's component.
* @see [Documentation and Examples]{@link https://docs.infinite.red/ignite-cli/boilerplate/app/utils/useHeader.tsx/}
* @param {HeaderProps} headerProps - The props for the `Header` component.
* @param {any[]} deps - The dependencies to watch for changes to update the header.
*/
export function useHeader(
headerProps: HeaderProps,
deps: Parameters<typeof useLayoutEffect>[1] = [],
) {
const navigation = useNavigation()
const { i18n } = useTranslation()
/**
* We need to have multiple implementations of this hook for web and mobile.
* Web needs to use useEffect to avoid a rendering loop.
* In mobile and also to avoid a visible header jump when navigating between screens, we use
* `useLayoutEffect`, which will apply the settings before the screen renders.
*/
const usePlatformEffect = Platform.OS === "web" ? useEffect : useLayoutEffect
// To avoid a visible header jump when navigating between screens, we use
// `useLayoutEffect`, which will apply the settings before the screen renders.
usePlatformEffect(() => {
navigation.setOptions({
headerShown: true,
header: () => <Header {...headerProps} />,
})
// intentionally created API to have user set when they want to update the header via `deps`
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...deps, navigation, i18n.language])
}

View File

@@ -0,0 +1,18 @@
import { useEffect, useCallback, useRef } from "react"
/**
* A common react custom hook to check if the component is mounted.
* @returns {() => boolean} - A function that returns true if the component is mounted.
*/
export function useIsMounted() {
const isMounted = useRef(false)
useEffect(() => {
isMounted.current = true
return () => {
isMounted.current = false
}
}, [])
return useCallback(() => isMounted.current, [])
}

View File

@@ -0,0 +1,46 @@
import { Edge, useSafeAreaInsets } from "react-native-safe-area-context"
export type ExtendedEdge = Edge | "start" | "end"
const propertySuffixMap = {
top: "Top",
bottom: "Bottom",
left: "Start",
right: "End",
start: "Start",
end: "End",
}
const edgeInsetMap: Record<string, Edge> = {
start: "left",
end: "right",
}
export type SafeAreaInsetsStyle<
Property extends "padding" | "margin" = "padding",
Edges extends Array<ExtendedEdge> = Array<ExtendedEdge>,
> = {
[K in Edges[number] as `${Property}${Capitalize<K>}`]: number
}
/**
* A hook that can be used to create a safe-area-aware style object that can be passed directly to a View.
* @see [Documentation and Examples]{@link https://docs.infinite.red/ignite-cli/boilerplate/app/utils/useSafeAreaInsetsStyle.ts/}
* @param {ExtendedEdge[]} safeAreaEdges - The edges to apply the safe area insets to.
* @param {"padding" | "margin"} property - The property to apply the safe area insets to.
* @returns {SafeAreaInsetsStyle<Property, Edges>} - The style object with the safe area insets applied.
*/
export function useSafeAreaInsetsStyle<
Property extends "padding" | "margin" = "padding",
Edges extends Array<ExtendedEdge> = [],
>(
safeAreaEdges: Edges = [] as unknown as Edges,
property: Property = "padding" as Property,
): SafeAreaInsetsStyle<Property, Edges> {
const insets = useSafeAreaInsets()
return safeAreaEdges.reduce((acc, e) => {
const value = edgeInsetMap[e] ?? e
return { ...acc, [`${property}${propertySuffixMap[e]}`]: insets[value] }
}, {}) as SafeAreaInsetsStyle<Property, Edges>
}