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,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[]
}

View File

@@ -0,0 +1,108 @@
import { FC, useCallback } from "react"
import { TextStyle, View, ViewStyle } from "react-native"
import { useMMKVString } from "react-native-mmkv"
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 { ImmutableThemeContextModeT } from "@/theme/types"
import type { ThemedStyle } from "@/theme/types"
import { s } from "@/utils/responsive"
import { storage } from "@/utils/storage"
import { useHeader } from "@/utils/useHeader"
type ThemeOption = {
value: ImmutableThemeContextModeT | "system"
labelKey: string
}
const THEME_OPTIONS: ThemeOption[] = [
{ value: "system", labelKey: "themeScreen:system" },
{ value: "light", labelKey: "themeScreen:light" },
{ value: "dark", labelKey: "themeScreen:dark" },
]
export const ThemeScreen: FC<AppStackScreenProps<"Theme">> = function ThemeScreen({ navigation }) {
const { themed, theme, setThemeContextOverride } = useAppTheme()
// Read the stored theme preference directly from MMKV
// undefined = system, "light" = light, "dark" = dark
const [themeScheme] = useMMKVString("ignite.themeScheme", storage)
// Determine current selection: if no stored value, it's "system"
const currentTheme = themeScheme === "light" || themeScheme === "dark" ? themeScheme : "system"
const handleSelectTheme = useCallback(
(value: ImmutableThemeContextModeT | "system") => {
if (value === "system") {
setThemeContextOverride(undefined)
} else {
setThemeContextOverride(value)
}
navigation.goBack()
},
[setThemeContextOverride, navigation],
)
useHeader(
{
title: translate("themeScreen:title"),
leftIcon: "back",
onLeftPress: () => navigation.goBack(),
},
[],
)
return (
<Screen
preset="scroll"
safeAreaEdges={["bottom"]}
contentContainerStyle={[$styles.container, themed($container)]}
>
<Text size="xs" style={themed($hint)}>
{translate("themeScreen:selectHint")}
</Text>
<View style={themed($listContainer)}>
{THEME_OPTIONS.map((option) => {
const isSelected = currentTheme === option.value
return (
<ListItem
key={option.value}
text={translate(option.labelKey as any)}
textStyle={isSelected ? themed($selectedText) : undefined}
RightComponent={
isSelected ? <Icon icon="check" size={20} color={theme.colors.tint} /> : undefined
}
onPress={() => handleSelectTheme(option.value)}
/>
)
})}
</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 $selectedText: ThemedStyle<TextStyle> = ({ colors }) => ({
color: colors.tint,
fontWeight: "600",
})

View File

@@ -0,0 +1,111 @@
import { FC } from "react"
import { Image, ImageStyle, TextStyle, View, ViewStyle } from "react-native"
import { Button } from "@/components/Button"
import { Screen } from "@/components/Screen"
import { Text } from "@/components/Text"
import { useAuth } from "@/context/AuthContext"
import { isRTL } from "@/i18n"
import type { 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"
import { useSafeAreaInsetsStyle } from "@/utils/useSafeAreaInsetsStyle"
const welcomeLogo = require("@assets/images/logo.png")
const welcomeFace = require("@assets/images/welcome-face.png")
interface WelcomeScreenProps extends AppStackScreenProps<"Welcome"> {}
export const WelcomeScreen: FC<WelcomeScreenProps> = function WelcomeScreen(_props) {
const { themed, theme } = useAppTheme()
const { navigation } = _props
const { logout } = useAuth()
function goNext() {
navigation.navigate("Main", { screen: "Showroom", params: {} })
}
useHeader(
{
rightTx: "common:logOut",
onRightPress: logout,
},
[logout],
)
const $bottomContainerInsets = useSafeAreaInsetsStyle(["bottom"])
return (
<Screen preset="fixed" contentContainerStyle={$styles.flex1}>
<View style={themed($topContainer)}>
<Image style={themed($welcomeLogo)} source={welcomeLogo} resizeMode="contain" />
<Text
testID="welcome-heading"
style={themed($welcomeHeading)}
tx="welcomeScreen:readyForLaunch"
preset="heading"
/>
<Text tx="welcomeScreen:exciting" preset="subheading" />
<Image
style={$welcomeFace}
source={welcomeFace}
resizeMode="contain"
tintColor={theme.colors.palette.neutral900}
/>
</View>
<View style={themed([$bottomContainer, $bottomContainerInsets])}>
<Text tx="welcomeScreen:postscript" size="md" />
<Button
testID="next-screen-button"
preset="reversed"
tx="welcomeScreen:letsGo"
onPress={goNext}
/>
</View>
</Screen>
)
}
const $topContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
flexShrink: 1,
flexGrow: 1,
flexBasis: "57%",
justifyContent: "center",
paddingHorizontal: spacing.lg,
})
const $bottomContainer: ThemedStyle<ViewStyle> = ({ colors, spacing }) => ({
flexShrink: 1,
flexGrow: 0,
flexBasis: "43%",
backgroundColor: colors.palette.neutral100,
borderTopLeftRadius: s(16),
borderTopRightRadius: s(16),
paddingHorizontal: spacing.lg,
justifyContent: "space-around",
})
const $welcomeLogo: ThemedStyle<ImageStyle> = ({ spacing }) => ({
height: s(88),
width: "100%",
marginBottom: spacing.xxl,
})
const $welcomeFace: ImageStyle = {
height: s(169),
width: s(269),
position: "absolute",
bottom: s(-47),
right: s(-80),
transform: [{ scaleX: isRTL ? -1 : 1 }],
}
const $welcomeHeading: ThemedStyle<TextStyle> = ({ spacing }) => ({
marginBottom: spacing.md,
})