Squash merge feat/expo-app: app-expo, .cursor, workflows, package.json, .husky; remove app-android, app-ios, react-app
This commit is contained in:
642
app-expo/src/app/(main)/chapter/[id].tsx
Normal file
642
app-expo/src/app/(main)/chapter/[id].tsx
Normal file
@@ -0,0 +1,642 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useLocalSearchParams } from 'expo-router';
|
||||
import { Settings, X } from 'lucide-react-native';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { ScreenHeader } from '@/components/screen-header';
|
||||
import { ScreenGutter } from '@/constants/layout';
|
||||
import { useChapterDetail } from '@/features/memoir/hooks';
|
||||
|
||||
// Life-Echo reading colors (from HTML reference)
|
||||
const READING_COLORS = {
|
||||
background: '#F8F9FA',
|
||||
backgroundSepia: '#F2E8CF',
|
||||
primary: '#8177A6',
|
||||
onSurface: '#1b1b1f',
|
||||
onSurfaceVariant: '#48454e',
|
||||
divider: 'rgba(141, 140, 144, 0.2)',
|
||||
surfaceContainerHigh: '#e9e7ec',
|
||||
outlineVariant: 'rgba(121, 117, 127, 0.3)',
|
||||
};
|
||||
|
||||
type FontSize = 'small' | 'default' | 'large';
|
||||
type FontFamily = 'serif' | 'sans';
|
||||
type BackgroundTheme = 'white' | 'sepia';
|
||||
|
||||
const FONT_SIZES: Record<FontSize, number> = {
|
||||
small: 16,
|
||||
default: 20,
|
||||
large: 24,
|
||||
};
|
||||
|
||||
const LINE_HEIGHTS: Record<FontSize, number> = {
|
||||
small: 30,
|
||||
default: 38,
|
||||
large: 44,
|
||||
};
|
||||
|
||||
const FONT_FAMILIES: Record<FontFamily, string> = {
|
||||
serif:
|
||||
Platform.select({ ios: 'Georgia', android: 'serif', default: 'serif' }) ??
|
||||
'serif',
|
||||
sans:
|
||||
Platform.select({
|
||||
ios: 'System',
|
||||
android: 'sans-serif',
|
||||
default: 'sans-serif',
|
||||
}) ?? 'sans-serif',
|
||||
};
|
||||
|
||||
const BACKGROUND_COLORS: Record<BackgroundTheme, string> = {
|
||||
white: READING_COLORS.background,
|
||||
sepia: READING_COLORS.backgroundSepia,
|
||||
};
|
||||
|
||||
function ChapterContent({
|
||||
sections,
|
||||
coverImageUrl,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
backgroundColor,
|
||||
}: {
|
||||
sections: { content: string; image: { url: string | null } | null }[];
|
||||
coverImageUrl: string | null;
|
||||
fontSize: FontSize;
|
||||
fontFamily: FontFamily;
|
||||
backgroundColor: BackgroundTheme;
|
||||
}) {
|
||||
const { width } = useWindowDimensions();
|
||||
const contentWidth = Math.min(width - ScreenGutter * 2, 672);
|
||||
const heroAspectRatio = 4 / 5;
|
||||
const bodySize = FONT_SIZES[fontSize];
|
||||
const lineHeight = LINE_HEIGHTS[fontSize];
|
||||
const fontFam = FONT_FAMILIES[fontFamily];
|
||||
const bgColor = BACKGROUND_COLORS[backgroundColor];
|
||||
|
||||
const [heroLoadFailed, setHeroLoadFailed] = useState(false);
|
||||
useEffect(() => {
|
||||
setHeroLoadFailed(false);
|
||||
}, [coverImageUrl]);
|
||||
const hasCoverImage = !!coverImageUrl && !heroLoadFailed;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hero Image: 仅在有封面图且加载成功时显示,避免无图或加载失败时大片空白 */}
|
||||
{hasCoverImage && (
|
||||
<View
|
||||
style={{
|
||||
width: '100%',
|
||||
aspectRatio: heroAspectRatio,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: coverImageUrl! }}
|
||||
alt="Chapter hero"
|
||||
onError={() => setHeroLoadFailed(true)}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
<LinearGradient
|
||||
colors={['transparent', bgColor]}
|
||||
locations={[0.3, 1]}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Memoir Content */}
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: ScreenGutter,
|
||||
marginTop: hasCoverImage ? -60 : 0,
|
||||
paddingTop: hasCoverImage ? 0 : 12,
|
||||
paddingBottom: 48,
|
||||
backgroundColor: bgColor,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{ maxWidth: contentWidth, alignSelf: 'center', width: '100%' }}
|
||||
>
|
||||
{(() => {
|
||||
const indent = '\u3000\u3000';
|
||||
let isFirstParagraph = true;
|
||||
|
||||
return sections.flatMap((section, i) => {
|
||||
const text = section.content?.trim() ?? '';
|
||||
const hasImage = !!section.image?.url;
|
||||
if (!text && !hasImage) return [];
|
||||
|
||||
const paragraphs = text
|
||||
? text
|
||||
.split(/\n\n+/)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
const nodes: React.ReactNode[] = [];
|
||||
|
||||
paragraphs.forEach((para, pIdx) => {
|
||||
const firstChar = para.charAt(0);
|
||||
const rest = para.slice(1);
|
||||
const isLastInSection =
|
||||
pIdx === paragraphs.length - 1 && !hasImage;
|
||||
const mb = isLastInSection ? 20 : 16;
|
||||
|
||||
if (isFirstParagraph && firstChar) {
|
||||
const firstParaLineHeight = Math.round(lineHeight * 1.35);
|
||||
nodes.push(
|
||||
<Text
|
||||
key={`${i}-${pIdx}`}
|
||||
selectable
|
||||
style={{
|
||||
fontSize: bodySize,
|
||||
lineHeight: firstParaLineHeight,
|
||||
color: READING_COLORS.onSurfaceVariant,
|
||||
fontFamily: fontFam,
|
||||
marginBottom: mb,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: bodySize * 2.2,
|
||||
fontWeight: '700',
|
||||
color: READING_COLORS.primary,
|
||||
lineHeight: firstParaLineHeight,
|
||||
fontFamily: fontFam,
|
||||
}}
|
||||
>
|
||||
{firstChar}
|
||||
</Text>
|
||||
{rest}
|
||||
</Text>,
|
||||
);
|
||||
isFirstParagraph = false;
|
||||
} else {
|
||||
nodes.push(
|
||||
<Text
|
||||
key={`${i}-${pIdx}`}
|
||||
selectable
|
||||
style={{
|
||||
fontSize: bodySize,
|
||||
lineHeight,
|
||||
color: READING_COLORS.onSurfaceVariant,
|
||||
fontFamily: fontFam,
|
||||
marginBottom: mb,
|
||||
}}
|
||||
>
|
||||
{indent}
|
||||
{para}
|
||||
</Text>,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (hasImage) {
|
||||
nodes.push(
|
||||
<Image
|
||||
key={`${i}-img`}
|
||||
source={{ uri: section.image!.url }}
|
||||
style={{
|
||||
width: '100%',
|
||||
aspectRatio: 16 / 9,
|
||||
borderRadius: 12,
|
||||
marginTop: paragraphs.length > 0 ? 12 : 0,
|
||||
marginBottom: 20,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
return nodes;
|
||||
});
|
||||
})()}
|
||||
|
||||
{sections.length > 0 && (
|
||||
<View
|
||||
style={{
|
||||
paddingTop: 24,
|
||||
paddingBottom: 24,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 96,
|
||||
height: 1,
|
||||
backgroundColor: READING_COLORS.divider,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadingSettingsModal({
|
||||
visible,
|
||||
onClose,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
backgroundColor,
|
||||
onFontSizeChange,
|
||||
onFontFamilyChange,
|
||||
onBackgroundChange,
|
||||
t,
|
||||
}: {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
fontSize: FontSize;
|
||||
fontFamily: FontFamily;
|
||||
backgroundColor: BackgroundTheme;
|
||||
onFontSizeChange: (v: FontSize) => void;
|
||||
onFontFamilyChange: (v: FontFamily) => void;
|
||||
onBackgroundChange: (v: BackgroundTheme) => void;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="slide"
|
||||
transparent
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<Pressable style={readingSettingsStyles.backdrop} onPress={onClose}>
|
||||
<Pressable
|
||||
style={[
|
||||
readingSettingsStyles.sheet,
|
||||
{ paddingBottom: insets.bottom + 24 },
|
||||
]}
|
||||
onPress={() => {}}
|
||||
>
|
||||
<View style={readingSettingsStyles.handle} />
|
||||
<View style={readingSettingsStyles.header}>
|
||||
<Text style={readingSettingsStyles.title}>
|
||||
{t('chapterReading.readingSettings')}
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
hitSlop={12}
|
||||
style={({ pressed }) => [
|
||||
readingSettingsStyles.closeBtn,
|
||||
pressed && { opacity: 0.6 },
|
||||
]}
|
||||
accessibilityLabel={t('chapterReading.close')}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Icon as={X} size={22} color={READING_COLORS.onSurfaceVariant} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={readingSettingsStyles.section}>
|
||||
<Text style={readingSettingsStyles.label}>
|
||||
{t('chapterReading.fontSize')}
|
||||
</Text>
|
||||
<View style={readingSettingsStyles.segmented}>
|
||||
{(['small', 'default', 'large'] as const).map((s) => (
|
||||
<Pressable
|
||||
key={s}
|
||||
onPress={() => onFontSizeChange(s)}
|
||||
style={({ pressed }) => [
|
||||
readingSettingsStyles.segItem,
|
||||
fontSize === s && readingSettingsStyles.segItemActive,
|
||||
pressed && { opacity: 0.8 },
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
readingSettingsStyles.segText,
|
||||
fontSize === s && readingSettingsStyles.segTextActive,
|
||||
]}
|
||||
>
|
||||
{t(
|
||||
`chapterReading.fontSize${s.charAt(0).toUpperCase() + s.slice(1)}`,
|
||||
)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={readingSettingsStyles.section}>
|
||||
<Text style={readingSettingsStyles.label}>
|
||||
{t('chapterReading.typography')}
|
||||
</Text>
|
||||
<View style={readingSettingsStyles.segmented}>
|
||||
{(['serif', 'sans'] as const).map((f) => (
|
||||
<Pressable
|
||||
key={f}
|
||||
onPress={() => onFontFamilyChange(f)}
|
||||
style={({ pressed }) => [
|
||||
readingSettingsStyles.segItem,
|
||||
fontFamily === f && readingSettingsStyles.segItemActive,
|
||||
pressed && { opacity: 0.8 },
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
readingSettingsStyles.segText,
|
||||
fontFamily === f && readingSettingsStyles.segTextActive,
|
||||
{ fontFamily: FONT_FAMILIES[f] },
|
||||
]}
|
||||
>
|
||||
{t(
|
||||
`chapterReading.font${f.charAt(0).toUpperCase() + f.slice(1)}`,
|
||||
)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={readingSettingsStyles.section}>
|
||||
<Text style={readingSettingsStyles.label}>
|
||||
{t('chapterReading.backgroundColor')}
|
||||
</Text>
|
||||
<View style={readingSettingsStyles.bgRow}>
|
||||
{(['white', 'sepia'] as const).map((theme) => (
|
||||
<Pressable
|
||||
key={theme}
|
||||
onPress={() => onBackgroundChange(theme)}
|
||||
style={({ pressed }) => [
|
||||
readingSettingsStyles.bgOption,
|
||||
{ backgroundColor: BACKGROUND_COLORS[theme] },
|
||||
backgroundColor === theme &&
|
||||
readingSettingsStyles.bgOptionActive,
|
||||
pressed && { opacity: 0.9 },
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
readingSettingsStyles.bgSwatch,
|
||||
{ backgroundColor: BACKGROUND_COLORS[theme] },
|
||||
theme === 'sepia' && {
|
||||
borderColor: 'rgba(91,77,62,0.5)',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
readingSettingsStyles.bgLabel,
|
||||
theme === 'sepia' && { color: '#5B4D3E' },
|
||||
backgroundColor === theme &&
|
||||
readingSettingsStyles.bgLabelActive,
|
||||
]}
|
||||
>
|
||||
{t(
|
||||
`chapterReading.bg${theme === 'white' ? 'PureWhite' : 'Sepia'}`,
|
||||
)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const readingSettingsStyles = StyleSheet.create({
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||
},
|
||||
sheet: {
|
||||
backgroundColor: '#fff',
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
paddingTop: 8,
|
||||
paddingHorizontal: ScreenGutter,
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: -2 },
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 12,
|
||||
},
|
||||
android: { elevation: 12 },
|
||||
}),
|
||||
},
|
||||
handle: {
|
||||
alignSelf: 'center',
|
||||
width: 40,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: 'rgba(0,0,0,0.2)',
|
||||
marginBottom: 16,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 17,
|
||||
fontWeight: '700',
|
||||
color: READING_COLORS.onSurface,
|
||||
},
|
||||
closeBtn: { padding: 8 },
|
||||
label: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.5,
|
||||
color: READING_COLORS.onSurfaceVariant,
|
||||
marginBottom: 10,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
section: { marginBottom: 20 },
|
||||
segmented: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#eeeef0',
|
||||
borderRadius: 10,
|
||||
padding: 4,
|
||||
},
|
||||
segItem: {
|
||||
flex: 1,
|
||||
minHeight: 44,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: 8,
|
||||
},
|
||||
segItemActive: {
|
||||
backgroundColor: READING_COLORS.primary,
|
||||
},
|
||||
segText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
color: READING_COLORS.onSurfaceVariant,
|
||||
},
|
||||
segTextActive: {
|
||||
fontWeight: '700',
|
||||
color: '#fff',
|
||||
},
|
||||
bgRow: { flexDirection: 'row', gap: 12 },
|
||||
bgOption: {
|
||||
flex: 1,
|
||||
minHeight: 64,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 10,
|
||||
borderRadius: 10,
|
||||
borderWidth: 2,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
bgOptionActive: {
|
||||
borderColor: READING_COLORS.primary,
|
||||
},
|
||||
bgSwatch: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 6,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(0,0,0,0.1)',
|
||||
},
|
||||
bgLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
color: READING_COLORS.onSurface,
|
||||
},
|
||||
bgLabelActive: { fontWeight: '700' },
|
||||
});
|
||||
|
||||
export default function ChapterScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation('memoir');
|
||||
const { data: chapter, isLoading } = useChapterDetail(id ?? '');
|
||||
|
||||
const [settingsVisible, setSettingsVisible] = useState(false);
|
||||
const [fontSize, setFontSize] = useState<FontSize>('default');
|
||||
const [fontFamily, setFontFamily] = useState<FontFamily>('serif');
|
||||
const [backgroundColor, setBackgroundColor] =
|
||||
useState<BackgroundTheme>('white');
|
||||
|
||||
const bgColor = BACKGROUND_COLORS[backgroundColor];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: bgColor,
|
||||
}}
|
||||
>
|
||||
<ActivityIndicator size="large" color={READING_COLORS.primary} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!chapter) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: bgColor,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
selectable
|
||||
style={{ color: READING_COLORS.onSurfaceVariant, fontSize: 16 }}
|
||||
>
|
||||
{t('chapterReading.chapterNotFound')}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const sections = chapter.sections ?? [];
|
||||
const coverImageUrl = chapter.cover_image?.url ?? null;
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: bgColor }}>
|
||||
<ScreenHeader
|
||||
variant="reading"
|
||||
absolute
|
||||
backgroundColor={bgColor}
|
||||
title={chapter.title}
|
||||
backAccessibilityLabel={t('chapterReading.back')}
|
||||
right={
|
||||
<Pressable
|
||||
onPress={() => setSettingsVisible(true)}
|
||||
style={({ pressed }) => ({
|
||||
padding: 8,
|
||||
marginRight: -8,
|
||||
borderRadius: 9999,
|
||||
opacity: pressed ? 0.7 : 1,
|
||||
})}
|
||||
accessibilityLabel={t('chapterReading.settings')}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Icon as={Settings} size={24} color={READING_COLORS.primary} />
|
||||
</Pressable>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior="never"
|
||||
contentContainerStyle={{
|
||||
paddingTop: insets.top + 72,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
style={{ backgroundColor: bgColor }}
|
||||
>
|
||||
<ChapterContent
|
||||
sections={sections}
|
||||
coverImageUrl={coverImageUrl}
|
||||
fontSize={fontSize}
|
||||
fontFamily={fontFamily}
|
||||
backgroundColor={backgroundColor}
|
||||
/>
|
||||
</ScrollView>
|
||||
|
||||
<ReadingSettingsModal
|
||||
visible={settingsVisible}
|
||||
onClose={() => setSettingsVisible(false)}
|
||||
fontSize={fontSize}
|
||||
fontFamily={fontFamily}
|
||||
backgroundColor={backgroundColor}
|
||||
onFontSizeChange={setFontSize}
|
||||
onFontFamilyChange={setFontFamily}
|
||||
onBackgroundChange={setBackgroundColor}
|
||||
t={t as (key: string) => string}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user