9 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
46 changed files with 1381 additions and 194 deletions

View File

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

View File

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

View File

@@ -1,9 +1,13 @@
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 { clampContentWidth, isIPadLike } from '@/src/utils/device';
export default function SettingsScreen() {
const { t } = useTranslation();
const { width, height } = useWindowDimensions();
const isTablet = isIPadLike(width, height);
const contentWidth = isTablet ? clampContentWidth(width, 720, 24) : undefined;
const version =
Constants.expoConfig?.version ??
@@ -12,6 +16,7 @@ export default function SettingsScreen() {
return (
<View style={styles.container}>
<View style={[styles.contentWrap, contentWidth ? { width: contentWidth } : null]}>
<View style={styles.section}>
<Text style={styles.label}>{t('settings.version')}</Text>
<Text style={styles.value}>{version}</Text>
@@ -22,11 +27,17 @@ export default function SettingsScreen() {
<Text style={styles.cardText}>{t('settings.widgetDesc')}</Text>
</View>
</View>
</View>
);
}
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: {
borderRadius: 14,
padding: 16,
@@ -38,7 +49,12 @@ const styles = StyleSheet.create({
justifyContent: 'space-between',
},
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: {
borderRadius: 16,
padding: 16,

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import FontAwesome from '@expo/vector-icons/FontAwesome';
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
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 Notifications from 'expo-notifications';
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 { initI18n } from '@/src/i18n';
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';
// 配置通知处理方式(即使不发送也建议配置,以确保权限接口正常)
@@ -149,6 +150,7 @@ export default function RootLayout() {
function RootLayoutNav() {
const colorScheme = useColorScheme();
const router = useRouter();
useEffect(() => {
// iOS 小组件:启动时把必要信息写入共享区,并尽力刷新一次“每日推荐”
@@ -165,6 +167,44 @@ function RootLayoutNav() {
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 (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack screenOptions={{ headerShown: false }}>

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
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 { useSafeAreaInsets } from 'react-native-safe-area-context';
import { OnboardingColors } from '@/constants/OnboardingTheme';
@@ -16,10 +16,13 @@ interface NameInputStepProps {
export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProps) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const { width, height } = useWindowDimensions();
const isTablet = Platform.OS === 'ios' && Math.min(width, height) >= 768;
const [isFocused, setIsFocused] = useState(false);
const [keyboardHeight, setKeyboardHeight] = useState(0);
const blinkAnim = useRef(new Animated.Value(1)).current;
const hasInput = value.trim().length > 0;
const inputCardWidth = isTablet ? Math.min(520, Math.floor(width * 0.72)) : 335;
useEffect(() => {
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
@@ -62,7 +65,7 @@ export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProp
return (
<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.displayLayer}>

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
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 { OnboardingColors, OnboardingFont } from '@/constants/OnboardingTheme';
import BtnNotClicked from '@/assets/images/icon/btn_Notclicked.svg';
@@ -19,9 +19,12 @@ interface 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 insets = useSafeAreaInsets();
const footerBottom = insets.bottom + 16;
const footerBottom = insets.bottom + (isTablet ? 38 : 28);
const footerButtonHeight = 57;
// 底部留白加大,避免最后一项与按钮边框视觉重叠
const footerPaddingBottom = footerBottom + footerButtonHeight + 40;
@@ -31,14 +34,24 @@ export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip }
<ScrollView
style={styles.scroll}
showsVerticalScrollIndicator={false}
contentContainerStyle={[styles.optionsList, { paddingBottom: footerPaddingBottom }]}
contentContainerStyle={[
styles.optionsList,
{
paddingBottom: footerPaddingBottom,
alignItems: 'center',
},
]}
>
{options.map((option) => {
const isSelected = selectedIds.includes(option.id);
return (
<TouchableOpacity
key={option.id}
style={[styles.optionCard, isSelected && styles.optionCardSelected]}
style={[
styles.optionCard,
maxOptionWidth ? { maxWidth: maxOptionWidth } : null,
isSelected && styles.optionCardSelected,
]}
onPress={() => onToggle(option.id)}
activeOpacity={0.7}
>

View File

@@ -1,5 +1,5 @@
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 { useSafeAreaInsets } from 'react-native-safe-area-context';
import Animated, {
@@ -10,7 +10,6 @@ import Animated, {
withTiming,
} from 'react-native-reanimated';
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
const FIXED_TOP_GAP = 100; // 统一距离顶部的高度
type Props = {
@@ -30,11 +29,13 @@ type Props = {
export default function SheetModal({ visible, title, onClose, children, leftIcon, height: customHeight }: Props) {
const { t } = useTranslation();
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 progress = useSharedValue(0); // 0: 关闭, 1: 打开
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(() => {
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 保持退场动画
return (
@@ -107,6 +111,7 @@ export default function SheetModal({ visible, title, onClose, children, leftIcon
{...panResponder.panHandlers}
style={[
styles.sheet,
isTablet ? styles.sheetTablet : null,
sheetStyle,
{
height: sheetHeight,
@@ -159,6 +164,14 @@ const styles = StyleSheet.create({
paddingTop: 8,
paddingHorizontal: 16,
},
sheetTablet: {
width: '100%',
alignSelf: 'stretch',
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
},
handleContainer: {
alignItems: 'center',
paddingVertical: 8,

View File

@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objectVersion = 70;
objects = {
/* Begin PBXBuildFile section */
@@ -11,7 +11,7 @@
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
1A1DE01D4133812B2E2BA692 /* libPods-client.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E3328F0E595C1F4A244DF238 /* libPods-client.a */; };
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 */; };
A8C1D2E3F4A5B6C7D8E9F0A3 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */; };
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>"; };
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>"; };
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>"; };
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>"; };
@@ -74,7 +74,7 @@
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
EB3DAF952F2A4B8F00450593 /* Exceptions for "情绪小组件" folder in "情绪小组件Extension" target */ = {
EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
EmotionWidget.swift,
@@ -85,18 +85,7 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
EB3DAF952F2A4B8F00450593 /* Exceptions for "情绪小组件" folder in "情绪小组件Extension" target */,
);
explicitFileTypes = {
};
explicitFolders = (
);
path = "情绪小组件";
sourceTree = "<group>";
};
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = "情绪小组件"; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -213,7 +202,7 @@
EB3DAFD42F2A5FC100450593 /* Recovered References */ = {
isa = PBXGroup;
children = (
A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */,
A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */,
);
name = "Recovered References";
sourceTree = "<group>";
@@ -482,7 +471,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A1B2C3D4E5F60718293A4B5C /* EmotionWidget.swift in Sources */,
A1B2C3D4E5F60718293A4B5C /* 情绪小组件/EmotionWidget.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -536,7 +525,7 @@
SWIFT_OBJC_BRIDGING_HEADER = "client/client-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
@@ -576,7 +565,7 @@
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_OBJC_BRIDGING_HEADER = "client/client-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
@@ -756,7 +745,7 @@
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
@@ -808,7 +797,7 @@
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
TARGETED_DEVICE_FAMILY = "1,2";
};
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>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSLocalNetworkUsageDescription</key>
<string>用于连接局域网服务以获取内容与同步数据(仅在需要访问内网地址时使用)。</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
@@ -47,6 +45,8 @@
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSLocalNetworkUsageDescription</key>
<string>用于连接局域网服务以获取内容与同步数据(仅在需要访问内网地址时使用)。</string>
<key>NSUserActivityTypes</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
@@ -60,6 +60,8 @@
<string>arm64</string>
</array>
<key>UIRequiresFullScreen</key>
<true/>
<key>UIStatusBarHidden</key>
<false/>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleDefault</string>
@@ -72,8 +74,6 @@
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Automatic</string>

View File

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

View File

@@ -46,7 +46,7 @@ function getApiBaseUrl(env: AppRuntimeEnv): string {
return getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_PROD', getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_LOCAL', 'https://api.damer.fun'));
}
export const API_BASE_URL = getApiBaseUrl(APPpai qa
export const API_BASE_URL = getApiBaseUrl(APP_ENV);
/**
* 调试:打印环境变量注入结果(仅开发环境)

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_AT = 'push.lastRegisteredAt'; // 旧:时间(保留兼容)
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 Reaction = 'like' | 'dislike';
@@ -112,6 +114,52 @@ export async function setLastRegisteredPushPayload(payload: Omit<LastRegisteredP
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 = {
content_id: number;
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 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"])
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繁体
ResolvedLang = Literal["en", "tc"]
BILINGUAL_CONTENT_LANGUAGE = "en, zh-Hant"
class LegalLinksResponse(BaseModel):
@@ -36,13 +90,23 @@ def _resolve_lang(accept_language: Optional[str]) -> ResolvedLang:
if not accept_language:
return "en"
s = accept_language.lower()
# 目前只支持 EN / TC只要是中文或显式 tc都归到 tc
if "tc" in s:
# 按语言优先级顺序逐项解析,避免“只要包含 zh 就全判 tc”。
# 例如en-US,en;q=0.9,zh-CN;q=0.8 应命中 en而不是 tc。
items = [seg.strip().lower() for seg in accept_language.split(",") if seg.strip()]
for item in items:
lang_tag = item.split(";", 1)[0].strip()
if not lang_tag:
continue
if lang_tag == "tc":
return "tc"
if "zh" in s or "hant" in s or "tw" in s or "hk" in s or "mo" in s:
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"
@@ -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)
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)
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")
if accept_language:
lang = _resolve_lang(accept_language)
content, resolved = choose_content_by_lang(PRIVACY_POLICY_MD, lang)
title = "Dear Mama | Privacy Policy" if resolved == "en" else "Dear Mama隱私權政策"
page = render_as_simple_html(title=title, content=content)
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)
@@ -123,9 +206,37 @@ async def get_terms_of_use(request: Request) -> HTMLResponse:
"""
accept_language = request.headers.get("accept-language")
if accept_language:
lang = _resolve_lang(accept_language)
content, resolved = choose_content_by_lang(TERMS_OF_USE_MD, lang)
title = "Dear Mama Terms of Use" if resolved == "en" else "Dear Mama 使用條款"
page = render_as_simple_html(title=title, content=content)
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_send_log import PushSendLog
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.worker import celery_app
@@ -153,6 +154,32 @@ async def register(req: PushRegisterRequest, db: AsyncSession = Depends(get_db))
token.is_active = True
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()
return {"status": "ok"}
@@ -187,7 +214,10 @@ async def put_preferences(req: PushPreferencesRequest, db: AsyncSession = Depend
else:
pref.enabled = enabled
pref.times_per_day = times
# 注意:只在客户端显式传入时覆盖,避免把已保存的 timezone/locale 清空导致排程回退到 UTC
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:
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"
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
return {"status": "ok", "expo": expo_res}

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
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 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")
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="创建时间")

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"
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 保留换行并自动换行)。
不做 Markdown 渲染,避免引入额外依赖,确保“最小可用、必有内容”。
@@ -273,8 +273,9 @@ def render_as_simple_html(title: str, content: str) -> str:
safe_title = html.escape(title)
safe_content = html.escape(content)
safe_lang = html.escape(html_lang or "en")
return f"""<!doctype html>
<html lang="en">
<html lang="{safe_lang}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />

View File

@@ -16,6 +16,7 @@ from app.db.models.push_preference import PushPreference
from app.db.models.push_send_log import PushSendLog
from app.db.models.push_token import PushToken
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.user_profile_scoring.scoring import build_user_profile_from_questionnaire
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:
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]:
@@ -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]:
"""
将窗口均匀切分为 n 个区间,并在每段内随机取一个时间点(抖动)
将窗口均匀切分为 n 个区间,并在每段内取“中点 + 受限抖动”的时间点
目的:
- 尽量均匀分布(避免相邻两条推送随机到非常接近的时间)
- 仍保留一定随机性,避免过于机械
"""
if n <= 0:
@@ -84,8 +90,15 @@ def _uniform_jitter_times(*, start: datetime, end: datetime, n: int) -> list[dat
if seg <= 0:
out.append(seg_start)
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
@@ -288,24 +301,77 @@ async def _send_once_async(*, client_user_id: str, local_date: date, slot_index:
# 关键:这里不能调用 tasks.reco.generate内部会 asyncio.run否则会嵌套事件循环崩溃。
from app.tasks.reco import run_reco_payload_async
body = ""
# 去重:用户推送过的内容尽量不再推送
# 说明:
# - 依赖 push_send_log.content_id需先完成对应 DB 迁移)
# - 为避免历史过长导致 already_recommended_ids 过大,这里取“最近若干条已推送内容”近似全量去重
used_ids: list[int] = []
try:
reco_payload = await run_reco_payload_async(scene="push", user_profile=user_profile, k=1, locale=reco_locale)
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 = ""
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):
body = str(items[0].get("text") or "").strip()
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:
body = "给自己一句温柔的话。"
# tc 语言兜底文案使用繁体
body = "給自己一句溫柔的話。" if reco_locale == "tc" else "给自己一句温柔的话。"
# 5) 发送
expo_res = await _send_expo_push(
to=str(token.push_token),
title=title,
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
@@ -329,6 +395,9 @@ async def _send_once_async(*, client_user_id: str, local_date: date, slot_index:
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:

View File

@@ -53,7 +53,8 @@ celery_app.conf.beat_schedule = {
},
"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},
"options": {"queue": f"{prefix}:celery"},
}

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 只发一次;用户中途关闭/降次数会跳过)
- 发送文案复用推荐模块 `scene="push"`(降风险)
- 后端:`pytest` 全量通过27 passed
- 客户端:新增通知点击消费链路,支持前后台/冷启动点击每日推荐 Push 后,将文案暂存并在进入 `home` 时优先展示
- 后端:每日推荐 Push payload 新增 `target_screen/home_text/content_id/deep_link`,保证客户端点击通知后可恢复首页展示上下文
## Project Bootstrap
@@ -202,6 +204,9 @@
- 后端新增内置协议内容页:
- `GET /v1/legal/privacy`
- `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`
- 客户端工程化:新增统一 HTTP 封装 `client/src/utils/http.ts`baseURL/超时/JSON/统一错误),并将 `legalApi.ts` / `recoApi.ts` 接入
- 客户端接入两处入口:`app/(splash)/splash.tsx``components/home/ProfileModal.tsx`