template_0205
This commit is contained in:
81
RN_TEMPLATE/app/utils/authErrorTranslator.ts
Normal file
81
RN_TEMPLATE/app/utils/authErrorTranslator.ts
Normal 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:")
|
||||
}
|
||||
62
RN_TEMPLATE/app/utils/crashReporting.ts
Normal file
62
RN_TEMPLATE/app/utils/crashReporting.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
6
RN_TEMPLATE/app/utils/delay.ts
Normal file
6
RN_TEMPLATE/app/utils/delay.ts
Normal 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))
|
||||
49
RN_TEMPLATE/app/utils/formatDate.ts
Normal file
49
RN_TEMPLATE/app/utils/formatDate.ts
Normal 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)
|
||||
}
|
||||
3
RN_TEMPLATE/app/utils/gestureHandler.native.ts
Normal file
3
RN_TEMPLATE/app/utils/gestureHandler.native.ts
Normal 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"
|
||||
6
RN_TEMPLATE/app/utils/gestureHandler.ts
Normal file
6
RN_TEMPLATE/app/utils/gestureHandler.ts
Normal 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"
|
||||
8
RN_TEMPLATE/app/utils/openLinkInBrowser.ts
Normal file
8
RN_TEMPLATE/app/utils/openLinkInBrowser.ts
Normal 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))
|
||||
}
|
||||
14
RN_TEMPLATE/app/utils/responsive.ts
Normal file
14
RN_TEMPLATE/app/utils/responsive.ts
Normal 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))
|
||||
}
|
||||
82
RN_TEMPLATE/app/utils/storage/index.ts
Normal file
82
RN_TEMPLATE/app/utils/storage/index.ts
Normal 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 {}
|
||||
}
|
||||
61
RN_TEMPLATE/app/utils/storage/storage.test.ts
Normal file
61
RN_TEMPLATE/app/utils/storage/storage.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
39
RN_TEMPLATE/app/utils/useHeader.tsx
Normal file
39
RN_TEMPLATE/app/utils/useHeader.tsx
Normal 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])
|
||||
}
|
||||
18
RN_TEMPLATE/app/utils/useIsMounted.ts
Normal file
18
RN_TEMPLATE/app/utils/useIsMounted.ts
Normal 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, [])
|
||||
}
|
||||
46
RN_TEMPLATE/app/utils/useSafeAreaInsetsStyle.ts
Normal file
46
RN_TEMPLATE/app/utils/useSafeAreaInsetsStyle.ts
Normal 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>
|
||||
}
|
||||
Reference in New Issue
Block a user