fix:
1. 修复登录界面文字被遮挡问题 2. 大字模式关闭后显示异常问题 3. 重新调整大字模式是否开启时的字体显示效果
This commit is contained in:
@@ -960,13 +960,14 @@ export default function ConversationScreen() {
|
||||
/** 大字模式:朗读图标与触控区与气泡字号同档放大 */
|
||||
const chatReadAloudIconSize = largeText ? 24 : 20;
|
||||
const chatReadAloudButtonSize = largeText ? 52 : 44;
|
||||
const chatVoiceDurationStyle = useMemo(
|
||||
() => ({
|
||||
fontSize: largeText ? typography.headingSmall : typography.titleMedium,
|
||||
const chatVoiceDurationStyle = useMemo(() => {
|
||||
const fs = largeText ? typography.headingSmall : typography.titleMedium;
|
||||
return {
|
||||
fontSize: fs,
|
||||
lineHeight: Math.ceil(fs * 1.25),
|
||||
fontWeight: '500' as const,
|
||||
}),
|
||||
[typography, largeText],
|
||||
);
|
||||
};
|
||||
}, [typography, largeText]);
|
||||
const chatTypingLabelStyle = useMemo(
|
||||
() => ({
|
||||
fontSize: largeText ? typography.bodySmall : typography.captionLarge,
|
||||
@@ -977,6 +978,9 @@ export default function ConversationScreen() {
|
||||
const headerTitleFontStyle = useMemo(
|
||||
() => ({
|
||||
fontSize: largeText ? typography.headingMedium : typography.headingSmall,
|
||||
lineHeight: largeText
|
||||
? Math.ceil(typography.headingMedium * 1.4)
|
||||
: Math.ceil(typography.headingSmall * 1.28),
|
||||
}),
|
||||
[typography, largeText],
|
||||
);
|
||||
@@ -992,13 +996,14 @@ export default function ConversationScreen() {
|
||||
}),
|
||||
[typography, inputLineHeight, largeText],
|
||||
);
|
||||
const connectionNoticeTitleStyle = useMemo(
|
||||
() => ({
|
||||
fontSize: largeText ? typography.titleSmall : typography.captionLarge,
|
||||
const connectionNoticeTitleStyle = useMemo(() => {
|
||||
const fs = largeText ? typography.titleSmall : typography.captionLarge;
|
||||
return {
|
||||
fontSize: fs,
|
||||
lineHeight: Math.ceil(fs * 1.28),
|
||||
fontWeight: '700' as const,
|
||||
}),
|
||||
[typography, largeText],
|
||||
);
|
||||
};
|
||||
}, [typography, largeText]);
|
||||
const connectionNoticeBodyStyle = useMemo(
|
||||
() => ({
|
||||
fontSize: largeText ? typography.bodySmall : typography.captionLarge,
|
||||
@@ -1401,7 +1406,10 @@ export default function ConversationScreen() {
|
||||
variant="chat"
|
||||
title={
|
||||
<View style={styles.headerTitleBlock}>
|
||||
<Text style={[styles.headerTitle, headerTitleFontStyle]}>
|
||||
<Text
|
||||
style={[styles.headerTitle, headerTitleFontStyle]}
|
||||
includeFontPadding={Platform.OS === 'android' ? false : undefined}
|
||||
>
|
||||
{tApp('name')}
|
||||
</Text>
|
||||
{showConnectionBadge ? (
|
||||
@@ -1757,6 +1765,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
voiceDurationText: {
|
||||
fontSize: 18,
|
||||
lineHeight: 24,
|
||||
fontWeight: '500',
|
||||
},
|
||||
voiceDurationTextUser: {
|
||||
@@ -1879,15 +1888,13 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
voiceRecordDurationWrap: {
|
||||
minWidth: 40,
|
||||
height: 20,
|
||||
minHeight: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 1,
|
||||
},
|
||||
/** 字号/行高由 voiceRecordDurationStyle 提供,避免大字模式仍锁 20px 高裁切 */
|
||||
voiceRecordDuration: {
|
||||
fontSize: 12,
|
||||
/** 与容器等高,避免 Android/iOS 数字相对胶囊上下偏移 */
|
||||
height: 20,
|
||||
lineHeight: 20,
|
||||
paddingVertical: 0,
|
||||
marginVertical: 0,
|
||||
color: 'rgba(27, 27, 31, 0.86)',
|
||||
@@ -1905,7 +1912,7 @@ const styles = StyleSheet.create({
|
||||
width: '100%',
|
||||
},
|
||||
sendButton: {
|
||||
height: 44,
|
||||
minHeight: 44,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 11,
|
||||
borderRadius: 22,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { TabBarIcon } from '@/components/tab-bar-icon';
|
||||
import { useTypography } from '@/core/typography-context';
|
||||
import { useSession } from '@/features/auth/hooks';
|
||||
|
||||
// Life-Echo bottom nav colors (from HTML reference)
|
||||
@@ -27,6 +28,7 @@ const TAB_BAR_PADDING_HORIZONTAL = 12;
|
||||
|
||||
export default function TabsLayout() {
|
||||
const { status } = useSession();
|
||||
const typography = useTypography();
|
||||
const { colorScheme } = useColorScheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isDark = colorScheme === 'dark';
|
||||
@@ -75,7 +77,8 @@ export default function TabsLayout() {
|
||||
marginBottom: 0,
|
||||
},
|
||||
tabBarLabelStyle: {
|
||||
fontSize: 11,
|
||||
fontSize: typography.captionLarge,
|
||||
lineHeight: typography.lineHeightTight,
|
||||
fontWeight: '600',
|
||||
textTransform: 'uppercase',
|
||||
marginTop: 4,
|
||||
|
||||
@@ -154,14 +154,16 @@ function ConversationCard({
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text
|
||||
className="shrink-0 text-sm font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
variant="captionMedium"
|
||||
className="shrink-0 font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
style={{ fontVariant: ['tabular-nums'] }}
|
||||
>
|
||||
{formatRelativeConversationListTime(item.latestMessageTime, t)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
className="min-w-0 self-stretch text-base font-semibold text-muted-foreground"
|
||||
variant="bodyMedium"
|
||||
className="min-w-0 self-stretch font-semibold text-muted-foreground"
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
@@ -202,7 +204,8 @@ function SwipeableConversationCard({
|
||||
>
|
||||
<Icon as={Trash2} className="text-destructive-foreground" size={24} />
|
||||
<Text
|
||||
className="mt-1 text-xs font-semibold text-destructive-foreground"
|
||||
variant="captionSmall"
|
||||
className="mt-1 font-semibold text-destructive-foreground"
|
||||
selectable={false}
|
||||
>
|
||||
{t('delete')}
|
||||
@@ -405,11 +408,13 @@ export default function ConversationsScreen() {
|
||||
<Text
|
||||
variant="h2"
|
||||
className="text-center font-display text-primary"
|
||||
style={{ borderWidth: 0, fontSize: 28, lineHeight: 38 }}
|
||||
>
|
||||
{t('greetingTitle')}
|
||||
</Text>
|
||||
<Text className="text-center text-base font-medium leading-6 text-muted-foreground">
|
||||
<Text
|
||||
variant="bodyLarge"
|
||||
className="text-center font-medium text-muted-foreground"
|
||||
>
|
||||
{t('emptyGreetingSubtitle')}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -429,13 +434,15 @@ export default function ConversationsScreen() {
|
||||
<Text
|
||||
variant="h2"
|
||||
className="text-center font-display text-primary"
|
||||
style={{ borderWidth: 0, fontSize: 28, lineHeight: 38 }}
|
||||
>
|
||||
{showResumeEntry
|
||||
? t('resumeChatTitle')
|
||||
: t('greetingTitle')}
|
||||
</Text>
|
||||
<Text className="text-center text-base font-medium leading-6 text-muted-foreground">
|
||||
<Text
|
||||
variant="bodyLarge"
|
||||
className="text-center font-medium text-muted-foreground"
|
||||
>
|
||||
{showResumeEntry
|
||||
? t('resumeChatSubtitle')
|
||||
: t('emptyGreetingSubtitle')}
|
||||
@@ -449,10 +456,14 @@ export default function ConversationsScreen() {
|
||||
{t('recentChats')}
|
||||
</Text>
|
||||
<Pressable
|
||||
className="min-h-11 min-w-11 items-center justify-center active:opacity-70"
|
||||
className="min-h-11 items-center justify-center px-2 active:opacity-70"
|
||||
hitSlop={{ top: 6, bottom: 6, left: 4, right: 4 }}
|
||||
onPress={() => {}}
|
||||
>
|
||||
<Text className="text-sm font-semibold text-primary">
|
||||
<Text
|
||||
variant="bodySmall"
|
||||
className="font-semibold text-primary"
|
||||
>
|
||||
{t('viewAll')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Icon } from '@/components/ui/icon';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { ScreenGutter } from '@/constants/layout';
|
||||
import { useTypography } from '@/core/typography-context';
|
||||
import { useCreateConversation } from '@/features/conversation/hooks';
|
||||
import {
|
||||
buildFrameworkChapterPlaceholders,
|
||||
@@ -77,9 +78,18 @@ function ChapterCard({
|
||||
onReadPress: () => void;
|
||||
onContinuePress: () => void;
|
||||
}) {
|
||||
const typography = useTypography();
|
||||
const { width } = useWindowDimensions();
|
||||
const contentWidth = Math.min(width - ScreenGutter * 2, 672);
|
||||
const gutter = ScreenGutter;
|
||||
const completedTitleLineHeight = Math.max(
|
||||
typography.lineHeightTight + 6,
|
||||
typography.headingMedium + 6,
|
||||
);
|
||||
const draftingTitleLineHeight = Math.max(
|
||||
typography.lineHeightTight + 4,
|
||||
typography.titleLarge + 4,
|
||||
);
|
||||
|
||||
const chapterIndex = item.orderIndex + 1;
|
||||
const chapterLabel = t('chapterLabel').replace(
|
||||
@@ -142,7 +152,8 @@ function ChapterCard({
|
||||
<View style={{ padding: gutter, gap: 12 }}>
|
||||
<View style={{ gap: 4, minHeight: 72 }}>
|
||||
<Text
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
variant="captionMedium"
|
||||
className="font-medium text-muted-foreground"
|
||||
selectable
|
||||
>
|
||||
{chapterLabel}
|
||||
@@ -150,7 +161,7 @@ function ChapterCard({
|
||||
<Text
|
||||
variant="h2"
|
||||
className="text-foreground"
|
||||
style={{ lineHeight: 34 }}
|
||||
style={{ lineHeight: completedTitleLineHeight }}
|
||||
numberOfLines={2}
|
||||
selectable
|
||||
>
|
||||
@@ -161,7 +172,8 @@ function ChapterCard({
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Icon as={FileText} className="text-muted-foreground" size={16} />
|
||||
<Text
|
||||
className="text-sm font-medium text-muted-foreground"
|
||||
variant="bodySmall"
|
||||
className="font-medium text-muted-foreground"
|
||||
style={{ fontVariant: ['tabular-nums'] }}
|
||||
selectable
|
||||
>
|
||||
@@ -174,7 +186,10 @@ function ChapterCard({
|
||||
style={{ borderCurve: 'continuous' }}
|
||||
onPress={onReadPress}
|
||||
>
|
||||
<Text className="text-base font-semibold text-primary-foreground">
|
||||
<Text
|
||||
variant="bodyLarge"
|
||||
className="font-semibold text-primary-foreground"
|
||||
>
|
||||
{t('readMemory')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
@@ -197,7 +212,8 @@ function ChapterCard({
|
||||
<View className="gap-1">
|
||||
<View>
|
||||
<Text
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
variant="captionMedium"
|
||||
className="font-medium text-muted-foreground"
|
||||
selectable
|
||||
>
|
||||
{chapterLabel}
|
||||
@@ -205,7 +221,7 @@ function ChapterCard({
|
||||
<Text
|
||||
variant="h4"
|
||||
className="mt-0.5 text-foreground"
|
||||
style={{ lineHeight: 28 }}
|
||||
style={{ lineHeight: draftingTitleLineHeight }}
|
||||
numberOfLines={2}
|
||||
selectable
|
||||
>
|
||||
@@ -213,7 +229,11 @@ function ChapterCard({
|
||||
</Text>
|
||||
</View>
|
||||
<View className="mt-2 flex-row items-center gap-2">
|
||||
<Text className="text-sm font-medium text-secondary" selectable>
|
||||
<Text
|
||||
variant="bodySmall"
|
||||
className="font-medium text-secondary"
|
||||
selectable
|
||||
>
|
||||
{t('statusDrafting')}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -223,7 +243,7 @@ function ChapterCard({
|
||||
style={{ borderCurve: 'continuous' }}
|
||||
onPress={onContinuePress}
|
||||
>
|
||||
<Text className="text-base font-semibold text-secondary">
|
||||
<Text variant="bodyLarge" className="font-semibold text-secondary">
|
||||
{t('continueWriting')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
@@ -237,7 +257,7 @@ function MemoirLoadError({ onRetry }: { onRetry: () => void }) {
|
||||
const { t } = useTranslation('memoir');
|
||||
return (
|
||||
<View className="items-center gap-4 rounded-2xl border border-dashed border-border bg-muted/20 p-10">
|
||||
<Text className="text-center text-base text-destructive">
|
||||
<Text variant="bodyLarge" className="text-center text-destructive">
|
||||
{t('loadErrorMessage')}
|
||||
</Text>
|
||||
<Pressable
|
||||
@@ -245,7 +265,10 @@ function MemoirLoadError({ onRetry }: { onRetry: () => void }) {
|
||||
style={{ borderCurve: 'continuous' }}
|
||||
onPress={onRetry}
|
||||
>
|
||||
<Text className="font-semibold text-primary-foreground">
|
||||
<Text
|
||||
variant="bodyMedium"
|
||||
className="font-semibold text-primary-foreground"
|
||||
>
|
||||
{t('loadErrorRetry')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
@@ -90,11 +90,15 @@ function LanguageRow({
|
||||
<Icon as={Globe} className="text-muted-foreground" size={20} />
|
||||
<View>
|
||||
<Text className="font-medium text-foreground">{label}</Text>
|
||||
<Text className="text-sm text-muted-foreground">{description}</Text>
|
||||
<Text variant="bodySmall" className="text-muted-foreground">
|
||||
{description}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Text className="text-sm text-muted-foreground">{currentLabel}</Text>
|
||||
<Text variant="bodySmall" className="text-muted-foreground">
|
||||
{currentLabel}
|
||||
</Text>
|
||||
<Icon as={ChevronRight} className="text-muted-foreground" size={20} />
|
||||
</View>
|
||||
</Pressable>
|
||||
@@ -120,7 +124,9 @@ function SettingRow({
|
||||
<Icon as={SettingIcon} className="text-muted-foreground" size={20} />
|
||||
<View>
|
||||
<Text className="font-medium text-foreground">{label}</Text>
|
||||
<Text className="text-sm text-muted-foreground">{description}</Text>
|
||||
<Text variant="bodySmall" className="text-muted-foreground">
|
||||
{description}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Switch checked={value} onCheckedChange={onValueChange} />
|
||||
@@ -203,7 +209,7 @@ export default function ProfileScreen() {
|
||||
<Text variant="h3" className="text-foreground">
|
||||
{user?.nickname ?? t('userNamePlaceholder')}
|
||||
</Text>
|
||||
<Text className="text-sm text-muted-foreground">
|
||||
<Text variant="bodySmall" className="text-muted-foreground">
|
||||
{t('userTier', { tier: tierLabel })}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { router } from 'expo-router';
|
||||
import { ArrowLeft } from 'lucide-react-native';
|
||||
import React from 'react';
|
||||
import { Pressable, View } from 'react-native';
|
||||
import { Platform, Pressable, Text as RNText, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { useTypography } from '@/core/typography-context';
|
||||
import { ScreenGutter } from '@/constants/layout';
|
||||
import { useAppSettings } from '@/hooks/use-app-settings';
|
||||
|
||||
/** 默认最小触控目标 48dp;大字模式下与标题字号匹配,略向左扩展便于够到边缘 */
|
||||
const BACK_HIT_MIN = 48;
|
||||
const BACK_HIT_MIN_LARGE = 56;
|
||||
const BACK_ICON_SIZE = 24;
|
||||
const BACK_ICON_SIZE_LARGE = 30;
|
||||
const BACK_EXTRA_HIT_LEFT = 4;
|
||||
|
||||
export type ScreenHeaderVariant = 'default' | 'chat' | 'reading';
|
||||
|
||||
@@ -64,21 +72,44 @@ export function ScreenHeader({
|
||||
useSafeArea = true,
|
||||
}: ScreenHeaderProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { largeText } = useAppSettings();
|
||||
const typography = useTypography();
|
||||
const colors = VARIANT_COLORS[variant];
|
||||
|
||||
const backTouchMin = largeText ? BACK_HIT_MIN_LARGE : BACK_HIT_MIN;
|
||||
const backIconSize = largeText ? BACK_ICON_SIZE_LARGE : BACK_ICON_SIZE;
|
||||
|
||||
const handleBack = onBack ?? (() => router.back());
|
||||
const bgColor = backgroundColor ?? colors.background;
|
||||
const titleColor = colors.title;
|
||||
const iconColor = colors.icon ?? colors.iconSecondary;
|
||||
|
||||
/**
|
||||
* 不要用外层 minHeight「框死」整块栏:paddingTop 含安全区时会把内容区压扁。
|
||||
* 标题行最小高度随当前排版 token 计算(关大字也要留够,避免裁字 / Android font padding)。
|
||||
*/
|
||||
const barPaddingBottom = largeText ? 18 : 16;
|
||||
const titleRowPaddingV = largeText ? 8 : 4;
|
||||
const titleFontSize =
|
||||
variant === 'chat'
|
||||
? largeText
|
||||
? typography.headingMedium
|
||||
: typography.headingSmall
|
||||
: Math.max(typography.titleLarge, typography.headingSmall);
|
||||
const titleLineMin =
|
||||
variant === 'chat'
|
||||
? Math.ceil(titleFontSize * (largeText ? 1.45 : 1.38)) +
|
||||
(largeText ? 14 : 10)
|
||||
: Math.ceil(titleFontSize * 1.32) + (largeText ? 10 : 8);
|
||||
const titleRowMinHeight = Math.max(backTouchMin, titleLineMin);
|
||||
|
||||
const containerStyle = {
|
||||
flexDirection: 'row' as const,
|
||||
alignItems: 'center' as const,
|
||||
justifyContent: 'space-between' as const,
|
||||
paddingHorizontal: Math.max(ScreenGutter, 16),
|
||||
paddingTop: useSafeArea ? Math.max(insets.top, 12) : 12,
|
||||
paddingBottom: 16,
|
||||
minHeight: 56,
|
||||
paddingBottom: barPaddingBottom,
|
||||
...(bgColor && { backgroundColor: bgColor }),
|
||||
...(absolute && {
|
||||
position: 'absolute' as const,
|
||||
@@ -105,41 +136,74 @@ export function ScreenHeader({
|
||||
gap: 12,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
paddingVertical: titleRowPaddingV,
|
||||
minHeight: titleRowMinHeight,
|
||||
}}
|
||||
>
|
||||
{showBack && (
|
||||
<Pressable
|
||||
onPress={handleBack}
|
||||
hitSlop={
|
||||
Platform.OS === 'android'
|
||||
? {
|
||||
top: largeText ? 10 : 6,
|
||||
bottom: largeText ? 10 : 6,
|
||||
right: 8,
|
||||
left: BACK_EXTRA_HIT_LEFT,
|
||||
}
|
||||
: {
|
||||
top: largeText ? 8 : 4,
|
||||
bottom: largeText ? 8 : 4,
|
||||
left: 2,
|
||||
right: 4,
|
||||
}
|
||||
}
|
||||
android_ripple={
|
||||
Platform.OS === 'android'
|
||||
? { borderless: true, radius: backTouchMin / 2 }
|
||||
: undefined
|
||||
}
|
||||
style={({ pressed }) => ({
|
||||
padding: 8,
|
||||
minWidth: backTouchMin,
|
||||
minHeight: backTouchMin,
|
||||
marginLeft: -BACK_EXTRA_HIT_LEFT,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 9999,
|
||||
opacity: pressed ? 0.7 : 1,
|
||||
opacity: Platform.OS === 'android' ? 1 : pressed ? 0.7 : 1,
|
||||
})}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={backAccessibilityLabel}
|
||||
>
|
||||
{iconColor ? (
|
||||
<Icon as={ArrowLeft} size={24} color={iconColor} />
|
||||
<Icon as={ArrowLeft} size={backIconSize} color={iconColor} />
|
||||
) : (
|
||||
<Icon as={ArrowLeft} size={24} className="text-foreground" />
|
||||
<Icon
|
||||
as={ArrowLeft}
|
||||
size={backIconSize}
|
||||
className="text-foreground"
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
)}
|
||||
{typeof title === 'string' ? (
|
||||
<Text
|
||||
<RNText
|
||||
numberOfLines={1}
|
||||
selectable
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: variant === 'chat' ? 24 : 18,
|
||||
fontSize: variant === 'chat' ? titleFontSize : 18,
|
||||
lineHeight:
|
||||
variant === 'chat'
|
||||
? Math.ceil(titleFontSize * 1.35)
|
||||
: Math.ceil(18 * 1.32),
|
||||
fontWeight: '700',
|
||||
letterSpacing: -0.5,
|
||||
...(titleColor && { color: titleColor }),
|
||||
}}
|
||||
className={!titleColor ? 'text-foreground' : undefined}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
</RNText>
|
||||
) : (
|
||||
<View style={{ flex: 1, minWidth: 0 }}>{title}</View>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { buttonTextVariants, buttonVariants } from '@/components/ui/button';
|
||||
import { NativeOnlyAnimatedView } from '@/components/ui/native-only-animated-view';
|
||||
import { TextClassContext } from '@/components/ui/text';
|
||||
import { useTypography } from '@/core/typography-context';
|
||||
import { cn } from '@/lib/utils';
|
||||
import * as AlertDialogPrimitive from '@rn-primitives/alert-dialog';
|
||||
import * as React from 'react';
|
||||
@@ -97,11 +98,20 @@ function AlertDialogFooter({ className, ...props }: ViewProps) {
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}: AlertDialogPrimitive.TitleProps & React.RefAttributes<AlertDialogPrimitive.TitleRef>) {
|
||||
const typography = useTypography();
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
className={cn('min-w-0 text-foreground text-lg font-semibold', className)}
|
||||
className={cn('min-w-0 text-foreground font-semibold', className)}
|
||||
style={[
|
||||
{
|
||||
fontSize: typography.titleLarge,
|
||||
lineHeight: typography.lineHeightTight,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -113,15 +123,20 @@ function AlertDialogDescription({
|
||||
...props
|
||||
}: AlertDialogPrimitive.DescriptionProps &
|
||||
React.RefAttributes<AlertDialogPrimitive.DescriptionRef>) {
|
||||
const typography = useTypography();
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
className={cn(
|
||||
// shrink: RN 中 Text 在 flex 内需 flexShrink 才能按父宽换行
|
||||
'w-full min-w-0 shrink text-muted-foreground text-sm',
|
||||
'w-full min-w-0 shrink text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
style={[
|
||||
Platform.OS === 'web' ? undefined : { flexShrink: 1 },
|
||||
{
|
||||
fontSize: typography.bodyMedium,
|
||||
lineHeight: typography.lineHeightLoose,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
{...props}
|
||||
@@ -135,7 +150,15 @@ function AlertDialogAction({
|
||||
}: AlertDialogPrimitive.ActionProps & React.RefAttributes<AlertDialogPrimitive.ActionRef>) {
|
||||
return (
|
||||
<TextClassContext.Provider value={buttonTextVariants({ className })}>
|
||||
<AlertDialogPrimitive.Action className={cn(buttonVariants(), className)} {...props} />
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(
|
||||
buttonVariants(),
|
||||
// 大字模式下文与行高变大,固定 h-10 会裁切标签(如「知道了」)
|
||||
'h-auto min-h-14 py-3.5',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TextClassContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -147,7 +170,11 @@ function AlertDialogCancel({
|
||||
return (
|
||||
<TextClassContext.Provider value={buttonTextVariants({ className, variant: 'outline' })}>
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: 'outline' }), className)}
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'h-auto min-h-14 py-3.5',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TextClassContext.Provider>
|
||||
|
||||
@@ -3,7 +3,13 @@ import { cn } from '@/lib/utils';
|
||||
import * as Slot from '@rn-primitives/slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import * as React from 'react';
|
||||
import { Platform, Text as RNText, type Role } from 'react-native';
|
||||
import {
|
||||
Platform,
|
||||
StyleSheet,
|
||||
Text as RNText,
|
||||
type Role,
|
||||
type TextStyle,
|
||||
} from 'react-native';
|
||||
|
||||
/**
|
||||
* Maps Text variant to design-token keys for fontSize and lineHeight.
|
||||
@@ -122,6 +128,22 @@ const ARIA_LEVEL: Partial<Record<TextVariant, string>> = {
|
||||
|
||||
const TextClassContext = React.createContext<string | undefined>(undefined);
|
||||
|
||||
/** Android 上 lineHeight < fontSize 会裁切字形(如标题配 lineHeightTight) */
|
||||
const MIN_LINE_HEIGHT_RATIO = 1.2;
|
||||
|
||||
function ensureMinimumLineHeight(style: TextStyle): TextStyle {
|
||||
const fs = style.fontSize;
|
||||
const lh = style.lineHeight;
|
||||
if (typeof fs !== 'number' || typeof lh !== 'number') {
|
||||
return style;
|
||||
}
|
||||
const minLh = Math.ceil(fs * MIN_LINE_HEIGHT_RATIO);
|
||||
if (lh < minLh) {
|
||||
return { ...style, lineHeight: minLh };
|
||||
}
|
||||
return style;
|
||||
}
|
||||
|
||||
function Text({
|
||||
className,
|
||||
asChild = false,
|
||||
@@ -151,12 +173,19 @@ function Text({
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const flatStyle = StyleSheet.flatten([
|
||||
{ textAlign: 'left' as const },
|
||||
typographyStyle,
|
||||
style,
|
||||
]) as TextStyle;
|
||||
const resolvedStyle = ensureMinimumLineHeight(flatStyle);
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={cn(textVariants({ variant }), textClass, className)}
|
||||
role={variant ? ROLE[variant] : undefined}
|
||||
aria-level={variant ? ARIA_LEVEL[variant] : undefined}
|
||||
style={[{ textAlign: 'left' }, typographyStyle, style]}
|
||||
style={resolvedStyle}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
/**
|
||||
* Typography context for large text mode (大字模式).
|
||||
* Global typography from design tokens.
|
||||
*
|
||||
* - **关大字模式** → `typography.large`(舒适基准,不用过小的 `normal` 档)
|
||||
* - **开大字模式** → `typography.xlarge`(一级页、正文等整体再放大一档)
|
||||
*
|
||||
* 对话气泡等仍可在页面内用 `largeText` 做更大一级的 token 选择。
|
||||
*
|
||||
* Uses React Context instead of NativeWind vars() because vars() returns empty
|
||||
* objects on native (iOS/Android), so CSS variables never cascade to children.
|
||||
* See: https://github.com/nativewind/nativewind/issues/1113
|
||||
*
|
||||
* NativeWind vars() docs (works on web only):
|
||||
* https://www.nativewind.dev/docs/api/vars
|
||||
* objects on native (iOS/Android). See:
|
||||
* https://github.com/nativewind/nativewind/issues/1113
|
||||
*/
|
||||
import React, {
|
||||
createContext,
|
||||
@@ -19,18 +21,23 @@ import { useAppSettings } from '@/hooks/use-app-settings';
|
||||
|
||||
import tokens from '../../design-tokens.json';
|
||||
|
||||
export type TypographyMode = 'normal' | 'large';
|
||||
export type TypographyScale = 'large' | 'xlarge';
|
||||
|
||||
export type TypographyTokens = Record<string, number>;
|
||||
|
||||
const TypographyContext = createContext<TypographyTokens | null>(null);
|
||||
|
||||
const SCALE: Record<TypographyScale, TypographyTokens> = {
|
||||
large: tokens.typography.large as TypographyTokens,
|
||||
xlarge: tokens.typography.xlarge as TypographyTokens,
|
||||
};
|
||||
|
||||
export function TypographyProvider({ children }: PropsWithChildren) {
|
||||
const { largeText } = useAppSettings();
|
||||
const typography = useMemo(() => {
|
||||
const mode: TypographyMode = largeText ? 'large' : 'normal';
|
||||
return tokens.typography[mode] as TypographyTokens;
|
||||
}, [largeText]);
|
||||
const typography = useMemo(
|
||||
() => SCALE[largeText ? 'xlarge' : 'large'],
|
||||
[largeText],
|
||||
);
|
||||
|
||||
return (
|
||||
<TypographyContext.Provider value={typography}>
|
||||
|
||||
Reference in New Issue
Block a user