11 Commits

Author SHA1 Message Date
吕新雨
22443c82b6 fix:APP-push点击文案显示home页 2026-03-12 18:02:25 +08:00
吕新雨
d37262876b Ipad适配问题 2026-03-03 19:55:53 +08:00
吕新雨
0ad21da246 fix:更新隐私协议语言问题 2026-02-25 17:46:59 +08:00
吕新雨
43e33eb991 fix:更新协议隐私问题 2026-02-25 17:42:34 +08:00
吕新雨
7fcc64f6e3 fix:更新技术支持网址 2026-02-25 17:13:29 +08:00
吕新雨
9d6829eceb fix:修复错误 2026-02-24 10:51:32 +08:00
吕新雨
4838bcef4b fix:更新图片 2026-02-24 10:44:36 +08:00
吕新雨
0fcf85a081 更新任务生成 2026-02-13 22:46:01 +08:00
吕新雨
62fcc4bfce fix:每日推荐修复 2026-02-12 13:54:34 +08:00
吕新雨
eef5210c99 fix:更新定时任务push 2026-02-11 13:50:02 +08:00
吕新雨
402cbf90eb 修复:定时任务 2026-02-11 11:14:51 +08:00
47 changed files with 1652 additions and 333 deletions

View File

@@ -15,6 +15,7 @@
}, },
"ios": { "ios": {
"supportsTablet": true, "supportsTablet": true,
"requireFullScreen": true,
"bundleIdentifier": "com.damer.mindfulness" "bundleIdentifier": "com.damer.mindfulness"
}, },
"android": { "android": {

View File

@@ -2,13 +2,14 @@ import { useEffect, useLayoutEffect, useMemo, useState, useCallback, useRef } fr
import { import {
StyleSheet, StyleSheet,
View, View,
Dimensions,
Text, Text,
Pressable, Pressable,
PanResponder, PanResponder,
AppState,
Animated as RNAnimated, Animated as RNAnimated,
ImageBackground, ImageBackground,
Platform, Platform,
useWindowDimensions,
} from 'react-native'; } from 'react-native';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useFocusEffect } from 'expo-router'; import { useFocusEffect } from 'expo-router';
@@ -29,6 +30,8 @@ import {
getUserProfile, getUserProfile,
setReaction, setReaction,
setThemeMode, setThemeMode,
clearPendingHomePushMessage,
getPendingHomePushMessage,
getRecoFeedCache, getRecoFeedCache,
setRecoFeedCache, setRecoFeedCache,
getUserProfileScoring, getUserProfileScoring,
@@ -41,6 +44,7 @@ import {
} from '@/src/storage/appStorage'; } from '@/src/storage/appStorage';
import { fetchRecoFeed } from '@/src/services/recoApi'; import { fetchRecoFeed } from '@/src/services/recoApi';
import { subscribeHomePushMessage } from '@/src/services/pushNotificationRoute';
import { toBackendLocaleFromLanguageTag } from '@/src/i18n/locale'; import { toBackendLocaleFromLanguageTag } from '@/src/i18n/locale';
import ProfileModal from '@/components/home/ProfileModal'; import ProfileModal from '@/components/home/ProfileModal';
@@ -57,8 +61,6 @@ import { wrapText } from '@/src/features/textWrap';
import { defaultMeasureWidthImpl } from '@/src/features/textWrap/measure'; import { defaultMeasureWidthImpl } from '@/src/features/textWrap/measure';
import { ensureDailyWidgetRecoUpToDate } from '@/src/modules/dailyWidgetReco'; import { ensureDailyWidgetRecoUpToDate } from '@/src/modules/dailyWidgetReco';
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
// 预定义风景图列表 // 预定义风景图列表
const NATURE_IMAGES = [ const NATURE_IMAGES = [
require('@/assets/theme/nature/1.png'), require('@/assets/theme/nature/1.png'),
@@ -97,7 +99,9 @@ type FeedItem = { content_id: string; text: string };
export default function HomeScreen() { export default function HomeScreen() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const isEnglish = i18n.language?.startsWith('en'); const isEnglish = i18n.language?.startsWith('en');
const isTablet = Platform.OS === 'ios' && Math.min(windowWidth, windowHeight) >= 768;
const recoLang: 'en' | 'tc' = toBackendLocaleFromLanguageTag(i18n.language); const recoLang: 'en' | 'tc' = toBackendLocaleFromLanguageTag(i18n.language);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);
@@ -109,6 +113,7 @@ export default function HomeScreen() {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [likeFilled, setLikeFilled] = useState(false); const [likeFilled, setLikeFilled] = useState(false);
const [feedItems, setFeedItems] = useState<FeedItem[]>([]); const [feedItems, setFeedItems] = useState<FeedItem[]>([]);
const [pendingPushItem, setPendingPushItem] = useState<FeedItem | null>(null);
const [isFetching, setIsFetching] = useState(false); const [isFetching, setIsFetching] = useState(false);
const [cardWidth, setCardWidth] = useState<number | null>(null); const [cardWidth, setCardWidth] = useState<number | null>(null);
const [wrappedText, setWrappedText] = useState<string>(''); const [wrappedText, setWrappedText] = useState<string>('');
@@ -119,6 +124,24 @@ export default function HomeScreen() {
const likedIdsRef = useRef<Set<string>>(new Set()); const likedIdsRef = useRef<Set<string>>(new Set());
const likeInFlightRef = useRef(false); const likeInFlightRef = useRef(false);
const applyPendingPushItem = useCallback(async (message: { notification_id: string; content_id?: number; text: string }) => {
const nextItem: FeedItem = {
content_id: message.content_id != null ? String(message.content_id) : `push:${message.notification_id}`,
text: message.text,
};
setPendingPushItem(nextItem);
indexRef.current = 0;
setIndex(0);
setLikeFilled(likedIdsRef.current.has(String(nextItem.content_id)));
await clearPendingHomePushMessage();
}, []);
const consumePendingPushItem = useCallback(async () => {
const pendingMessage = await getPendingHomePushMessage();
if (!pendingMessage?.text) return;
await applyPendingPushItem(pendingMessage);
}, [applyPendingPushItem]);
useEffect(() => { useEffect(() => {
busyRef.current = busy; busyRef.current = busy;
}, [busy]); }, [busy]);
@@ -187,14 +210,25 @@ export default function HomeScreen() {
// 统一文案对象结构 // 统一文案对象结构
const currentFeed = useMemo(() => { const currentFeed = useMemo(() => {
if (feedItems.length > 0) { const baseFeed =
return feedItems; feedItems.length > 0
? feedItems
: MOCK_CONTENT.map(item => ({
content_id: item.id,
text: t(item.textKey)
}));
if (!pendingPushItem) {
return baseFeed;
} }
return MOCK_CONTENT.map(item => ({
content_id: item.id, return [
text: t(item.textKey) pendingPushItem,
})); ...baseFeed.filter((entry) => (
}, [feedItems, t]); String(entry.content_id) !== String(pendingPushItem.content_id) && entry.text !== pendingPushItem.text
)),
];
}, [feedItems, pendingPushItem, t]);
useEffect(() => { useEffect(() => {
currentFeedRef.current = currentFeed; currentFeedRef.current = currentFeed;
}, [currentFeed]); }, [currentFeed]);
@@ -248,8 +282,8 @@ export default function HomeScreen() {
}); });
const fontSpec = { const fontSpec = {
fontSize: 22, fontSize: 24,
fontWeight: lang === 'EN' ? '600' : '700', fontWeight: lang === 'EN' ? '700' : '800',
fontFamily: String(fontFamily ?? 'System'), fontFamily: String(fontFamily ?? 'System'),
}; };
@@ -381,13 +415,20 @@ export default function HomeScreen() {
useCallback(() => { useCallback(() => {
let cancelled = false; let cancelled = false;
(async () => { (async () => {
const mode = await getThemeMode(); const [mode, profile, cache, pendingMessage] = await Promise.all([
const profile = await getUserProfile(); getThemeMode(),
const cache = await getRecoFeedCache(); getUserProfile(),
getRecoFeedCache(),
getPendingHomePushMessage(),
]);
if (cancelled) return; if (cancelled) return;
setThemeModeState(mode); setThemeModeState(mode);
setProfileName(profile.name); setProfileName(profile.name);
if (pendingMessage?.text) {
await applyPendingPushItem(pendingMessage);
if (cancelled) return;
}
// 随心:若当前主题为随心,进入 Home 时确保状态就绪(仅冷启动会话重算) // 随心:若当前主题为随心,进入 Home 时确保状态就绪(仅冷启动会话重算)
if (mode === 'suixin') { if (mode === 'suixin') {
@@ -415,9 +456,24 @@ export default function HomeScreen() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [fetchNewFeed, recoLang, ensureSuixinReady]) }, [applyPendingPushItem, fetchNewFeed, recoLang, ensureSuixinReady])
); );
useEffect(() => {
const unsubscribe = subscribeHomePushMessage((message) => {
void applyPendingPushItem(message);
});
return unsubscribe;
}, [applyPendingPushItem]);
useEffect(() => {
const sub = AppState.addEventListener('change', (state) => {
if (state !== 'active') return;
void consumePendingPushItem();
});
return () => sub.remove();
}, [consumePendingPushItem]);
const backgroundColor = useMemo(() => { const backgroundColor = useMemo(() => {
if (themeMode === 'suixin') { if (themeMode === 'suixin') {
return suixinBgColor; return suixinBgColor;
@@ -659,6 +715,10 @@ export default function HomeScreen() {
} }
} }
const actionsBottom = isTablet
? Math.max(insets.bottom + 36, Math.min(windowHeight * 0.12, 140))
: windowHeight * 0.16;
return ( return (
<View style={[styles.container, { backgroundColor }]} {...panResponder.panHandlers}> <View style={[styles.container, { backgroundColor }]} {...panResponder.panHandlers}>
{themeMode === 'scenery' && ( {themeMode === 'scenery' && (
@@ -670,7 +730,15 @@ export default function HomeScreen() {
)} )}
{/* 自绘顶部按钮:不使用系统 Header彻底避免 iOS 导航栏的毛玻璃/液玻璃材质 */} {/* 自绘顶部按钮:不使用系统 Header彻底避免 iOS 导航栏的毛玻璃/液玻璃材质 */}
<View style={[styles.topRight, { top: insets.top + 8 }]}> <View
style={[
styles.topRight,
{
top: insets.top + (isTablet ? 16 : 8),
right: isTablet ? 26 : 20,
},
]}
>
<CircleIconButton <CircleIconButton
onPress={() => setThemeOpen(true)} onPress={() => setThemeOpen(true)}
accessibilityLabel={t('home.theme')} accessibilityLabel={t('home.theme')}
@@ -685,19 +753,21 @@ export default function HomeScreen() {
</CircleIconButton> </CircleIconButton>
</View> </View>
<Animated.View <Animated.View style={[styles.card, textAnimatedStyle, themeMode === 'scenery' && styles.sceneryCard]}>
style={[styles.card, textAnimatedStyle, themeMode === 'scenery' && styles.sceneryCard]} <View
onLayout={(e) => { style={[styles.textMeasureBox, isTablet && styles.textMeasureBoxTablet]}
const w = e.nativeEvent.layout.width; onLayout={(e) => {
if (Number.isFinite(w) && w > 0) setCardWidth(w); const w = e.nativeEvent.layout.width;
}} if (Number.isFinite(w) && w > 0) setCardWidth(w);
> }}
<Text style={[styles.text, isEnglish && styles.textEnglish, themeMode === 'scenery' && styles.sceneryText]}> >
{wrappedText || item.text} <Text style={[styles.text, isEnglish && styles.textEnglish, themeMode === 'scenery' && styles.sceneryText]}>
</Text> {wrappedText || item.text}
</Text>
</View>
</Animated.View> </Animated.View>
<View style={styles.actions}> <View style={[styles.actions, { bottom: actionsBottom }]}>
<Animated.View style={[styles.reactionButton, likeAnimatedStyle]}> <Animated.View style={[styles.reactionButton, likeAnimatedStyle]}>
<Pressable <Pressable
onPress={onPressLike} onPress={onPressLike}
@@ -762,7 +832,6 @@ const styles = StyleSheet.create({
}, },
topRight: { topRight: {
position: 'absolute', position: 'absolute',
right: 20,
flexDirection: 'row', flexDirection: 'row',
gap: 10, gap: 10,
zIndex: 30, zIndex: 30,
@@ -787,16 +856,23 @@ const styles = StyleSheet.create({
zIndex: 5, // 降低层级,防止遮挡底部按钮 zIndex: 5, // 降低层级,防止遮挡底部按钮
}, },
text: { text: {
fontSize: 22, fontSize: 24,
lineHeight: 32, lineHeight: 34,
color: '#5E2A28', color: '#5E2A28',
fontWeight: '700', fontWeight: '800',
textAlign: 'center', textAlign: 'center',
}, },
textMeasureBox: {
width: '100%',
alignItems: 'center',
},
textMeasureBoxTablet: {
maxWidth: 760,
},
textEnglish: { textEnglish: {
fontFamily: 'STIXTwoText', fontFamily: 'STIXTwoText',
// 英文字体观感更细一点,避免过粗 // 英文字体保持较粗但避免过度发黑
fontWeight: '600', fontWeight: '700',
}, },
sceneryCard: { sceneryCard: {
// 风景模式下稍微收窄文案宽度,增加呼吸感 // 风景模式下稍微收窄文案宽度,增加呼吸感
@@ -810,7 +886,6 @@ const styles = StyleSheet.create({
}, },
actions: { actions: {
position: 'absolute', position: 'absolute',
bottom: SCREEN_HEIGHT * 0.16,
left: 0, left: 0,
right: 0, right: 0,
flexDirection: 'row', flexDirection: 'row',

View File

@@ -1,9 +1,13 @@
import Constants from 'expo-constants'; import Constants from 'expo-constants';
import { StyleSheet, Text, View } from 'react-native'; import { Platform, StyleSheet, Text, View, useWindowDimensions } from 'react-native';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { clampContentWidth, isIPadLike } from '@/src/utils/device';
export default function SettingsScreen() { export default function SettingsScreen() {
const { t } = useTranslation(); const { t } = useTranslation();
const { width, height } = useWindowDimensions();
const isTablet = isIPadLike(width, height);
const contentWidth = isTablet ? clampContentWidth(width, 720, 24) : undefined;
const version = const version =
Constants.expoConfig?.version ?? Constants.expoConfig?.version ??
@@ -12,6 +16,7 @@ export default function SettingsScreen() {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<View style={[styles.contentWrap, contentWidth ? { width: contentWidth } : null]}>
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.label}>{t('settings.version')}</Text> <Text style={styles.label}>{t('settings.version')}</Text>
<Text style={styles.value}>{version}</Text> <Text style={styles.value}>{version}</Text>
@@ -21,12 +26,18 @@ export default function SettingsScreen() {
<Text style={styles.cardTitle}>{t('settings.widgetTitle')}</Text> <Text style={styles.cardTitle}>{t('settings.widgetTitle')}</Text>
<Text style={styles.cardText}>{t('settings.widgetDesc')}</Text> <Text style={styles.cardText}>{t('settings.widgetDesc')}</Text>
</View> </View>
</View>
</View> </View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, padding: 16, gap: 16 }, container: { flex: 1, width: '100%', alignSelf: 'stretch', padding: 16 },
contentWrap: {
width: '100%',
alignSelf: 'center',
gap: 16,
},
section: { section: {
borderRadius: 14, borderRadius: 14,
padding: 16, padding: 16,
@@ -38,7 +49,12 @@ const styles = StyleSheet.create({
justifyContent: 'space-between', justifyContent: 'space-between',
}, },
label: { color: '#374151', fontSize: 16 }, label: { color: '#374151', fontSize: 16 },
value: { color: '#111827', fontSize: 16, fontWeight: '600' }, value: {
color: '#111827',
fontSize: 16,
fontWeight: '600',
fontFamily: Platform.OS === 'ios' ? 'STIX Two Text' : undefined,
},
card: { card: {
borderRadius: 16, borderRadius: 16,
padding: 16, padding: 16,

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Dimensions, Platform, Alert, Image } from 'react-native'; import { View, Text, StyleSheet, TouchableOpacity, Platform, Alert, Image, useWindowDimensions } from 'react-native';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import * as WebBrowser from 'expo-web-browser'; import * as WebBrowser from 'expo-web-browser';
import { Trans, useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
@@ -14,8 +14,6 @@ import { isTraditionalChineseLocaleTag } from '@/src/i18n/locale';
import FlowersBg from '../../assets/images/index/flowers_endbg.svg'; import FlowersBg from '../../assets/images/index/flowers_endbg.svg';
import WelcomeBtn from '../../assets/images/index/welcome_btn.svg'; import WelcomeBtn from '../../assets/images/index/welcome_btn.svg';
const { width, height } = Dimensions.get('window');
// 繁中開屏 consent 文案:寫死在元件內,避免 Metro/iOS bundle 快取導致永遠顯示舊文案。 // 繁中開屏 consent 文案:寫死在元件內,避免 Metro/iOS bundle 快取導致永遠顯示舊文案。
// 若需修改,請改這裡並同步 client/src/i18n/locales/zh-TW.json 的 consent 區塊。 // 若需修改,請改這裡並同步 client/src/i18n/locales/zh-TW.json 的 consent 區塊。
const ZH_TW_CONSENT = { const ZH_TW_CONSENT = {
@@ -27,7 +25,9 @@ const ZH_TW_CONSENT = {
export default function SplashScreen() { export default function SplashScreen() {
const router = useRouter(); const router = useRouter();
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { width, height } = useWindowDimensions();
const [showConsent, setShowConsent] = useState(false); const [showConsent, setShowConsent] = useState(false);
const isTablet = Platform.OS === 'ios' && Math.min(width, height) >= 768;
// 繁中時強制使用上方常數(含 zh-TW / zh-Hant / zh-Hant-TW其餘用 i18n // 繁中時強制使用上方常數(含 zh-TW / zh-Hant / zh-Hant-TW其餘用 i18n
const isZhTW = isTraditionalChineseLocaleTag(i18n.language || ''); const isZhTW = isTraditionalChineseLocaleTag(i18n.language || '');
@@ -127,52 +127,67 @@ export default function SplashScreen() {
void refreshLegalLinks(); void refreshLegalLinks();
}, []); }, []);
const bgDecorationTop = 363; const bgDecorationHeight = isTablet ? Math.round(height * 0.55) : height * 0.6;
const bgDecorationHeight = height * 0.6; const bgDecorationTop = height - bgDecorationHeight;
const contentTop = bgDecorationTop + (bgDecorationHeight * 0.25); const contentTop = bgDecorationTop + (bgDecorationHeight * 0.25);
const contentWidth = isTablet ? Math.min(640, Math.floor(width * 0.76)) : width;
const topImageWidth = isTablet ? Math.min(430, Math.floor(width * 0.48)) : 308;
const topImageHeight = isTablet ? Math.min(460, Math.floor(height * 0.45)) : 354;
const topImageMarginTop = isTablet ? 148 : 60;
const bgWidth = width + 10;
const bgLeft = -3;
const buttonSize = isTablet ? { width: 108, height: 70 } : { width: 87, height: 57 };
const bottomOffset = isTablet ? 28 : 60;
const buttonBottomGap = isTablet ? 28 : 40;
const noticeStyle = isTablet
? { fontSize: 14, lineHeight: 20, paddingHorizontal: 32, maxWidth: contentWidth }
: null;
const noticeLinkStyle = isTablet ? { fontSize: 14 } : null;
const titleStyle = isTablet ? { fontSize: 50, lineHeight: 60 } : null;
const subtitleStyle = isTablet ? { marginTop: 10, fontSize: 18, lineHeight: 24 } : null;
return ( return (
<View style={styles.container}> <View style={styles.container}>
{/* 中间的背景装饰 SVG (现在放在上面,作为上层) */} {/* 中间的背景装饰 SVG (现在放在上面,作为上层) */}
<View style={[styles.bgDecorationContainer, { top: bgDecorationTop }]}> <View style={[styles.bgDecorationContainer, { bottom: 0, left: bgLeft }]}>
<FlowersBg width={width + 10} height={bgDecorationHeight} /> <FlowersBg width={bgWidth} height={bgDecorationHeight} preserveAspectRatio="none" />
</View> </View>
{/* 顶部的花图片 (现在放在下面,作为下层) */} {/* 顶部的花图片 (现在放在下面,作为下层) */}
<View style={styles.topImageContainer}> <View style={[styles.topImageContainer, { marginTop: topImageMarginTop }]}>
<Image <Image
source={require('../../assets/images/index/index_flowers.png')} source={require('../../assets/images/index/index_flowers.png')}
style={styles.topImage} style={[styles.topImage, { width: topImageWidth, height: topImageHeight }]}
resizeMode="contain" resizeMode="contain"
/> />
</View> </View>
{/* 文案内容:主標題兩行 + 可選二級標題(字號更小、顏色更淺);繁中為元件內常數,其餘用 i18n */} {/* 文案内容:主標題兩行 + 可選二級標題(字號更小、顏色更淺);繁中為元件內常數,其餘用 i18n */}
<View style={[styles.contentContainer, { position: 'absolute', top: contentTop }]}> <View style={[styles.contentContainer, { position: 'absolute', top: contentTop, width: contentWidth }]}>
<Text style={styles.titleText}> <Text style={[styles.titleText, titleStyle]}>
{title} {title}
{'\n'} {'\n'}
{subtitle} {subtitle}
</Text> </Text>
{subtitleSecondary ? ( {subtitleSecondary ? (
<Text style={styles.consentSubtitleSecondary}>{subtitleSecondary}</Text> <Text style={[styles.consentSubtitleSecondary, subtitleStyle]}>{subtitleSecondary}</Text>
) : null} ) : null}
</View> </View>
<SafeAreaView style={styles.bottomContainer} edges={['bottom']}> <SafeAreaView style={[styles.bottomContainer, { bottom: bottomOffset, width: contentWidth }]} edges={['bottom']}>
{showConsent && ( {showConsent && (
<> <>
<TouchableOpacity <TouchableOpacity
onPress={handleAgree} onPress={handleAgree}
activeOpacity={0.8} activeOpacity={0.8}
style={styles.buttonWrapper} style={[styles.buttonWrapper, { marginBottom: buttonBottomGap }]}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={t('consent.agree')} accessibilityLabel={t('consent.agree')}
> >
<WelcomeBtn width={87} height={57} /> <WelcomeBtn width={buttonSize.width} height={buttonSize.height} />
</TouchableOpacity> </TouchableOpacity>
<Text style={styles.noticeText}> <Text style={[styles.noticeText, noticeStyle]}>
<Trans <Trans
i18nKey="consent.noticeRich" i18nKey="consent.noticeRich"
values={{ values={{
@@ -184,14 +199,14 @@ export default function SplashScreen() {
components={{ components={{
privacy: ( privacy: (
<Text <Text
style={[styles.noticeLinkText, !links.privacy && styles.noticeLinkTextDisabled]} style={[styles.noticeLinkText, noticeLinkStyle, !links.privacy && styles.noticeLinkTextDisabled]}
onPress={() => void handleOpenLegal('privacy')} onPress={() => void handleOpenLegal('privacy')}
suppressHighlighting suppressHighlighting
/> />
), ),
terms: ( terms: (
<Text <Text
style={[styles.noticeLinkText, !links.terms && styles.noticeLinkTextDisabled]} style={[styles.noticeLinkText, noticeLinkStyle, !links.terms && styles.noticeLinkTextDisabled]}
onPress={() => void handleOpenLegal('terms')} onPress={() => void handleOpenLegal('terms')}
suppressHighlighting suppressHighlighting
/> />
@@ -209,11 +224,13 @@ export default function SplashScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
width: '100%',
height: '100%',
alignSelf: 'stretch',
backgroundColor: '#F5D3B5', // 匹配 Figma 背景色 backgroundColor: '#F5D3B5', // 匹配 Figma 背景色
alignItems: 'center', alignItems: 'center',
}, },
topImageContainer: { topImageContainer: {
marginTop: 60,
zIndex: 1, // 降低层级 zIndex: 1, // 降低层级
}, },
topImage: { topImage: {
@@ -222,7 +239,6 @@ const styles = StyleSheet.create({
}, },
bgDecorationContainer: { bgDecorationContainer: {
position: 'absolute', position: 'absolute',
left: -3,
zIndex: 2, // 提高层级,使其覆盖在图片之上 zIndex: 2, // 提高层级,使其覆盖在图片之上
}, },
contentContainer: { contentContainer: {
@@ -247,8 +263,6 @@ const styles = StyleSheet.create({
}, },
bottomContainer: { bottomContainer: {
position: 'absolute', position: 'absolute',
bottom: 60,
width: '100%',
alignItems: 'center', alignItems: 'center',
zIndex: 4, zIndex: 4,
}, },

View File

@@ -1,18 +1,25 @@
import { Link, Stack } from 'expo-router'; import { Link, Stack } from 'expo-router';
import { StyleSheet } from 'react-native'; import { StyleSheet, useWindowDimensions } from 'react-native';
import { Text, View } from '@/components/Themed'; import { Text, View } from '@/components/Themed';
import { clampContentWidth, isIPadLike } from '@/src/utils/device';
export default function NotFoundScreen() { export default function NotFoundScreen() {
const { width, height } = useWindowDimensions();
const isTablet = isIPadLike(width, height);
const contentWidth = isTablet ? clampContentWidth(width, 680, 24) : undefined;
return ( return (
<> <>
<Stack.Screen options={{ title: 'Oops!' }} /> <Stack.Screen options={{ title: 'Oops!' }} />
<View style={styles.container}> <View style={styles.container}>
<Text style={styles.title}>This screen doesn't exist.</Text> <View style={[styles.contentWrap, contentWidth ? { width: contentWidth } : null]}>
<Text style={styles.title}>This screen doesn't exist.</Text>
<Link href="/" style={styles.link}> <Link href="/" style={styles.link}>
<Text style={styles.linkText}>Go to home screen!</Text> <Text style={styles.linkText}>Go to home screen!</Text>
</Link> </Link>
</View>
</View> </View>
</> </>
); );
@@ -24,6 +31,12 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
padding: 20, padding: 20,
width: '100%',
alignSelf: 'stretch',
},
contentWrap: {
width: '100%',
alignItems: 'center',
}, },
title: { title: {
fontSize: 20, fontSize: 20,

View File

@@ -1,7 +1,7 @@
import FontAwesome from '@expo/vector-icons/FontAwesome'; import FontAwesome from '@expo/vector-icons/FontAwesome';
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'; import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { useFonts } from 'expo-font'; import { useFonts } from 'expo-font';
import { Stack } from 'expo-router'; import { Stack, useRouter } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen'; import * as SplashScreen from 'expo-splash-screen';
import * as Notifications from 'expo-notifications'; import * as Notifications from 'expo-notifications';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -11,7 +11,8 @@ import { Animated, AppState, Image, StyleSheet, View } from 'react-native';
import { useColorScheme } from '@/components/useColorScheme'; import { useColorScheme } from '@/components/useColorScheme';
import { initI18n } from '@/src/i18n'; import { initI18n } from '@/src/i18n';
import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromStorage } from '@/src/modules/dailyWidgetReco'; import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromStorage } from '@/src/modules/dailyWidgetReco';
import { getOrCreateClientUserId } from '@/src/storage/appStorage'; import { getConsentAccepted, getOnboardingCompleted, getOrCreateClientUserId } from '@/src/storage/appStorage';
import { persistHomePushMessageFromResponse } from '@/src/services/pushNotificationRoute';
import { ensurePushTokenRegisteredIfPermitted } from '@/src/services/pushApi'; import { ensurePushTokenRegisteredIfPermitted } from '@/src/services/pushApi';
// 配置通知处理方式(即使不发送也建议配置,以确保权限接口正常) // 配置通知处理方式(即使不发送也建议配置,以确保权限接口正常)
@@ -149,6 +150,7 @@ export default function RootLayout() {
function RootLayoutNav() { function RootLayoutNav() {
const colorScheme = useColorScheme(); const colorScheme = useColorScheme();
const router = useRouter();
useEffect(() => { useEffect(() => {
// iOS 小组件:启动时把必要信息写入共享区,并尽力刷新一次“每日推荐” // iOS 小组件:启动时把必要信息写入共享区,并尽力刷新一次“每日推荐”
@@ -165,6 +167,44 @@ function RootLayoutNav() {
return () => sub.remove(); return () => sub.remove();
}, []); }, []);
const handleNotificationResponse = useCallback(
async (response: Notifications.NotificationResponse) => {
const message = await persistHomePushMessageFromResponse(response);
if (!message) return;
const [consentAccepted, onboardingCompleted] = await Promise.all([
getConsentAccepted(),
getOnboardingCompleted(),
]);
if (consentAccepted && onboardingCompleted) {
router.replace('/(app)/home');
}
},
[router]
);
useEffect(() => {
let cancelled = false;
Notifications.getLastNotificationResponseAsync()
.then((response) => {
if (cancelled || !response) return;
return handleNotificationResponse(response);
})
.catch(() => {
// ignore通知冷启动读取失败不阻塞主流程
});
const sub = Notifications.addNotificationResponseReceivedListener((response) => {
void handleNotificationResponse(response);
});
return () => {
cancelled = true;
sub.remove();
};
}, [handleNotificationResponse]);
return ( return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}> <ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack screenOptions={{ headerShown: false }}> <Stack screenOptions={{ headerShown: false }}>

View File

@@ -1,14 +1,18 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { ActivityIndicator, StyleSheet, View } from 'react-native'; import { ActivityIndicator, StyleSheet, View, useWindowDimensions } from 'react-native';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { getOnboardingCompleted, getConsentAccepted } from '@/src/storage/appStorage'; import { getOnboardingCompleted, getConsentAccepted } from '@/src/storage/appStorage';
import { clampContentWidth, isIPadLike } from '@/src/utils/device';
/** /**
* 启动分发:根据 consent 和 onboarding 状态跳转 * 启动分发:根据 consent 和 onboarding 状态跳转
*/ */
export default function Index() { export default function Index() {
const router = useRouter(); const router = useRouter();
const { width, height } = useWindowDimensions();
const isTablet = isIPadLike(width, height);
const loaderWidth = isTablet ? clampContentWidth(width, 680, 24) : undefined;
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -41,11 +45,24 @@ export default function Index() {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<ActivityIndicator /> <View style={[styles.loaderWrap, loaderWidth ? { width: loaderWidth } : null]}>
<ActivityIndicator />
</View>
</View> </View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, alignItems: 'center', justifyContent: 'center' }, container: {
flex: 1,
width: '100%',
height: '100%',
alignSelf: 'stretch',
alignItems: 'center',
justifyContent: 'center',
},
loaderWrap: {
alignItems: 'center',
justifyContent: 'center',
},
}); });

View File

@@ -1,15 +1,22 @@
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import { Platform, StyleSheet } from 'react-native'; import { Platform, StyleSheet, useWindowDimensions } from 'react-native';
import EditScreenInfo from '@/components/EditScreenInfo'; import EditScreenInfo from '@/components/EditScreenInfo';
import { Text, View } from '@/components/Themed'; import { Text, View } from '@/components/Themed';
import { clampContentWidth, isIPadLike } from '@/src/utils/device';
export default function ModalScreen() { export default function ModalScreen() {
const { width, height } = useWindowDimensions();
const isTablet = isIPadLike(width, height);
const contentWidth = isTablet ? clampContentWidth(width, 700, 24) : undefined;
return ( return (
<View style={styles.container}> <View style={styles.container}>
<Text style={styles.title}>Modal</Text> <View style={[styles.contentWrap, contentWidth ? { width: contentWidth } : null]}>
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" /> <Text style={styles.title}>Modal</Text>
<EditScreenInfo path="app/modal.tsx" /> <View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
<EditScreenInfo path="app/modal.tsx" />
</View>
{/* Use a light status bar on iOS to account for the black space above the modal */} {/* Use a light status bar on iOS to account for the black space above the modal */}
<StatusBar style={Platform.OS === 'ios' ? 'light' : 'auto'} /> <StatusBar style={Platform.OS === 'ios' ? 'light' : 'auto'} />
@@ -22,6 +29,12 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
width: '100%',
alignSelf: 'stretch',
},
contentWrap: {
width: '100%',
alignItems: 'center',
}, },
title: { title: {
fontSize: 20, fontSize: 20,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useState, useRef, useCallback } from 'react'; import React, { useEffect, useMemo, useState, useRef, useCallback } from 'react';
import { Alert, FlatList, Image, Pressable, StyleSheet, Text, View, Platform, Dimensions } from 'react-native'; import { Alert, FlatList, Image, Pressable, StyleSheet, Text, View, Platform, useWindowDimensions } from 'react-native';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { Switch } from 'react-native'; import { Switch } from 'react-native';
@@ -42,8 +42,7 @@ import * as Notifications from 'expo-notifications';
import { changeLanguage } from '@/src/i18n'; import { changeLanguage } from '@/src/i18n';
import { fetchLegalLinks } from '@/src/services/legalApi'; import { fetchLegalLinks } from '@/src/services/legalApi';
import { ensurePushTokenRegisteredIfPermitted, setPushPreferences } from '@/src/services/pushApi'; import { ensurePushTokenRegisteredIfPermitted, setPushPreferences } from '@/src/services/pushApi';
import { isIPadLike } from '@/src/utils/device';
const { width } = Dimensions.get('window');
type Props = { type Props = {
visible: boolean; visible: boolean;
@@ -79,6 +78,12 @@ const NATURE_IMAGES = [
export default function ProfileModal({ visible, name: propName, onClose }: Props) { export default function ProfileModal({ visible, name: propName, onClose }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const { width, height } = useWindowDimensions();
const isTablet = isIPadLike(width, height);
const contentWidth = isTablet ? width - 40 : width - 40;
const thumbWidth = isTablet ? Math.min(420, contentWidth - 120) : Math.min(width * 0.6, 300);
const widgetImageWidth = isTablet ? Math.min(520, contentWidth - 24) : width * 0.9;
const howToSlideWidth = isTablet ? Math.min(640, contentWidth) : width - 32;
const [page, setPage] = useState<Page>('root'); const [page, setPage] = useState<Page>('root');
const [navDirection, setNavDirection] = useState<NavDirection>('forward'); const [navDirection, setNavDirection] = useState<NavDirection>('forward');
@@ -192,6 +197,7 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
{page === 'root' ? ( {page === 'root' ? (
<RootPage <RootPage
name={currentName} name={currentName}
contentWidth={contentWidth}
onOpenFavorites={() => go('favorites', 'forward')} onOpenFavorites={() => go('favorites', 'forward')}
onOpenWidget={() => go('widget', 'forward')} onOpenWidget={() => go('widget', 'forward')}
onOpenDailyReminder={() => go('dailyReminder', 'forward')} onOpenDailyReminder={() => go('dailyReminder', 'forward')}
@@ -200,15 +206,15 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
onOpenTerms={() => openLink(legalLinks.terms)} onOpenTerms={() => openLink(legalLinks.terms)}
/> />
) : page === 'favorites' ? ( ) : page === 'favorites' ? (
<FavoritesPage visible={visible} page={page} /> <FavoritesPage visible={visible} page={page} thumbWidth={thumbWidth} contentWidth={contentWidth} />
) : page === 'dailyReminder' ? ( ) : page === 'dailyReminder' ? (
<DailyReminderPage visible={visible} onDone={() => go('root', 'back')} /> <DailyReminderPage visible={visible} onDone={() => go('root', 'back')} contentWidth={contentWidth} />
) : page === 'language' ? ( ) : page === 'language' ? (
<LanguagePage /> <LanguagePage contentWidth={contentWidth} />
) : page === 'widgetHowTo' ? ( ) : page === 'widgetHowTo' ? (
<WidgetHowToPage /> <WidgetHowToPage howToSlideWidth={howToSlideWidth} widgetImageWidth={widgetImageWidth} />
) : ( ) : (
<WidgetPage onOpenHowTo={() => go('widgetHowTo', 'forward')} /> <WidgetPage onOpenHowTo={() => go('widgetHowTo', 'forward')} widgetImageWidth={widgetImageWidth} />
)} )}
</Animated.View> </Animated.View>
</View> </View>
@@ -222,6 +228,7 @@ function toastTodo(t: (key: string) => string) {
function RootPage({ function RootPage({
name, name,
contentWidth,
onOpenFavorites, onOpenFavorites,
onOpenWidget, onOpenWidget,
onOpenDailyReminder, onOpenDailyReminder,
@@ -230,6 +237,7 @@ function RootPage({
onOpenTerms, onOpenTerms,
}: { }: {
name?: string; name?: string;
contentWidth: number;
onOpenFavorites: () => void; onOpenFavorites: () => void;
onOpenWidget: () => void; onOpenWidget: () => void;
onOpenDailyReminder: () => void; onOpenDailyReminder: () => void;
@@ -239,7 +247,7 @@ function RootPage({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <View style={[styles.sectionWrap, { width: contentWidth }]}>
<View style={styles.header}> <View style={styles.header}>
<AvatarIcon width={234} height={183} /> <AvatarIcon width={234} height={183} />
<Text style={styles.name}>{name || 'Hali'}</Text> <Text style={styles.name}>{name || 'Hali'}</Text>
@@ -276,11 +284,23 @@ function RootPage({
onPress={onOpenLanguage} onPress={onOpenLanguage}
/> />
</View> </View>
</>
<Text style={styles.versionText}>V1.0.0</Text>
</View>
); );
} }
function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) { function FavoritesPage({
visible,
page,
thumbWidth,
contentWidth,
}: {
visible: boolean;
page: Page;
thumbWidth: number;
contentWidth: number;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const [favorites, setFavorites] = useState<(FavoriteItem & { text: string })[]>([]); const [favorites, setFavorites] = useState<(FavoriteItem & { text: string })[]>([]);
@@ -317,7 +337,7 @@ function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) {
} }
return ( return (
<View style={styles.favContainer}> <View style={[styles.favContainer, { width: contentWidth, alignSelf: 'center' }]}>
{favorites.length === 0 ? ( {favorites.length === 0 ? (
<Text style={styles.favEmpty}>{t('favorites.empty')}</Text> <Text style={styles.favEmpty}>{t('favorites.empty')}</Text>
) : ( ) : (
@@ -339,6 +359,7 @@ function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) {
<View style={styles.favRight}> <View style={styles.favRight}>
<View style={[ <View style={[
styles.favThumb, styles.favThumb,
{ width: thumbWidth },
item.themeMode === 'scenery' ? {} : { backgroundColor: item.background } item.themeMode === 'scenery' ? {} : { backgroundColor: item.background }
]}> ]}>
{item.themeMode === 'scenery' ? ( {item.themeMode === 'scenery' ? (
@@ -346,7 +367,7 @@ function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) {
<Image <Image
source={NATURE_IMAGES[parseInt(item.background)]} source={NATURE_IMAGES[parseInt(item.background)]}
style={{ style={{
width: width * 0.6, width: thumbWidth,
height: 800, // 假设原图较高,设置一个较大的高度 height: 800, // 假设原图较高,设置一个较大的高度
position: 'absolute', position: 'absolute',
bottom: 0, // 关键:将图片底部对齐容器底部 bottom: 0, // 关键:将图片底部对齐容器底部
@@ -378,7 +399,15 @@ function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) {
); );
} }
function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () => void }) { function DailyReminderPage({
visible,
onDone,
contentWidth,
}: {
visible: boolean;
onDone: () => void;
contentWidth: number;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [timesPerDay, setTimesPerDay] = useState(3); const [timesPerDay, setTimesPerDay] = useState(3);
@@ -525,7 +554,7 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
</Pressable> </Pressable>
</View> </View>
<View style={styles.remindRow}> <View style={[styles.remindRow, { width: contentWidth }]}>
<View style={styles.rowLeft}> <View style={styles.rowLeft}>
<View style={styles.rowIcon}> <View style={styles.rowIcon}>
<RemindIcon width={18} height={18} /> <RemindIcon width={18} height={18} />
@@ -556,7 +585,7 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
); );
} }
function WidgetPage({ onOpenHowTo }: { onOpenHowTo: () => void }) { function WidgetPage({ onOpenHowTo, widgetImageWidth }: { onOpenHowTo: () => void; widgetImageWidth: number }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const currentLang = i18n.language; const currentLang = i18n.language;
// 需求:个人主页弹窗「小工具」页暂时隐藏锁屏小工具说明/入口 // 需求:个人主页弹窗「小工具」页暂时隐藏锁屏小工具说明/入口
@@ -580,13 +609,13 @@ function WidgetPage({ onOpenHowTo }: { onOpenHowTo: () => void }) {
<View style={styles.widgetScroll}> <View style={styles.widgetScroll}>
{showLockScreenWidget ? ( {showLockScreenWidget ? (
<Pressable style={styles.widgetItem} onPress={onOpenHowTo}> <Pressable style={styles.widgetItem} onPress={onOpenHowTo}>
<Image source={widget1} style={styles.widgetImg1} resizeMode="contain" /> <Image source={widget1} style={[styles.widgetImg1, { width: widgetImageWidth, height: widgetImageWidth * (156 / 311) }]} resizeMode="contain" />
<Text style={styles.widgetLabel}>{t('widget.lockScreen')}</Text> <Text style={styles.widgetLabel}>{t('widget.lockScreen')}</Text>
</Pressable> </Pressable>
) : null} ) : null}
<Pressable style={styles.widgetItem} onPress={onOpenHowTo}> <Pressable style={styles.widgetItem} onPress={onOpenHowTo}>
<Image source={widget2} style={styles.widgetImg2} resizeMode="contain" /> <Image source={widget2} style={[styles.widgetImg2, { width: widgetImageWidth, height: widgetImageWidth * (175 / 311) }]} resizeMode="contain" />
<Text style={styles.widgetLabel}>{t('widget.homeScreen')}</Text> <Text style={styles.widgetLabel}>{t('widget.homeScreen')}</Text>
</Pressable> </Pressable>
</View> </View>
@@ -594,7 +623,7 @@ function WidgetPage({ onOpenHowTo }: { onOpenHowTo: () => void }) {
); );
} }
function WidgetHowToPage() { function WidgetHowToPage({ howToSlideWidth, widgetImageWidth }: { howToSlideWidth: number; widgetImageWidth: number }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const currentLang = i18n.language; const currentLang = i18n.language;
const flatListRef = useRef<FlatList>(null); const flatListRef = useRef<FlatList>(null);
@@ -631,7 +660,7 @@ function WidgetHowToPage() {
const onScroll = (event: any) => { const onScroll = (event: any) => {
const x = event.nativeEvent.contentOffset.x; const x = event.nativeEvent.contentOffset.x;
const index = Math.round(x / (width - 32)); const index = Math.round(x / howToSlideWidth);
if (index !== activeIndex) { if (index !== activeIndex) {
setActiveIndex(index); setActiveIndex(index);
} }
@@ -655,14 +684,14 @@ function WidgetHowToPage() {
onScrollBeginDrag={onScrollBeginDrag} onScrollBeginDrag={onScrollBeginDrag}
scrollEventThrottle={16} scrollEventThrottle={16}
renderItem={({ item }) => ( renderItem={({ item }) => (
<View style={styles.howToSlide}> <View style={[styles.howToSlide, { width: howToSlideWidth }]}>
<Image source={item.src} style={styles.howToImg} resizeMode="contain" /> <Image source={item.src} style={[styles.howToImg, { width: widgetImageWidth, height: widgetImageWidth * (234 / 326) }]} resizeMode="contain" />
<Text style={styles.howToDesc}>{item.desc}</Text> <Text style={styles.howToDesc}>{item.desc}</Text>
</View> </View>
)} )}
/> />
<View style={styles.pagination}> <View style={[styles.pagination, { top: widgetImageWidth * (234 / 326) + 35 }]}>
{images.map((_, i) => ( {images.map((_, i) => (
<View <View
key={i} key={i}
@@ -677,7 +706,7 @@ function WidgetHowToPage() {
); );
} }
function LanguagePage() { function LanguagePage({ contentWidth }: { contentWidth: number }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const currentLang = i18n.language; const currentLang = i18n.language;
@@ -687,8 +716,8 @@ function LanguagePage() {
]; ];
return ( return (
<View style={styles.langPage}> <View style={[styles.langPage, { width: contentWidth, alignSelf: 'center' }]}>
<View style={styles.langList}> <View style={[styles.langList, { width: contentWidth }]}>
{languages.map((lang, index) => ( {languages.map((lang, index) => (
<Pressable <Pressable
key={lang.id} key={lang.id}
@@ -750,6 +779,9 @@ const styles = StyleSheet.create({
pageWrap: { pageWrap: {
// 给页面切换动画一个稳定的容器,避免布局抖动 // 给页面切换动画一个稳定的容器,避免布局抖动
}, },
sectionWrap: {
alignSelf: 'center',
},
backRow: { backRow: {
alignSelf: 'flex-start', alignSelf: 'flex-start',
paddingVertical: 4, paddingVertical: 4,
@@ -806,6 +838,13 @@ const styles = StyleSheet.create({
overflow: 'hidden', overflow: 'hidden',
marginBottom: 8, marginBottom: 8,
}, },
versionText: {
marginTop: 12,
textAlign: 'center',
color: 'rgba(94,42,40,0.45)',
fontSize: 12,
fontWeight: '500',
},
item: { item: {
height: 52, height: 52,
paddingHorizontal: 18, paddingHorizontal: 18,
@@ -848,7 +887,7 @@ const styles = StyleSheet.create({
paddingVertical: 100, paddingVertical: 100,
}, },
favList: { favList: {
paddingHorizontal: 20, paddingHorizontal: 8,
paddingBottom: 80, paddingBottom: 80,
}, },
favCard: { favCard: {
@@ -872,7 +911,6 @@ const styles = StyleSheet.create({
backgroundColor: '#FFF4EA', backgroundColor: '#FFF4EA',
borderRadius: 16, borderRadius: 16,
padding: 20, padding: 20,
width: width * 0.6,
height: 161, height: 161,
justifyContent: 'center', justifyContent: 'center',
position: 'relative', position: 'relative',
@@ -945,7 +983,6 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
marginBottom: 18, marginBottom: 18,
width: width - 40, // 屏幕宽度减去左右各 20pt
alignSelf: 'center', alignSelf: 'center',
}, },
rowLeft: { flexDirection: 'row', alignItems: 'center', gap: 10 }, rowLeft: { flexDirection: 'row', alignItems: 'center', gap: 10 },
@@ -1004,12 +1041,12 @@ const styles = StyleSheet.create({
width: '100%', width: '100%',
}, },
widgetImg1: { widgetImg1: {
width: width * 0.9, width: 320,
height: (width * 0.9) * (156 / 311), height: 160,
}, },
widgetImg2: { widgetImg2: {
width: width * 0.9, width: 320,
height: (width * 0.9) * (175 / 311), height: 176,
}, },
widgetLabel: { widgetLabel: {
marginTop: 12, marginTop: 12,
@@ -1023,12 +1060,12 @@ const styles = StyleSheet.create({
paddingTop: 20, paddingTop: 20,
}, },
howToSlide: { howToSlide: {
width: width - 32, // 减去 SheetModal 的 paddingHorizontal: 16 * 2 width: 320,
alignItems: 'center', alignItems: 'center',
}, },
howToImg: { howToImg: {
width: width * 0.9, width: 320,
height: (width * 0.9) * (234 / 326), height: 230,
marginBottom: 40, marginBottom: 40,
}, },
howToDesc: { howToDesc: {
@@ -1042,7 +1079,7 @@ const styles = StyleSheet.create({
pagination: { pagination: {
flexDirection: 'row', flexDirection: 'row',
position: 'absolute', position: 'absolute',
top: (width * 0.9) * (234 / 326) + 35, // 根据新的图片高度动态计算 top: 265,
gap: 8, gap: 8,
}, },
dot: { dot: {
@@ -1064,7 +1101,6 @@ const styles = StyleSheet.create({
backgroundColor: '#FFFFFF', backgroundColor: '#FFFFFF',
borderRadius: 20, borderRadius: 20,
overflow: 'hidden', overflow: 'hidden',
width: width - 40, // 屏幕宽度减去左右各 20pt
alignSelf: 'center', alignSelf: 'center',
}, },
langItem: { langItem: {

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Image, Pressable, StyleSheet, Text, View } from 'react-native'; import { Image, Pressable, StyleSheet, Text, View, Platform, useWindowDimensions } from 'react-native';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import SheetModal from '@/components/ui/SheetModal'; import SheetModal from '@/components/ui/SheetModal';
@@ -15,8 +15,11 @@ type Props = {
export default function ThemeModal({ visible, mode, onSelect, onClose }: Props) { export default function ThemeModal({ visible, mode, onSelect, onClose }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const { width, height } = useWindowDimensions();
const isTablet = Platform.OS === 'ios' && Math.min(width, height) >= 768;
return ( return (
<SheetModal visible={visible} title={t('theme.title')} onClose={onClose} height={360}> <SheetModal visible={visible} title={t('theme.title')} onClose={onClose} height={isTablet ? 560 : 360}>
<View style={styles.row}> <View style={styles.row}>
<ThemeCard <ThemeCard
title={t('theme.scenery')} title={t('theme.scenery')}
@@ -100,9 +103,9 @@ const styles = StyleSheet.create({
row: { row: {
flexDirection: 'row', flexDirection: 'row',
flexWrap: 'nowrap', flexWrap: 'nowrap',
gap: 12, gap: 8,
paddingHorizontal: 4, paddingHorizontal: 0,
paddingBottom: 50, paddingBottom: 22,
paddingTop: 20, paddingTop: 20,
justifyContent: 'space-between', justifyContent: 'space-between',
}, },

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { View, StyleSheet, TextInput, Platform, Animated, TouchableOpacity, Text, Keyboard, Pressable } from 'react-native'; import { View, StyleSheet, TextInput, Platform, Animated, TouchableOpacity, Text, Keyboard, Pressable, useWindowDimensions } from 'react-native';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { OnboardingColors } from '@/constants/OnboardingTheme'; import { OnboardingColors } from '@/constants/OnboardingTheme';
@@ -16,10 +16,13 @@ interface NameInputStepProps {
export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProps) { export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { width, height } = useWindowDimensions();
const isTablet = Platform.OS === 'ios' && Math.min(width, height) >= 768;
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const [keyboardHeight, setKeyboardHeight] = useState(0); const [keyboardHeight, setKeyboardHeight] = useState(0);
const blinkAnim = useRef(new Animated.Value(1)).current; const blinkAnim = useRef(new Animated.Value(1)).current;
const hasInput = value.trim().length > 0; const hasInput = value.trim().length > 0;
const inputCardWidth = isTablet ? Math.min(520, Math.floor(width * 0.72)) : 335;
useEffect(() => { useEffect(() => {
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
@@ -62,7 +65,7 @@ export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProp
return ( return (
<Pressable style={styles.container} onPress={Keyboard.dismiss} accessible={false}> <Pressable style={styles.container} onPress={Keyboard.dismiss} accessible={false}>
<View style={styles.inputCard}> <View style={[styles.inputCard, { width: inputCardWidth }]}>
<View style={styles.inputWrapper}> <View style={styles.inputWrapper}>
{/* 显示层:文案 + 跟随的光标 */} {/* 显示层:文案 + 跟随的光标 */}
<View style={styles.displayLayer}> <View style={styles.displayLayer}>

View File

@@ -1,5 +1,5 @@
import React, { useRef, useEffect } from 'react'; import React, { useRef, useEffect } from 'react';
import { View, StyleSheet, SafeAreaView, TouchableOpacity, StatusBar, Text, Image, Platform, Animated, Easing } from 'react-native'; import { View, StyleSheet, SafeAreaView, TouchableOpacity, StatusBar, Text, Image, Platform, Animated, Easing, useWindowDimensions } from 'react-native';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { OnboardingColors, OnboardingFont } from '@/constants/OnboardingTheme'; import { OnboardingColors, OnboardingFont } from '@/constants/OnboardingTheme';
@@ -29,6 +29,9 @@ export function OnboardingLayout({
userName = '', userName = '',
}: OnboardingLayoutProps) { }: OnboardingLayoutProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { width, height } = useWindowDimensions();
const isTablet = Platform.OS === 'ios' && Math.min(width, height) >= 768;
const contentMaxWidth = isTablet ? 620 : undefined;
const showGreeting = currentStep === 1 && userName.trim().length > 0; const showGreeting = currentStep === 1 && userName.trim().length > 0;
const displayName = userName.trim(); const displayName = userName.trim();
const prevStepRef = useRef(currentStep); const prevStepRef = useRef(currentStep);
@@ -72,7 +75,7 @@ export function OnboardingLayout({
<StatusBar barStyle="dark-content" /> <StatusBar barStyle="dark-content" />
<SafeAreaView style={styles.safeArea}> <SafeAreaView style={styles.safeArea}>
{/* Header: Back & Skip */} {/* Header: Back & Skip */}
<View style={styles.header}> <View style={[styles.header, contentMaxWidth ? { maxWidth: contentMaxWidth, width: '100%', alignSelf: 'center' } : null]}>
<View style={styles.headerLeft}> <View style={styles.headerLeft}>
{showBackButton && onBack && ( {showBackButton && onBack && (
<TouchableOpacity onPress={onBack} style={styles.iconButton}> <TouchableOpacity onPress={onBack} style={styles.iconButton}>
@@ -94,7 +97,7 @@ export function OnboardingLayout({
</View> </View>
{/* Title & Progress Row名字步骤后第一步且名字非空时显示招呼语 + 问题) */} {/* Title & Progress Row名字步骤后第一步且名字非空时显示招呼语 + 问题) */}
<View style={styles.titleRow}> <View style={[styles.titleRow, contentMaxWidth ? { maxWidth: contentMaxWidth, width: '100%', alignSelf: 'center' } : null]}>
<View style={styles.titleBlock}> <View style={styles.titleBlock}>
{showGreeting && ( {showGreeting && (
<Text style={styles.greetingText}>{t('onboardingSurvey.greeting', { name: displayName })}</Text> <Text style={styles.greetingText}>{t('onboardingSurvey.greeting', { name: displayName })}</Text>
@@ -108,6 +111,7 @@ export function OnboardingLayout({
<Animated.View <Animated.View
style={[ style={[
styles.content, styles.content,
contentMaxWidth ? { maxWidth: contentMaxWidth, width: '100%', alignSelf: 'center' } : null,
{ {
opacity, opacity,
transform: [{ translateX }], transform: [{ translateX }],

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { View, StyleSheet, TouchableOpacity, Text, Platform, ActivityIndicator } from 'react-native'; import { View, StyleSheet, TouchableOpacity, Text, Platform, ActivityIndicator, useWindowDimensions } from 'react-native';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
@@ -20,6 +20,9 @@ interface ReminderStepProps {
export function ReminderStep({ value, onChange, onFinish, loading = false }: ReminderStepProps) { export function ReminderStep({ value, onChange, onFinish, loading = false }: ReminderStepProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { width, height } = useWindowDimensions();
const isTablet = Platform.OS === 'ios' && Math.min(width, height) >= 768;
const contentMaxWidth = isTablet ? 560 : undefined;
const handleReduce = () => { const handleReduce = () => {
// 本页最小为 1不接收提醒请使用右上角 Skip // 本页最小为 1不接收提醒请使用右上角 Skip
@@ -32,7 +35,7 @@ export function ReminderStep({ value, onChange, onFinish, loading = false }: Rem
return ( return (
<View style={styles.container}> <View style={styles.container}>
<View style={styles.counterContainer}> <View style={[styles.counterContainer, contentMaxWidth ? { maxWidth: contentMaxWidth } : null]}>
<TouchableOpacity onPress={handleReduce} disabled={loading} activeOpacity={0.7}> <TouchableOpacity onPress={handleReduce} disabled={loading} activeOpacity={0.7}>
<ReduceIcon width={47} height={47} /> <ReduceIcon width={47} height={47} />
</TouchableOpacity> </TouchableOpacity>

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { View, StyleSheet, TouchableOpacity, ScrollView, Text } from 'react-native'; import { View, StyleSheet, TouchableOpacity, ScrollView, Text, Platform, useWindowDimensions } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { OnboardingColors, OnboardingFont } from '@/constants/OnboardingTheme'; import { OnboardingColors, OnboardingFont } from '@/constants/OnboardingTheme';
import BtnNotClicked from '@/assets/images/icon/btn_Notclicked.svg'; import BtnNotClicked from '@/assets/images/icon/btn_Notclicked.svg';
@@ -19,9 +19,12 @@ interface SelectionStepProps {
} }
export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip }: SelectionStepProps) { export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip }: SelectionStepProps) {
const { width, height } = useWindowDimensions();
const isTablet = Platform.OS === 'ios' && Math.min(width, height) >= 768;
const maxOptionWidth = isTablet ? 560 : undefined;
const hasSelection = selectedIds.length > 0; const hasSelection = selectedIds.length > 0;
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const footerBottom = insets.bottom + 16; const footerBottom = insets.bottom + (isTablet ? 38 : 28);
const footerButtonHeight = 57; const footerButtonHeight = 57;
// 底部留白加大,避免最后一项与按钮边框视觉重叠 // 底部留白加大,避免最后一项与按钮边框视觉重叠
const footerPaddingBottom = footerBottom + footerButtonHeight + 40; const footerPaddingBottom = footerBottom + footerButtonHeight + 40;
@@ -31,14 +34,24 @@ export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip }
<ScrollView <ScrollView
style={styles.scroll} style={styles.scroll}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
contentContainerStyle={[styles.optionsList, { paddingBottom: footerPaddingBottom }]} contentContainerStyle={[
styles.optionsList,
{
paddingBottom: footerPaddingBottom,
alignItems: 'center',
},
]}
> >
{options.map((option) => { {options.map((option) => {
const isSelected = selectedIds.includes(option.id); const isSelected = selectedIds.includes(option.id);
return ( return (
<TouchableOpacity <TouchableOpacity
key={option.id} key={option.id}
style={[styles.optionCard, isSelected && styles.optionCardSelected]} style={[
styles.optionCard,
maxOptionWidth ? { maxWidth: maxOptionWidth } : null,
isSelected && styles.optionCardSelected,
]}
onPress={() => onToggle(option.id)} onPress={() => onToggle(option.id)}
activeOpacity={0.7} activeOpacity={0.7}
> >

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useState, useRef } from 'react'; import React, { useEffect, useMemo, useState, useRef } from 'react';
import { Modal, Pressable, StyleSheet, Text, View, PanResponder, Animated as RNAnimated, Dimensions, Image, ImageSourcePropType } from 'react-native'; import { Modal, Pressable, StyleSheet, Text, View, PanResponder, Animated as RNAnimated, Image, ImageSourcePropType, Platform, useWindowDimensions } from 'react-native';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Animated, { import Animated, {
@@ -10,7 +10,6 @@ import Animated, {
withTiming, withTiming,
} from 'react-native-reanimated'; } from 'react-native-reanimated';
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
const FIXED_TOP_GAP = 100; // 统一距离顶部的高度 const FIXED_TOP_GAP = 100; // 统一距离顶部的高度
type Props = { type Props = {
@@ -30,11 +29,13 @@ type Props = {
export default function SheetModal({ visible, title, onClose, children, leftIcon, height: customHeight }: Props) { export default function SheetModal({ visible, title, onClose, children, leftIcon, height: customHeight }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const isTablet = Platform.OS === 'ios' && Math.min(windowWidth, windowHeight) >= 768;
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const progress = useSharedValue(0); // 0: 关闭, 1: 打开 const progress = useSharedValue(0); // 0: 关闭, 1: 打开
const dragY = useSharedValue(0); // 拖拽位移 const dragY = useSharedValue(0); // 拖拽位移
const sheetHeight = customHeight || (SCREEN_HEIGHT - FIXED_TOP_GAP); const sheetHeight = customHeight || (isTablet ? Math.min(windowHeight - 72, 760) : (windowHeight - FIXED_TOP_GAP));
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
@@ -90,7 +91,10 @@ export default function SheetModal({ visible, title, onClose, children, leftIcon
}; };
}); });
const containerPaddingBottom = useMemo(() => Math.max(insets.bottom, 80), [insets.bottom]); // 增加底部间距至 80约占 350 高度的 22%,确保内容不被截断并留出足够呼吸感 const containerPaddingBottom = useMemo(() => {
if (customHeight) return Math.max(insets.bottom, 16);
return Math.max(insets.bottom, isTablet ? 20 : 80);
}, [customHeight, insets.bottom, isTablet]);
// 注意Modal 的 visible 必须为 true 才会渲染,因此用 mounted 保持退场动画 // 注意Modal 的 visible 必须为 true 才会渲染,因此用 mounted 保持退场动画
return ( return (
@@ -107,6 +111,7 @@ export default function SheetModal({ visible, title, onClose, children, leftIcon
{...panResponder.panHandlers} {...panResponder.panHandlers}
style={[ style={[
styles.sheet, styles.sheet,
isTablet ? styles.sheetTablet : null,
sheetStyle, sheetStyle,
{ {
height: sheetHeight, height: sheetHeight,
@@ -159,6 +164,14 @@ const styles = StyleSheet.create({
paddingTop: 8, paddingTop: 8,
paddingHorizontal: 16, paddingHorizontal: 16,
}, },
sheetTablet: {
width: '100%',
alignSelf: 'stretch',
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
},
handleContainer: { handleContainer: {
alignItems: 'center', alignItems: 'center',
paddingVertical: 8, paddingVertical: 8,

View File

@@ -2540,30 +2540,30 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon/yoga" :path: "../node_modules/react-native/ReactCommon/yoga"
SPEC CHECKSUMS: SPEC CHECKSUMS:
EXApplication: 1e98d4b1dccdf30627f92917f4b2c5a53c330e5f EXApplication: 13420f8139864183f8a04fd6099077bdf8cfb186
EXConstants: fce59a631a06c4151602843667f7cfe35f81e271 EXConstants: 3feb66fd1d94202fc1f0946d74e029d8b224b60e
EXJSONUtils: 1d3e4590438c3ee593684186007028a14b3686cd EXJSONUtils: 1d3e4590438c3ee593684186007028a14b3686cd
EXManifests: a8d97683e5c7a3b026ffbd58559c64dc655b747b EXManifests: 83ef0844fcf06d6099b12a7bdbd7d36fc0e1dd16
EXNotifications: 9eec98712cc814ceff916d876cb53859003b0597 EXNotifications: 2a3feb7af6194828d9aafda72f63a9a03866230a
Expo: 4e503a041c59c4e34c8be262a135848ad5cd3710 Expo: b8d64eb9a496ebe8c71e3dae7eeb7f394b146b80
expo-dev-client: 425ee077d6754a98cfe3a2e2410d29b440b24c9d expo-dev-client: 12ef7d5b14d93e309922acea78dcd851db583a87
expo-dev-launcher: a4f4cdef064ab1fb8621e5b8c7c457cd6e9568c3 expo-dev-launcher: 47994056008ffdc30a6a5e328a375b3e30a8db05
expo-dev-menu: 05b18812110c175814c6af0d09dd658abcc5e00d expo-dev-menu: ea4fb803ace52e60d7cd8060c7cd379612a140b2
expo-dev-menu-interface: 600df12ea01efecdd822daaf13cc0ac091775533 expo-dev-menu-interface: 600df12ea01efecdd822daaf13cc0ac091775533
ExpoAsset: f867e55ceb428aab99e1e8c082b5aee7c159ea18 ExpoAsset: d999f3bbd998a750f3b74cb913229848901b926b
ExpoCrypto: b6105ebaa15d6b38a811e71e43b52cd934945322 ExpoCrypto: 4d23a9ff67c25e2ed23ca792d81e58817a7ea1b9
ExpoDevice: 6327c3c200816795708885adf540d26ecab83d1a ExpoDevice: 0773c782b055558ca9b40b74aa4a8133a66cd0d2
ExpoFileSystem: 858a44267a3e6e9057e0888ad7c7cfbf55d52063 ExpoFileSystem: aefcd337b94b874f88752ebefc52813b84992fad
ExpoFont: f543ce20a228dd702813668b1a07b46f51878d47 ExpoFont: c625dbd97ed57e9089b172b2a7bb99003d074664
ExpoHead: 4425246bc93411f0fe7f6945f95f698e91db8780 ExpoHead: b691a2ed7ab02ed820b6c6468941832d34969c29
ExpoKeepAwake: 55f75eca6499bb9e4231ebad6f3e9cb8f99c0296 ExpoKeepAwake: 44bf6715bc1d2ddb17afe19d927cd039cda123f0
ExpoLinearGradient: 809102bdb979f590083af49f7fa4805cd931bd58 ExpoLinearGradient: 814a21fc4056c3cf606e4f19e31e47074c5b5a86
ExpoLinking: 8f0aaf69aa56f832913030503b6263dc6f647f37 ExpoLinking: ebf543fd411d56375cb4eee07f6ab4e31c7ad959
ExpoLocalization: d9168d5300a5b03e5e78b986124d11fb6ec3ebbd ExpoLocalization: 6ac6f326210f0a3141ef6f58ab8f8f4ed003b485
ExpoModulesCore: f3da4f1ab5a8375d0beafab763739dbee8446583 ExpoModulesCore: 77496909fd3c800f97f7f2007dd26aeac4bb3798
ExpoSplashScreen: bc3cffefca2716e5f22350ca109badd7e50ec14d ExpoSplashScreen: 72fbc6dd9d6404dd9d0725a56c9ac1383bc0b14f
ExpoWebBrowser: 17b064c621789e41d4816c95c93f429b84971f52 ExpoWebBrowser: 88b116cd378d9609c776c0903fe4070fca461588
EXUpdatesInterface: 5adf50cb41e079c861da6d9b4b954c3db9a50734 EXUpdatesInterface: 1436757deb0d574b84bba063bd024c315e0ec08b
FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12 FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12
hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172 hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172
RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990 RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990
@@ -2571,72 +2571,72 @@ SPEC CHECKSUMS:
RCTTypeSafety: 16a4144ca3f959583ab019b57d5633df10b5e97c RCTTypeSafety: 16a4144ca3f959583ab019b57d5633df10b5e97c
React: 914f8695f9bf38e6418228c2ffb70021e559f92f React: 914f8695f9bf38e6418228c2ffb70021e559f92f
React-callinvoker: 1c0808402aee0c6d4a0d8e7220ce6547af9fba71 React-callinvoker: 1c0808402aee0c6d4a0d8e7220ce6547af9fba71
React-Core: c61410ef0ca6055e204a963992e363227e0fd1c5 React-Core: 4ae98f9e8135b8ddbd7c98730afb6fdae883db90
React-Core-prebuilt: 02f0ad625ddd47463c009c2d0c5dd35c0d982599 React-Core-prebuilt: 8f4cca589c14e8cf8fc6db4587ef1c2056b5c151
React-CoreModules: 1f6d1744b5f9f2ec684a4bb5ced25370f87e5382 React-CoreModules: e878a90bb19b8f3851818af997dbae3b3b0a27ac
React-cxxreact: 3af79478e8187b63ffc22b794cd42d3fc1f1f2da React-cxxreact: 28af9844f6dc87be1385ab521fbfb3746f19563c
React-debug: 6328c2228e268846161f10082e80dc69eac2e90a React-debug: 6328c2228e268846161f10082e80dc69eac2e90a
React-defaultsnativemodule: d635ef36d755321e5d6fc065bd166b2c5a0e9833 React-defaultsnativemodule: afc9d809ec75780f39464a6949c07987fbea488c
React-domnativemodule: dd28f6d96cd21236e020be2eff6fe0b7d4ec3b66 React-domnativemodule: 91a233260411d41f27f67aa1358b7f9f0bfd101d
React-Fabric: 2e32c3fdbb1fbcf5fde54607e3abe453c6652ce2 React-Fabric: 21f349b5e93f305a3c38c885902683a9c79cf983
React-FabricComponents: 5ed0cdb81f6b91656cb4d3be432feaa28a58071a React-FabricComponents: 47ac634cc9ecc64b30a9997192f510eebe4177e4
React-FabricImage: 2bc714f818cb24e454f5d3961864373271b2faf8 React-FabricImage: 21873acd6d4a51a0b97c133141051c7acb11cc86
React-featureflags: 847642f41fa71ad4eec5e0351badebcad4fe6171 React-featureflags: 653f469f0c3c9dc271d610373e3b6e66a9fd847d
React-featureflagsnativemodule: c868a544b2c626fa337bcbd364b1befe749f0d3f React-featureflagsnativemodule: c91a8a3880e0f4838286402241ead47db43aed28
React-graphics: 192ec701def5b3f2a07db2814dfba5a44986cff6 React-graphics: b4bdb0f635b8048c652a5d2b73eb8b1ddd950f24
React-hermes: e875778b496c86d07ab2ccaa36a9505d248a254b React-hermes: fcfad3b917400f49026f3232561e039c9d1c34bf
React-idlecallbacksnativemodule: 4d57965cdf82c14ee3b337189836cd8491632b76 React-idlecallbacksnativemodule: 8cb83207e39f8179ac1d344b6177c6ab3ccebcdc
React-ImageManager: bd0b99e370b13de82c9cd15f0f08144ff3de079e React-ImageManager: 396128004783fc510e629124dce682d38d1088e7
React-jserrorhandler: a2fdef4cbcfdcdf3fa9f5d1f7190f7fd4535248d React-jserrorhandler: b58b788d788cdbf8bda7db74a88ebfcffc8a0795
React-jsi: 89d43d1e7d4d0663f8ba67e0b39eb4e4672c27de React-jsi: d2c3f8555175371c02da6dfe7ed1b64b55a9d6c0
React-jsiexecutor: abe4874aaab90dfee5dec480680220b2f8af07e3 React-jsiexecutor: ba537434eb45ee018b590ed7d29ee233fddb8669
React-jsinspector: a0b3e051aef842b0b2be2353790ae2b2a5a65a8f React-jsinspector: f21b6654baf96cb9f71748844a32468a5f73ad51
React-jsinspectorcdp: 6346013b2247c6263fbf5199adf4a8751e53bd89 React-jsinspectorcdp: 3f8be4830694c3c1c39442e50f8db877966d43f0
React-jsinspectornetwork: 26281aa50d49fc1ec93abf981d934698fa95714f React-jsinspectornetwork: 70e41469565712ad60e11d9c8b8f999b9f7f61eb
React-jsinspectortracing: 55eedf6d57540507570259a778663b90060bbd6e React-jsinspectortracing: eccf9bfa4ec7f130d514f215cfb2222dc3c0e270
React-jsitooling: 0e001113fa56d8498aa8ac28437ac0d36348e51a React-jsitooling: b376a695f5a507627f7934748533b24eed1751ca
React-jsitracing: b713793eb8a5bbc4d86a84e9d9e5023c0f58cbaf React-jsitracing: 5c8c3273dda2d95191cc0612fb5e71c4d9018d2a
React-logger: 50fdb9a8236da90c0b1072da5c32ee03aeb5bf28 React-logger: c3e2f8a2e284341205f61eef3d4677ab5a309dfd
React-Mapbuffer: 9050ee10c19f4f7fca8963d0211b2854d624973e React-Mapbuffer: 603c18db65844bb81dbe62fee8fcc976eaeb7108
React-microtasksnativemodule: f775db9e991c6f3b8ccbc02bfcde22770f96e23b React-microtasksnativemodule: d77e0c426fce34c23227394c96ca1033b30c813c
react-native-safe-area-context: 37e680fc4cace3c0030ee46e8987d24f5d3bdab2 react-native-safe-area-context: 53f796cb6c814661bbe99fbdfd0585d07b996cdd
React-NativeModulesApple: 8969913947d5b576de4ed371a939455a8daf28aa React-NativeModulesApple: 1664340b8750d64e0ef3907c5e53d9481f74bcbd
React-oscompat: ce47230ed20185e91de62d8c6d139ae61763d09c React-oscompat: ce47230ed20185e91de62d8c6d139ae61763d09c
React-perflogger: 02b010e665772c7dcb859d85d44c1bfc5ac7c0e4 React-perflogger: b1af3cfb3f095f819b2814910000392a8e17ba9f
React-performancetimeline: 130db956b5a83aa4fb41ddf5ae68da89f3fb1526 React-performancetimeline: f9ec65b77bcadbc7bd8b47a6f4b4b697da7b1490
React-RCTActionSheet: 0b14875b3963e9124a5a29a45bd1b22df8803916 React-RCTActionSheet: 0b14875b3963e9124a5a29a45bd1b22df8803916
React-RCTAnimation: a7b90fd2af7bb9c084428867445a1481a8cb112e React-RCTAnimation: 60f6eca214a62b9673f64db6df3830cee902b5af
React-RCTAppDelegate: 3262bedd01263f140ec62b7989f4355f57cec016 React-RCTAppDelegate: 37734b39bac108af30a0fd9d3e1149ec68b82c28
React-RCTBlob: c17531368702f1ebed5d0ada75a7cf5915072a53 React-RCTBlob: 83fbcbd57755caf021787324aac2fe9b028cc264
React-RCTFabric: 6409edd8cfdc3133b6cc75636d3b858fdb1d11ea React-RCTFabric: a05cb1df484008db3753c8b4a71e4c6d9f1e43a6
React-RCTFBReactNativeSpec: c004b27b4fa3bd85878ad2cf53de3bbec85da797 React-RCTFBReactNativeSpec: d58d7ae9447020bbbac651e3b0674422aba18266
React-RCTImage: c68078a120d0123f4f07a5ac77bea3bb10242f32 React-RCTImage: 47aba3be7c6c64f956b7918ab933769602406aac
React-RCTLinking: cf8f9391fe7fe471f96da3a5f0435235eca18c5b React-RCTLinking: 2dbaa4df2e4523f68baa07936bd8efdfa34d5f31
React-RCTNetwork: ca31f7c879355760c2d9832a06ee35f517938a20 React-RCTNetwork: 1fca7455f9dedf7de2b95bec438da06680f3b000
React-RCTRuntime: a6cf4a1e42754fc87f493e538f2ac6b820e45418 React-RCTRuntime: 17819dd1dfc8613efaf4cbb9d8686baae4a83e5b
React-RCTSettings: e0e140b2ff4bf86d34e9637f6316848fc00be035 React-RCTSettings: 01bf91c856862354d3d2f642ccb82f3697a4284a
React-RCTText: 75915bace6f7877c03a840cc7b6c622fb62bfa6b React-RCTText: cb576a3797dcb64933613c522296a07eaafc0461
React-RCTVibration: 25f26b85e5e432bb3c256f8b384f9269e9529f25 React-RCTVibration: 560af8c086741f3525b8456a482cdbe27f9d098e
React-rendererconsistency: 2dac03f448ff337235fd5820b10f81633328870d React-rendererconsistency: 2dac03f448ff337235fd5820b10f81633328870d
React-renderercss: 477da167bb96b5ac86d30c5d295412fb853f5453 React-renderercss: c5c6b7a15948dd28facca39a18ac269073718490
React-rendererdebug: 2a1798c6f3ef5f22d466df24c33653edbabb5b89 React-rendererdebug: 3c9d5e1634273f5a24d84cc5669f290ce0bdc812
React-RuntimeApple: 28cf4d8eb18432f6a21abbed7d801ab7f6b6f0b4 React-RuntimeApple: 887637d1e12ea8262df7d32bc100467df2302613
React-RuntimeCore: 41bf0fd56a00de5660f222415af49879fa49c4f0 React-RuntimeCore: 91f779835dc4f8f84777fe5dd24f1a22f96454e4
React-runtimeexecutor: 1afb774dde3011348e8334be69d2f57a359ea43e React-runtimeexecutor: 8bb6b738f37b0ada4a6269e6f8ab1133dea0285c
React-RuntimeHermes: f3b158ea40e8212b1a723a68b4315e7a495c5fc6 React-RuntimeHermes: 4cb93de9fa8b1cc753d200dbe61a01b9ec5f5562
React-runtimescheduler: 3e1e2bec7300bae512533107d8e54c6e5c63fe0f React-runtimescheduler: 83dc28f530bfbd2fce84ed13aa7feebdc24e5af7
React-timing: 6fa9883de2e41791e5dc4ec404e5e37f3f50e801 React-timing: 03c7217455d2bff459b27a3811be25796b600f47
React-utils: 6e2035b53d087927768649a11a26c4e092448e34 React-utils: 6d46795ae0444ec8a5d9a5f201157b286bf5250a
ReactAppDependencyProvider: 1bcd3527ac0390a1c898c114f81ff954be35ed79 ReactAppDependencyProvider: c277c5b231881ad4f00cd59e3aa0671b99d7ebee
ReactCodegen: 7d4593f7591f002d137fe40cef3f6c11f13c88cc ReactCodegen: 4c44b74b77fc41ae25b9e2c7e9bd6e2bc772c23f
ReactCommon: 08810150b1206cc44aecf5f6ae19af32f29151a8 ReactCommon: e6e232202a447d353e5531f2be82f50f47cbaa9a
ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a
RNCAsyncStorage: 3a4f5e2777dae1688b781a487923a08569e27fe4 RNCAsyncStorage: e85a99325df9eb0191a6ee2b2a842644c7eb29f4
RNGestureHandler: e0d0bce5599f6120b7adf90c38d2805e2935795f RNGestureHandler: 40c2d1c168e54715fe52e0fb16cb38c54611e4f3
RNReanimated: e5c702a3e24cc1c68b2de67671713f35461678f4 RNReanimated: 43f611f1c85c90e0273df7399bf1536f8e2bd125
RNScreens: d8d6f1792f6e7ac12b0190d33d8d390efc0c1845 RNScreens: dd61bc3a3e6f6901ad833efa411917d44827cf51
RNSVG: 31d6639663c249b7d5abc9728dde2041eb2a3c34 RNSVG: 2825ee146e0f6a16221e852299943e4cceef4528
RNWorklets: 76fce72926e28e304afb44f0da23b2d24f2c1fa0 RNWorklets: 28ee7370ca8da356fcc914e3e68b97e9752196d2
Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a
PODFILE CHECKSUM: c2c3838f0b2a579fef2350bff2ecaa005e27145d PODFILE CHECKSUM: c2c3838f0b2a579fef2350bff2ecaa005e27145d

View File

@@ -3,7 +3,7 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 56; objectVersion = 70;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
@@ -11,7 +11,7 @@
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
1A1DE01D4133812B2E2BA692 /* libPods-client.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E3328F0E595C1F4A244DF238 /* libPods-client.a */; }; 1A1DE01D4133812B2E2BA692 /* libPods-client.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E3328F0E595C1F4A244DF238 /* libPods-client.a */; };
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; }; 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
A1B2C3D4E5F60718293A4B5C /* EmotionWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */; }; A1B2C3D4E5F60718293A4B5C /* 情绪小组件/EmotionWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */; };
A8C1D2E3F4A5B6C7D8E9F0A2 /* AppGroupStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C1D2E3F4A5B6C7D8E9F0A1 /* AppGroupStorage.swift */; }; A8C1D2E3F4A5B6C7D8E9F0A2 /* AppGroupStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C1D2E3F4A5B6C7D8E9F0A1 /* AppGroupStorage.swift */; };
A8C1D2E3F4A5B6C7D8E9F0A3 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */; }; A8C1D2E3F4A5B6C7D8E9F0A3 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */; };
A8C1D2E3F4A5B6C7D8E9F0B2 /* AppGroupStorageBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */; }; A8C1D2E3F4A5B6C7D8E9F0B2 /* AppGroupStorageBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */; };
@@ -54,7 +54,7 @@
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = client/Info.plist; sourceTree = "<group>"; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = client/Info.plist; sourceTree = "<group>"; };
3C76CA16D0801CBF0D731C7C /* Pods-client.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-client.release.xcconfig"; path = "Target Support Files/Pods-client/Pods-client.release.xcconfig"; sourceTree = "<group>"; }; 3C76CA16D0801CBF0D731C7C /* Pods-client.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-client.release.xcconfig"; path = "Target Support Files/Pods-client/Pods-client.release.xcconfig"; sourceTree = "<group>"; };
75F52ADE07CAE9D9736D7671 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = client/PrivacyInfo.xcprivacy; sourceTree = "<group>"; }; 75F52ADE07CAE9D9736D7671 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = client/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "情绪小组件/EmotionWidget.swift"; sourceTree = "<group>"; }; A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "情绪小组件/EmotionWidget.swift"; sourceTree = "<group>"; };
A8C1D2E3F4A5B6C7D8E9F0A1 /* AppGroupStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppGroupStorage.swift; path = client/AppGroupStorage.swift; sourceTree = "<group>"; }; A8C1D2E3F4A5B6C7D8E9F0A1 /* AppGroupStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppGroupStorage.swift; path = client/AppGroupStorage.swift; sourceTree = "<group>"; };
A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = AppGroupStorageBridge.m; path = client/AppGroupStorageBridge.m; sourceTree = "<group>"; }; A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = AppGroupStorageBridge.m; path = client/AppGroupStorageBridge.m; sourceTree = "<group>"; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = client/SplashScreen.storyboard; sourceTree = "<group>"; }; AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = client/SplashScreen.storyboard; sourceTree = "<group>"; };
@@ -74,7 +74,7 @@
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
EB3DAF952F2A4B8F00450593 /* Exceptions for "情绪小组件" folder in "情绪小组件Extension" target */ = { EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet; isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = ( membershipExceptions = (
EmotionWidget.swift, EmotionWidget.swift,
@@ -85,18 +85,7 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = { EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = "情绪小组件"; sourceTree = "<group>"; };
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
EB3DAF952F2A4B8F00450593 /* Exceptions for "情绪小组件" folder in "情绪小组件Extension" target */,
);
explicitFileTypes = {
};
explicitFolders = (
);
path = "情绪小组件";
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */ /* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -213,7 +202,7 @@
EB3DAFD42F2A5FC100450593 /* Recovered References */ = { EB3DAFD42F2A5FC100450593 /* Recovered References */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */, A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */,
); );
name = "Recovered References"; name = "Recovered References";
sourceTree = "<group>"; sourceTree = "<group>";
@@ -482,7 +471,7 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
A1B2C3D4E5F60718293A4B5C /* EmotionWidget.swift in Sources */, A1B2C3D4E5F60718293A4B5C /* 情绪小组件/EmotionWidget.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -536,7 +525,7 @@
SWIFT_OBJC_BRIDGING_HEADER = "client/client-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "client/client-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1; TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic"; VERSIONING_SYSTEM = "apple-generic";
}; };
name = Debug; name = Debug;
@@ -576,7 +565,7 @@
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_OBJC_BRIDGING_HEADER = "client/client-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "client/client-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1; TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic"; VERSIONING_SYSTEM = "apple-generic";
}; };
name = Release; name = Release;
@@ -756,7 +745,7 @@
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1; TARGETED_DEVICE_FAMILY = "1,2";
}; };
name = Debug; name = Debug;
}; };
@@ -808,7 +797,7 @@
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1; TARGETED_DEVICE_FAMILY = "1,2";
}; };
name = Release; name = Release;
}; };

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 143 KiB

View File

@@ -38,8 +38,6 @@
<string>12.0</string> <string>12.0</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSLocalNetworkUsageDescription</key>
<string>用于连接局域网服务以获取内容与同步数据(仅在需要访问内网地址时使用)。</string>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
<dict> <dict>
<key>NSAllowsArbitraryLoads</key> <key>NSAllowsArbitraryLoads</key>
@@ -47,6 +45,8 @@
<key>NSAllowsLocalNetworking</key> <key>NSAllowsLocalNetworking</key>
<true/> <true/>
</dict> </dict>
<key>NSLocalNetworkUsageDescription</key>
<string>用于连接局域网服务以获取内容与同步数据(仅在需要访问内网地址时使用)。</string>
<key>NSUserActivityTypes</key> <key>NSUserActivityTypes</key>
<array> <array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string> <string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
@@ -60,6 +60,8 @@
<string>arm64</string> <string>arm64</string>
</array> </array>
<key>UIRequiresFullScreen</key> <key>UIRequiresFullScreen</key>
<true/>
<key>UIStatusBarHidden</key>
<false/> <false/>
<key>UIStatusBarStyle</key> <key>UIStatusBarStyle</key>
<string>UIStatusBarStyleDefault</string> <string>UIStatusBarStyleDefault</string>
@@ -72,8 +74,6 @@
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string> <string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>UIUserInterfaceStyle</key> <key>UIUserInterfaceStyle</key>
<string>Automatic</string> <string>Automatic</string>

View File

@@ -280,12 +280,12 @@ struct EmotionWidgetView: View {
Text(entry.text) Text(entry.text)
.font(fontForFamily()) .font(fontForFamily())
.foregroundColor(widgetTextColor) .foregroundColor(widgetTextColor)
.multilineTextAlignment(.leading) .multilineTextAlignment(.center)
.lineSpacing(lineSpacingForFamily()) .lineSpacing(lineSpacingForFamily())
.lineLimit(lineLimitForFamily()) .lineLimit(lineLimitForFamily())
.minimumScaleFactor(0.78) .minimumScaleFactor(0.78)
.padding(paddingForFamily()) .padding(paddingForFamily())
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.widgetSolidBackground(widgetBackgroundColor) .widgetSolidBackground(widgetBackgroundColor)
.widgetURL(deepLink) .widgetURL(deepLink)
} }

View File

@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from 'vitest';
vi.mock('../../storage/appStorage', () => ({
getLastHandledNotificationId: vi.fn(async () => null),
setLastHandledNotificationId: vi.fn(async () => undefined),
setPendingHomePushMessage: vi.fn(async () => undefined),
}));
import { buildPendingHomePushMessageFromResponse } from '../pushNotificationRoute';
describe('pushNotificationRoute.buildPendingHomePushMessageFromResponse', () => {
it('能从每日推荐 push payload 提取 home 文案', () => {
const result = buildPendingHomePushMessageFromResponse({
notification: {
request: {
identifier: 'notif-1',
content: {
title: '每日推荐',
body: '先用通知正文兜底',
data: {
scene: 'push',
target_screen: 'home',
home_text: '点击推送后回到首页展示这句文案',
content_id: '42',
},
},
},
},
});
expect(result).toMatchObject({
notification_id: 'notif-1',
title: '每日推荐',
text: '点击推送后回到首页展示这句文案',
content_id: 42,
scene: 'push',
});
});
it('home_text 缺失时回退到通知正文', () => {
const result = buildPendingHomePushMessageFromResponse({
notification: {
request: {
identifier: 'notif-2',
content: {
body: '直接展示通知正文',
data: {
target_screen: 'home',
},
},
},
},
});
expect(result?.text).toBe('直接展示通知正文');
expect(result?.notification_id).toBe('notif-2');
});
it('非 home 目标且非 push 场景时忽略', () => {
const result = buildPendingHomePushMessageFromResponse({
notification: {
request: {
identifier: 'notif-3',
content: {
body: '这条不应该进入首页',
data: {
target_screen: 'profile',
scene: 'other',
},
},
},
},
});
expect(result).toBeNull();
});
});

View File

@@ -0,0 +1,100 @@
import {
getLastHandledNotificationId,
setLastHandledNotificationId,
setPendingHomePushMessage,
type PendingHomePushMessage,
} from '../storage/appStorage';
type NotificationContentLike = {
title?: string | null;
body?: string | null;
data?: Record<string, unknown> | null;
};
export type NotificationResponseLike = {
notification?: {
request?: {
identifier?: string;
content?: NotificationContentLike;
};
};
};
type HomePushListener = (message: PendingHomePushMessage) => void;
const homePushListeners = new Set<HomePushListener>();
function readString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
function readContentId(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
return Math.trunc(value);
}
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return Math.trunc(parsed);
}
return undefined;
}
function notifyHomePushListeners(message: PendingHomePushMessage): void {
for (const listener of homePushListeners) {
listener(message);
}
}
export function subscribeHomePushMessage(listener: HomePushListener): () => void {
homePushListeners.add(listener);
return () => {
homePushListeners.delete(listener);
};
}
export function buildPendingHomePushMessageFromResponse(
response: NotificationResponseLike
): PendingHomePushMessage | null {
const request = response.notification?.request;
const content = request?.content;
const data =
content?.data && typeof content.data === 'object' && !Array.isArray(content.data)
? content.data
: {};
const targetScreen = readString(data.target_screen);
const scene = readString(data.scene);
const shouldOpenHome = targetScreen === 'home' || scene === 'push';
if (!shouldOpenHome) return null;
const text = readString(data.home_text) ?? readString(content?.body);
if (!text) return null;
return {
notification_id: readString(request?.identifier) ?? `push-${Date.now()}`,
received_at: new Date().toISOString(),
text,
title: readString(content?.title),
content_id: readContentId(data.content_id),
scene,
};
}
export async function persistHomePushMessageFromResponse(
response: NotificationResponseLike
): Promise<PendingHomePushMessage | null> {
const message = buildPendingHomePushMessageFromResponse(response);
if (!message) return null;
const lastHandledNotificationId = await getLastHandledNotificationId();
if (lastHandledNotificationId === message.notification_id) {
return null;
}
await setPendingHomePushMessage(message);
await setLastHandledNotificationId(message.notification_id);
notifyHomePushListeners(message);
return message;
}

View File

@@ -21,6 +21,8 @@ const KEY_DAILY_REMINDER_SETTINGS = 'dailyReminder.settings';
const KEY_PUSH_LAST_REGISTERED_TOKEN = 'push.lastRegisteredToken'; // 旧:仅 token保留兼容读取 const KEY_PUSH_LAST_REGISTERED_TOKEN = 'push.lastRegisteredToken'; // 旧:仅 token保留兼容读取
const KEY_PUSH_LAST_REGISTERED_AT = 'push.lastRegisteredAt'; // 旧:时间(保留兼容) const KEY_PUSH_LAST_REGISTERED_AT = 'push.lastRegisteredAt'; // 旧:时间(保留兼容)
const KEY_PUSH_LAST_REGISTERED_PAYLOAD = 'push.lastRegisteredPayload'; // 新token+client_user_id+env+app_id const KEY_PUSH_LAST_REGISTERED_PAYLOAD = 'push.lastRegisteredPayload'; // 新token+client_user_id+env+app_id
const KEY_PUSH_PENDING_HOME_MESSAGE = 'push.pendingHomeMessage';
const KEY_PUSH_LAST_HANDLED_NOTIFICATION_ID = 'push.lastHandledNotificationId';
export type PushPromptState = 'enabled' | 'skipped' | 'unknown'; export type PushPromptState = 'enabled' | 'skipped' | 'unknown';
export type Reaction = 'like' | 'dislike'; export type Reaction = 'like' | 'dislike';
@@ -112,6 +114,52 @@ export async function setLastRegisteredPushPayload(payload: Omit<LastRegisteredP
await setLastRegisteredPushToken(payload.pushToken); await setLastRegisteredPushToken(payload.pushToken);
} }
export type PendingHomePushMessage = {
notification_id: string;
received_at: string; // ISO8601
text: string;
title?: string;
content_id?: number;
scene?: string;
};
export async function getPendingHomePushMessage(): Promise<PendingHomePushMessage | null> {
const raw = await AsyncStorage.getItem(KEY_PUSH_PENDING_HOME_MESSAGE);
if (!raw) return null;
try {
const obj = JSON.parse(raw) as Partial<PendingHomePushMessage>;
if (!obj || typeof obj !== 'object') return null;
if (!obj.notification_id || !obj.received_at || !obj.text) return null;
return {
notification_id: String(obj.notification_id),
received_at: String(obj.received_at),
text: String(obj.text),
title: obj.title ? String(obj.title) : undefined,
content_id: Number.isFinite(obj.content_id) ? Number(obj.content_id) : undefined,
scene: obj.scene ? String(obj.scene) : undefined,
};
} catch {
return null;
}
}
export async function setPendingHomePushMessage(message: PendingHomePushMessage): Promise<void> {
await AsyncStorage.setItem(KEY_PUSH_PENDING_HOME_MESSAGE, JSON.stringify(message));
}
export async function clearPendingHomePushMessage(): Promise<void> {
await AsyncStorage.removeItem(KEY_PUSH_PENDING_HOME_MESSAGE);
}
export async function getLastHandledNotificationId(): Promise<string | null> {
const raw = await AsyncStorage.getItem(KEY_PUSH_LAST_HANDLED_NOTIFICATION_ID);
return raw ? String(raw) : null;
}
export async function setLastHandledNotificationId(notificationId: string): Promise<void> {
await AsyncStorage.setItem(KEY_PUSH_LAST_HANDLED_NOTIFICATION_ID, String(notificationId));
}
export type RecoFeedCacheItem = { export type RecoFeedCacheItem = {
content_id: number; content_id: number;
text: string; text: string;

View File

@@ -0,0 +1,9 @@
import { Platform } from 'react-native';
export function isIPadLike(width: number, height: number): boolean {
return Platform.OS === 'ios' && Math.min(width, height) >= 768;
}
export function clampContentWidth(width: number, maxWidth: number, horizontalPadding: number): number {
return Math.min(maxWidth, Math.max(0, width - horizontalPadding * 2));
}

View File

@@ -0,0 +1,31 @@
"""add push_send_log payload snapshot
Revision ID: 0003_add_push_send_log_payload
Revises: 0002_init_push_tables
Create Date: 2026-02-12
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "0003_add_push_send_log_payload"
down_revision = "0002_init_push_tables"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("push_send_log", sa.Column("content_id", sa.Integer(), nullable=True, comment="推送文案内容 ID可选"))
op.add_column("push_send_log", sa.Column("title", sa.String(length=128), nullable=True, comment="推送标题(可选)"))
op.add_column("push_send_log", sa.Column("body", sa.Text(), nullable=True, comment="推送正文(可选)"))
def downgrade() -> None:
op.drop_column("push_send_log", "body")
op.drop_column("push_send_log", "title")
op.drop_column("push_send_log", "content_id")

View File

@@ -7,16 +7,70 @@ from fastapi.responses import HTMLResponse
from pydantic import BaseModel, HttpUrl from pydantic import BaseModel, HttpUrl
from app.core.config import get_settings from app.core.config import get_settings
from app.legal_docs import PRIVACY_POLICY_MD, TERMS_OF_USE_MD, choose_content_by_lang, render_as_simple_html from app.legal_docs import (
PRIVACY_POLICY_MD,
TERMS_OF_USE_MD,
choose_content_by_lang,
render_as_simple_html,
split_bilingual_markdown,
)
router = APIRouter(prefix="/v1/legal", tags=["legal"]) router = APIRouter(prefix="/v1/legal", tags=["legal"])
INTERNAL_SENTINEL = "__internal__" INTERNAL_SENTINEL = "__internal__"
# 技术支持页文案EN / TC
SUPPORT_MD = """Dear Mama | Technical Support
Last updated: February 2026
If you need technical support for Dear Mama, please contact us:
- Support email: leitinglan@project-c.org
Support scope (including but not limited to):
- App installation or update issues
- App crashes, freezes, or abnormal behavior
- Notification or widget related issues
- Difficulty accessing privacy policy or terms pages
To help us process your request faster, please include:
- Device model and OS version
- App version
- A brief issue description and occurrence time
- Screenshots or screen recording (if available)
Service commitment:
- We generally respond within 3-5 business days
- Emergency availability may vary by holidays and local time
---
Dear Mama技術支援
最後更新日期2026 年 2 月
如需 Dear Mama 的技術支援,請透過以下方式聯絡我們:
- 支援信箱leitinglan@project-c.org
支援範圍(包含但不限於):
- App 安裝或更新問題
- App 閃退、卡頓或異常行為
- 通知或小組件相關問題
- 隱私政策或使用條款頁面無法開啟
為了加快處理,建議提供:
- 裝置型號與系統版本
- App 版本號
- 問題描述與發生時間
- 截圖或螢幕錄影(如有)
服務承諾:
- 一般會在 3-5 個工作日內回覆
- 節假日或時區差異時,回覆時間可能延長
"""
# 当前多语言仅支持 EN / TC繁体 # 当前多语言仅支持 EN / TC繁体
ResolvedLang = Literal["en", "tc"] ResolvedLang = Literal["en", "tc"]
BILINGUAL_CONTENT_LANGUAGE = "en, zh-Hant"
class LegalLinksResponse(BaseModel): class LegalLinksResponse(BaseModel):
@@ -36,13 +90,23 @@ def _resolve_lang(accept_language: Optional[str]) -> ResolvedLang:
if not accept_language: if not accept_language:
return "en" return "en"
s = accept_language.lower()
# 目前只支持 EN / TC只要是中文或显式 tc都归到 tc # 按语言优先级顺序逐项解析,避免“只要包含 zh 就全判 tc”。
if "tc" in s: # 例如en-US,en;q=0.9,zh-CN;q=0.8 应命中 en而不是 tc。
return "tc" items = [seg.strip().lower() for seg in accept_language.split(",") if seg.strip()]
if "zh" in s or "hant" in s or "tw" in s or "hk" in s or "mo" in s: for item in items:
return "tc" lang_tag = item.split(";", 1)[0].strip()
if not lang_tag:
continue
if lang_tag == "tc":
return "tc"
if lang_tag.startswith(("zh-hant", "zh-tw", "zh-hk", "zh-mo")):
return "tc"
if lang_tag.startswith("zh"):
return "tc"
if lang_tag.startswith("en"):
return "en"
return "en" return "en"
@@ -85,6 +149,18 @@ def _normalize_internal_url(request: Request, url: str, internal_path: str) -> s
return _join_base_url(str(request.base_url), internal_path) return _join_base_url(str(request.base_url), internal_path)
def _build_bilingual_content(text: str) -> str:
"""
固定输出双语协议内容EN 在前TC 在后。
若缺少 TC则仅返回 EN。
"""
en, tc = split_bilingual_markdown(text)
if not tc:
return en
return f"{en}\n\n---\n{tc}"
@router.get("/links", response_model=LegalLinksResponse) @router.get("/links", response_model=LegalLinksResponse)
async def get_legal_links(request: Request) -> LegalLinksResponse: async def get_legal_links(request: Request) -> LegalLinksResponse:
""" """
@@ -109,11 +185,18 @@ async def get_privacy_policy(request: Request) -> HTMLResponse:
""" """
accept_language = request.headers.get("accept-language") accept_language = request.headers.get("accept-language")
lang = _resolve_lang(accept_language) if accept_language:
content, resolved = choose_content_by_lang(PRIVACY_POLICY_MD, lang) lang = _resolve_lang(accept_language)
title = "Dear Mama | Privacy Policy" if resolved == "en" else "Dear Mama隱私權政策" content, resolved = choose_content_by_lang(PRIVACY_POLICY_MD, lang)
page = render_as_simple_html(title=title, content=content) title = "Dear Mama | Privacy Policy" if resolved == "en" else "Dear Mama隱私權政策"
return HTMLResponse(content=page, headers={"Content-Language": "en" if resolved == "en" else "zh-Hant"}) html_lang = "en" if resolved == "en" else "zh-Hant"
page = render_as_simple_html(title=title, content=content, html_lang=html_lang)
return HTMLResponse(content=page, headers={"Content-Language": html_lang})
content = _build_bilingual_content(PRIVACY_POLICY_MD)
title = "Dear Mama | Privacy Policy / 隱私權政策"
page = render_as_simple_html(title=title, content=content, html_lang="en")
return HTMLResponse(content=page, headers={"Content-Language": BILINGUAL_CONTENT_LANGUAGE})
@router.get("/terms", response_class=HTMLResponse) @router.get("/terms", response_class=HTMLResponse)
@@ -123,9 +206,37 @@ async def get_terms_of_use(request: Request) -> HTMLResponse:
""" """
accept_language = request.headers.get("accept-language") accept_language = request.headers.get("accept-language")
lang = _resolve_lang(accept_language) if accept_language:
content, resolved = choose_content_by_lang(TERMS_OF_USE_MD, lang) lang = _resolve_lang(accept_language)
title = "Dear Mama Terms of Use" if resolved == "en" else "Dear Mama 使用條款" content, resolved = choose_content_by_lang(TERMS_OF_USE_MD, lang)
page = render_as_simple_html(title=title, content=content) title = "Dear Mama Terms of Use" if resolved == "en" else "Dear Mama 使用條款"
return HTMLResponse(content=page, headers={"Content-Language": "en" if resolved == "en" else "zh-Hant"}) html_lang = "en" if resolved == "en" else "zh-Hant"
page = render_as_simple_html(title=title, content=content, html_lang=html_lang)
return HTMLResponse(content=page, headers={"Content-Language": html_lang})
content = _build_bilingual_content(TERMS_OF_USE_MD)
title = "Dear Mama Terms of Use / 使用條款"
page = render_as_simple_html(title=title, content=content, html_lang="en")
return HTMLResponse(content=page, headers={"Content-Language": BILINGUAL_CONTENT_LANGUAGE})
@router.get("/support", response_class=HTMLResponse)
async def get_support_page(request: Request) -> HTMLResponse:
"""
技术支持页面(用于 App 审核的可访问 URL
"""
accept_language = request.headers.get("accept-language")
if accept_language:
lang = _resolve_lang(accept_language)
content, resolved = choose_content_by_lang(SUPPORT_MD, lang)
title = "Dear Mama | Technical Support" if resolved == "en" else "Dear Mama技術支援"
html_lang = "en" if resolved == "en" else "zh-Hant"
page = render_as_simple_html(title=title, content=content, html_lang=html_lang)
return HTMLResponse(content=page, headers={"Content-Language": html_lang})
content = _build_bilingual_content(SUPPORT_MD)
title = "Dear Mama | Technical Support / 技術支援"
page = render_as_simple_html(title=title, content=content, html_lang="en")
return HTMLResponse(content=page, headers={"Content-Language": BILINGUAL_CONTENT_LANGUAGE})

View File

@@ -16,6 +16,7 @@ from app.db.models.push_preference import PushPreference
from app.db.models.push_token import PushToken from app.db.models.push_token import PushToken
from app.db.models.push_send_log import PushSendLog from app.db.models.push_send_log import PushSendLog
from app.db.session import get_db from app.db.session import get_db
from app.features.push_payload import build_home_push_data
from app.features.user_profile_scoring.types import UserProfileV1_2 from app.features.user_profile_scoring.types import UserProfileV1_2
from app.worker import celery_app from app.worker import celery_app
@@ -153,6 +154,32 @@ async def register(req: PushRegisterRequest, db: AsyncSession = Depends(get_db))
token.is_active = True token.is_active = True
token.last_seen_at = _ensure_utc(now) token.last_seen_at = _ensure_utc(now)
# 额外:尽早写入/补齐时区与语言(用于按用户时区生成排程)
# 说明:
# - 用户首次授权后会立即调用 /register但不一定马上进入“每日提醒”确认页
# - 若 push_preferences 里 timezone 为空,会导致排程回退到 UTC体验不符合预期
if req.device_meta:
tz = (req.device_meta.timezone or "").strip() or None
loc = (req.device_meta.locale or "").strip() or None
if tz or loc:
qpref = select(PushPreference).where(PushPreference.client_user_id == req.client_user_id)
rpref = await db.execute(qpref)
pref = rpref.scalar_one_or_none()
if pref is None:
pref = PushPreference(
client_user_id=req.client_user_id,
enabled=False,
times_per_day=0,
timezone=tz,
locale=loc,
)
db.add(pref)
else:
if tz and not (pref.timezone or "").strip():
pref.timezone = tz
if loc and not (pref.locale or "").strip():
pref.locale = loc
await db.commit() await db.commit()
return {"status": "ok"} return {"status": "ok"}
@@ -187,8 +214,11 @@ async def put_preferences(req: PushPreferencesRequest, db: AsyncSession = Depend
else: else:
pref.enabled = enabled pref.enabled = enabled
pref.times_per_day = times pref.times_per_day = times
pref.timezone = req.timezone # 注意:只在客户端显式传入时覆盖,避免把已保存的 timezone/locale 清空导致排程回退到 UTC
pref.locale = req.locale if req.timezone is not None:
pref.timezone = req.timezone
if req.locale is not None:
pref.locale = req.locale
if req.user_profile is not None: if req.user_profile is not None:
pref.user_profile_json = req.user_profile.model_dump(mode="json") pref.user_profile_json = req.user_profile.model_dump(mode="json")
@@ -260,7 +290,16 @@ async def test_push(
title = req.title or "Dear Mama" title = req.title or "Dear Mama"
body = req.body or "这是一条测试推送dev" body = req.body or "这是一条测试推送dev"
expo_res = await _send_expo_push(to=token.push_token, title=title, body=body, data={"client_user_id": req.client_user_id}) expo_res = await _send_expo_push(
to=token.push_token,
title=title,
body=body,
data=build_home_push_data(
client_user_id=req.client_user_id,
body=body,
scene="push",
),
)
_ = accept_language _ = accept_language
return {"status": "ok", "expo": expo_res} return {"status": "ok", "expo": expo_res}

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import date, datetime from datetime import date, datetime
from sqlalchemy import Date, DateTime, Index, SmallInteger, String, Text, UniqueConstraint, func from sqlalchemy import Date, DateTime, Index, Integer, SmallInteger, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base from app.db.base import Base
@@ -33,5 +33,10 @@ class PushSendLog(Base):
status: Mapped[str] = mapped_column(String(length=16), nullable=False, server_default="scheduled", comment="scheduled/sent/failed") status: Mapped[str] = mapped_column(String(length=16), nullable=False, server_default="scheduled", comment="scheduled/sent/failed")
error: Mapped[str | None] = mapped_column(Text, nullable=True, comment="失败原因(可选)") error: Mapped[str | None] = mapped_column(Text, nullable=True, comment="失败原因(可选)")
# 发送内容快照(用于观测 + 去重)
content_id: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="推送文案内容 ID可选")
title: Mapped[str | None] = mapped_column(String(length=128), nullable=True, comment="推送标题(可选)")
body: Mapped[str | None] = mapped_column(Text, nullable=True, comment="推送正文(可选)")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now(), comment="创建时间") created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now(), comment="创建时间")

View File

@@ -0,0 +1,26 @@
from __future__ import annotations
from typing import Any, Optional
def build_home_push_data(
*,
client_user_id: str,
body: str,
scene: str = "push",
content_id: Optional[int] = None,
) -> dict[str, Any]:
"""
构建客户端点击通知后回到 Home 所需的最小 payload。
"""
data: dict[str, Any] = {
"client_user_id": str(client_user_id),
"scene": str(scene),
"target_screen": "home",
"deep_link": "client://home",
"home_text": str(body),
}
if content_id is not None:
data["content_id"] = int(content_id)
return data

View File

@@ -265,7 +265,7 @@ def choose_content_by_lang(text: str, lang: ResolvedLang) -> tuple[str, Resolved
return en, "en" return en, "en"
def render_as_simple_html(title: str, content: str) -> str: def render_as_simple_html(title: str, content: str, html_lang: str = "en") -> str:
""" """
将文本以简单 HTML 的方式展示(使用 pre 保留换行并自动换行)。 将文本以简单 HTML 的方式展示(使用 pre 保留换行并自动换行)。
不做 Markdown 渲染,避免引入额外依赖,确保“最小可用、必有内容”。 不做 Markdown 渲染,避免引入额外依赖,确保“最小可用、必有内容”。
@@ -273,8 +273,9 @@ def render_as_simple_html(title: str, content: str) -> str:
safe_title = html.escape(title) safe_title = html.escape(title)
safe_content = html.escape(content) safe_content = html.escape(content)
safe_lang = html.escape(html_lang or "en")
return f"""<!doctype html> return f"""<!doctype html>
<html lang="en"> <html lang="{safe_lang}">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />

View File

@@ -9,13 +9,14 @@ from zoneinfo import ZoneInfo
import httpx import httpx
from celery import current_app, shared_task from celery import current_app, shared_task
from sqlalchemy import select from sqlalchemy import select, update
from app.core.config import get_settings from app.core.config import get_settings
from app.db.models.push_preference import PushPreference from app.db.models.push_preference import PushPreference
from app.db.models.push_send_log import PushSendLog from app.db.models.push_send_log import PushSendLog
from app.db.models.push_token import PushToken from app.db.models.push_token import PushToken
from app.db.session import AsyncSessionLocal from app.db.session import AsyncSessionLocal
from app.features.push_payload import build_home_push_data
from app.features.personalized_reco.content_repository.types import normalize_locale from app.features.personalized_reco.content_repository.types import normalize_locale
from app.features.user_profile_scoring.scoring import build_user_profile_from_questionnaire from app.features.user_profile_scoring.scoring import build_user_profile_from_questionnaire
from app.features.user_profile_scoring.types import QuestionnaireAnswersV1_2, UserProfileV1_2 from app.features.user_profile_scoring.types import QuestionnaireAnswersV1_2, UserProfileV1_2
@@ -44,7 +45,8 @@ def _pick_reco_locale(pref_locale: Optional[str]) -> str:
def _pick_title(locale: str) -> str: def _pick_title(locale: str) -> str:
return "每日提醒" if str(locale) == "tc" else "Daily Reminder" # 需求tc 语言使用繁体标题
return "每日推薦" if str(locale) == "tc" else "Daily Reminder"
async def _send_expo_push(*, to: str, title: str, body: str, data: Optional[dict[str, Any]] = None) -> dict[str, Any]: async def _send_expo_push(*, to: str, title: str, body: str, data: Optional[dict[str, Any]] = None) -> dict[str, Any]:
@@ -67,7 +69,11 @@ async def _send_expo_push(*, to: str, title: str, body: str, data: Optional[dict
def _uniform_jitter_times(*, start: datetime, end: datetime, n: int) -> list[datetime]: def _uniform_jitter_times(*, start: datetime, end: datetime, n: int) -> list[datetime]:
""" """
将窗口均匀切分为 n 个区间,并在每段内随机取一个时间点(抖动) 将窗口均匀切分为 n 个区间,并在每段内取“中点 + 受限抖动”的时间点
目的:
- 尽量均匀分布(避免相邻两条推送随机到非常接近的时间)
- 仍保留一定随机性,避免过于机械
""" """
if n <= 0: if n <= 0:
@@ -84,8 +90,15 @@ def _uniform_jitter_times(*, start: datetime, end: datetime, n: int) -> list[dat
if seg <= 0: if seg <= 0:
out.append(seg_start) out.append(seg_start)
continue continue
jitter = random.random() * seg
out.append(seg_start + timedelta(seconds=jitter)) # 受限抖动:在每段的 [25%, 75%] 区间内取点
# 这样相邻两段的最小间隔为 50% 段长,能显著减少“随机挤在一起”。
mid = seg_start + timedelta(seconds=seg * 0.5)
jitter = (random.random() - 0.5) * (seg * 0.5) # [-0.25*seg, +0.25*seg]
out.append(mid + timedelta(seconds=jitter))
# 保序(理论上天然有序,这里再保险)
out.sort()
return out return out
@@ -167,16 +180,25 @@ async def _generate_schedule_once(*, now_utc: datetime, max_users: int = 5000) -
created += 1 created += 1
# 投递 ETA 发送任务 # 投递 ETA 发送任务
current_app.send_task( try:
"tasks.push.send_scheduled", current_app.send_task(
kwargs={ "tasks.push.send_scheduled",
"client_user_id": pref.client_user_id, kwargs={
"local_date": target.local_date.isoformat(), "client_user_id": pref.client_user_id,
"slot_index": int(idx), "local_date": target.local_date.isoformat(),
}, "slot_index": int(idx),
eta=dt_utc, },
) eta=dt_utc,
scheduled += 1 )
scheduled += 1
except Exception as e:
# 关键:如果投递失败(例如 broker 短暂不可用),不要让 log 永远卡在 scheduled
log.status = "failed"
log.error = f"enqueue_failed:{type(e).__name__}"
try:
await session.commit()
except Exception:
await session.rollback()
return {"created": created, "scheduled": scheduled} return {"created": created, "scheduled": scheduled}
@@ -209,6 +231,34 @@ async def _send_once_async(*, client_user_id: str, local_date: date, slot_index:
return {"status": "noop", "reason": "no_log"} return {"status": "noop", "reason": "no_log"}
if str(log.status) == "sent": if str(log.status) == "sent":
return {"status": "noop", "reason": "already_sent"} return {"status": "noop", "reason": "already_sent"}
if str(log.status) not in ("scheduled", "sending"):
# 例如 failed/skipped不再重复尝试
return {"status": "noop", "reason": f"not_retryable:{log.status}"}
# 原子抢占:避免重复发送
# - scheduled正常抢占 scheduled -> sending
# - sending如果长时间卡在 sending进程崩溃/网络异常等),允许“超时接管”继续执行
now_utc_naive = datetime.now(timezone.utc).replace(tzinfo=None)
steal_cutoff = now_utc_naive - timedelta(minutes=10)
res = await session.execute(
update(PushSendLog)
.where(
PushSendLog.id == log.id,
(
(PushSendLog.status == "scheduled")
| (
(PushSendLog.status == "sending")
& (PushSendLog.sent_at.is_(None))
& (PushSendLog.scheduled_at <= steal_cutoff)
)
),
)
.values(status="sending", error=None)
)
await session.commit()
if (res.rowcount or 0) <= 0:
return {"status": "noop", "reason": "already_in_progress_or_processed"}
log.status = "sending"
# 2) 当前偏好检查(用户可能中途关闭/改次数) # 2) 当前偏好检查(用户可能中途关闭/改次数)
qpref = select(PushPreference).where(PushPreference.client_user_id == client_user_id) qpref = select(PushPreference).where(PushPreference.client_user_id == client_user_id)
@@ -235,71 +285,128 @@ async def _send_once_async(*, client_user_id: str, local_date: date, slot_index:
await session.commit() await session.commit()
return {"status": "failed", "reason": "no_active_token"} return {"status": "failed", "reason": "no_active_token"}
# 4) 生成文案(复用推荐模块 push 场景)
reco_locale = str(normalize_locale(_pick_reco_locale(pref.locale)))
title = _pick_title(reco_locale)
if pref.user_profile_json:
user_profile = UserProfileV1_2.model_validate(pref.user_profile_json)
else:
# 无画像:用“全跳过”的默认画像(降个性化/降风险)
user_profile = UserProfileV1_2.model_validate(
build_user_profile_from_questionnaire(QuestionnaireAnswersV1_2()).model_dump()
)
# 直接复用 reco 的 Celery 任务实现(同步函数)
from app.tasks.reco import generate as reco_generate
reco_payload = reco_generate(scene="push", user_profile=user_profile.model_dump(), k=1, locale=reco_locale)
body = ""
try: try:
items = (reco_payload or {}).get("items") or [] # 4) 生成文案(复用推荐模块 push 场景)
if items and isinstance(items, list): reco_locale = str(normalize_locale(_pick_reco_locale(pref.locale)))
body = str(items[0].get("text") or "").strip() title = _pick_title(reco_locale)
except Exception:
if pref.user_profile_json:
user_profile = UserProfileV1_2.model_validate(pref.user_profile_json)
else:
# 无画像:用“全跳过”的默认画像(降个性化/降风险)
user_profile = UserProfileV1_2.model_validate(
build_user_profile_from_questionnaire(QuestionnaireAnswersV1_2()).model_dump()
)
# 关键:这里不能调用 tasks.reco.generate内部会 asyncio.run否则会嵌套事件循环崩溃。
from app.tasks.reco import run_reco_payload_async
# 去重:用户推送过的内容尽量不再推送
# 说明:
# - 依赖 push_send_log.content_id需先完成对应 DB 迁移)
# - 为避免历史过长导致 already_recommended_ids 过大,这里取“最近若干条已推送内容”近似全量去重
used_ids: list[int] = []
try:
qused = (
select(PushSendLog.content_id)
.where(
PushSendLog.client_user_id == client_user_id,
PushSendLog.content_id.is_not(None),
PushSendLog.id != log.id,
)
# 优先排除最近发送过的内容
.order_by(PushSendLog.local_date.desc(), PushSendLog.slot_index.desc())
.limit(5000)
)
rused = await session.execute(qused)
used_ids = [int(x) for x in rused.scalars().all() if x is not None]
except Exception:
used_ids = []
body = "" body = ""
picked_content_id: int | None = None
try:
reco_payload = await run_reco_payload_async(
scene="push",
user_profile=user_profile,
k=3,
locale=reco_locale,
already_recommended_ids=used_ids,
)
items = (reco_payload or {}).get("items") or []
if items and isinstance(items, list):
for it in items:
if not isinstance(it, dict):
continue
cid = it.get("content_id")
txt = str(it.get("text") or "").strip()
if not txt:
continue
if cid is not None:
try:
cid_i = int(cid)
except Exception:
cid_i = None
else:
cid_i = None
if cid_i is not None and cid_i in used_ids:
continue
picked_content_id = cid_i
body = txt
break
except Exception:
body = ""
if not body: if not body:
body = "给自己一句温柔的话。" # tc 语言兜底文案使用繁体
body = "給自己一句溫柔的話。" if reco_locale == "tc" else "给自己一句温柔的话。"
# 5) 发送 # 5) 发送
try:
expo_res = await _send_expo_push( expo_res = await _send_expo_push(
to=str(token.push_token), to=str(token.push_token),
title=title, title=title,
body=body, body=body,
data={"client_user_id": client_user_id, "scene": "push"}, data=build_home_push_data(
client_user_id=client_user_id,
body=body,
scene="push",
content_id=picked_content_id,
),
) )
# 6) 解析 Expo 回执,必要时停用 token
try:
data_list = (expo_res or {}).get("data") or []
if data_list and isinstance(data_list, list):
first = data_list[0] or {}
if first.get("status") == "error":
details = first.get("details") or {}
err = str(details.get("error") or first.get("message") or "expo_error")
log.status = "failed"
log.error = err
if "DeviceNotRegistered" in err:
token.is_active = False
await session.commit()
return {"status": "failed", "expo": expo_res}
except Exception:
# 忽略解析异常,继续按成功处理
pass
log.status = "sent"
log.sent_at = datetime.now(timezone.utc).replace(tzinfo=None)
log.error = None
log.title = title
log.body = body
log.content_id = picked_content_id
await session.commit()
return {"status": "sent", "expo": expo_res}
except Exception as e: except Exception as e:
# 兜底:任何未预期异常都不要让状态卡在 sending
log.status = "failed" log.status = "failed"
log.error = f"send_failed:{type(e).__name__}" log.error = f"unexpected:{type(e).__name__}"
await session.commit() await session.commit()
return {"status": "failed", "error": str(e)} return {"status": "failed", "error": str(e)}
# 6) 解析 Expo 回执,必要时停用 token
try:
data_list = (expo_res or {}).get("data") or []
if data_list and isinstance(data_list, list):
first = data_list[0] or {}
if first.get("status") == "error":
details = first.get("details") or {}
err = str(details.get("error") or first.get("message") or "expo_error")
log.status = "failed"
log.error = err
if "DeviceNotRegistered" in err:
token.is_active = False
await session.commit()
return {"status": "failed", "expo": expo_res}
except Exception:
# 忽略解析异常,继续按成功处理
pass
log.status = "sent"
log.sent_at = datetime.now(timezone.utc).replace(tzinfo=None)
log.error = None
await session.commit()
return {"status": "sent", "expo": expo_res}
@shared_task(name="tasks.push.send_scheduled") @shared_task(name="tasks.push.send_scheduled")
def send_scheduled(*, client_user_id: str, local_date: str, slot_index: int) -> dict[str, Any]: def send_scheduled(*, client_user_id: str, local_date: str, slot_index: int) -> dict[str, Any]:
@@ -310,3 +417,50 @@ def send_scheduled(*, client_user_id: str, local_date: str, slot_index: int) ->
d = date.fromisoformat(str(local_date)) d = date.fromisoformat(str(local_date))
return asyncio.run(_send_once_async(client_user_id=str(client_user_id), local_date=d, slot_index=int(slot_index))) return asyncio.run(_send_once_async(client_user_id=str(client_user_id), local_date=d, slot_index=int(slot_index)))
@shared_task(name="tasks.push.requeue_overdue")
def requeue_overdue(*, grace_seconds: int = 300, limit: int = 200) -> dict[str, Any]:
"""
补偿任务:扫描“已到时间但仍处于 scheduled”的记录并重新投递发送任务。
目的:
- 覆盖 broker 短暂不可用、worker 重启、ETA 任务丢失等导致的“scheduled 卡住”
- 与 send_scheduled 内部的原子状态抢占配合,避免重复发送
"""
now_utc_naive = datetime.now(timezone.utc).replace(tzinfo=None)
cutoff = now_utc_naive - timedelta(seconds=int(grace_seconds))
async def _run() -> dict[str, Any]:
requeued = 0
async with AsyncSessionLocal() as session:
q = (
select(PushSendLog)
.where(
PushSendLog.status.in_(("scheduled", "sending")),
PushSendLog.sent_at.is_(None),
PushSendLog.scheduled_at <= cutoff,
)
.order_by(PushSendLog.scheduled_at.asc())
.limit(int(limit))
)
rows = await session.execute(q)
logs = list(rows.scalars().all())
for log in logs:
try:
current_app.send_task(
"tasks.push.send_scheduled",
kwargs={
"client_user_id": str(log.client_user_id),
"local_date": str(log.local_date),
"slot_index": int(log.slot_index),
},
)
requeued += 1
except Exception:
# 忽略单条投递失败,交给下一轮补偿
continue
return {"status": "ok", "requeued": requeued, "cutoff": cutoff.isoformat()}
return asyncio.run(_run())

View File

@@ -53,6 +53,45 @@ async def _run_reco_async(
) )
async def run_reco_payload_async(
*,
scene: Scene,
user_profile: UserProfileV1_2,
already_recommended_ids: Optional[list[Any]] = None,
touched_or_viewed_ids: Optional[list[Any]] = None,
k: Optional[int] = None,
now: Optional[datetime] = None,
locale: Optional[str] = None,
) -> dict[str, Any]:
"""
在“已有事件循环”内运行推荐并返回 payload。
用途:
- 供 Push 等 async 任务内部调用,避免 `asyncio.run()` 嵌套导致 RuntimeError
- 也便于未来在 API/任务间复用
"""
effective_now = _ensure_now(now)
effective_locale = _ensure_locale(locale)
# k 默认按场景(与 generate 保持一致)
if k is None:
k_i = 30 if scene == "feed" else 1
else:
k_i = int(k)
result = await _run_reco_async(
scene=scene,
user_profile=user_profile,
already_recommended_ids=list(already_recommended_ids or []),
touched_or_viewed_ids=list(touched_or_viewed_ids or []),
k=int(k_i),
now=effective_now,
locale=effective_locale,
)
return result.model_dump()
def _run_reco_sync( def _run_reco_sync(
*, *,
scene: Scene, scene: Scene,

View File

@@ -53,10 +53,19 @@ celery_app.conf.beat_schedule = {
}, },
"push-generate-daily-schedule": { "push-generate-daily-schedule": {
"task": "tasks.push.generate_daily_schedule", "task": "tasks.push.generate_daily_schedule",
"schedule": crontab(minute=10, hour=0), # 由“每天一次”调整为“每 2 小时一次”UTC
"schedule": crontab(minute=10, hour="*/2"),
"kwargs": {"max_users": 5000}, "kwargs": {"max_users": 5000},
"options": {"queue": f"{prefix}:celery"}, "options": {"queue": f"{prefix}:celery"},
} }
,
# 补偿:每 5 分钟扫描一次 overdue scheduled 并重投递
"push-requeue-overdue": {
"task": "tasks.push.requeue_overdue",
"schedule": crontab(minute="*/5"),
"kwargs": {"grace_seconds": 300, "limit": 200},
"options": {"queue": f"{prefix}:celery"},
},
} }
# 自动发现任务(约定:导入 app.tasks 触发其内部对子模块的显式导入) # 自动发现任务(约定:导入 app.tasks 触发其内部对子模块的显式导入)

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
from app.features.push_payload import build_home_push_data
def test_build_home_push_data_contains_home_route_fields() -> None:
payload = build_home_push_data(
client_user_id="client-123",
body="今天也请温柔地对自己说话。",
scene="push",
content_id=9,
)
assert payload == {
"client_user_id": "client-123",
"scene": "push",
"target_screen": "home",
"deep_link": "client://home",
"home_text": "今天也请温柔地对自己说话。",
"content_id": 9,
}
def test_build_home_push_data_omits_content_id_when_missing() -> None:
payload = build_home_push_data(
client_user_id="client-123",
body="先看到这句,再回到首页。",
)
assert "content_id" not in payload
assert payload["target_screen"] == "home"
assert payload["home_text"] == "先看到这句,再回到首页。"

View File

@@ -0,0 +1,137 @@
# iPad Adaptation技术计划
## 1. 计划目标
基于 `spec.md`,落地“全应用 iPad 适配(含 iOS Widget”实施方案确保
- 客户端所有页面在 iPad 下可用、可读、可交互;
-**竖屏** 为主目标完成逐页适配与验收;
- iOS Widget 在 iPad 相关尺寸下展示稳定;
- 全过程保持 **iPhone 零回归**
## 2. 默认技术决策
- **适配方式**React Native 断点 + 条件样式(不改 iPhone 基线参数)。
- **iPad 判定**`Platform.OS === 'ios' && Math.min(width, height) >= 768`
- **尺寸获取**:统一使用 `useWindowDimensions()`,避免 `Dimensions.get()` 静态值问题。
- **布局原则**:页面采用“最大内容宽度 + 居中 + 弹性留白”。
- **改造策略**:按页面逐个交付,单页完成后冻结,待确认再进入下一页。
## 3. 工程配置基线(先决条件)
### 3.1 原生配置核查
- `app.json``expo.ios.supportsTablet = true`(已要求)。
- iOS 工程目标设备族支持 iPad`TARGETED_DEVICE_FAMILY` 需包含 `1,2`)。
- 方向策略与产品要求一致(当前优先竖屏;若仅竖屏,则 iPad 也收敛为竖屏策略)。
- 避免 iPad 以 iPhone 兼容模式运行(该模式会导致系统黑边)。
### 3.2 运行模式与验收环境
- iPad 模拟器:至少 1 台主流尺寸(如 11-inch
- iPad 真机:至少 1 台(若可用),用于确认系统级显示行为。
- iPhone 回归机型:至少小屏 + 大屏各 1 个。
## 4. 页面适配实施策略(逐页)
### 4.1 页面分批顺序
1. 启动链路:`splash / consent / index`
2. Onboarding 全流程页
3. 主应用页:`home`、详情/弹层、设置、收藏等
4. 边缘页:`modal``not-found`、其他辅助页
### 4.2 单页改造标准模板
每页按以下模板执行:
1. 梳理页面结构:首屏视觉区、正文区、底部操作区、浮层区。
2. 抽离 iPad 分支参数:最大宽度、字号、行高、间距、按钮尺寸。
3. 保持 iPhone 参数不变:原样式分支保留。
4. 自测:
- iPad 竖屏显示完整;
- 文案不截断、按钮可点击;
- iPhone 关键路径不回归。
5. 输出截图与验收点,待确认后冻结该页。
### 4.3 可复用样式基线(建议)
- 页面容器:`flex: 1` + `width/height: '100%'` + `alignSelf: 'stretch'`
- 内容最大宽度:按页面类型设置(例如 560/620/680 分级),统一居中。
- 底部操作区:基于安全区与窗口高度计算,不使用硬编码魔法值。
- 文案区限制最大阅读宽度iPad 适度增大字号与行高。
- 图片/插画:按比例缩放,优先保持构图稳定,不压缩变形。
## 5. iOS Widget 适配计划
### 5.1 覆盖范围
- 小/中/大组件在 iPad 下的展示一致性;
- 文案长度、换行与截断策略;
- 图形与文本的层级、边距、留白;
- 浅色模式基线(深色模式按资源情况补充)。
### 5.2 实施要点
- 统一组件内边距与字号层级映射;
- 对长文本提供优雅截断(避免溢出与跳变);
- 验证不同语言长度对布局影响;
- 输出尺寸矩阵截图作为最终验收材料。
## 6. iPhone 零回归保障
- 所有 iPad 适配均以条件分支或断点参数实现;
- 禁止直接覆盖 iPhone 基线字号/间距;
- 每完成一页,执行 iPhone 冒烟回归:
- 启动流程;
- Onboarding 关键交互;
- Home 关键按钮与弹层;
- Settings/Favorites 基础可用性。
## 7. 验收清单与交付物
### 7.1 页面级验收清单
- 布局完整:无重叠、无错位、无异常黑边;
- 文案可读:不截断(可接受预期截断场景需说明);
- 交互可用:触控面积合理、按钮不被遮挡;
- 状态一致:加载/空态/异常态显示正常;
- iPhone 回归:关键路径无变化。
### 7.2 交付物
- 每页适配说明(改动点 + 参数策略);
- iPad 改前/改后截图;
- iPhone 回归截图;
- Widget 各尺寸截图;
- 最终页面覆盖矩阵与验收记录。
## 8. 分阶段里程碑
1. **M1 基线搭建**
- 完成原生配置核查与适配基线工具/约定;
- 输出页面清单与验收模板。
2. **M2 页面逐页适配**
- 按既定顺序逐页改造;
- 每页交付后等待确认,再继续下一页。
3. **M3 Widget 收口**
- 完成 iPad 尺寸矩阵验证;
- 修复文本/间距/层级问题。
4. **M4 全量回归与发布前验收**
- iPad 全页验收 + iPhone 零回归确认;
- 形成最终验收文档。
## 9. 风险与应对
- **风险**:页面存在大量固定像素值,局部改动引发联动。
**应对**:参数分层、按区块替换、单页冻结机制。
- **风险**iPad 多窗口/舞台管理导致尺寸波动。
**应对**:以竖屏全屏为主验收,同时保留窗口变化最小兼容策略。
- **风险**:多语言文案长度影响 widget 与页面稳定性。
**应对**:统一截断/换行规则,增加长文案样本回归。

View File

@@ -0,0 +1,92 @@
# iPad Adaptation Spec
## Background
当前应用主要按 iPhone 体验实现iPad 上存在以下问题:
- 页面布局在竖屏/横屏或不同窗口尺寸下出现留黑边、内容拥挤、元素比例失衡。
- 各页面对 iPad 的适配策略不统一,样式行为不可预测。
- iOS Widget 在 iPad 场景下缺少完整的尺寸与排版一致性规范。
本需求定义“全应用 iPad 适配”高层规范,覆盖客户端全部页面与 iOS 小组件,确保不回归现有 iPhone 体验。
## Goals
1. 为客户端所有页面建立统一的 iPad 适配规范(优先竖屏,兼容 iPad 常见窗口模式)。
2. 完成全部页面在 iPad 下的布局、间距、字号、交互可用性优化。
3. 完成 iOS Widget 在 iPad 相关尺寸上的视觉与信息层级适配。
4. 明确“iPad 适配不影响 iPhone”的约束、验收与回归策略。
## Non-Goals
- 不重做品牌视觉与核心交互流程。
- 不引入与 iPad 适配无关的新业务功能。
- 不调整后端接口契约(除非为 Widget 展示字段做最小兼容补充)。
## Scope
### In Scope
- `client/app/` 下所有用户可见页面启动、协议、onboarding、home、modal、not-found 等)。
- 共享组件与页面级组件在 iPad 场景下的布局策略容器宽度、断点、字号、触控面积、safe area 处理)。
- iPad 竖屏主流程视觉一致性与可用性。
- iOS Widget小/中/大尺寸)在 iPad 的展示、文本截断、间距与点击目标。
- iPad 相关工程配置核查(如设备家族支持、方向策略与运行模式)。
### Out of Scope
- Android 平板专项适配。
- Web 端平板适配。
- 新增 widget 类型或新增推荐策略。
## Core Requirements
### R1. 统一适配基线
- 定义 iPad 判定与布局断点策略,避免各页面各自实现。
- 页面默认使用“内容最大宽度 + 居中 + 弹性留白”模式,不出现视觉黑边误判。
- 明确安全区、状态栏、底部操作区在 iPad 下的通用规则。
### R2. 页面逐页适配
- 按页面清单逐页交付,单页可独立验收。
- 每页适配需覆盖:首屏构图、正文可读性、底部操作区、长文案换行与触控可用性。
- 已完成页面进入“冻结状态”,未经确认不回改。
### R3. iPhone 零回归
- 所有 iPad 样式调整必须使用条件分支或断点方案,不修改 iPhone 基线参数。
- 每次页面适配后执行 iPhone 快速回归(关键路径与关键组件)。
### R4. iOS Widget 适配
- 覆盖 iPad 下 widget 尺寸与展示密度差异,保证文本与图形不溢出、不遮挡。
- 小组件与主 App 的主题、字体层级、文案截断策略保持一致。
- 提供 widget 预览/截图验收基线(至少包含浅色模式)。
### R5. 验收与质量
- 建立页面级验收清单:布局完整性、可读性、点击可达性、状态一致性、异常文案表现。
- 关键页面提供 iPad 对比截图(改前/改后)与 iPhone 回归截图。
- 适配完成后输出覆盖清单,确保无遗漏页面。
## Acceptance Criteria
1. 应用在 iPad 真机/模拟器上以 iPad 模式运行,不出现 iPhone 兼容模式导致的系统黑边。
2. 全部页面在 iPad 竖屏下通过视觉与交互验收,页面无明显错位、截断、重叠。
3. iPhone 主流尺寸下关键路径无样式与交互回归。
4. iOS Widget 在 iPad 对应尺寸下通过展示验收。
5. 提供最终“页面覆盖矩阵 + 验收记录”。
## Risks
- 现有页面存在大量固定像素值,逐页改造可能引入局部联动风险。
- iPad 多窗口/舞台管理会带来额外窗口尺寸变化,需要明确支持级别。
- Widget 文案长度受多语言影响,需预留截断与回退策略。
## Milestones (High-Level)
1. 基线与清单:完成断点策略、页面与组件清单、验收模板。
2. 页面适配:按“启动链路 -> onboarding -> 主页面 -> 弹层/边缘页面”逐页交付。
3. Widget 适配:完成尺寸验证与视觉一致性收口。
4. 全量回归iPad 全页检查 + iPhone 零回归确认 + 发布前验收。

View File

@@ -0,0 +1,151 @@
# iPad Adaptation任务清单
> 说明:本清单由 `plan.md` 拆解,强调“详细、可执行、可验收”。
> 执行规则:完成后将 `- [ ]` 改为 `- [x]`;阻塞项需补充阻塞原因与解除条件。
> 约束:所有 iPad 改动不得影响 iPhone 现有适配。
## 0. 基线与准备
- [ ] **T0-1 建立页面与组件盘点清单**
- 输出:`client/app/` 页面清单 + 关键共享组件清单(含负责人/优先级)
- 验收清单覆盖启动链路、onboarding、home、settings、favorites、modal、not-found
- [ ] **T0-2 建立验收模板(页面级)**
- 输出统一验收模板布局、文案、交互、状态、iPhone 回归、截图)
- 验收:模板可用于每页独立签收
- [x] **T0-3 建立 iPad 适配基线工具函数/约定**
- 内容:统一 `isTablet` 判定、宽度分级560/620/680、容器基线写法
- 验收:至少在 1 个页面实际接入并可复用
## 1. 原生配置修正(黑边先决条件)
- [x] **T1-1 修正 iOS 目标设备族为 iPhone+iPad**
- 文件:`client/ios/client.xcodeproj/project.pbxproj`
- 要求:主 App targetDebug/Release`TARGETED_DEVICE_FAMILY` 包含 `1,2`
- 验收iPad 运行不再是 iPhone 兼容模式
- [x] **T1-2 校验方向策略与产品要求一致(优先竖屏)**
- 文件:`client/app.json``client/ios/client/Info.plist`
- 要求iPad 方向策略与“竖屏优先”一致,不引入系统级黑边
- 验收iPad 竖屏全屏显示稳定
- [ ] **T1-3 设备验证(基础冒烟)**
- 场景iPad 模拟器(至少 11-inch+ iPhone 两档尺寸
- 验收:启动页无系统黑边,应用可正常进入主流程
## 2. 启动链路页面适配(第一批)
- [x] **T2-1 适配 `app/(splash)/splash.tsx`(同意页)**
- 范围:首屏构图、文案可读、底部按钮区、安全区
- 要求iPad 使用独立参数分支iPhone 参数保持不变
- 验收iPad 竖屏无错位、无遮挡iPhone 对比无回归
- [x] **T2-2 适配 `app/index.tsx`(启动分发页)**
- 范围:加载态容器尺寸、跳转前视觉稳定性
- 验收iPad 下不闪烁、不出现异常留边
- [ ] **T2-3 启动链路验收与冻结**
- 输出iPad 改前/改后截图 + iPhone 回归截图
- 验收:产品确认后标记“冻结”,不再改动本批页面
## 3. Onboarding 全流程适配(第二批)
- [x] **T3-1 适配 `components/onboarding/OnboardingLayout.tsx`**
- 范围:标题区、进度区、内容最大宽度、顶部操作区
- 验收iPad 竖屏布局层级清晰,交互区域可达
- [x] **T3-2 适配 `components/onboarding/NameInputStep.tsx`**
- 范围:输入卡片宽度、键盘抬升、底部按钮区
- 验收iPad 输入过程不卡位、不遮挡按钮
- [x] **T3-3 适配 `components/onboarding/SelectionStep.tsx`**
- 范围:选项卡宽度/间距、滚动区底部留白、底部按钮区
- 验收:末项可见且不被按钮覆盖
- [x] **T3-4 适配 `components/onboarding/ReminderStep.tsx`**
- 范围:数字区比例、加减按钮间距、完成按钮区域
- 验收iPad 读数清晰,触控误触率低
- [x] **T3-5 适配 `app/(onboarding)/onboarding.tsx`(流程壳)**
- 范围步骤切换稳定性、loading 态布局一致性
- 验收:全流程在 iPad 竖屏可连续通过
- [ ] **T3-6 Onboarding 批次验收与冻结**
- 输出:逐页截图 + 关键交互录屏(可选) + iPhone 回归截图
- 验收:确认后冻结本批页面
## 4. 主应用页面适配(第三批)
- [x] **T4-1 适配 `app/(app)/home.tsx`**
- 范围:卡片区、顶部入口、底部操作、空/加载状态
- 验收iPad 信息层级清晰,无文本溢出
- [ ] **T4-2 适配 Home 相关弹层组件**
- 范围:`components/home/` 下弹层、设置卡片、协议入口弹窗
- 验收:弹层在 iPad 下尺寸与点击区合理
- [ ] **T4-3 适配收藏/设置相关页面与入口**
- 范围Favorites、Settings 及关联子组件
- 验收:列表与信息卡在 iPad 下无拥挤/空旷失衡
- [ ] **T4-4 主应用批次验收与冻结**
- 输出:关键页面截图 + 回归记录
- 验收:产品确认后冻结
## 5. 边缘页面与通用组件适配(第四批)
- [x] **T5-1 适配边缘路由页面**
- 范围:`modal``+not-found`、其他辅助页
- 验收iPad 下无明显样式异常
- [ ] **T5-2 清理固定像素高风险点**
- 范围:扫描固定宽高/绝对定位集中区域,替换为分支参数
- 验收:高风险点清单完成闭环
- [ ] **T5-3 通用组件收口**
- 范围:复用组件(如 Sheet、按钮、卡片容器统一 iPad 参数
- 验收:跨页面表现一致
## 6. iOS Widget iPad 适配
- [ ] **T6-1 盘点 Widget 展示尺寸与当前问题**
- 范围Small/Medium/Large 在 iPad 下的展示差异
- 验收:输出问题矩阵(文字溢出/留白/层级)
- [ ] **T6-2 调整 Widget 排版参数**
- 范围:边距、字号、行高、文本截断策略
- 验收:各尺寸均无溢出、无遮挡、层级清晰
- [ ] **T6-3 多语言长文案回归**
- 范围:至少 TC/EN 长短文案样本
- 验收:不同语言下展示稳定
- [ ] **T6-4 Widget 验收截图归档**
- 输出:各尺寸截图(浅色模式必选)
- 验收:可用于最终发布验收材料
## 7. 全量回归与发布前验收
- [ ] **T7-1 iPad 全页面走查**
- 范围:按页面清单逐项验证布局/交互/状态
- 验收:无 P0/P1 视觉与交互问题
- [ ] **T7-2 iPhone 零回归冒烟**
- 范围启动、onboarding、home、settings、favorites、关键弹层
- 验收:关键路径行为与样式无回归
- [ ] **T7-3 输出最终覆盖矩阵与验收记录**
- 输出:页面覆盖表、问题清单、处理结论、剩余风险
- 验收:可直接作为发布前审阅材料
## 8. 收尾与文档同步(全部完成后执行)
- [ ] **T8-1 更新 `spec_kit/overview.md` 对应条目**
- 要求:新增 `iPad Adaptation` 小节,记录目标、范围、阶段产物、完成状态
- 验收:`overview.md` 可一眼看出该需求“任务已全部执行完毕”
- [ ] **T8-2 标记本文件完成状态**
- 要求:`task.md` 全部条目改为 `[x]`,并在文件顶部补“已完成日期/负责人”
- 验收:任务清单闭环

View File

@@ -49,6 +49,8 @@
- ETA 发送任务(幂等:同一用户同一天同一 slot 只发一次;用户中途关闭/降次数会跳过) - ETA 发送任务(幂等:同一用户同一天同一 slot 只发一次;用户中途关闭/降次数会跳过)
- 发送文案复用推荐模块 `scene="push"`(降风险) - 发送文案复用推荐模块 `scene="push"`(降风险)
- 后端:`pytest` 全量通过27 passed - 后端:`pytest` 全量通过27 passed
- 客户端:新增通知点击消费链路,支持前后台/冷启动点击每日推荐 Push 后,将文案暂存并在进入 `home` 时优先展示
- 后端:每日推荐 Push payload 新增 `target_screen/home_text/content_id/deep_link`,保证客户端点击通知后可恢复首页展示上下文
## Project Bootstrap ## Project Bootstrap
@@ -202,6 +204,9 @@
- 后端新增内置协议内容页: - 后端新增内置协议内容页:
- `GET /v1/legal/privacy` - `GET /v1/legal/privacy`
- `GET /v1/legal/terms` - `GET /v1/legal/terms`
- `GET /v1/legal/support`(技术支持页面,提供审核可用的公开支持信息)
- 协议展示策略调整:`/v1/legal/privacy``/v1/legal/terms``/v1/legal/support` 在携带 `Accept-Language` 时按语言单语展示EN/TC缺省时展示 EN + TCEN 在前、TC 在后)
- 协议页面语义修复HTML 根节点 `lang` 属性不再写死,改为随页面实际语言输出(单语 en/zh-Hant双语默认 en
- 客户端新增协议接口封装 `client/src/services/legalApi.ts` - 客户端新增协议接口封装 `client/src/services/legalApi.ts`
- 客户端工程化:新增统一 HTTP 封装 `client/src/utils/http.ts`baseURL/超时/JSON/统一错误),并将 `legalApi.ts` / `recoApi.ts` 接入 - 客户端工程化:新增统一 HTTP 封装 `client/src/utils/http.ts`baseURL/超时/JSON/统一错误),并将 `legalApi.ts` / `recoApi.ts` 接入
- 客户端接入两处入口:`app/(splash)/splash.tsx``components/home/ProfileModal.tsx` - 客户端接入两处入口:`app/(splash)/splash.tsx``components/home/ProfileModal.tsx`