template_0205
This commit is contained in:
217
RN_TEMPLATE/app/screens/AboutScreen.tsx
Normal file
217
RN_TEMPLATE/app/screens/AboutScreen.tsx
Normal 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",
|
||||
})
|
||||
88
RN_TEMPLATE/app/screens/AuthWelcomeScreen.tsx
Normal file
88
RN_TEMPLATE/app/screens/AuthWelcomeScreen.tsx
Normal 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%",
|
||||
})
|
||||
540
RN_TEMPLATE/app/screens/ChangeEmailScreen.tsx
Normal file
540
RN_TEMPLATE/app/screens/ChangeEmailScreen.tsx
Normal 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,
|
||||
})
|
||||
216
RN_TEMPLATE/app/screens/ChangePasswordScreen.tsx
Normal file
216
RN_TEMPLATE/app/screens/ChangePasswordScreen.tsx
Normal 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,
|
||||
})
|
||||
70
RN_TEMPLATE/app/screens/CommunityScreen.tsx
Normal file
70
RN_TEMPLATE/app/screens/CommunityScreen.tsx
Normal 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,
|
||||
})
|
||||
76
RN_TEMPLATE/app/screens/ErrorScreen/ErrorBoundary.tsx
Normal file
76
RN_TEMPLATE/app/screens/ErrorScreen/ErrorBoundary.tsx
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
99
RN_TEMPLATE/app/screens/ErrorScreen/ErrorDetails.tsx
Normal file
99
RN_TEMPLATE/app/screens/ErrorScreen/ErrorDetails.tsx
Normal 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,
|
||||
})
|
||||
351
RN_TEMPLATE/app/screens/ForgotPasswordScreen.tsx
Normal file
351
RN_TEMPLATE/app/screens/ForgotPasswordScreen.tsx
Normal 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,
|
||||
})
|
||||
108
RN_TEMPLATE/app/screens/LanguageScreen.tsx
Normal file
108
RN_TEMPLATE/app/screens/LanguageScreen.tsx
Normal 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",
|
||||
})
|
||||
430
RN_TEMPLATE/app/screens/LoginScreen.tsx
Normal file
430
RN_TEMPLATE/app/screens/LoginScreen.tsx
Normal 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",
|
||||
})
|
||||
556
RN_TEMPLATE/app/screens/ProfileScreen.tsx
Normal file
556
RN_TEMPLATE/app/screens/ProfileScreen.tsx
Normal 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,
|
||||
})
|
||||
421
RN_TEMPLATE/app/screens/RegisterScreen.tsx
Normal file
421
RN_TEMPLATE/app/screens/RegisterScreen.tsx
Normal 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,
|
||||
})
|
||||
81
RN_TEMPLATE/app/screens/SecurityScreen.tsx
Normal file
81
RN_TEMPLATE/app/screens/SecurityScreen.tsx
Normal 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
|
||||
})
|
||||
515
RN_TEMPLATE/app/screens/SessionManagementScreen.tsx
Normal file
515
RN_TEMPLATE/app/screens/SessionManagementScreen.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
170
RN_TEMPLATE/app/screens/SettingsScreen.tsx
Normal file
170
RN_TEMPLATE/app/screens/SettingsScreen.tsx
Normal 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,
|
||||
})
|
||||
65
RN_TEMPLATE/app/screens/ShowroomScreen/DemoDivider.tsx
Normal file
65
RN_TEMPLATE/app/screens/ShowroomScreen/DemoDivider.tsx
Normal 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%",
|
||||
})
|
||||
51
RN_TEMPLATE/app/screens/ShowroomScreen/DemoUseCase.tsx
Normal file
51
RN_TEMPLATE/app/screens/ShowroomScreen/DemoUseCase.tsx
Normal 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,
|
||||
})
|
||||
119
RN_TEMPLATE/app/screens/ShowroomScreen/DrawerIconButton.tsx
Normal file
119
RN_TEMPLATE/app/screens/ShowroomScreen/DrawerIconButton.tsx
Normal 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,
|
||||
}
|
||||
@@ -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
|
||||
320
RN_TEMPLATE/app/screens/ShowroomScreen/ShowroomScreen.tsx
Normal file
320
RN_TEMPLATE/app/screens/ShowroomScreen/ShowroomScreen.tsx
Normal 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,
|
||||
})
|
||||
229
RN_TEMPLATE/app/screens/ShowroomScreen/demos/DemoAutoImage.tsx
Normal file
229
RN_TEMPLATE/app/screens/ShowroomScreen/demos/DemoAutoImage.tsx
Normal file
File diff suppressed because one or more lines are too long
226
RN_TEMPLATE/app/screens/ShowroomScreen/demos/DemoButton.tsx
Normal file
226
RN_TEMPLATE/app/screens/ShowroomScreen/demos/DemoButton.tsx
Normal 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>,
|
||||
],
|
||||
}
|
||||
180
RN_TEMPLATE/app/screens/ShowroomScreen/demos/DemoCard.tsx
Normal file
180
RN_TEMPLATE/app/screens/ShowroomScreen/demos/DemoCard.tsx
Normal 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>,
|
||||
],
|
||||
}
|
||||
@@ -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>,
|
||||
],
|
||||
}
|
||||
150
RN_TEMPLATE/app/screens/ShowroomScreen/demos/DemoHeader.tsx
Normal file
150
RN_TEMPLATE/app/screens/ShowroomScreen/demos/DemoHeader.tsx
Normal 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>,
|
||||
],
|
||||
}
|
||||
109
RN_TEMPLATE/app/screens/ShowroomScreen/demos/DemoIcon.tsx
Normal file
109
RN_TEMPLATE/app/screens/ShowroomScreen/demos/DemoIcon.tsx
Normal 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>,
|
||||
],
|
||||
}
|
||||
217
RN_TEMPLATE/app/screens/ShowroomScreen/demos/DemoListItem.tsx
Normal file
217
RN_TEMPLATE/app/screens/ShowroomScreen/demos/DemoListItem.tsx
Normal 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>,
|
||||
],
|
||||
}
|
||||
142
RN_TEMPLATE/app/screens/ShowroomScreen/demos/DemoText.tsx
Normal file
142
RN_TEMPLATE/app/screens/ShowroomScreen/demos/DemoText.tsx
Normal 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>,
|
||||
],
|
||||
}
|
||||
231
RN_TEMPLATE/app/screens/ShowroomScreen/demos/DemoTextField.tsx
Normal file
231
RN_TEMPLATE/app/screens/ShowroomScreen/demos/DemoTextField.tsx
Normal 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>,
|
||||
],
|
||||
}
|
||||
348
RN_TEMPLATE/app/screens/ShowroomScreen/demos/DemoToggle.tsx
Normal file
348
RN_TEMPLATE/app/screens/ShowroomScreen/demos/DemoToggle.tsx
Normal 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>,
|
||||
],
|
||||
}
|
||||
10
RN_TEMPLATE/app/screens/ShowroomScreen/demos/index.ts
Normal file
10
RN_TEMPLATE/app/screens/ShowroomScreen/demos/index.ts
Normal 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"
|
||||
10
RN_TEMPLATE/app/screens/ShowroomScreen/demos/types.ts
Normal file
10
RN_TEMPLATE/app/screens/ShowroomScreen/demos/types.ts
Normal 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[]
|
||||
}
|
||||
108
RN_TEMPLATE/app/screens/ThemeScreen.tsx
Normal file
108
RN_TEMPLATE/app/screens/ThemeScreen.tsx
Normal 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",
|
||||
})
|
||||
111
RN_TEMPLATE/app/screens/WelcomeScreen.tsx
Normal file
111
RN_TEMPLATE/app/screens/WelcomeScreen.tsx
Normal 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,
|
||||
})
|
||||
Reference in New Issue
Block a user